From 7e5b6fa8e971d5dbffd690076aa5b05983dcf43a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 04:57:45 -0700 Subject: [PATCH 001/140] Make crates for this --- .github/workflows/check.yml | 4 ++++ .github/workflows/devskim.yml | 4 ++-- .gitignore | 4 ++++ bstack_raii/Cargo.toml | 29 +++++++++++++++++++++++++++++ bstack_raii/derive/Cargo.toml | 17 +++++++++++++++++ 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 bstack_raii/Cargo.toml create mode 100644 bstack_raii/derive/Cargo.toml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b627f06..60bb954 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -3,8 +3,12 @@ name: Check on: push: branches: [ master ] + # The nested bstack_raii crate is its own workspace and not part of the + # bstack package; skip these checks when only it changed. + paths-ignore: [ bstack_raii/** ] pull_request: branches: [ master ] + paths-ignore: [ bstack_raii/** ] permissions: contents: read diff --git a/.github/workflows/devskim.yml b/.github/workflows/devskim.yml index 976ab8e..655ce33 100644 --- a/.github/workflows/devskim.yml +++ b/.github/workflows/devskim.yml @@ -8,10 +8,10 @@ name: DevSkim on: push: branches: [ "master" ] - paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock'] + paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock', 'bstack_raii/**'] pull_request: branches: [ "master" ] - paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock'] + paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock', 'bstack_raii/**'] jobs: lint: diff --git a/.gitignore b/.gitignore index d2cc613..d702b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Rust /target +# Nested bstack_raii crate build output (its own self-contained workspace) +/bstack_raii/target +/bstack_raii/**/target +/bstack_raii/Cargo.lock # C /obj diff --git a/bstack_raii/Cargo.toml b/bstack_raii/Cargo.toml new file mode 100644 index 0000000..609e408 --- /dev/null +++ b/bstack_raii/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bstack_raii" +version = "0.0.0" +edition = "2024" +authors = ["William Wu ", "Claude "] +license = "MIT" +description = "Typed, RAII-style ownership and lifetime layer over the bstack allocation primitives" +repository = "https://github.com/williamwutq/bstack" +publish = false + +# This crate is developed inside the `bstack` repository but is NOT part of the +# `bstack` package or its CI. It is its own self-contained workspace so that +# root-level `cargo` commands in the bstack repo never descend into it. See +# RAII.md (in the repo root) for the full design this crate implements. +[workspace] +members = ["derive"] + +[dependencies] +# bstack 0.4.0 is feature-complete but not yet published to crates.io, so we +# depend on the GitHub source for now. Pin a `branch`/`rev`/`tag` here once the +# RAII primitives land on a stable ref (or swap to a `path = ".."` dep for local +# iteration). RAII requires `alloc` + `set`; `atomic` is needed for on-disk +# refcount CAS (control blocks, strong/weak counters). +bstack = { git = "https://github.com/williamwutq/bstack", features = ["alloc", "set", "atomic"] } +bstack_raii_derive = { path = "derive", version = "0.0.0" } + +# Gate for POD (plain-old-data) inline fields: any `bytemuck::Pod` type is safe +# to store inline in an on-disk block and receives the blanket no-op BStackDrop. +bytemuck = { version = "1", features = ["derive"] } diff --git a/bstack_raii/derive/Cargo.toml b/bstack_raii/derive/Cargo.toml new file mode 100644 index 0000000..7f42932 --- /dev/null +++ b/bstack_raii/derive/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bstack_raii_derive" +version = "0.0.0" +edition = "2024" +authors = ["William Wu ", "Claude "] +license = "MIT" +description = "Procedural macros for bstack_raii (#[bstack_block], bstack_move!, bstack_cast!)" +repository = "https://github.com/williamwutq/bstack" +publish = false + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" From ab0ee0341755a50f46e865e139adffdbc8046c2b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 04:59:29 -0700 Subject: [PATCH 002/140] Start things --- bstack_raii/README.md | 48 +++++++ bstack_raii/derive/src/lib.rs | 75 +++++++++++ bstack_raii/src/lib.rs | 233 ++++++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 bstack_raii/README.md create mode 100644 bstack_raii/derive/src/lib.rs create mode 100644 bstack_raii/src/lib.rs diff --git a/bstack_raii/README.md b/bstack_raii/README.md new file mode 100644 index 0000000..40aea16 --- /dev/null +++ b/bstack_raii/README.md @@ -0,0 +1,48 @@ +# bstack_raii + +A typed, RAII-style ownership, lifetime, and on-disk-layout layer over the +[`bstack`](https://github.com/williamwutq/bstack) allocation primitives +(`BStackRange`, `BStackSlice`, `BStackOwnedSlice`). It decouples disk-level +destruction (`BStackDrop`) from Rust's process-scoped `Drop`, giving persistent +storage the ergonomics of C++ `unique_ptr` / `shared_ptr` / `weak_ptr`. + +The full design is in [`RAII.md`](../RAII.md) at the repository root. This crate +is its implementation. + +## Why a separate crate (not a `bstack` feature) + +`bstack` keeps stable features ABI-stable. The RAII layer introduces a large, +not-yet-stable ABI surface (block layouts, control blocks, refcounting), so it +lives outside the `bstack` package until it settles. + +## Layout + +``` +bstack_raii/ # runtime: traits + on-disk header + handle types + src/lib.rs + derive/ # proc-macro crate: #[bstack_block], bstack_move!, bstack_cast! + src/lib.rs +``` + +`bstack_raii` re-exports the macros, so downstream code depends only on +`bstack_raii`. It is a **self-contained cargo workspace**: the outer `bstack` +repo has no `[workspace]`, so root-level `cargo` commands never build this crate, +and `bstack`'s CI is configured to ignore `bstack_raii/**`. + +## Status + +Scaffold. Traits, on-disk header, typed-reference and child-handle types, and +the three proc-macro entry points are stubbed with their final signatures and +generation contracts; the bodies (marked `todo!()` / `TODO`) are the work ahead. + +## Dependency on bstack + +`bstack` 0.4.0 is feature-complete but not yet on crates.io, so this crate +depends on the GitHub source: + +```toml +bstack = { git = "https://github.com/williamwutq/bstack", features = ["alloc", "set", "atomic"] } +``` + +Pin a `branch`/`rev`/`tag` (or switch to `path = ".."` for local iteration) once +the RAII primitives land on a stable ref. diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs new file mode 100644 index 0000000..b08a731 --- /dev/null +++ b/bstack_raii/derive/src/lib.rs @@ -0,0 +1,75 @@ +//! Procedural macros for [`bstack_raii`](https://github.com/williamwutq/bstack). +//! +//! Three entry points, all documented in `RAII.md` at the repository root: +//! +//! * [`macro@bstack_block`] — attribute macro. Turns an ergonomic struct into a +//! parallel `#[repr(C, packed)]` on-disk representation plus generated +//! accessors, `BStackDrop`, `BStackCast`, and (for `(rc, weak)`) +//! `BStackWeakable` impls. +//! * [`bstack_move`] — function-like macro. Destructures a `BStackOwned`, +//! transferring ownership of every field out as a tuple of typed handles. +//! * [`bstack_cast`] — function-like macro. Direction-inferring typed/untyped +//! handle conversion. +//! +//! Everything below is a scaffold: the parsing/validation/codegen bodies are the +//! work to be filled in. The signatures and the emitted-shape contracts are +//! fixed so the runtime crate and downstream callers can be developed in +//! parallel. + +use proc_macro::TokenStream; + +/// `#[bstack_block]` — generate the on-disk layout and typed handle machinery. +/// +/// Accepts optional mode arguments: `#[bstack_block]`, `#[bstack_block(rc)]`, or +/// `#[bstack_block(rc, weak)]`. +/// +/// Must generate, for an input `struct X { .. }`: +/// 1. `struct XOnDisk` — `#[repr(C, packed)]`, `header: BlockHeader` first, then +/// each field lowered per its annotation: +/// * `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / +/// `#[bstack_ref]` → `BStackRef` (validate exactly one annotation on +/// each non-POD field). +/// * un-annotated field → stored inline; must be `bytemuck::Pod` (reject +/// otherwise at expansion time). +/// * `(rc)` injects `refcount: AtomicU64` after the header; `(rc, weak)` +/// instead injects `ctrl: BStackRef` and emits a separate +/// `struct XOnDiskRef` control block (`strong`, `weak`, back-pointer). +/// 2. Field accessor methods on `X` that `read_into` a buffer and read the field. +/// 3. `impl BStackDrop for X` — post-order: one child-handle `.bstack_drop(allocator)?` +/// per non-`#[bstack_ref]`, non-POD field, then `dealloc_range` of the block. +/// 4. `impl BStackCast for X` with an `EightCC` derived from the type name. +/// 5. `impl BStackWeakable for X { type Control = XOnDiskRef; }` only for +/// `(rc, weak)`. +#[proc_macro_attribute] +pub fn bstack_block(_args: TokenStream, item: TokenStream) -> TokenStream { + // Scaffold: re-emit the input struct unchanged so downstream compiles. + // TODO: parse args + fields, validate annotations, emit the items above. + item +} + +/// `bstack_move!(x)` — transfer every field out of a `BStackOwned`. +/// +/// Expands to a block that reads each field's `BStackRef`/POD value from +/// `XOnDisk` (capturing `ctrl` refs for `(rc, weak)` / weak fields first), +/// `dealloc_range`s the parent shell only, then reconstructs typed handles with +/// the allocator attached and returns them as a `Result<(..), io::Error>` tuple. +/// Callable only on `BStackOwned`; not defined for `(rc)` / `(rc, weak)` +/// blocks. See RAII.md "`bstack_move!`". +#[proc_macro] +pub fn bstack_move(_input: TokenStream) -> TokenStream { + // Scaffold: emit an unimplemented expression so any (currently nonexistent) + // call site type-checks. TODO: implement the destructuring expansion. + "::core::todo!(\"bstack_move! not yet implemented\")".parse().unwrap() +} + +/// `bstack_cast!(handle)` — type-checked handle conversion, direction inferred +/// from the target type. +/// +/// Emits `.cast_into::()` / `.into_slice()` (owned) or `.cast_as::()` / +/// `.as_slice()` (borrowed) depending on whether the target is a concrete +/// `#[bstack_block]` type (downcast) or a `BStackOwnedSlice` / `BStackSlice` +/// (upcast). See RAII.md "`bstack_cast!`". +#[proc_macro] +pub fn bstack_cast(_input: TokenStream) -> TokenStream { + "::core::todo!(\"bstack_cast! not yet implemented\")".parse().unwrap() +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs new file mode 100644 index 0000000..d58a5ac --- /dev/null +++ b/bstack_raii/src/lib.rs @@ -0,0 +1,233 @@ +//! # `bstack_raii` +//! +//! A typed, RAII-style ownership, lifetime, and on-disk-layout layer built on +//! top of the mainline `bstack` `alloc` primitives ([`bstack::BStackRange`], +//! [`bstack::BStackSlice`], [`bstack::BStackOwnedSlice`]). It decouples +//! disk-level destruction ([`BStackDrop`]) from Rust's process-scoped `Drop`, +//! providing persistent-storage ownership semantics with C++-style +//! `unique_ptr` / `shared_ptr` / `weak_ptr` conveniences. +//! +//! The full design lives in `RAII.md` at the repository root. This crate is the +//! implementation of that document. It is intentionally kept as a separate +//! crate (rather than a `bstack` feature flag) because it introduces a large, +//! not-yet-stable ABI surface (block layouts, control blocks, refcounting) that +//! must not gate `bstack`'s own ABI stability. +//! +//! ## Layout of this crate +//! +//! * This crate ([`bstack_raii`]) holds the **runtime**: the [`BStackDrop`], +//! [`BStackWeakable`], and [`BStackCast`] traits, the on-disk [`BlockHeader`] +//! / [`EightCC`], the typed handle types ([`BStackRef`], and — once +//! implemented — `BStackOwned` / `BStackRc` / `BStackWeak`), and the small +//! `Copy` child-handle types that carry the recursive/atomic teardown logic. +//! * The nested [`bstack_raii_derive`] proc-macro crate holds the **code +//! generators**: [`macro@bstack_block`], [`bstack_move`], and +//! [`bstack_cast`]. They emit calls into the runtime defined here. +//! +//! ## Status +//! +//! Scaffold only. The types and traits below establish the shape described in +//! `RAII.md`; method bodies marked `todo!()` are the work to be filled in. + +#![allow(dead_code)] + +use core::marker::PhantomData; +use std::io; + +use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; + +// Re-export the procedural macros so callers only depend on `bstack_raii`. +pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_move}; + +// --------------------------------------------------------------------------- +// On-disk header +// --------------------------------------------------------------------------- + +/// An 8-byte type tag stored in every block header. +/// +/// Used instead of the traditional 4-byte `FourCC` because `bstack` offsets are +/// 64-bit, so 8-byte alignment is natural. Derived from the block type's name at +/// `#[bstack_block]` expansion time and compared during safe downcasts +/// ([`BStackCast`]). +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct EightCC(pub [u8; 8]); + +impl EightCC { + /// Construct a tag from a raw 8-byte array. + pub const fn new(tag: [u8; 8]) -> Self { + Self(tag) + } +} + +/// The header prefixing every on-disk block. 16 bytes, `#[repr(C, packed)]`. +/// +/// The `size` field is the payload length in bytes; `tag` is the [`EightCC`] +/// discriminant written by the allocator at block creation. +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct BlockHeader { + pub size: u64, + pub tag: EightCC, +} + +// --------------------------------------------------------------------------- +// Typed reference: BStackRef +// --------------------------------------------------------------------------- + +/// A typed, non-owning wrapper over a [`BStackRange`]. +/// +/// Like `BStackRange`, it carries no backing reference and performs no I/O on +/// its own — it is the serialization form of a typed pointer and is `Copy`. +/// Resolving it into a live handle requires an allocator or stack supplied +/// externally. On disk it occupies the same bytes as a `BStackRange`. +#[repr(transparent)] +pub struct BStackRef { + range: BStackRange, + _marker: PhantomData T>, +} + +impl BStackRef { + /// Wrap a raw range as a typed reference. + /// + /// # Safety + /// The caller asserts that `range` refers to a validly allocated block of + /// type `T` (or will, by the time it is resolved). + pub const unsafe fn from_range(range: BStackRange) -> Self { + Self { range, _marker: PhantomData } + } + + /// The underlying untyped range. + pub const fn into_range(self) -> BStackRange { + self.range + } +} + +impl Clone for BStackRef { + fn clone(&self) -> Self { + *self + } +} +impl Copy for BStackRef {} + +// --------------------------------------------------------------------------- +// Core traits +// --------------------------------------------------------------------------- + +/// Disk-level recursive destruction, fully decoupled from Rust's `Drop`. +/// +/// Implemented by every `#[bstack_block]` type (destroys the block and recurses +/// into its owned children) and by the small child-handle types below. Takes +/// `self` (the *without-allocator* handle) plus an explicit allocator, so it is +/// generic over all handle-like types rather than tied to `BStackOwnedSlice`. +/// +/// Because a bare [`BStackRange`] carries no allocator, freeing a block is done +/// by reconstructing a [`BStackOwnedSlice`] from the range via +/// [`BStackOwnedSlice::from_raw_range`] (`unsafe`) and handing it to the +/// allocator's `dealloc` — there is deliberately no `dealloc_range` on the +/// allocator itself; see the free [`dealloc_range`] helper. +/// +/// The allocator is bound to [`BStackOwnedSliceAllocator`] rather than the bare +/// `BStackAllocator`. That supertrait pins `Allocated<'a> = BStackOwnedSlice<'a, +/// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and +/// `Error = io::Error` (so the whole layer speaks [`io::Result`], like the rest +/// of `bstack`). +pub trait BStackDrop: Sized { + fn bstack_drop(self, allocator: &A) -> io::Result<()>; +} + +/// Marker implemented only for blocks declared `#[bstack_block(rc, weak)]`. +/// +/// Its presence is what lets the generated `BStackRc` carry a `ctrl` field +/// and expose `downgrade`/`upgrade`. Plain `#[bstack_block(rc)]` blocks do not +/// implement it, so weak references to them are a compile error rather than a +/// runtime hazard. +pub trait BStackWeakable: BStackDrop { + /// The generated on-disk control-block type (`XOnDiskRef`) holding the + /// `strong`/`weak` counters. + type Control; +} + +/// Downcast discriminant. Implemented by every `#[bstack_block]` type. +/// +/// The returned [`EightCC`] must match the tag in a block's [`BlockHeader`] for +/// a safe downcast to succeed. +pub trait BStackCast { + fn eightcc() -> EightCC; +} + +// --------------------------------------------------------------------------- +// Child handle types (small, Copy, constructed transiently during teardown) +// +// Each maps to one field annotation and encapsulates its own destruction logic +// so that the code generated per block type stays a flat, uniform sequence of +// `.bstack_drop(allocator)?` calls. See RAII.md "Child Handle Types". +// --------------------------------------------------------------------------- + +/// `#[bstack_owned]`: an exclusively-owned child. Teardown recurses via +/// `T::bstack_drop`. +#[derive(Clone, Copy)] +pub struct OwnedRef(pub BStackRef); + +/// `#[bstack_strong]` on a plain `(rc)` `T`. Teardown decrements the inline +/// refcount and calls `T::bstack_drop` at zero. +#[derive(Clone, Copy)] +pub struct StrongRef(pub BStackRef); + +/// `#[bstack_strong]` on an `(rc, weak)` `T`. Holds both the data ref and the +/// control-block ref; teardown runs the two-phase strong-then-weak decrement. +#[derive(Clone, Copy)] +pub struct StrongWeakRef(pub BStackRef, pub BStackRef); + +/// `#[bstack_weak]` on an `(rc, weak)` `T`. Holds only the control-block ref; +/// teardown decrements `ctrl.weak` and frees the control block at zero. The data +/// block is never touched. +#[derive(Clone, Copy)] +pub struct WeakRef(pub BStackRef); + +impl BStackDrop for OwnedRef { + fn bstack_drop(self, _allocator: &A) -> io::Result<()> { + // Recurse into T, then reconstruct a BStackOwnedSlice from the range and + // dealloc it. See RAII.md "Generated bstack_drop". + todo!("recurse T::bstack_drop, then dealloc_range") + } +} + +impl BStackDrop for StrongRef { + fn bstack_drop(self, _allocator: &A) -> io::Result<()> { + todo!("CAS-decrement inline refcount; at zero, T::bstack_drop then dealloc_range") + } +} + +impl BStackDrop for StrongWeakRef { + fn bstack_drop(self, _allocator: &A) -> io::Result<()> { + todo!("decrement ctrl.strong; at zero free data block + release phantom weak") + } +} + +impl BStackDrop for WeakRef { + fn bstack_drop(self, _allocator: &A) -> io::Result<()> { + todo!("decrement ctrl.weak; free control block at zero") + } +} + +/// Free a raw block range by reconstructing an owned slice and delegating to the +/// allocator. Central helper the generated `bstack_drop` code funnels through, +/// since ranges carry no allocator of their own. +/// +/// The [`BStackOwnedSliceAllocator`] bound makes this well-typed: it pins +/// `Allocated<'a> = BStackOwnedSlice<'a, A>` (so the reconstructed slice is the +/// handle `dealloc` accepts) and `Error = io::Error` (so the failure maps +/// straight through `BStackAllocError::source`). +/// +/// # Safety +/// `range` must be a live allocation owned by `allocator` that no other live +/// handle will also free. +pub unsafe fn dealloc_range( + allocator: &A, + range: BStackRange, +) -> io::Result<()> { + let owned: BStackOwnedSlice<'_, A> = + unsafe { BStackOwnedSlice::from_raw_range(allocator, range) }; + allocator.dealloc(owned).map_err(|e| e.source) +} From c0f6ccaf6610b6fe9eabf26db67587216602da2f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 05:13:30 -0700 Subject: [PATCH 003/140] Make skeleton --- bstack_raii/derive/src/lib.rs | 8 +- bstack_raii/src/block.rs | 45 ++++++ bstack_raii/src/clone.rs | 13 ++ bstack_raii/src/handle.rs | 106 +++++++++++++ bstack_raii/src/layout.rs | 51 +++++++ bstack_raii/src/lib.rs | 274 +++++++--------------------------- bstack_raii/src/owned.rs | 56 +++++++ bstack_raii/src/refcount.rs | 34 +++++ bstack_raii/src/reference.rs | 76 ++++++++++ bstack_raii/src/shared.rs | 92 ++++++++++++ bstack_raii/src/teardown.rs | 41 +++++ 11 files changed, 574 insertions(+), 222 deletions(-) create mode 100644 bstack_raii/src/block.rs create mode 100644 bstack_raii/src/clone.rs create mode 100644 bstack_raii/src/handle.rs create mode 100644 bstack_raii/src/layout.rs create mode 100644 bstack_raii/src/owned.rs create mode 100644 bstack_raii/src/refcount.rs create mode 100644 bstack_raii/src/reference.rs create mode 100644 bstack_raii/src/shared.rs create mode 100644 bstack_raii/src/teardown.rs diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index b08a731..4997538 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -59,7 +59,9 @@ pub fn bstack_block(_args: TokenStream, item: TokenStream) -> TokenStream { pub fn bstack_move(_input: TokenStream) -> TokenStream { // Scaffold: emit an unimplemented expression so any (currently nonexistent) // call site type-checks. TODO: implement the destructuring expansion. - "::core::todo!(\"bstack_move! not yet implemented\")".parse().unwrap() + "::core::todo!(\"bstack_move! not yet implemented\")" + .parse() + .unwrap() } /// `bstack_cast!(handle)` — type-checked handle conversion, direction inferred @@ -71,5 +73,7 @@ pub fn bstack_move(_input: TokenStream) -> TokenStream { /// (upcast). See RAII.md "`bstack_cast!`". #[proc_macro] pub fn bstack_cast(_input: TokenStream) -> TokenStream { - "::core::todo!(\"bstack_cast! not yet implemented\")".parse().unwrap() + "::core::todo!(\"bstack_cast! not yet implemented\")" + .parse() + .unwrap() } diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs new file mode 100644 index 0000000..f5b97de --- /dev/null +++ b/bstack_raii/src/block.rs @@ -0,0 +1,45 @@ +//! The trait contracts every `#[bstack_block]` type implements. +//! +//! Written by hand for now; generated by the macro later. They let generic +//! runtime code (handles, `bstack_move!`) name a block's on-disk shape, tag, and +//! control block without knowing the concrete type. + +use bstack::BStackRange; +use bytemuck::Pod; + +use crate::layout::EightCC; +use crate::teardown::BStackDrop; + +/// The downcast discriminant. The returned [`EightCC`] must match the tag in a +/// block's [`crate::BlockHeader`] for a safe downcast to succeed. +pub trait BStackCast { + fn eightcc() -> EightCC; +} + +/// A typed handle over a block: a newtype around a [`BStackRange`] that knows its +/// on-disk representation and how to be recursively freed. +/// +/// `OnDisk` is the generated `#[repr(C, packed)]` payload struct; it must be +/// [`Pod`] so it can be read back with `bytemuck::from_bytes`. +pub trait BStackBlock: BStackCast + BStackDrop + Sized { + /// The on-disk payload layout (the generated `XOnDisk`). + type OnDisk: Pod; + + /// Wrap a range as a typed handle (no I/O, no validation). + fn from_range(range: BStackRange) -> Self; + + /// The underlying range this handle points at. + fn range(&self) -> BStackRange; +} + +/// Implemented only for blocks declared `#[bstack_block(rc, weak)]`. +/// +/// Its presence is what lets [`crate::BStackRc`] expose `downgrade` and +/// [`crate::BStackWeak`] exist for the type. `Control` is the generated +/// `XOnDiskRef` control-block payload holding the `strong`/`weak` counters. +/// Plain `#[bstack_block(rc)]` blocks do not implement it, so weak references to +/// them are a compile error rather than a runtime hazard. +pub trait BStackWeakable: BStackBlock { + /// The on-disk control-block payload (the generated `XOnDiskRef`). + type Control: Pod; +} diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs new file mode 100644 index 0000000..a7ee93f --- /dev/null +++ b/bstack_raii/src/clone.rs @@ -0,0 +1,13 @@ +//! [`TryClone`]: a fallible clone for handles whose duplication touches disk. +//! +//! [`crate::BStackRc`] and [`crate::BStackWeak`] cannot implement `Clone`, +//! because duplicating them must atomically bump an on-disk refcount, which can +//! fail with an [`io::Error`]. `Clone::clone` has no way to report that, so this +//! layer exposes an explicit fallible clone instead. + +use std::io; + +/// Duplicate `self`, performing any fallible I/O the duplication requires. +pub trait TryClone: Sized { + fn try_clone(&self) -> io::Result; +} diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs new file mode 100644 index 0000000..8fcbe7f --- /dev/null +++ b/bstack_raii/src/handle.rs @@ -0,0 +1,106 @@ +//! Without-allocator inner handles: small `Copy` types constructed transiently +//! during teardown, each encapsulating one field annotation's destruction logic. +//! +//! Keeping the per-annotation logic here (rather than in generated block code) +//! means `#[bstack_block]` can emit a flat, uniform sequence of +//! `.bstack_drop(allocator)?` calls. The with-allocator wrappers +//! ([`crate::BStackOwned`], [`crate::BStackRc`], [`crate::BStackWeak`]) hold one +//! of these plus an allocator reference. +//! +//! ## Open design note — what generic teardown needs from a block type +//! +//! The `todo!()` bodies below all need layout facts that only the concrete block +//! type knows, and which the macro can generate. Before implementing them, we +//! must decide how the block-type traits expose: +//! +//! * **Plain `(rc)`**: the byte offset of the inline `refcount` within `OnDisk` +//! (for [`StrongRef`]). +//! * **`(rc, weak)`**: the `ctrl` back-pointer range out of the data `OnDisk`, +//! the `x` forward-pointer range out of the `Control` block, and the byte +//! offsets of `strong` / `weak` within `Control` (for [`StrongWeakRef`] / +//! [`WeakRef`] / `upgrade`). +//! +//! These are additive trait members (associated `const`s + small accessors) on +//! [`crate::BStackBlock`] / [`crate::BStackWeakable`]; they are intentionally +//! left out until we settle the shape, to avoid baking in surface prematurely. + +use std::io; + +use bstack::BStackOwnedSliceAllocator; + +use crate::block::{BStackBlock, BStackWeakable}; +use crate::reference::BStackRef; +use crate::teardown::BStackDrop; + +/// `#[bstack_owned]`: an exclusively-owned child. +#[derive(Clone, Copy)] +pub struct OwnedRef(pub BStackRef); + +/// `#[bstack_strong]` on a plain `(rc)` `T`: holds just the data ref; teardown +/// decrements the inline refcount and frees at zero. +#[derive(Clone, Copy)] +pub struct StrongRef(pub BStackRef); + +/// `#[bstack_strong]` on an `(rc, weak)` `T`: holds the data ref and the control +/// ref; teardown runs the two-phase strong-then-weak release. +#[derive(Clone, Copy)] +pub struct StrongWeakRef(pub BStackRef, pub BStackRef); + +/// `#[bstack_weak]` on an `(rc, weak)` `T`: holds only the control ref; teardown +/// decrements `ctrl.weak` and frees the control block at zero. The data block is +/// never touched. +#[derive(Clone, Copy)] +pub struct WeakRef(pub BStackRef); + +impl BStackDrop for OwnedRef { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // An owned child is freed by running the block's own recursive teardown, + // which frees its children (post-order) and then deallocs the block. + T::from_range(self.0.into_range()).bstack_drop(allocator) + } +} + +impl BStackDrop for StrongRef { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // CAS-decrement the inline refcount; at zero, run T's teardown. + todo!("decrement inline refcount (needs refcount offset); free at zero") + } +} + +impl StrongWeakRef { + /// Resolve the control ref from the data block's on-disk `ctrl` back-pointer + /// with a single read, then pair it with the data ref. + pub fn from_disk( + data_ref: BStackRef, + allocator: &A, + ) -> io::Result { + todo!("read T::OnDisk, extract ctrl back-pointer, pair with data_ref") + } +} + +impl BStackDrop for StrongWeakRef { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // Phase 1: decrement ctrl.strong. At zero, free the data block (children + // + shell), then release the phantom weak by decrementing ctrl.weak; + // if that hits zero, free the control block too. + todo!("two-phase strong release; see RAII.md 'Two-Phase Teardown'") + } +} + +impl WeakRef { + /// Resolve the control ref from the data block's on-disk `ctrl` back-pointer + /// with a single read. + pub fn from_disk( + data_ref: BStackRef, + allocator: &A, + ) -> io::Result { + todo!("read T::OnDisk, extract ctrl back-pointer") + } +} + +impl BStackDrop for WeakRef { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // Decrement ctrl.weak; free the control block when it reaches zero. + todo!("decrement ctrl.weak; free control block at zero") + } +} diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs new file mode 100644 index 0000000..50c91df --- /dev/null +++ b/bstack_raii/src/layout.rs @@ -0,0 +1,51 @@ +//! On-disk primitives shared by every block: the type tag and the block header. +//! +//! Both are [`bytemuck::Pod`] so they can be embedded directly in a generated +//! `XOnDisk` struct and read back with `bytemuck::from_bytes`. + +use bytemuck::{Pod, Zeroable}; + +/// An 8-byte type tag stored in every [`BlockHeader`]. +/// +/// Used instead of a 4-byte `FourCC` because `bstack` offsets are 64-bit, so +/// 8-byte alignment is natural. The `#[bstack_block]` macro derives it from the +/// block type's name via [`EightCC::from_name`]; [`crate::BStackCast`] compares +/// it during safe downcasts. +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Pod, Zeroable)] +pub struct EightCC(pub [u8; 8]); + +impl EightCC { + /// Wrap a raw 8-byte tag. + pub const fn new(tag: [u8; 8]) -> Self { + Self(tag) + } + + /// Derive a tag from a type name: the first 8 bytes, zero-padded (or + /// truncated). `const` so the macro can emit it in a `const` context. + pub const fn from_name(name: &str) -> Self { + let bytes = name.as_bytes(); + let mut out = [0u8; 8]; + let mut i = 0; + while i < 8 && i < bytes.len() { + out[i] = bytes[i]; + i += 1; + } + Self(out) + } +} + +/// The header prefixing every on-disk block. 16 bytes. +/// +/// `size` is the payload length in bytes; `tag` is the [`EightCC`] discriminant +/// written by the allocator at block creation. Declared `#[repr(C)]` rather than +/// `#[repr(C, packed)]`: a `u64` followed by an 8-byte tag is already densely +/// packed with no padding, and avoiding `packed` keeps field access sound. The +/// *generated* `XOnDisk` structs that embed this and then mix in smaller POD +/// fields are the ones that need `packed`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct BlockHeader { + pub size: u64, + pub tag: EightCC, +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index d58a5ac..db6081d 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -4,230 +4,64 @@ //! top of the mainline `bstack` `alloc` primitives ([`bstack::BStackRange`], //! [`bstack::BStackSlice`], [`bstack::BStackOwnedSlice`]). It decouples //! disk-level destruction ([`BStackDrop`]) from Rust's process-scoped `Drop`, -//! providing persistent-storage ownership semantics with C++-style -//! `unique_ptr` / `shared_ptr` / `weak_ptr` conveniences. +//! providing persistent-storage ownership with C++-style `unique_ptr` / +//! `shared_ptr` / `weak_ptr` conveniences. //! -//! The full design lives in `RAII.md` at the repository root. This crate is the -//! implementation of that document. It is intentionally kept as a separate -//! crate (rather than a `bstack` feature flag) because it introduces a large, -//! not-yet-stable ABI surface (block layouts, control blocks, refcounting) that -//! must not gate `bstack`'s own ABI stability. +//! The full design lives in `RAII.md` at the repository root. This crate is its +//! implementation. It is a separate crate (not a `bstack` feature) so that its +//! large, not-yet-stable ABI surface never gates `bstack`'s ABI stability. //! -//! ## Layout of this crate +//! ## Module map (bottom-up) //! -//! * This crate ([`bstack_raii`]) holds the **runtime**: the [`BStackDrop`], -//! [`BStackWeakable`], and [`BStackCast`] traits, the on-disk [`BlockHeader`] -//! / [`EightCC`], the typed handle types ([`BStackRef`], and — once -//! implemented — `BStackOwned` / `BStackRc` / `BStackWeak`), and the small -//! `Copy` child-handle types that carry the recursive/atomic teardown logic. -//! * The nested [`bstack_raii_derive`] proc-macro crate holds the **code -//! generators**: [`macro@bstack_block`], [`bstack_move`], and -//! [`bstack_cast`]. They emit calls into the runtime defined here. +//! | Module | Contents | +//! |----------------|-------------------------------------------------------------| +//! | [`layout`] | On-disk primitives: [`EightCC`], [`BlockHeader`] (both Pod). | +//! | [`reference`] | [`BStackRef`]: typed range wrapper + buffered `OnDisk` read. | +//! | [`teardown`] | [`BStackDrop`] trait + [`dealloc_range`] helper. | +//! | [`block`] | Block-type contracts: [`BStackCast`], [`BStackBlock`], [`BStackWeakable`]. | +//! | [`refcount`] | Little-endian atomic CAS ops over on-disk `u64` counters. | +//! | [`clone`] | [`TryClone`]: fallible clone for handles that touch disk. | +//! | [`handle`] | Without-allocator inner handles: [`OwnedRef`], [`StrongRef`], [`StrongWeakRef`], [`WeakRef`]. | +//! | [`owned`] | [`BStackOwned`]: with-allocator unique handle. | +//! | [`shared`] | [`BStackRc`] + [`BStackWeak`]: with-allocator shared handles.| +//! +//! ## Conventions fixed by the ABI +//! +//! * All on-disk multi-byte integers (refcounts, offsets) are **little-endian**, +//! matching `bstack`'s own on-disk format. +//! * Reads are **buffer-based** (`read_into` + `bytemuck::from_bytes`); there is +//! no zero-copy path without `mmap`. +//! * The whole layer speaks [`std::io::Result`] / [`std::io::Error`], a +//! consequence of binding allocators to [`bstack::BStackOwnedSliceAllocator`] +//! (which pins `Error = io::Error` and `Allocated<'a> = BStackOwnedSlice`). //! //! ## Status //! -//! Scaffold only. The types and traits below establish the shape described in -//! `RAII.md`; method bodies marked `todo!()` are the work to be filled in. - -#![allow(dead_code)] - -use core::marker::PhantomData; -use std::io; - -use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; - -// Re-export the procedural macros so callers only depend on `bstack_raii`. +//! Types and traits are laid out with their final signatures; method bodies +//! marked `todo!()` are the work ahead. Procedural macros +//! ([`macro@bstack_block`], [`bstack_move`], [`bstack_cast`]) come after the +//! runtime is filled in. + +#![allow(dead_code, unused_imports, unused_variables)] + +mod block; +mod clone; +mod handle; +mod layout; +mod owned; +mod refcount; +mod reference; +mod shared; +mod teardown; + +pub use block::{BStackBlock, BStackCast, BStackWeakable}; +pub use clone::TryClone; +pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; +pub use layout::{BlockHeader, EightCC}; +pub use owned::BStackOwned; +pub use reference::BStackRef; +pub use shared::{BStackRc, BStackWeak}; +pub use teardown::{BStackDrop, dealloc_range}; + +// Procedural macros, re-exported so downstream depends only on `bstack_raii`. pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_move}; - -// --------------------------------------------------------------------------- -// On-disk header -// --------------------------------------------------------------------------- - -/// An 8-byte type tag stored in every block header. -/// -/// Used instead of the traditional 4-byte `FourCC` because `bstack` offsets are -/// 64-bit, so 8-byte alignment is natural. Derived from the block type's name at -/// `#[bstack_block]` expansion time and compared during safe downcasts -/// ([`BStackCast`]). -#[repr(transparent)] -#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] -pub struct EightCC(pub [u8; 8]); - -impl EightCC { - /// Construct a tag from a raw 8-byte array. - pub const fn new(tag: [u8; 8]) -> Self { - Self(tag) - } -} - -/// The header prefixing every on-disk block. 16 bytes, `#[repr(C, packed)]`. -/// -/// The `size` field is the payload length in bytes; `tag` is the [`EightCC`] -/// discriminant written by the allocator at block creation. -#[repr(C, packed)] -#[derive(Clone, Copy)] -pub struct BlockHeader { - pub size: u64, - pub tag: EightCC, -} - -// --------------------------------------------------------------------------- -// Typed reference: BStackRef -// --------------------------------------------------------------------------- - -/// A typed, non-owning wrapper over a [`BStackRange`]. -/// -/// Like `BStackRange`, it carries no backing reference and performs no I/O on -/// its own — it is the serialization form of a typed pointer and is `Copy`. -/// Resolving it into a live handle requires an allocator or stack supplied -/// externally. On disk it occupies the same bytes as a `BStackRange`. -#[repr(transparent)] -pub struct BStackRef { - range: BStackRange, - _marker: PhantomData T>, -} - -impl BStackRef { - /// Wrap a raw range as a typed reference. - /// - /// # Safety - /// The caller asserts that `range` refers to a validly allocated block of - /// type `T` (or will, by the time it is resolved). - pub const unsafe fn from_range(range: BStackRange) -> Self { - Self { range, _marker: PhantomData } - } - - /// The underlying untyped range. - pub const fn into_range(self) -> BStackRange { - self.range - } -} - -impl Clone for BStackRef { - fn clone(&self) -> Self { - *self - } -} -impl Copy for BStackRef {} - -// --------------------------------------------------------------------------- -// Core traits -// --------------------------------------------------------------------------- - -/// Disk-level recursive destruction, fully decoupled from Rust's `Drop`. -/// -/// Implemented by every `#[bstack_block]` type (destroys the block and recurses -/// into its owned children) and by the small child-handle types below. Takes -/// `self` (the *without-allocator* handle) plus an explicit allocator, so it is -/// generic over all handle-like types rather than tied to `BStackOwnedSlice`. -/// -/// Because a bare [`BStackRange`] carries no allocator, freeing a block is done -/// by reconstructing a [`BStackOwnedSlice`] from the range via -/// [`BStackOwnedSlice::from_raw_range`] (`unsafe`) and handing it to the -/// allocator's `dealloc` — there is deliberately no `dealloc_range` on the -/// allocator itself; see the free [`dealloc_range`] helper. -/// -/// The allocator is bound to [`BStackOwnedSliceAllocator`] rather than the bare -/// `BStackAllocator`. That supertrait pins `Allocated<'a> = BStackOwnedSlice<'a, -/// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and -/// `Error = io::Error` (so the whole layer speaks [`io::Result`], like the rest -/// of `bstack`). -pub trait BStackDrop: Sized { - fn bstack_drop(self, allocator: &A) -> io::Result<()>; -} - -/// Marker implemented only for blocks declared `#[bstack_block(rc, weak)]`. -/// -/// Its presence is what lets the generated `BStackRc` carry a `ctrl` field -/// and expose `downgrade`/`upgrade`. Plain `#[bstack_block(rc)]` blocks do not -/// implement it, so weak references to them are a compile error rather than a -/// runtime hazard. -pub trait BStackWeakable: BStackDrop { - /// The generated on-disk control-block type (`XOnDiskRef`) holding the - /// `strong`/`weak` counters. - type Control; -} - -/// Downcast discriminant. Implemented by every `#[bstack_block]` type. -/// -/// The returned [`EightCC`] must match the tag in a block's [`BlockHeader`] for -/// a safe downcast to succeed. -pub trait BStackCast { - fn eightcc() -> EightCC; -} - -// --------------------------------------------------------------------------- -// Child handle types (small, Copy, constructed transiently during teardown) -// -// Each maps to one field annotation and encapsulates its own destruction logic -// so that the code generated per block type stays a flat, uniform sequence of -// `.bstack_drop(allocator)?` calls. See RAII.md "Child Handle Types". -// --------------------------------------------------------------------------- - -/// `#[bstack_owned]`: an exclusively-owned child. Teardown recurses via -/// `T::bstack_drop`. -#[derive(Clone, Copy)] -pub struct OwnedRef(pub BStackRef); - -/// `#[bstack_strong]` on a plain `(rc)` `T`. Teardown decrements the inline -/// refcount and calls `T::bstack_drop` at zero. -#[derive(Clone, Copy)] -pub struct StrongRef(pub BStackRef); - -/// `#[bstack_strong]` on an `(rc, weak)` `T`. Holds both the data ref and the -/// control-block ref; teardown runs the two-phase strong-then-weak decrement. -#[derive(Clone, Copy)] -pub struct StrongWeakRef(pub BStackRef, pub BStackRef); - -/// `#[bstack_weak]` on an `(rc, weak)` `T`. Holds only the control-block ref; -/// teardown decrements `ctrl.weak` and frees the control block at zero. The data -/// block is never touched. -#[derive(Clone, Copy)] -pub struct WeakRef(pub BStackRef); - -impl BStackDrop for OwnedRef { - fn bstack_drop(self, _allocator: &A) -> io::Result<()> { - // Recurse into T, then reconstruct a BStackOwnedSlice from the range and - // dealloc it. See RAII.md "Generated bstack_drop". - todo!("recurse T::bstack_drop, then dealloc_range") - } -} - -impl BStackDrop for StrongRef { - fn bstack_drop(self, _allocator: &A) -> io::Result<()> { - todo!("CAS-decrement inline refcount; at zero, T::bstack_drop then dealloc_range") - } -} - -impl BStackDrop for StrongWeakRef { - fn bstack_drop(self, _allocator: &A) -> io::Result<()> { - todo!("decrement ctrl.strong; at zero free data block + release phantom weak") - } -} - -impl BStackDrop for WeakRef { - fn bstack_drop(self, _allocator: &A) -> io::Result<()> { - todo!("decrement ctrl.weak; free control block at zero") - } -} - -/// Free a raw block range by reconstructing an owned slice and delegating to the -/// allocator. Central helper the generated `bstack_drop` code funnels through, -/// since ranges carry no allocator of their own. -/// -/// The [`BStackOwnedSliceAllocator`] bound makes this well-typed: it pins -/// `Allocated<'a> = BStackOwnedSlice<'a, A>` (so the reconstructed slice is the -/// handle `dealloc` accepts) and `Error = io::Error` (so the failure maps -/// straight through `BStackAllocError::source`). -/// -/// # Safety -/// `range` must be a live allocation owned by `allocator` that no other live -/// handle will also free. -pub unsafe fn dealloc_range( - allocator: &A, - range: BStackRange, -) -> io::Result<()> { - let owned: BStackOwnedSlice<'_, A> = - unsafe { BStackOwnedSlice::from_raw_range(allocator, range) }; - allocator.dealloc(owned).map_err(|e| e.source) -} diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs new file mode 100644 index 0000000..39d5cae --- /dev/null +++ b/bstack_raii/src/owned.rs @@ -0,0 +1,56 @@ +//! [`BStackOwned`]: the with-allocator unique handle. +//! +//! A newtype over `(ManuallyDrop, &'a A)`. Rust's `Drop` takes the inner `T` +//! out and calls [`BStackDrop::bstack_drop`]; errors are swallowed, matching the +//! contract of `Drop`. `bstack_move!` consumes it via [`BStackOwned::into_raw_parts`], +//! which defuses this `Drop` so no parallel destruction path exists. + +use core::mem::ManuallyDrop; + +use bstack::BStackOwnedSliceAllocator; + +use crate::teardown::BStackDrop; + +/// An owned, allocator-bound handle to a block whose `Drop` recursively frees it +/// on disk via [`BStackDrop`]. +pub struct BStackOwned<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { + inner: ManuallyDrop, + allocator: &'a A, +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { + /// Wrap an inner handle and allocator into an owned handle. + /// + /// # Safety + /// The caller asserts `inner` describes a live allocation owned by + /// `allocator` and that no other handle will also free it. + pub unsafe fn from_raw(inner: T, allocator: &'a A) -> Self { + Self { + inner: ManuallyDrop::new(inner), + allocator, + } + } + + /// Split into the raw inner handle and allocator **without** running the + /// disk-level `Drop`. This is the destructuring entry point `bstack_move!` + /// uses; the caller takes over responsibility for the allocation. + pub fn into_raw_parts(self) -> (T, &'a A) { + // Wrapping `self` in ManuallyDrop prevents our own `Drop` from running, + // so `bstack_drop` is not called; then move the inner `T` out. + let mut me = ManuallyDrop::new(self); + let inner = unsafe { ManuallyDrop::take(&mut me.inner) }; + (inner, me.allocator) + } + + /// The allocator this handle is bound to. + pub fn allocator(&self) -> &'a A { + self.allocator + } +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for BStackOwned<'a, T, A> { + fn drop(&mut self) { + let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; + let _ = inner.bstack_drop(self.allocator); + } +} diff --git a/bstack_raii/src/refcount.rs b/bstack_raii/src/refcount.rs new file mode 100644 index 0000000..7ff2de7 --- /dev/null +++ b/bstack_raii/src/refcount.rs @@ -0,0 +1,34 @@ +//! Atomic operations on on-disk `u64` counters, built on [`bstack::BStack::cas`]. +//! +//! Every counter is stored **little-endian** (fixed by the `bstack` ABI). Each +//! function takes the absolute byte offset of the counter within the stack +//! payload. `cas` gives a byte-range compare-and-swap; the increment/decrement +//! helpers wrap it in a read-modify-CAS retry loop. + +use std::io; + +use bstack::BStack; + +/// Load the current value of the counter at `offset` (little-endian). +pub fn load(stack: &BStack, offset: u64) -> io::Result { + todo!("read 8 LE bytes at offset") +} + +/// Atomically add `delta`, returning the previous value. Retries on contention. +pub fn fetch_add(stack: &BStack, offset: u64, delta: u64) -> io::Result { + todo!("read-modify-CAS loop: load, cas(old -> old + delta), retry on mismatch") +} + +/// Atomically subtract `delta`, returning the previous value. Retries on +/// contention. Callers must ensure the counter never underflows. +pub fn fetch_sub(stack: &BStack, offset: u64, delta: u64) -> io::Result { + todo!("read-modify-CAS loop: load, cas(old -> old - delta), retry on mismatch") +} + +/// Increment the counter only if it is currently non-zero, returning the new +/// value on success or `None` if it was zero. This is the primitive behind +/// [`crate::BStackWeak::upgrade`]: it must not resurrect a counter that a +/// concurrent drop has already driven to zero. +pub fn increment_if_nonzero(stack: &BStack, offset: u64) -> io::Result> { + todo!("read-modify-CAS loop, bailing out with None when the loaded value is 0") +} diff --git a/bstack_raii/src/reference.rs b/bstack_raii/src/reference.rs new file mode 100644 index 0000000..c00d46d --- /dev/null +++ b/bstack_raii/src/reference.rs @@ -0,0 +1,76 @@ +//! [`BStackRef`]: a typed, non-owning wrapper over a [`bstack::BStackRange`]. + +use core::marker::PhantomData; +use std::io; + +use bstack::{BStack, BStackRange, BStackSlice}; + +use crate::block::BStackBlock; + +/// A typed reference to a block of type `T`. +/// +/// Like [`BStackRange`], it carries no backing reference and performs no I/O of +/// its own — it is the serialization form of a typed pointer and is `Copy`. +/// Resolving it into a live handle requires an allocator or stack supplied +/// externally. +/// +/// The in-memory form wraps a [`BStackRange`]. The *on-disk* encoding of a ref +/// (little-endian, fixed width) is a separate `Pod` representation the macro +/// emits inside `XOnDisk`; it is not this type, because `BStackRange` is not +/// itself `bytemuck::Pod`. +#[repr(transparent)] +pub struct BStackRef { + range: BStackRange, + _marker: PhantomData T>, +} + +impl BStackRef { + /// Wrap a raw range as a typed reference. + /// + /// # Safety + /// The caller asserts `range` refers to a validly allocated block of type + /// `T` (or will, by the time it is resolved). + pub const unsafe fn from_range(range: BStackRange) -> Self { + Self { + range, + _marker: PhantomData, + } + } + + /// The underlying untyped range. + pub const fn into_range(self) -> BStackRange { + self.range + } + + /// Reinterpret this reference as pointing at a different type `U`, keeping + /// the same range. Used to move between a data ref and its control-block ref. + /// + /// # Safety + /// The caller asserts the range is valid for `U`. + pub const unsafe fn cast(self) -> BStackRef { + BStackRef { + range: self.range, + _marker: PhantomData, + } + } +} + +impl BStackRef { + /// Read this block's on-disk payload into `buf` and reinterpret it. + /// + /// Buffer-based (no zero-copy without `mmap`): `buf` must be at least + /// `size_of::()` bytes. The returned reference borrows `buf`. + pub fn read_on_disk<'b>(self, stack: &BStack, buf: &'b mut [u8]) -> io::Result<&'b T::OnDisk> { + // let slice = unsafe { BStackSlice::from_raw_range(stack, self.range) }; + // slice.read_into(&mut buf[..size_of::()])?; + // Ok(bytemuck::from_bytes(&buf[..size_of::()])) + todo!("read_into buf via BStackSlice, then bytemuck::from_bytes") + } +} + +impl Clone for BStackRef { + fn clone(&self) -> Self { + *self + } +} +impl Copy for BStackRef {} diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs new file mode 100644 index 0000000..40251a7 --- /dev/null +++ b/bstack_raii/src/shared.rs @@ -0,0 +1,92 @@ +//! [`BStackRc`] + [`BStackWeak`]: the with-allocator shared handles. + +use std::io; + +use bstack::{BStackOwnedSliceAllocator, BStackRange}; + +use crate::block::{BStackBlock, BStackWeakable}; +use crate::clone::TryClone; +use crate::reference::BStackRef; + +/// A shared, refcounted, allocator-bound handle. +/// +/// Serves **both** block kinds. `ctrl` distinguishes them at runtime: +/// * `None` — a plain `#[bstack_block(rc)]` block, whose refcount lives inline +/// in the data block. +/// * `Some(range)` — an `#[bstack_block(rc, weak)]` block, whose `strong`/`weak` +/// counters live in a separate control block at `range`. +/// +/// Carrying this as a runtime `Option` (rather than a type-level split via an +/// associated `Strong` handle) keeps `BStackRc<'a, T, A>`'s public signature +/// fixed at three parameters. If a zero-cost, branch-free representation is +/// wanted later, it can be introduced behind this same signature without +/// breaking callers. Freeing at zero reconstructs the appropriate inner handle +/// ([`crate::StrongRef`] / [`crate::StrongWeakRef`]) — note the `Some` path only +/// needs `T: BStackBlock` (it drives the raw control-block counters directly), +/// so `BStackRc` need not bound `T: BStackWeakable`. +pub struct BStackRc<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { + data: BStackRef, + ctrl: Option, + allocator: &'a A, +} + +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { + fn try_clone(&self) -> io::Result { + // Increment the strong count (inline for `None`, `ctrl.strong` for + // `Some`), then return a new handle over the same refs + allocator. + todo!("atomically increment strong count, then clone the handle") + } +} + +impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { + /// Create a weak handle to the same block by incrementing `ctrl.weak`. + /// + /// Available only for `(rc, weak)` blocks (`T: BStackWeakable`), so a plain + /// `(rc)` block's `BStackRc` has no `downgrade` at all — a compile error, not + /// a runtime hazard. + pub fn downgrade(&self) -> io::Result> { + todo!("increment ctrl.weak; wrap the control ref into a BStackWeak") + } +} + +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> Drop for BStackRc<'a, T, A> { + fn drop(&mut self) { + // `None`: StrongRef(self.data).bstack_drop(..). + // `Some(ctrl)`: raw two-phase release driving ctrl.strong/ctrl.weak, + // using T::bstack_drop for the data block (no T: BStackWeakable needed). + todo!("decrement strong count; free at zero per block kind") + } +} + +/// A non-owning weak handle to an `(rc, weak)` block's control block. +/// +/// Obtained from [`BStackRc::downgrade`] or [`TryClone::try_clone`]. It keeps the +/// control block alive (so [`upgrade`](BStackWeak::upgrade) can check liveness) +/// but never pins the data block. +pub struct BStackWeak<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { + ctrl: BStackRef, + allocator: &'a A, +} + +impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { + /// Attempt to promote to a strong handle. Succeeds iff `ctrl.strong` is + /// currently non-zero (CAS-increment-if-nonzero), reading `ctrl.x` to + /// recover the data ref. Returns `None` if the data block is already gone. + pub fn upgrade(&self) -> io::Result>> { + todo!("CAS-increment ctrl.strong if nonzero; on success build a BStackRc") + } +} + +impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWeak<'a, T, A> { + fn try_clone(&self) -> io::Result { + todo!("increment ctrl.weak, then clone the handle") + } +} + +impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> Drop for BStackWeak<'a, T, A> { + fn drop(&mut self) { + // WeakRef(self.ctrl).bstack_drop(self.allocator): decrement ctrl.weak, + // free the control block at zero. + todo!("decrement ctrl.weak; free control block at zero") + } +} diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs new file mode 100644 index 0000000..d741a14 --- /dev/null +++ b/bstack_raii/src/teardown.rs @@ -0,0 +1,41 @@ +//! Disk-level recursive destruction, fully decoupled from Rust's `Drop`. +//! +//! [`BStackDrop`] is implemented by every `#[bstack_block]` type (frees the +//! block and recurses into its owned children) and by the small child-handle +//! types in [`crate::handle`]. It takes `self` (a *without-allocator* handle) +//! plus an explicit allocator, so it is generic over all handle-like types. + +use std::io; + +use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; + +/// Recursively free a block and all of its owned children. +/// +/// Because a bare [`BStackRange`] carries no allocator, freeing is done by +/// reconstructing a [`BStackOwnedSlice`] and handing it to the allocator's +/// `dealloc` — see [`dealloc_range`]. There is deliberately no `dealloc_range` +/// method on the allocator trait itself. +/// +/// The allocator is bound to [`BStackOwnedSliceAllocator`] rather than the bare +/// `BStackAllocator`: that supertrait pins `Allocated<'a> = BStackOwnedSlice<'a, +/// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and +/// `Error = io::Error` (so the layer speaks [`io::Result`]). +pub trait BStackDrop: Sized { + fn bstack_drop(self, allocator: &A) -> io::Result<()>; +} + +/// Free a raw block range by reconstructing an owned slice and delegating to the +/// allocator. The central sink the generated `bstack_drop` code funnels through, +/// since ranges carry no allocator of their own. +/// +/// # Safety +/// `range` must be a live allocation owned by `allocator` that no other live +/// handle will also free. +pub unsafe fn dealloc_range( + allocator: &A, + range: BStackRange, +) -> io::Result<()> { + let owned: BStackOwnedSlice<'_, A> = + unsafe { BStackOwnedSlice::from_raw_range(allocator, range) }; + allocator.dealloc(owned).map_err(|e| e.source) +} From 513cfb111180cd2ccf357dc3e761d8a4cd69f206 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 05:31:55 -0700 Subject: [PATCH 004/140] Refcount --- bstack_raii/src/refcount.rs | 106 +++++++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/bstack_raii/src/refcount.rs b/bstack_raii/src/refcount.rs index 7ff2de7..b0bae4f 100644 --- a/bstack_raii/src/refcount.rs +++ b/bstack_raii/src/refcount.rs @@ -1,34 +1,112 @@ -//! Atomic operations on on-disk `u64` counters, built on [`bstack::BStack::cas`]. +//! Atomic operations on on-disk `u64` counters, built on [`bstack::BStack`]. //! //! Every counter is stored **little-endian** (fixed by the `bstack` ABI). Each -//! function takes the absolute byte offset of the counter within the stack -//! payload. `cas` gives a byte-range compare-and-swap; the increment/decrement -//! helpers wrap it in a read-modify-CAS retry loop. +//! function takes the absolute payload offset of the counter within the stack. +//! +//! The read-modify-write helpers use [`BStack::process`], which reads the +//! counter, runs a closure to mutate it in place, and writes it back — all under +//! one held write lock, crash-atomically. That does the whole RMW in a single +//! lock acquisition with **no compare-and-swap spin loop**. (`BStack::cas` would +//! also be correct here, since each counter's dangerous value — zero — is a sink +//! state and so immune to ABA; `process` is preferred purely to avoid retrying.) +//! +//! Because `process`'s closure returns `()` and always writes the buffer back, +//! the error paths (overflow / underflow) signal out through a captured flag and +//! leave the buffer *unchanged*, so the write-back is a no-op on those paths. use std::io; use bstack::BStack; -/// Load the current value of the counter at `offset` (little-endian). +fn read_u64(buf: &[u8]) -> u64 { + u64::from_le_bytes(buf[..8].try_into().unwrap()) +} + +fn overflow_err() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "refcount overflow") +} + +fn underflow_err() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "refcount underflow") +} + +/// Load the current value of the counter at `offset` (little-endian). Read-only, +/// so it takes only `get_into` (no lock upgrade, no write-back). pub fn load(stack: &BStack, offset: u64) -> io::Result { - todo!("read 8 LE bytes at offset") + let mut bytes = [0u8; 8]; + stack.get_into(offset, &mut bytes)?; + Ok(u64::from_le_bytes(bytes)) } -/// Atomically add `delta`, returning the previous value. Retries on contention. +/// Atomically add `delta`, returning the previous value. Errors on overflow +/// rather than wrapping (leaving the counter unchanged in that case). pub fn fetch_add(stack: &BStack, offset: u64, delta: u64) -> io::Result { - todo!("read-modify-CAS loop: load, cas(old -> old + delta), retry on mismatch") + let mut prev = 0u64; + let mut overflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = read_u64(buf); + prev = cur; + match cur.checked_add(delta) { + Some(new) => buf.copy_from_slice(&new.to_le_bytes()), + None => overflow = true, // leave buf unchanged; report below + } + })?; + if overflow { + return Err(overflow_err()); + } + Ok(prev) } -/// Atomically subtract `delta`, returning the previous value. Retries on -/// contention. Callers must ensure the counter never underflows. +/// Atomically subtract `delta`, returning the previous value. Errors on +/// underflow rather than wrapping (leaving the counter unchanged in that case). pub fn fetch_sub(stack: &BStack, offset: u64, delta: u64) -> io::Result { - todo!("read-modify-CAS loop: load, cas(old -> old - delta), retry on mismatch") + let mut prev = 0u64; + let mut underflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = read_u64(buf); + prev = cur; + match cur.checked_sub(delta) { + Some(new) => buf.copy_from_slice(&new.to_le_bytes()), + None => underflow = true, // leave buf unchanged; report below + } + })?; + if underflow { + return Err(underflow_err()); + } + Ok(prev) } /// Increment the counter only if it is currently non-zero, returning the new -/// value on success or `None` if it was zero. This is the primitive behind -/// [`crate::BStackWeak::upgrade`]: it must not resurrect a counter that a +/// value on success or `None` if it was zero. The primitive behind +/// [`crate::BStackWeak::upgrade`]: it must never resurrect a counter that a /// concurrent drop has already driven to zero. +/// +/// A read-only fast path returns `None` without any write when the counter is +/// already zero (the common "the object is long dead" case); zero is terminal, +/// so that observation is authoritative. When the fast path sees non-zero, the +/// `process` closure re-checks under the lock — the value may have raced to zero +/// in between — before committing the increment. pub fn increment_if_nonzero(stack: &BStack, offset: u64) -> io::Result> { - todo!("read-modify-CAS loop, bailing out with None when the loaded value is 0") + if load(stack, offset)? == 0 { + return Ok(None); + } + let mut result = None; + let mut overflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = read_u64(buf); + if cur == 0 { + return; // raced to zero after the fast-path read; leave unchanged + } + match cur.checked_add(1) { + Some(new) => { + buf.copy_from_slice(&new.to_le_bytes()); + result = Some(new); + } + None => overflow = true, + } + })?; + if overflow { + return Err(overflow_err()); + } + Ok(result) } From 7f05a50d2c5147e5fbdf6d3ca3639f4fffbf71c5 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 05:34:44 -0700 Subject: [PATCH 005/140] Make teardown logic and layout --- bstack_raii/src/handle.rs | 98 ++++++++++++++++++++++++------------ bstack_raii/src/layout.rs | 51 +++++++++++++++++++ bstack_raii/src/reference.rs | 19 +++++-- 3 files changed, 132 insertions(+), 36 deletions(-) diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 8fcbe7f..9ccfed3 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -7,30 +7,22 @@ //! ([`crate::BStackOwned`], [`crate::BStackRc`], [`crate::BStackWeak`]) hold one //! of these plus an allocator reference. //! -//! ## Open design note — what generic teardown needs from a block type -//! -//! The `todo!()` bodies below all need layout facts that only the concrete block -//! type knows, and which the macro can generate. Before implementing them, we -//! must decide how the block-type traits expose: -//! -//! * **Plain `(rc)`**: the byte offset of the inline `refcount` within `OnDisk` -//! (for [`StrongRef`]). -//! * **`(rc, weak)`**: the `ctrl` back-pointer range out of the data `OnDisk`, -//! the `x` forward-pointer range out of the `Control` block, and the byte -//! offsets of `strong` / `weak` within `Control` (for [`StrongWeakRef`] / -//! [`WeakRef`] / `upgrade`). -//! -//! These are additive trait members (associated `const`s + small accessors) on -//! [`crate::BStackBlock`] / [`crate::BStackWeakable`]; they are intentionally -//! left out until we settle the shape, to avoid baking in surface prematurely. +//! All the layout facts these teardowns need are constants in [`crate::layout`] +//! (the injected refcount / control fields sit at fixed offsets after the +//! header, per RAII.md) plus the `OnDisk` / `Control` sizes from +//! [`BStackBlock`] / [`BStackWeakable`]. No per-type layout members are +//! required. +use core::mem::size_of; use std::io; -use bstack::BStackOwnedSliceAllocator; +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackWeakable}; +use crate::layout; use crate::reference::BStackRef; -use crate::teardown::BStackDrop; +use crate::refcount; +use crate::teardown::{dealloc_range, BStackDrop}; /// `#[bstack_owned]`: an exclusively-owned child. #[derive(Clone, Copy)] @@ -38,6 +30,10 @@ pub struct OwnedRef(pub BStackRef); /// `#[bstack_strong]` on a plain `(rc)` `T`: holds just the data ref; teardown /// decrements the inline refcount and frees at zero. +/// +/// The macro only emits this for children whose type is `#[bstack_block(rc)]`, +/// so the inline `refcount` at [`layout::RC_REFCOUNT_OFFSET`] is guaranteed +/// present; the type system does not otherwise enforce it. #[derive(Clone, Copy)] pub struct StrongRef(pub BStackRef); @@ -52,6 +48,21 @@ pub struct StrongWeakRef(pub BStackRef, pub BStackRef(pub BStackRef); +/// Read a data block's `ctrl` back-pointer (a `u64` offset at +/// [`layout::CTRL_BACKPTR_OFFSET`]) and resolve it to a typed control ref, +/// recovering the control block's length from `size_of::()`. +fn read_ctrl_ref( + data_ref: BStackRef, + allocator: &A, +) -> io::Result> { + let pos = data_ref.into_range().start() + layout::CTRL_BACKPTR_OFFSET; + let mut bytes = [0u8; 8]; + allocator.stack().get_into(pos, &mut bytes)?; + let ctrl_offset = u64::from_le_bytes(bytes); + let ctrl_range = BStackRange::new(ctrl_offset, size_of::() as u64); + Ok(unsafe { BStackRef::from_range(ctrl_range) }) +} + impl BStackDrop for OwnedRef { fn bstack_drop(self, allocator: &A) -> io::Result<()> { // An owned child is freed by running the block's own recursive teardown, @@ -62,45 +73,68 @@ impl BStackDrop for OwnedRef { impl BStackDrop for StrongRef { fn bstack_drop(self, allocator: &A) -> io::Result<()> { - // CAS-decrement the inline refcount; at zero, run T's teardown. - todo!("decrement inline refcount (needs refcount offset); free at zero") + let data_range = self.0.into_range(); + let off = data_range.start() + layout::RC_REFCOUNT_OFFSET; + // Decrement the inline refcount; only the last owner frees the block. + if refcount::fetch_sub(allocator.stack(), off, 1)? == 1 { + T::from_range(data_range).bstack_drop(allocator)?; + } + Ok(()) } } impl StrongWeakRef { - /// Resolve the control ref from the data block's on-disk `ctrl` back-pointer - /// with a single read, then pair it with the data ref. + /// Resolve the control ref from the data block's `ctrl` back-pointer with a + /// single read, then pair it with the data ref. pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { - todo!("read T::OnDisk, extract ctrl back-pointer, pair with data_ref") + let ctrl = read_ctrl_ref(data_ref, allocator)?; + Ok(StrongWeakRef(data_ref, ctrl)) } } impl BStackDrop for StrongWeakRef { fn bstack_drop(self, allocator: &A) -> io::Result<()> { - // Phase 1: decrement ctrl.strong. At zero, free the data block (children - // + shell), then release the phantom weak by decrementing ctrl.weak; - // if that hits zero, free the control block too. - todo!("two-phase strong release; see RAII.md 'Two-Phase Teardown'") + let stack = allocator.stack(); + let data_range = self.0.into_range(); + let ctrl_range = self.1.into_range(); + let strong_off = ctrl_range.start() + layout::CTRL_STRONG_OFFSET; + // Phase 1: last strong owner frees the data block (children + shell), + // then releases the phantom weak the strong owners collectively held. + if refcount::fetch_sub(stack, strong_off, 1)? == 1 { + T::from_range(data_range).bstack_drop(allocator)?; + let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; + // Phase 2 (early): if no real weak handles remain, the phantom + // release drives weak to zero and the control block is freed here. + if refcount::fetch_sub(stack, weak_off, 1)? == 1 { + unsafe { dealloc_range(allocator, ctrl_range)? }; + } + } + Ok(()) } } impl WeakRef { - /// Resolve the control ref from the data block's on-disk `ctrl` back-pointer - /// with a single read. + /// Resolve the control ref from the data block's `ctrl` back-pointer. pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { - todo!("read T::OnDisk, extract ctrl back-pointer") + Ok(WeakRef(read_ctrl_ref(data_ref, allocator)?)) } } impl BStackDrop for WeakRef { fn bstack_drop(self, allocator: &A) -> io::Result<()> { - // Decrement ctrl.weak; free the control block when it reaches zero. - todo!("decrement ctrl.weak; free control block at zero") + let ctrl_range = self.0.into_range(); + let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; + // Decrement ctrl.weak; free the control block when the last weak handle + // (or the phantom) drops it to zero. The data block is never touched. + if refcount::fetch_sub(allocator.stack(), weak_off, 1)? == 1 { + unsafe { dealloc_range(allocator, ctrl_range)? }; + } + Ok(()) } } diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index 50c91df..5a1b9a8 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -49,3 +49,54 @@ pub struct BlockHeader { pub size: u64, pub tag: EightCC, } + +/// Byte length of a [`BlockHeader`] — the offset at which a block's payload +/// begins. +pub const HEADER_SIZE: u64 = core::mem::size_of::() as u64; + +/// On-disk width of a reference. Per RAII.md, an on-disk `BStackRef` stores +/// only the `u64` offset; the length is recovered at resolve time from the +/// target type's fixed `size_of::()`. (This is why the RAII layer is, +/// for now, a fixed-size-block model.) +pub const REF_SIZE: u64 = 8; + +// -- Injected-field offsets ------------------------------------------------ +// +// RAII.md injects the refcount / control back-pointer / control counters +// immediately after the header, ahead of any user fields and in a fixed order. +// Their offsets are therefore the same for *every* block, so they live here as +// constants rather than as per-type trait members. + +/// `#[bstack_block(rc)]` data block: offset of the inline `refcount: AtomicU64`, +/// injected right after the header. +/// +/// ```text +/// struct XOnDisk { header, refcount: AtomicU64, } +/// ``` +pub const RC_REFCOUNT_OFFSET: u64 = HEADER_SIZE; + +/// `#[bstack_block(rc, weak)]` data block: offset of the `ctrl` back-pointer to +/// the control block, injected right after the header. +/// +/// ```text +/// struct XOnDisk { header, ctrl: BStackRef, } +/// ``` +pub const CTRL_BACKPTR_OFFSET: u64 = HEADER_SIZE; + +/// `#[bstack_block(rc, weak)]` control block (`XOnDiskRef`): offset of `strong`. +/// +/// ```text +/// struct XOnDiskRef { header, strong: AtomicU64, weak: AtomicU64, x: BStackRef } +/// ``` +pub const CTRL_STRONG_OFFSET: u64 = HEADER_SIZE; + +/// Control block: offset of `weak` (starts at 1 — the phantom weak held +/// collectively by all live strong owners). +pub const CTRL_WEAK_OFFSET: u64 = HEADER_SIZE + 8; + +/// Control block: offset of `x`, the forward pointer back to the data block. +/// Read by [`crate::BStackWeak::upgrade`] once it wins the strong CAS. +pub const CTRL_DATA_OFFSET: u64 = HEADER_SIZE + 16; + +// Guard the hand-derived offsets against a header size change. +const _: () = assert!(HEADER_SIZE == 16); diff --git a/bstack_raii/src/reference.rs b/bstack_raii/src/reference.rs index c00d46d..0296b11 100644 --- a/bstack_raii/src/reference.rs +++ b/bstack_raii/src/reference.rs @@ -61,10 +61,21 @@ impl BStackRef { /// Buffer-based (no zero-copy without `mmap`): `buf` must be at least /// `size_of::()` bytes. The returned reference borrows `buf`. pub fn read_on_disk<'b>(self, stack: &BStack, buf: &'b mut [u8]) -> io::Result<&'b T::OnDisk> { - // let slice = unsafe { BStackSlice::from_raw_range(stack, self.range) }; - // slice.read_into(&mut buf[..size_of::()])?; - // Ok(bytemuck::from_bytes(&buf[..size_of::()])) - todo!("read_into buf via BStackSlice, then bytemuck::from_bytes") + let size = core::mem::size_of::(); + if buf.len() < size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("read_on_disk: buffer of {} < OnDisk size {size}", buf.len()), + )); + } + let dst = &mut buf[..size]; + // `OnDisk` is `#[repr(C, packed)]` (alignment 1), so any buffer address is + // adequately aligned and `from_bytes` will not panic on alignment. + // `read_into` fills `min(dst.len(), block.len())`; for a fixed-size block + // those are equal. + let slice = unsafe { BStackSlice::from_raw_range(stack, self.range) }; + slice.read_into(dst)?; + Ok(bytemuck::from_bytes(dst)) } } From 9ef3424f21f81de8378b549364b073a779798140 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 05:41:25 -0700 Subject: [PATCH 006/140] Refactor handle logic and write strong release logic --- bstack_raii/src/handle.rs | 43 +++++++++------ bstack_raii/src/shared.rs | 108 ++++++++++++++++++++++++++++++-------- 2 files changed, 113 insertions(+), 38 deletions(-) diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 9ccfed3..06df2f8 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -95,24 +95,35 @@ impl StrongWeakRef { } } +/// The two-phase strong release for an `(rc, weak)` block, given raw data and +/// control ranges. Requires only `T: BStackBlock` (for the data block's own +/// recursive teardown), so it is shared by both [`StrongWeakRef::bstack_drop`] +/// and [`crate::BStackRc`]'s `Drop` — the latter carries `T: BStackBlock` and so +/// cannot construct a `StrongWeakRef` (which needs `BStackWeakable`) itself. +pub(crate) fn strong_release_ctrl( + allocator: &A, + data_range: BStackRange, + ctrl_range: BStackRange, +) -> io::Result<()> { + let stack = allocator.stack(); + let strong_off = ctrl_range.start() + layout::CTRL_STRONG_OFFSET; + // Phase 1: last strong owner frees the data block (children + shell), then + // releases the phantom weak the strong owners collectively held. + if refcount::fetch_sub(stack, strong_off, 1)? == 1 { + T::from_range(data_range).bstack_drop(allocator)?; + let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; + // Phase 2 (early): if no real weak handles remain, the phantom release + // drives weak to zero and the control block is freed here. + if refcount::fetch_sub(stack, weak_off, 1)? == 1 { + unsafe { dealloc_range(allocator, ctrl_range)? }; + } + } + Ok(()) +} + impl BStackDrop for StrongWeakRef { fn bstack_drop(self, allocator: &A) -> io::Result<()> { - let stack = allocator.stack(); - let data_range = self.0.into_range(); - let ctrl_range = self.1.into_range(); - let strong_off = ctrl_range.start() + layout::CTRL_STRONG_OFFSET; - // Phase 1: last strong owner frees the data block (children + shell), - // then releases the phantom weak the strong owners collectively held. - if refcount::fetch_sub(stack, strong_off, 1)? == 1 { - T::from_range(data_range).bstack_drop(allocator)?; - let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; - // Phase 2 (early): if no real weak handles remain, the phantom - // release drives weak to zero and the control block is freed here. - if refcount::fetch_sub(stack, weak_off, 1)? == 1 { - unsafe { dealloc_range(allocator, ctrl_range)? }; - } - } - Ok(()) + strong_release_ctrl::(allocator, self.0.into_range(), self.1.into_range()) } } diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 40251a7..144a048 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -1,40 +1,72 @@ //! [`BStackRc`] + [`BStackWeak`]: the with-allocator shared handles. +use core::mem::size_of; use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackWeakable}; use crate::clone::TryClone; +use crate::handle::{strong_release_ctrl, StrongRef, WeakRef}; +use crate::layout; use crate::reference::BStackRef; +use crate::refcount; +use crate::teardown::BStackDrop; /// A shared, refcounted, allocator-bound handle. /// /// Serves **both** block kinds. `ctrl` distinguishes them at runtime: /// * `None` — a plain `#[bstack_block(rc)]` block, whose refcount lives inline -/// in the data block. +/// in the data block at [`layout::RC_REFCOUNT_OFFSET`]. /// * `Some(range)` — an `#[bstack_block(rc, weak)]` block, whose `strong`/`weak` /// counters live in a separate control block at `range`. /// /// Carrying this as a runtime `Option` (rather than a type-level split via an /// associated `Strong` handle) keeps `BStackRc<'a, T, A>`'s public signature -/// fixed at three parameters. If a zero-cost, branch-free representation is -/// wanted later, it can be introduced behind this same signature without -/// breaking callers. Freeing at zero reconstructs the appropriate inner handle -/// ([`crate::StrongRef`] / [`crate::StrongWeakRef`]) — note the `Some` path only -/// needs `T: BStackBlock` (it drives the raw control-block counters directly), -/// so `BStackRc` need not bound `T: BStackWeakable`. +/// fixed at three parameters; a zero-cost representation can replace it later +/// without breaking callers. Freeing at zero reuses [`StrongRef`] (the `None` +/// path) or [`strong_release_ctrl`] (the `Some` path) — the latter needs only +/// `T: BStackBlock`, so `BStackRc` need not bound `T: BStackWeakable`. +/// +/// **Invariant:** for a `T: BStackWeakable` block, `ctrl` is always `Some` — such +/// blocks are only ever constructed through the control-block paths +/// ([`BStackWeak::upgrade`], `bstack_move!`). `downgrade` relies on this. pub struct BStackRc<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { data: BStackRef, ctrl: Option, allocator: &'a A, } +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { + /// Reconstruct a shared handle from its raw parts. + /// + /// # Safety + /// The refs must describe a live `(rc)` / `(rc, weak)` block owned by + /// `allocator`, and this handle must account for a strong count the caller + /// has already established (e.g. the allocation's initial `strong = 1`, or a + /// count bumped by `upgrade`). `ctrl` must be `Some` iff the block is + /// `(rc, weak)`. + pub unsafe fn from_raw( + data: BStackRef, + ctrl: Option, + allocator: &'a A, + ) -> Self { + Self { data, ctrl, allocator } + } + + /// Byte offset of the strong counter for this handle's block kind. + fn strong_offset(&self) -> u64 { + match self.ctrl { + None => self.data.into_range().start() + layout::RC_REFCOUNT_OFFSET, + Some(ctrl) => ctrl.start() + layout::CTRL_STRONG_OFFSET, + } + } +} + impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { - // Increment the strong count (inline for `None`, `ctrl.strong` for - // `Some`), then return a new handle over the same refs + allocator. - todo!("atomically increment strong count, then clone the handle") + refcount::fetch_add(self.allocator.stack(), self.strong_offset(), 1)?; + Ok(Self { data: self.data, ctrl: self.ctrl, allocator: self.allocator }) } } @@ -45,16 +77,26 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { /// `(rc)` block's `BStackRc` has no `downgrade` at all — a compile error, not /// a runtime hazard. pub fn downgrade(&self) -> io::Result> { - todo!("increment ctrl.weak; wrap the control ref into a BStackWeak") + // Invariant: a weakable block's `BStackRc` always carries a control ref. + let ctrl_range = self + .ctrl + .expect("BStackRc always has a control block"); + let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; + refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; + let ctrl = unsafe { BStackRef::::from_range(ctrl_range) }; + Ok(BStackWeak { ctrl, allocator: self.allocator }) } } impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> Drop for BStackRc<'a, T, A> { fn drop(&mut self) { - // `None`: StrongRef(self.data).bstack_drop(..). - // `Some(ctrl)`: raw two-phase release driving ctrl.strong/ctrl.weak, - // using T::bstack_drop for the data block (no T: BStackWeakable needed). - todo!("decrement strong count; free at zero per block kind") + // Errors are swallowed, matching the contract of Rust's `Drop`. + let _ = match self.ctrl { + None => StrongRef(self.data).bstack_drop(self.allocator), + Some(ctrl) => { + strong_release_ctrl::(self.allocator, self.data.into_range(), ctrl) + } + }; } } @@ -69,24 +111,46 @@ pub struct BStackWeak<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { } impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { + /// Reconstruct a weak handle from its raw control ref. + /// + /// # Safety + /// `ctrl` must describe a live control block owned by `allocator`, and this + /// handle must account for a weak count the caller has already established. + pub unsafe fn from_raw(ctrl: BStackRef, allocator: &'a A) -> Self { + Self { ctrl, allocator } + } + /// Attempt to promote to a strong handle. Succeeds iff `ctrl.strong` is - /// currently non-zero (CAS-increment-if-nonzero), reading `ctrl.x` to - /// recover the data ref. Returns `None` if the data block is already gone. + /// currently non-zero (CAS-increment-if-nonzero), reading `ctrl.x` to recover + /// the data ref. Returns `None` if the data block is already gone. pub fn upgrade(&self) -> io::Result>> { - todo!("CAS-increment ctrl.strong if nonzero; on success build a BStackRc") + let stack = self.allocator.stack(); + let ctrl_range = self.ctrl.into_range(); + let strong_off = ctrl_range.start() + layout::CTRL_STRONG_OFFSET; + if refcount::increment_if_nonzero(stack, strong_off)?.is_none() { + return Ok(None); + } + // Strong is now claimed; recover the data ref from the forward pointer. + let data_pos = ctrl_range.start() + layout::CTRL_DATA_OFFSET; + let mut bytes = [0u8; 8]; + stack.get_into(data_pos, &mut bytes)?; + let data_range = BStackRange::new(u64::from_le_bytes(bytes), size_of::() as u64); + let data = unsafe { BStackRef::::from_range(data_range) }; + Ok(Some(BStackRc { data, ctrl: Some(ctrl_range), allocator: self.allocator })) } } impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWeak<'a, T, A> { fn try_clone(&self) -> io::Result { - todo!("increment ctrl.weak, then clone the handle") + let weak_off = self.ctrl.into_range().start() + layout::CTRL_WEAK_OFFSET; + refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; + Ok(Self { ctrl: self.ctrl, allocator: self.allocator }) } } impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> Drop for BStackWeak<'a, T, A> { fn drop(&mut self) { - // WeakRef(self.ctrl).bstack_drop(self.allocator): decrement ctrl.weak, - // free the control block at zero. - todo!("decrement ctrl.weak; free control block at zero") + // Decrement ctrl.weak; free the control block at zero. Errors swallowed. + let _ = WeakRef::(self.ctrl).bstack_drop(self.allocator); } } From 78a611d670eec4b1f6fd2f15ffd560b147f37c4d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 21:06:37 -0700 Subject: [PATCH 007/140] Add concurrency tests --- bstack_raii/src/tests.rs | 286 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 bstack_raii/src/tests.rs diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs new file mode 100644 index 0000000..71353ac --- /dev/null +++ b/bstack_raii/src/tests.rs @@ -0,0 +1,286 @@ +//! Runtime tests against a real `BStack` + `FirstFitBStackAllocator`. +//! +//! These stand in for the (not-yet-written) `#[bstack_block]` macro by defining +//! a block type *by hand* — exactly the shape the macro will generate — and +//! exercising the refcount / two-phase-teardown machinery end to end. + +use core::mem::size_of; +use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, FirstFitBStackAllocator}; + +use crate::layout::{self, BlockHeader}; +use crate::{ + alloc_block, alloc_control, dealloc_range, BStackBlock, BStackCast, BStackDrop, BStackRc, + BStackRef, BStackWeakable, EightCC, TryClone, +}; + +// -------------------------------------------------------------------------- +// Temp-file harness +// -------------------------------------------------------------------------- + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// A uniquely named temp `.bstack` file, removed on drop. +struct TempStack { + path: std::path::PathBuf, +} + +impl TempStack { + fn new() -> Self { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut path = std::env::temp_dir(); + path.push(format!("bstack_raii_test_{}_{n}.bstack", std::process::id())); + let _ = std::fs::remove_file(&path); + TempStack { path } + } + + fn open(&self) -> BStack { + BStack::open(&self.path).unwrap() + } + + fn allocator(&self) -> FirstFitBStackAllocator { + FirstFitBStackAllocator::new(self.open()).unwrap() + } +} + +impl Drop for TempStack { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +// -------------------------------------------------------------------------- +// A hand-written `#[bstack_block(rc, weak)]`-shaped type with no children +// -------------------------------------------------------------------------- + +/// Data block payload: header + `ctrl` back-pointer (an on-disk `u64` ref). No +/// user fields, so teardown has no children to recurse into. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct TestOnDisk { + header: BlockHeader, + ctrl: u64, +} + +/// Control block payload: header + `strong`, `weak`, and `x` forward pointer — +/// at offsets 16 / 24 / 32, matching [`layout`]. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct TestControl { + header: BlockHeader, + strong: u64, + weak: u64, + x: u64, +} + +#[derive(Clone, Copy)] +struct TestBlock(BStackRange); + +impl BStackCast for TestBlock { + fn eightcc() -> EightCC { + EightCC::from_name("TESTDATA") + } +} + +impl BStackDrop for TestBlock { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // No owned children: just free the data block itself. + unsafe { dealloc_range(allocator, self.0) } + } +} + +impl BStackBlock for TestBlock { + type OnDisk = TestOnDisk; + fn from_range(range: BStackRange) -> Self { + TestBlock(range) + } + fn range(&self) -> BStackRange { + self.0 + } +} + +impl BStackWeakable for TestBlock { + type Control = TestControl; +} + +fn ctrl_tag() -> EightCC { + EightCC::from_name("TESTCTRL") +} + +/// Allocate and fully wire an `(rc, weak)` `TestBlock` (data + control), +/// returning both ranges. `strong = 1`, `weak = 1` on return. +fn build_rc_weak(alloc: &FirstFitBStackAllocator) -> (BStackRange, BStackRange) { + let data = alloc_block(alloc, TestBlock::eightcc(), size_of::() as u64).unwrap(); + let ctrl = alloc_control(alloc, ctrl_tag(), data, size_of::() as u64).unwrap(); + (data, ctrl) +} + +/// Wrap the data/control ranges into a strong handle accounting for the initial +/// `strong = 1`. +fn rc_of<'a>( + alloc: &'a FirstFitBStackAllocator, + data: BStackRange, + ctrl: BStackRange, +) -> BStackRc<'a, TestBlock, FirstFitBStackAllocator> { + unsafe { BStackRc::from_raw(BStackRef::from_range(data), Some(ctrl), alloc) } +} + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +#[test] +fn refcount_ops() { + let tmp = TempStack::new(); + let stack = tmp.open(); + // A single u64 counter living in the mutable region. + let off = stack.push(1u64.to_le_bytes()).unwrap(); + + assert_eq!(crate::refcount::load(&stack, off).unwrap(), 1); + assert_eq!(crate::refcount::fetch_add(&stack, off, 5).unwrap(), 1); // returns prev + assert_eq!(crate::refcount::load(&stack, off).unwrap(), 6); + assert_eq!(crate::refcount::fetch_sub(&stack, off, 2).unwrap(), 6); + assert_eq!(crate::refcount::load(&stack, off).unwrap(), 4); + assert_eq!(crate::refcount::increment_if_nonzero(&stack, off).unwrap(), Some(5)); + + // Drive to zero, then confirm zero is terminal for increment_if_nonzero. + assert_eq!(crate::refcount::fetch_sub(&stack, off, 5).unwrap(), 5); + assert_eq!(crate::refcount::load(&stack, off).unwrap(), 0); + assert_eq!(crate::refcount::increment_if_nonzero(&stack, off).unwrap(), None); + assert_eq!(crate::refcount::load(&stack, off).unwrap(), 0); + + // Underflow is an error, not a wrap. + assert!(crate::refcount::fetch_sub(&stack, off, 1).is_err()); +} + +#[test] +fn rc_weak_lifecycle() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let dsize = size_of::() as u64; + + let (data, ctrl) = build_rc_weak(&alloc); + + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + let weak_off = ctrl.start() + layout::CTRL_WEAK_OFFSET; + let load = |o: u64| crate::refcount::load(alloc.stack(), o).unwrap(); + + // Initial state and the wired back/forward pointers. + assert_eq!(load(strong_off), 1); + assert_eq!(load(weak_off), 1); + assert_eq!(load(data.start() + layout::CTRL_BACKPTR_OFFSET), ctrl.start()); + assert_eq!(load(ctrl.start() + layout::CTRL_DATA_OFFSET), data.start()); + + let rc = rc_of(&alloc, data, ctrl); + + let rc2 = rc.try_clone().unwrap(); + assert_eq!(load(strong_off), 2); + + let weak = rc.downgrade().unwrap(); + assert_eq!(load(weak_off), 2); + + drop(rc2); + assert_eq!(load(strong_off), 1); + + // upgrade succeeds while a strong owner is alive. + let rc3 = weak.upgrade().unwrap().expect("still alive"); + assert_eq!(load(strong_off), 2); + drop(rc3); + assert_eq!(load(strong_off), 1); + + // Last strong drop: frees the data block and releases the phantom weak + // (2 -> 1); the control block survives because a real weak handle remains. + drop(rc); + assert_eq!(load(strong_off), 0); + assert_eq!(load(weak_off), 1); + + // upgrade now fails — zero strong is terminal. + assert!(weak.upgrade().unwrap().is_none()); + + // The data block's slot was actually reclaimed: a fresh same-size alloc + // reuses its offset (first-fit picks the lowest free slot). + let reused = alloc_block(&alloc, TestBlock::eightcc(), dsize).unwrap(); + assert_eq!(reused.start(), data.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; + + // Last weak drop frees the control block. + drop(weak); +} + +/// Many threads hammering `try_clone` + `drop` on a shared strong handle. Each +/// iteration is a balanced +1/-1 on `strong`, and the main handle keeps `strong` +/// >= 1 throughout (so no teardown races). If the on-disk RMW were not atomic +/// under contention, lost updates would leave the final count off. +#[test] +fn concurrent_clone_drop() { + const THREADS: usize = 8; + const ITERS: usize = 500; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let (data, ctrl) = build_rc_weak(&alloc); + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + + let rc = rc_of(&alloc, data, ctrl); + + std::thread::scope(|s| { + for _ in 0..THREADS { + let rc = &rc; + s.spawn(move || { + for _ in 0..ITERS { + let clone = rc.try_clone().unwrap(); + drop(clone); + } + }); + } + }); + + // Only the main handle survives. + assert_eq!(crate::refcount::load(alloc.stack(), strong_off).unwrap(), 1); + // Clean teardown: strong -> 0 frees the data block, the phantom release + // drives weak (1) -> 0 and frees the control block. + drop(rc); +} + +/// Many threads concurrently `upgrade` (from a shared weak) and `try_clone` +/// (from a shared strong). A live strong owner keeps `strong` >= 1 so every +/// upgrade succeeds; each upgraded/cloned handle is balanced by an immediate +/// drop. Stresses `increment_if_nonzero` against `fetch_add`/`fetch_sub`. +#[test] +fn concurrent_upgrade_downgrade() { + const THREADS: usize = 8; + const ITERS: usize = 400; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let (data, ctrl) = build_rc_weak(&alloc); + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + let weak_off = ctrl.start() + layout::CTRL_WEAK_OFFSET; + + let rc = rc_of(&alloc, data, ctrl); + let weak = rc.downgrade().unwrap(); // weak = 2 (phantom + this handle) + + std::thread::scope(|s| { + for _ in 0..THREADS { + let rc = &rc; + let weak = &weak; + s.spawn(move || { + for _ in 0..ITERS { + if let Some(upgraded) = weak.upgrade().unwrap() { + drop(upgraded); + } + drop(rc.try_clone().unwrap()); + } + }); + } + }); + + // Both counts returned to their pre-thread values. + assert_eq!(crate::refcount::load(alloc.stack(), strong_off).unwrap(), 1); + assert_eq!(crate::refcount::load(alloc.stack(), weak_off).unwrap(), 2); + + drop(weak); // weak 2 -> 1 + drop(rc); // strong -> 0 frees data; phantom release frees control +} From 4100793989cc9cfe80503584bc48ae823012823d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 9 Jul 2026 21:06:49 -0700 Subject: [PATCH 008/140] Block allocation machinary --- bstack_raii/src/construct.rs | 93 ++++++++++++++++++++++++++++++++++++ bstack_raii/src/lib.rs | 5 ++ 2 files changed, 98 insertions(+) create mode 100644 bstack_raii/src/construct.rs diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs new file mode 100644 index 0000000..d501157 --- /dev/null +++ b/bstack_raii/src/construct.rs @@ -0,0 +1,93 @@ +//! Block creation: allocate, stamp the header, and initialize refcounts / +//! control blocks. The teardown side lives in [`crate::handle`]; this is the +//! matching build side. +//! +//! These are the low-level, type-agnostic primitives the `#[bstack_block]` +//! macro's generated constructors (and the tests) build on. Writing the +//! type-specific payload — child refs and POD fields after the header — is the +//! caller's job; these helpers only lay down the header and the injected +//! refcount / control machinery at the fixed offsets from [`crate::layout`]. + +use std::io; + +use bstack::{BStackOwnedSliceAllocator, BStackRange}; + +use crate::layout::{self, BlockHeader, EightCC}; +use crate::teardown::dealloc_range; + +/// Allocate a `size`-byte block and stamp its `BlockHeader { size, tag }`. +/// +/// Returns the block's range. The bytes after the header are left as the +/// allocator provided them; the caller fills in the payload. On a write failure +/// the freshly allocated block is released so nothing leaks. +pub fn alloc_block( + allocator: &A, + tag: EightCC, + size: u64, +) -> io::Result { + let mut slice = allocator.alloc(size)?; + let header = BlockHeader { size, tag }; + if let Err(e) = slice.write_range(0, bytemuck::bytes_of(&header)) { + let _ = allocator.dealloc(slice); + return Err(e); + } + Ok(slice.as_range()) +} + +/// Initialize a plain `#[bstack_block(rc)]` block's inline refcount to 1. +/// +/// Call once after [`alloc_block`] and after the payload is written. One is the +/// count the single returned `BStackRc` accounts for. +pub fn init_rc( + allocator: &A, + data: BStackRange, +) -> io::Result<()> { + let off = data.start() + layout::RC_REFCOUNT_OFFSET; + allocator.stack().set(off, 1u64.to_le_bytes()) +} + +/// Allocate and wire the control block for an already-allocated +/// `#[bstack_block(rc, weak)]` data block. +/// +/// Writes the control header, `strong = 1`, `weak = 1` (the phantom weak held by +/// the strong owners), and the `x` forward pointer to the data block; then +/// writes the data block's `ctrl` back-pointer. Returns the control block's +/// range. `control_size` is `size_of::()`. +/// +/// On failure the control block is released; the caller still owns (and must +/// release) the data block. +pub fn alloc_control( + allocator: &A, + ctrl_tag: EightCC, + data: BStackRange, + control_size: u64, +) -> io::Result { + // Build the entire control-block payload in memory and commit it in a single + // write: header, strong = 1, weak = 1 (phantom), x -> data. + let mut payload = vec![0u8; control_size as usize]; + let header = BlockHeader { size: control_size, tag: ctrl_tag }; + payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); + let put = |payload: &mut [u8], off: u64, val: u64| { + let o = off as usize; + payload[o..o + 8].copy_from_slice(&val.to_le_bytes()); + }; + put(&mut payload, layout::CTRL_STRONG_OFFSET, 1); + put(&mut payload, layout::CTRL_WEAK_OFFSET, 1); + put(&mut payload, layout::CTRL_DATA_OFFSET, data.start()); + + let mut slice = allocator.alloc(control_size)?; + let ctrl = slice.as_range(); + if let Err(e) = slice.write_range(0, &payload) { + let _ = allocator.dealloc(slice); + return Err(e); + } + + // The data block's `ctrl` back-pointer lives in a different block, so it is + // one more (unavoidable) write into that region. + let backptr = data.start() + layout::CTRL_BACKPTR_OFFSET; + if let Err(e) = allocator.stack().set(backptr, ctrl.start().to_le_bytes()) { + let _ = unsafe { dealloc_range(allocator, ctrl) }; + return Err(e); + } + Ok(ctrl) +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index db6081d..8d56ed2 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -46,6 +46,7 @@ mod block; mod clone; +mod construct; mod handle; mod layout; mod owned; @@ -54,8 +55,12 @@ mod reference; mod shared; mod teardown; +#[cfg(test)] +mod tests; + pub use block::{BStackBlock, BStackCast, BStackWeakable}; pub use clone::TryClone; +pub use construct::{alloc_block, alloc_control, init_rc}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; From 2d871cf0341ae164c5cfcc0868e86283b691e199 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 10 Jul 2026 00:40:52 -0700 Subject: [PATCH 009/140] Lock bytemuck and use published bstack --- bstack_raii/Cargo.lock | 107 +++++++++++++++++++++++++++++++++++++++++ bstack_raii/Cargo.toml | 11 ++--- 2 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 bstack_raii/Cargo.lock diff --git a/bstack_raii/Cargo.lock b/bstack_raii/Cargo.lock new file mode 100644 index 0000000..2a27ba0 --- /dev/null +++ b/bstack_raii/Cargo.lock @@ -0,0 +1,107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bstack" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3d25282e16e90e033662b78e911d036da268c3b2a27e345d8feb677c80cd07" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "bstack_raii" +version = "0.0.0" +dependencies = [ + "bstack", + "bstack_raii_derive", + "bytemuck", +] + +[[package]] +name = "bstack_raii_derive" +version = "0.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/bstack_raii/Cargo.toml b/bstack_raii/Cargo.toml index 609e408..9251d3e 100644 --- a/bstack_raii/Cargo.toml +++ b/bstack_raii/Cargo.toml @@ -16,14 +16,11 @@ publish = false members = ["derive"] [dependencies] -# bstack 0.4.0 is feature-complete but not yet published to crates.io, so we -# depend on the GitHub source for now. Pin a `branch`/`rev`/`tag` here once the -# RAII primitives land on a stable ref (or swap to a `path = ".."` dep for local -# iteration). RAII requires `alloc` + `set`; `atomic` is needed for on-disk -# refcount CAS (control blocks, strong/weak counters). -bstack = { git = "https://github.com/williamwutq/bstack", features = ["alloc", "set", "atomic"] } +# RAII requires `alloc` + `set`; `atomic` is needed for the on-disk refcount RMW +# (control blocks, strong/weak counters). +bstack = { version = "=0.4", features = ["alloc", "set", "atomic"] } bstack_raii_derive = { path = "derive", version = "0.0.0" } # Gate for POD (plain-old-data) inline fields: any `bytemuck::Pod` type is safe # to store inline in an on-disk block and receives the blanket no-op BStackDrop. -bytemuck = { version = "1", features = ["derive"] } +bytemuck = { version = "=1.25", features = ["derive"] } From 97755276c498a9c5fd6fa979ca89eeb0bb7e932e Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 10 Jul 2026 21:39:31 -0700 Subject: [PATCH 010/140] Macro works --- bstack_raii/derive/src/block.rs | 671 ++++++++++++++++++++++++++++++++ bstack_raii/derive/src/lib.rs | 24 +- bstack_raii/src/block.rs | 47 ++- bstack_raii/src/construct.rs | 77 +++- bstack_raii/src/handle.rs | 4 +- bstack_raii/src/lib.rs | 16 +- bstack_raii/src/owned.rs | 6 + bstack_raii/src/shared.rs | 58 ++- bstack_raii/src/tests.rs | 387 +++++++++++++++++- 9 files changed, 1252 insertions(+), 38 deletions(-) create mode 100644 bstack_raii/derive/src/block.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs new file mode 100644 index 0000000..2d6b28c --- /dev/null +++ b/bstack_raii/derive/src/block.rs @@ -0,0 +1,671 @@ +//! Implementation of the `#[bstack_block]` attribute macro. +//! +//! Given an ergonomic `struct X { .. }`, it emits: +//! * `struct X(BStackRange)` — the typed, without-allocator handle. +//! * `struct XOnDisk` — `#[repr(C, packed)]`, `Pod`, the on-disk payload. +//! * `impl BStackCast / BStackBlock / BStackDrop for X`. +//! * For `rc` / `(rc, weak)`: the injected refcount / `ctrl` field, an +//! `impl BStackShared`, and (for `rc, weak`) the `XOnDiskRef` control block +//! plus `impl BStackWeakable`. +//! * Field accessors, and (unless the block has a `#[bstack_weak]` field) a +//! `new` constructor that allocates and wires the block. + +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, quote}; +use syn::parse::Parser; +use syn::punctuated::Punctuated; +use syn::{Error, Fields, Ident, ItemStruct, Token, Type}; + +/// The block mode from the attribute arguments. +#[derive(Clone, Copy, PartialEq)] +enum Mode { + /// `#[bstack_block]` + Plain, + /// `#[bstack_block(rc)]` + Rc, + /// `#[bstack_block(rc, weak)]` + RcWeak, +} + +/// One field's ownership classification. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Owned, + Strong, + Weak, + Ref, + /// POD field stored inline. + Pod, +} + +pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result { + let mode = parse_mode(attr)?; + + if !input.generics.params.is_empty() { + return Err(Error::new_spanned( + &input.generics, + "#[bstack_block] does not support generic block types", + )); + } + + let fields = match &input.fields { + Fields::Named(named) => &named.named, + _ => { + return Err(Error::new_spanned( + &input.fields, + "#[bstack_block] requires a struct with named fields", + )); + } + }; + + let name = &input.ident; + let vis = &input.vis; + let on_disk = format_ident!("{}OnDisk", name); + let control = format_ident!("{}OnDiskRef", name); + + // On-disk fields: header, then the injected refcount/ctrl (if any), then user + // fields lowered per annotation. + let mut on_disk_fields = Vec::new(); + match mode { + Mode::Plain => {} + Mode::Rc => on_disk_fields.push(quote!(__bstack_refcount: u64,)), + Mode::RcWeak => on_disk_fields.push(quote!(__bstack_ctrl: u64,)), + } + + let mut drop_stmts = Vec::new(); + let mut pod_types: Vec<&Type> = Vec::new(); + let mut accessors = Vec::new(); + let mut setters = Vec::new(); + let mut ctor_params = Vec::new(); + let mut ctor_preps = Vec::new(); + let mut ctor_inits = Vec::new(); + // `bstack_move!` support (owned/ref/pod fields only, plain blocks only). + let mut mv_caps = Vec::new(); + let mut mv_types = Vec::new(); + let mut mv_recon = Vec::new(); + + for field in fields { + let fname = field.ident.as_ref().expect("named field"); + let fty = &field.ty; + let kind = classify(field)?; + + // On-disk lowering + teardown. + match kind { + Kind::Pod => { + on_disk_fields.push(quote!(#fname: #fty,)); + pod_types.push(fty); + } + _ => on_disk_fields.push(quote!(#fname: u64,)), + } + match kind { + Kind::Owned => drop_stmts.push(child_range_stmt( + fname, + fty, + quote! { + ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; + }, + )), + Kind::Strong => drop_stmts.push(child_range_stmt( + fname, + fty, + quote! { + <#fty as ::bstack_raii::BStackShared>::drop_strong_ref(__child, allocator)?; + }, + )), + // Weak fields store the child's *control-block* offset (sound even if + // the target's data is already freed), and may be null (0 = unset). + Kind::Weak => drop_stmts.push(quote! { + { + let __off = __on_disk.#fname; + if __off != 0 { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::< + <#fty as ::bstack_raii::BStackWeakable>::Control + >::from_range(::bstack_raii::BStackRange::new( + __off, + ::core::mem::size_of::< + <#fty as ::bstack_raii::BStackWeakable>::Control + >() as u64, + )) + }; + ::bstack_raii::WeakRef::<#fty>(__ctrl).bstack_drop(allocator)?; + } + } + }), + Kind::Ref | Kind::Pod => {} + } + + // Accessor. + accessors.push(accessor(vis, fname, fty, &on_disk, kind)); + + // Constructor pieces. Weak fields are not constructor parameters — they + // start null and are wired afterwards via the generated `set_`. + if kind == Kind::Weak { + ctor_inits.push(quote!(#fname: 0u64,)); + setters.push(weak_setter(vis, fname, fty, &on_disk)); + } else { + let (param, prep, init) = ctor_field(fname, fty, kind); + ctor_params.push(param); + ctor_preps.push(prep); + ctor_inits.push(init); + } + + // `bstack_move!` pieces: capture the field before the parent is freed, + // then reconstruct the transferred handle after. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + match kind { + Kind::Owned => { + mv_types.push(quote!(::bstack_raii::BStackOwned<'__mv, #fty, __A>)); + mv_recon.push(quote! { + unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#fty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + #cap, + ::core::mem::size_of::< + <#fty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ), + __alloc, + ) + } + }); + } + Kind::Ref => { + mv_types.push(quote!(::bstack_raii::BStackRef<#fty>)); + mv_recon.push(quote! { + unsafe { + ::bstack_raii::BStackRef::<#fty>::from_range( + ::bstack_raii::BStackRange::new( + #cap, + ::core::mem::size_of::< + <#fty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ) + } + }); + } + Kind::Pod => { + mv_types.push(quote!(#fty)); + mv_recon.push(quote!(#cap)); + } + Kind::Strong => { + // Rebuild a BStackRc, dispatching through BStackShared so the + // child's kind (rc vs rc,weak) picks up the control block if any. + mv_types.push(quote!(::bstack_raii::BStackRc<'__mv, #fty, __A>)); + mv_recon.push(quote! { + { + let __data = unsafe { + ::bstack_raii::BStackRef::<#fty>::from_range( + ::bstack_raii::BStackRange::new( + #cap, + ::core::mem::size_of::< + <#fty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ) + }; + let (__d, __c) = + <#fty as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } + } + }); + } + Kind::Weak => { + // The field holds the child's control offset directly; rebuild a + // BStackWeak, or None if the field was never set (0). + mv_types.push(quote! { + ::core::option::Option<::bstack_raii::BStackWeak<'__mv, #fty, __A>> + }); + mv_recon.push(quote! { + if #cap == 0 { + ::core::option::Option::None + } else { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::< + <#fty as ::bstack_raii::BStackWeakable>::Control + >::from_range(::bstack_raii::BStackRange::new( + #cap, + ::core::mem::size_of::< + <#fty as ::bstack_raii::BStackWeakable>::Control + >() as u64, + )) + }; + ::core::option::Option::Some( + unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } + ) + } + }); + } + } + } + + let tag = name.to_string(); + + // BStackShared / BStackWeakable / control block for the refcounted modes. + let shared_impl = match mode { + Mode::Plain => quote!(), + Mode::Rc => quote! { + impl ::bstack_raii::BStackShared for #name { + fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + ::bstack_raii::StrongRef(data).bstack_drop(allocator) + } + fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + _allocator: &__A, + ) -> ::std::io::Result<( + ::bstack_raii::BStackRef, + ::core::option::Option<::bstack_raii::BStackRange>, + )> { + ::std::result::Result::Ok((data, ::core::option::Option::None)) + } + } + }, + Mode::RcWeak => quote! { + impl ::bstack_raii::BStackShared for #name { + fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + ::bstack_raii::StrongWeakRef::from_disk(data, allocator)? + .bstack_drop(allocator) + } + fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<( + ::bstack_raii::BStackRef, + ::core::option::Option<::bstack_raii::BStackRange>, + )> { + let __swr = ::bstack_raii::StrongWeakRef::from_disk(data, allocator)?; + ::std::result::Result::Ok(( + __swr.0, + ::core::option::Option::Some(__swr.1.into_range()), + )) + } + } + }, + }; + + let weakable_items = if mode == Mode::RcWeak { + quote! { + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #control { + __bstack_header: ::bstack_raii::BlockHeader, + __bstack_strong: u64, + __bstack_weak: u64, + __bstack_x: u64, + } + unsafe impl ::bstack_raii::Zeroable for #control {} + unsafe impl ::bstack_raii::Pod for #control {} + + impl ::bstack_raii::BStackWeakable for #name { + type Control = #control; + } + } + } else { + quote!() + }; + + let constructor = constructor( + name, + vis, + &on_disk, + mode, + &ctor_params, + &ctor_preps, + &ctor_inits, + ); + + // `bstack_move!` is defined for plain blocks (not rc / rc,weak themselves); + // their fields may be any kind, including strong/weak. + let move_impl = if mode == Mode::Plain { + quote! { + impl<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> + ::bstack_raii::BStackMove for ::bstack_raii::BStackOwned<'__mv, #name, __A> + { + type Fields = ( #(#mv_types,)* ); + fn bstack_move(self) -> ::std::io::Result { + // Take the inner handle out (defusing the owned Drop) and read + // the payload before freeing anything. + let (__inner, __alloc) = self.into_raw_parts(); + let __stack = __alloc.stack(); + let __range = ::bstack_raii::BStackBlock::range(&__inner); + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::<#name>::from_range(__range) }; + let __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + #(#mv_caps)* + // Free the parent shell only; children stay live on disk. + unsafe { ::bstack_raii::dealloc_range(__alloc, __range)?; } + ::std::result::Result::Ok(( #(#mv_recon,)* )) + } + } + } + } else { + quote!() + }; + + Ok(quote! { + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #name(::bstack_raii::BStackRange); + + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #on_disk { + __bstack_header: ::bstack_raii::BlockHeader, + #(#on_disk_fields)* + } + + // SAFETY: `#[repr(C, packed)]` guarantees no padding, and every field is + // `Pod` (u64 for refs/injected counters, header is Pod, each inline field + // is asserted `Pod` below), so all bit patterns are valid. + unsafe impl ::bstack_raii::Zeroable for #on_disk {} + unsafe impl ::bstack_raii::Pod for #on_disk {} + + const _: fn() = || { + fn __assert_pod<__T: ::bstack_raii::Pod>() {} + #( __assert_pod::<#pod_types>(); )* + }; + + impl ::bstack_raii::BStackCast for #name { + fn eightcc() -> ::bstack_raii::EightCC { + ::bstack_raii::EightCC::from_name(#tag) + } + } + + impl ::bstack_raii::BStackBlock for #name { + type OnDisk = #on_disk; + fn from_range(range: ::bstack_raii::BStackRange) -> Self { + #name(range) + } + fn range(&self) -> ::bstack_raii::BStackRange { + self.0 + } + } + + impl ::bstack_raii::BStackDrop for #name { + fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + self, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + let __stack = allocator.stack(); + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __on_disk: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + #(#drop_stmts)* + unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } + } + } + + impl #name { + #(#accessors)* + #(#setters)* + #constructor + } + + #shared_impl + #weakable_items + #move_impl + }) +} + +/// Generate the reader method for one field. +fn accessor( + vis: &syn::Visibility, + fname: &Ident, + fty: &Type, + on_disk: &Ident, + kind: Kind, +) -> TokenStream { + // Weak fields hold a control offset; the accessor attempts a live upgrade. + if kind == Kind::Weak { + return quote! { + #vis fn #fname<'__u, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__u __A, + ) -> ::std::io::Result< + ::core::option::Option<::bstack_raii::BStackRc<'__u, #fty, __A>> + > { + let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + ::bstack_raii::upgrade_weak_field(allocator, __field) + } + }; + } + let read = quote! { + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; + }; + if kind == Kind::Pod { + quote! { + #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#fty> { + #read + ::std::result::Result::Ok(__od.#fname) + } + } + } else { + // Owned/strong/ref field: resolve the stored data offset to the handle. + quote! { + #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#fty> { + #read + let __range = ::bstack_raii::BStackRange::new( + __od.#fname, + ::core::mem::size_of::<<#fty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + ); + ::std::result::Result::Ok(<#fty as ::bstack_raii::BStackBlock>::from_range(__range)) + } + } + } +} + +/// Generate `(param, prep, init)` for one constructor field. Not called for +/// `#[bstack_weak]` fields. +fn ctor_field(fname: &Ident, fty: &Type, kind: Kind) -> (TokenStream, TokenStream, TokenStream) { + match kind { + Kind::Pod => (quote!(#fname: #fty,), quote!(), quote!(#fname: #fname,)), + Kind::Owned => ( + quote!(#fname: ::bstack_raii::BStackOwned<'__ctor, #fty, __A>,), + quote! { + let #fname: u64 = { + let (__h, _) = #fname.into_raw_parts(); + ::bstack_raii::BStackBlock::range(&__h).start() + }; + }, + quote!(#fname: #fname,), + ), + Kind::Strong => ( + quote!(#fname: ::bstack_raii::BStackRc<'__ctor, #fty, __A>,), + quote! { + let #fname: u64 = { + let (__d, _) = #fname.into_raw(); + __d.into_range().start() + }; + }, + quote!(#fname: #fname,), + ), + Kind::Ref => ( + quote!(#fname: ::bstack_raii::BStackRef<#fty>,), + quote!(), + quote!(#fname: #fname.into_range().start(),), + ), + Kind::Weak => unreachable!("weak fields are wired via set_, not the constructor"), + } +} + +/// Generate the `set_` method for a `#[bstack_weak]` field: point it at a +/// weak target (consumed), releasing whatever it held before. +fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Ident) -> TokenStream { + let setter = format_ident!("set_{}", fname); + quote! { + #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__s __A, + weak: ::bstack_raii::BStackWeak<'__s, #fty, __A>, + ) -> ::std::io::Result<()> { + let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + ::bstack_raii::set_weak_field(allocator, __field, weak) + } + } +} + +/// Assemble the `new` constructor. +fn constructor( + name: &Ident, + vis: &syn::Visibility, + on_disk: &Ident, + mode: Mode, + params: &[TokenStream], + preps: &[TokenStream], + inits: &[TokenStream], +) -> TokenStream { + let injected = match mode { + Mode::Plain => quote!(), + Mode::Rc => quote!(__bstack_refcount: 1u64,), + Mode::RcWeak => quote!(__bstack_ctrl: 0u64,), + }; + let ret = match mode { + Mode::Plain => quote!(::bstack_raii::BStackOwned<'__ctor, Self, __A>), + _ => quote!(::bstack_raii::BStackRc<'__ctor, Self, __A>), + }; + let finish = match mode { + Mode::Plain => quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__data), + allocator, + ) + }) + }, + Mode::Rc => quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::None, + allocator, + ) + }) + }, + Mode::RcWeak => { + let ctrl_tag = format!("{name}Ref"); + quote! { + let __ctrl = match ::bstack_raii::alloc_control( + allocator, + ::bstack_raii::EightCC::from_name(#ctrl_tag), + __data, + ::core::mem::size_of::<::Control>() as u64, + ) { + ::std::result::Result::Ok(__c) => __c, + ::std::result::Result::Err(__e) => { + let _ = unsafe { ::bstack_raii::dealloc_range(allocator, __data) }; + return ::std::result::Result::Err(__e); + } + }; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::Some(__ctrl), + allocator, + ) + }) + } + } + }; + + quote! { + #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__ctor __A, + #(#params)* + ) -> ::std::io::Result<#ret> { + #(#preps)* + let __on_disk = #on_disk { + __bstack_header: ::bstack_raii::BlockHeader { + size: ::core::mem::size_of::<#on_disk>() as u64, + tag: ::eightcc(), + }, + #injected + #(#inits)* + }; + let mut __slice = allocator.alloc(::core::mem::size_of::<#on_disk>() as u64)?; + let __data = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) + { + let _ = allocator.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + #finish + } + } +} + +/// Build a teardown statement that resolves a child field's `u64` offset into a +/// typed `BStackRef<#fty>` bound to `__child`, then runs `body`. +fn child_range_stmt(fname: &Ident, fty: &Type, body: TokenStream) -> TokenStream { + quote! { + { + let __off = __on_disk.#fname; + let __range = ::bstack_raii::BStackRange::new( + __off, + ::core::mem::size_of::<<#fty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + ); + let __child = unsafe { ::bstack_raii::BStackRef::<#fty>::from_range(__range) }; + #body + } + } +} + +/// Parse the attribute arguments into a [`Mode`]: ``, `rc`, or `rc, weak`. +fn parse_mode(attr: TokenStream) -> syn::Result { + if attr.is_empty() { + return Ok(Mode::Plain); + } + let parser = Punctuated::::parse_terminated; + let idents: Vec = parser + .parse2(attr)? + .into_iter() + .map(|i| i.to_string()) + .collect(); + match idents.as_slice() { + [rc] if rc == "rc" => Ok(Mode::Rc), + [rc, weak] if rc == "rc" && weak == "weak" => Ok(Mode::RcWeak), + _ => Err(Error::new( + Span::call_site(), + "expected `#[bstack_block]`, `#[bstack_block(rc)]`, or `#[bstack_block(rc, weak)]`", + )), + } +} + +/// Classify a field by its ownership annotation. +fn classify(field: &syn::Field) -> syn::Result { + let mut found: Option = None; + for attr in &field.attrs { + let Some(id) = attr.path().get_ident() else { + continue; + }; + let kind = match id.to_string().as_str() { + "bstack_owned" => Kind::Owned, + "bstack_strong" => Kind::Strong, + "bstack_weak" => Kind::Weak, + "bstack_ref" => Kind::Ref, + _ => continue, + }; + if found.is_some() { + return Err(Error::new_spanned( + attr, + "a field may carry at most one bstack ownership annotation", + )); + } + found = Some(kind); + } + Ok(found.unwrap_or(Kind::Pod)) +} diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 4997538..d0e5055 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -18,6 +18,8 @@ use proc_macro::TokenStream; +mod block; + /// `#[bstack_block]` — generate the on-disk layout and typed handle machinery. /// /// Accepts optional mode arguments: `#[bstack_block]`, `#[bstack_block(rc)]`, or @@ -41,10 +43,12 @@ use proc_macro::TokenStream; /// 5. `impl BStackWeakable for X { type Control = XOnDiskRef; }` only for /// `(rc, weak)`. #[proc_macro_attribute] -pub fn bstack_block(_args: TokenStream, item: TokenStream) -> TokenStream { - // Scaffold: re-emit the input struct unchanged so downstream compiles. - // TODO: parse args + fields, validate annotations, emit the items above. - item +pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as syn::ItemStruct); + match block::expand(args.into(), input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } } /// `bstack_move!(x)` — transfer every field out of a `BStackOwned`. @@ -56,12 +60,12 @@ pub fn bstack_block(_args: TokenStream, item: TokenStream) -> TokenStream { /// Callable only on `BStackOwned`; not defined for `(rc)` / `(rc, weak)` /// blocks. See RAII.md "`bstack_move!`". #[proc_macro] -pub fn bstack_move(_input: TokenStream) -> TokenStream { - // Scaffold: emit an unimplemented expression so any (currently nonexistent) - // call site type-checks. TODO: implement the destructuring expansion. - "::core::todo!(\"bstack_move! not yet implemented\")" - .parse() - .unwrap() +pub fn bstack_move(input: TokenStream) -> TokenStream { + // The per-field destructuring is generated by `#[bstack_block]` as a + // `BStackMove` impl on `BStackOwned`; here we just invoke it, letting + // type inference select the block's impl from the argument's type. + let expr = syn::parse_macro_input!(input as syn::Expr); + quote::quote!(::bstack_raii::BStackMove::bstack_move(#expr)).into() } /// `bstack_cast!(handle)` — type-checked handle conversion, direction inferred diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index f5b97de..3d4bc86 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -4,10 +4,13 @@ //! runtime code (handles, `bstack_move!`) name a block's on-disk shape, tag, and //! control block without knowing the concrete type. -use bstack::BStackRange; +use std::io; + +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; use crate::layout::EightCC; +use crate::reference::BStackRef; use crate::teardown::BStackDrop; /// The downcast discriminant. The returned [`EightCC`] must match the tag in a @@ -32,6 +35,48 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { fn range(&self) -> BStackRange; } +/// Destructure an owned block into its typed field handles. +/// +/// Implemented for `BStackOwned` by `#[bstack_block]` (plain blocks only) +/// and invoked by the `bstack_move!` macro. It transfers ownership of every +/// field out — owned children as `BStackOwned`, refs as `BStackRef`, POD by +/// value — and frees only the parent shell. Not implemented for `(rc)` / +/// `(rc, weak)` blocks, nor (yet) for blocks with `#[bstack_strong]` / +/// `#[bstack_weak]` fields. +pub trait BStackMove: Sized { + /// The tuple of field handles produced, in field-declaration order. + type Fields; + fn bstack_move(self) -> io::Result; +} + +/// Implemented by refcounted blocks (`#[bstack_block(rc)]` and +/// `#[bstack_block(rc, weak)]`), i.e. any block that can be the target of a +/// `#[bstack_strong]` field. +/// +/// It abstracts "drop one strong reference to a child of this type" so a +/// parent's generated teardown does not need to know whether the child is a +/// plain `(rc)` block (inline refcount, [`crate::StrongRef`]) or an +/// `(rc, weak)` block (control block, [`crate::StrongWeakRef`]). The child's own +/// `#[bstack_block]` expansion picks the right implementation. +pub trait BStackShared: BStackBlock { + /// Drop one strong reference to a block of this type located at `data`, + /// freeing it (and, for `(rc, weak)`, releasing the control block) when the + /// strong count reaches zero. + fn drop_strong_ref( + data: BStackRef, + allocator: &A, + ) -> io::Result<()>; + + /// Resolve the raw parts of a strong handle to a child of this type at + /// `data`: the data ref, plus the control-block range for `(rc, weak)` + /// blocks (`None` for plain `(rc)`). Used by `bstack_move!` to rebuild a + /// `BStackRc` for a `#[bstack_strong]` field. + fn strong_parts( + data: BStackRef, + allocator: &A, + ) -> io::Result<(BStackRef, Option)>; +} + /// Implemented only for blocks declared `#[bstack_block(rc, weak)]`. /// /// Its presence is what lets [`crate::BStackRc`] expose `downgrade` and diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index d501157..7ec411e 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -8,12 +8,17 @@ //! caller's job; these helpers only lay down the header and the injected //! refcount / control machinery at the fixed offsets from [`crate::layout`]. +use core::mem::size_of; use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::block::BStackWeakable; +use crate::handle::WeakRef; use crate::layout::{self, BlockHeader, EightCC}; -use crate::teardown::dealloc_range; +use crate::reference::BStackRef; +use crate::shared::{BStackRc, BStackWeak}; +use crate::teardown::{BStackDrop, dealloc_range}; /// Allocate a `size`-byte block and stamp its `BlockHeader { size, tag }`. /// @@ -38,10 +43,7 @@ pub fn alloc_block( /// /// Call once after [`alloc_block`] and after the payload is written. One is the /// count the single returned `BStackRc` accounts for. -pub fn init_rc( - allocator: &A, - data: BStackRange, -) -> io::Result<()> { +pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { let off = data.start() + layout::RC_REFCOUNT_OFFSET; allocator.stack().set(off, 1u64.to_le_bytes()) } @@ -65,7 +67,10 @@ pub fn alloc_control( // Build the entire control-block payload in memory and commit it in a single // write: header, strong = 1, weak = 1 (phantom), x -> data. let mut payload = vec![0u8; control_size as usize]; - let header = BlockHeader { size: control_size, tag: ctrl_tag }; + let header = BlockHeader { + size: control_size, + tag: ctrl_tag, + }; payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); let put = |payload: &mut [u8], off: u64, val: u64| { let o = off as usize; @@ -91,3 +96,63 @@ pub fn alloc_control( } Ok(ctrl) } + +/// Set a `#[bstack_weak]` field, located at absolute on-disk offset `field_off`, +/// to point at `new_weak` — releasing any weak reference the field previously +/// held. +/// +/// The field stores the child's **control-block** offset, not its data offset: +/// the control block outlives the data block (it lives while `weak > 0`), so +/// resolving it at teardown is sound even after the target's data has been +/// freed. `new_weak` is consumed and the weak count it holds becomes the field's; +/// a previous non-null target has its weak count decremented. 0 means "unset". +pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( + allocator: &A, + field_off: u64, + new_weak: BStackWeak<'w, T, A>, +) -> io::Result<()> { + let stack = allocator.stack(); + + // Release the control reference the field previously held, if any. + let mut buf = [0u8; 8]; + stack.get_into(field_off, &mut buf)?; + let old = u64::from_le_bytes(buf); + if old != 0 { + let old_ctrl = unsafe { + BStackRef::::from_range(BStackRange::new( + old, + size_of::() as u64, + )) + }; + WeakRef::(old_ctrl).bstack_drop(allocator)?; + } + + // Store the new control offset; the consumed weak's count is now the field's. + let ctrl = new_weak.into_raw(); + stack.set(field_off, ctrl.into_range().start().to_le_bytes()) +} + +/// Attempt to upgrade a `#[bstack_weak]` field (holding a control-block offset at +/// `field_off`) to a strong handle. Returns `None` if the field is unset (0) or +/// the target's strong count has already reached zero. What a generated weak +/// field accessor calls. +pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator>( + allocator: &'a A, + field_off: u64, +) -> io::Result>> { + let mut buf = [0u8; 8]; + allocator.stack().get_into(field_off, &mut buf)?; + let off = u64::from_le_bytes(buf); + if off == 0 { + return Ok(None); + } + let ctrl = unsafe { + BStackRef::::from_range(BStackRange::new(off, size_of::() as u64)) + }; + // Borrow a weak over the field's control ref just long enough to upgrade; + // consume it via `into_raw` so the field's own weak count is untouched. + let weak = unsafe { BStackWeak::from_raw(ctrl, allocator) }; + let result = weak.upgrade(); + let _ = weak.into_raw(); + result +} diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 06df2f8..91a0047 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -20,9 +20,9 @@ use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackWeakable}; use crate::layout; -use crate::reference::BStackRef; use crate::refcount; -use crate::teardown::{dealloc_range, BStackDrop}; +use crate::reference::BStackRef; +use crate::teardown::{BStackDrop, dealloc_range}; /// `#[bstack_owned]`: an exclusively-owned child. #[derive(Clone, Copy)] diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 8d56ed2..1c59659 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -44,6 +44,10 @@ #![allow(dead_code, unused_imports, unused_variables)] +// Lets code generated by `#[bstack_block]` reference this crate as +// `::bstack_raii::…` even from within the crate's own tests. +extern crate self as bstack_raii; + mod block; mod clone; mod construct; @@ -58,9 +62,9 @@ mod teardown; #[cfg(test)] mod tests; -pub use block::{BStackBlock, BStackCast, BStackWeakable}; +pub use block::{BStackBlock, BStackCast, BStackMove, BStackShared, BStackWeakable}; pub use clone::TryClone; -pub use construct::{alloc_block, alloc_control, init_rc}; +pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; @@ -68,5 +72,13 @@ pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{BStackDrop, dealloc_range}; +// Re-exports for use by `#[bstack_block]`-generated code (and callers), so that +// generated code can name everything through `::bstack_raii::…` and downstream +// crates need not depend on `bstack` or `bytemuck` directly. +pub use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; +pub use bytemuck::{Pod, Zeroable}; +// Re-exported whole so generated code can call `::bstack_raii::bytemuck::bytes_of`. +pub use bytemuck; + // Procedural macros, re-exported so downstream depends only on `bstack_raii`. pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_move}; diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index 39d5cae..bc39306 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -46,6 +46,12 @@ impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { pub fn allocator(&self) -> &'a A { self.allocator } + + /// Borrow the underlying typed handle, e.g. to call generated field + /// accessors: `owned.handle().field(stack)`. + pub fn handle(&self) -> &T { + &self.inner + } } impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for BStackOwned<'a, T, A> { diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 144a048..1d63154 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -7,10 +7,10 @@ use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackWeakable}; use crate::clone::TryClone; -use crate::handle::{strong_release_ctrl, StrongRef, WeakRef}; +use crate::handle::{StrongRef, WeakRef, strong_release_ctrl}; use crate::layout; -use crate::reference::BStackRef; use crate::refcount; +use crate::reference::BStackRef; use crate::teardown::BStackDrop; /// A shared, refcounted, allocator-bound handle. @@ -51,7 +51,26 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { ctrl: Option, allocator: &'a A, ) -> Self { - Self { data, ctrl, allocator } + Self { + data, + ctrl, + allocator, + } + } + + /// The underlying typed handle, e.g. to call generated field accessors: + /// `rc.handle().field(stack)`. Cheap: it just re-wraps the data ref and does + /// not touch the refcount. + pub fn handle(&self) -> T { + ::from_range(self.data.into_range()) + } + + /// Consume the handle into its raw parts **without** decrementing the strong + /// count — the count is transferred to the caller (e.g. into a parent's + /// `#[bstack_strong]` field). `ctrl` is `Some` for `(rc, weak)` blocks. + pub fn into_raw(self) -> (BStackRef, Option) { + let me = core::mem::ManuallyDrop::new(self); + (me.data, me.ctrl) } /// Byte offset of the strong counter for this handle's block kind. @@ -66,7 +85,11 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { refcount::fetch_add(self.allocator.stack(), self.strong_offset(), 1)?; - Ok(Self { data: self.data, ctrl: self.ctrl, allocator: self.allocator }) + Ok(Self { + data: self.data, + ctrl: self.ctrl, + allocator: self.allocator, + }) } } @@ -84,7 +107,10 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; let ctrl = unsafe { BStackRef::::from_range(ctrl_range) }; - Ok(BStackWeak { ctrl, allocator: self.allocator }) + Ok(BStackWeak { + ctrl, + allocator: self.allocator, + }) } } @@ -93,9 +119,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> Drop for BStackRc<'a, T, // Errors are swallowed, matching the contract of Rust's `Drop`. let _ = match self.ctrl { None => StrongRef(self.data).bstack_drop(self.allocator), - Some(ctrl) => { - strong_release_ctrl::(self.allocator, self.data.into_range(), ctrl) - } + Some(ctrl) => strong_release_ctrl::(self.allocator, self.data.into_range(), ctrl), }; } } @@ -120,6 +144,13 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { Self { ctrl, allocator } } + /// Consume the handle into its raw control ref **without** decrementing the + /// weak count — the count is transferred to the caller. + pub fn into_raw(self) -> BStackRef { + let me = core::mem::ManuallyDrop::new(self); + me.ctrl + } + /// Attempt to promote to a strong handle. Succeeds iff `ctrl.strong` is /// currently non-zero (CAS-increment-if-nonzero), reading `ctrl.x` to recover /// the data ref. Returns `None` if the data block is already gone. @@ -136,7 +167,11 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { stack.get_into(data_pos, &mut bytes)?; let data_range = BStackRange::new(u64::from_le_bytes(bytes), size_of::() as u64); let data = unsafe { BStackRef::::from_range(data_range) }; - Ok(Some(BStackRc { data, ctrl: Some(ctrl_range), allocator: self.allocator })) + Ok(Some(BStackRc { + data, + ctrl: Some(ctrl_range), + allocator: self.allocator, + })) } } @@ -144,7 +179,10 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWea fn try_clone(&self) -> io::Result { let weak_off = self.ctrl.into_range().start() + layout::CTRL_WEAK_OFFSET; refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; - Ok(Self { ctrl: self.ctrl, allocator: self.allocator }) + Ok(Self { + ctrl: self.ctrl, + allocator: self.allocator, + }) } } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 71353ac..a80b01d 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -8,12 +8,15 @@ use core::mem::size_of; use std::io; use std::sync::atomic::{AtomicU64, Ordering}; -use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, FirstFitBStackAllocator}; +use bstack::{ + BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, FirstFitBStackAllocator, +}; use crate::layout::{self, BlockHeader}; use crate::{ - alloc_block, alloc_control, dealloc_range, BStackBlock, BStackCast, BStackDrop, BStackRc, - BStackRef, BStackWeakable, EightCC, TryClone, + BStackBlock, BStackCast, BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, + BStackWeakable, EightCC, TryClone, alloc_block, alloc_control, bstack_block, bstack_move, + dealloc_range, }; // -------------------------------------------------------------------------- @@ -31,7 +34,10 @@ impl TempStack { fn new() -> Self { let n = COUNTER.fetch_add(1, Ordering::Relaxed); let mut path = std::env::temp_dir(); - path.push(format!("bstack_raii_test_{}_{n}.bstack", std::process::id())); + path.push(format!( + "bstack_raii_test_{}_{n}.bstack", + std::process::id() + )); let _ = std::fs::remove_file(&path); TempStack { path } } @@ -143,12 +149,18 @@ fn refcount_ops() { assert_eq!(crate::refcount::load(&stack, off).unwrap(), 6); assert_eq!(crate::refcount::fetch_sub(&stack, off, 2).unwrap(), 6); assert_eq!(crate::refcount::load(&stack, off).unwrap(), 4); - assert_eq!(crate::refcount::increment_if_nonzero(&stack, off).unwrap(), Some(5)); + assert_eq!( + crate::refcount::increment_if_nonzero(&stack, off).unwrap(), + Some(5) + ); // Drive to zero, then confirm zero is terminal for increment_if_nonzero. assert_eq!(crate::refcount::fetch_sub(&stack, off, 5).unwrap(), 5); assert_eq!(crate::refcount::load(&stack, off).unwrap(), 0); - assert_eq!(crate::refcount::increment_if_nonzero(&stack, off).unwrap(), None); + assert_eq!( + crate::refcount::increment_if_nonzero(&stack, off).unwrap(), + None + ); assert_eq!(crate::refcount::load(&stack, off).unwrap(), 0); // Underflow is an error, not a wrap. @@ -170,7 +182,10 @@ fn rc_weak_lifecycle() { // Initial state and the wired back/forward pointers. assert_eq!(load(strong_off), 1); assert_eq!(load(weak_off), 1); - assert_eq!(load(data.start() + layout::CTRL_BACKPTR_OFFSET), ctrl.start()); + assert_eq!( + load(data.start() + layout::CTRL_BACKPTR_OFFSET), + ctrl.start() + ); assert_eq!(load(ctrl.start() + layout::CTRL_DATA_OFFSET), data.start()); let rc = rc_of(&alloc, data, ctrl); @@ -284,3 +299,361 @@ fn concurrent_upgrade_downgrade() { drop(weak); // weak 2 -> 1 drop(rc); // strong -> 0 frees data; phantom release frees control } + +// -------------------------------------------------------------------------- +// #[bstack_block] macro — recursive teardown of an owned child +// -------------------------------------------------------------------------- + +#[bstack_block] +struct MacroLeaf { + val: u32, +} + +#[bstack_block] +struct MacroParent { + #[bstack_owned] + child: MacroLeaf, + tag: u32, +} + +#[test] +fn macro_recursive_drop() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let leaf_size = size_of::<::OnDisk>() as u64; + let parent_size = size_of::<::OnDisk>() as u64; + + let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + let parent = alloc_block(&alloc, MacroParent::eightcc(), parent_size).unwrap(); + // Wire parent.child -> leaf (the first user field sits right after the header). + alloc + .stack() + .set( + parent.start() + layout::HEADER_SIZE, + leaf.start().to_le_bytes(), + ) + .unwrap(); + + // Own the parent; dropping it must recursively free the child, then itself. + let owned = + unsafe { BStackOwned::from_raw(::from_range(parent), &alloc) }; + drop(owned); + + // The child's slot (allocated first, so the lowest offset) is reclaimed — + // proof the generated `bstack_drop` recursed into the owned child. + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// -------------------------------------------------------------------------- +// #[bstack_block(rc, weak)] macro — control block + recursive owned child +// -------------------------------------------------------------------------- + +#[bstack_block(rc, weak)] +struct MacroShared { + #[bstack_owned] + child: MacroLeaf, +} + +#[test] +fn macro_rc_weak_with_child() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let leaf_size = size_of::<::OnDisk>() as u64; + let data_size = size_of::<::OnDisk>() as u64; + let ctrl_size = size_of::<::Control>() as u64; + + let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + let data = alloc_block(&alloc, MacroShared::eightcc(), data_size).unwrap(); + // `child` sits after the header and the injected `ctrl` field (16 + 8). + alloc + .stack() + .set( + data.start() + layout::HEADER_SIZE + 8, + leaf.start().to_le_bytes(), + ) + .unwrap(); + let ctrl = alloc_control(&alloc, ctrl_tag(), data, ctrl_size).unwrap(); + + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + let weak_off = ctrl.start() + layout::CTRL_WEAK_OFFSET; + let load = |o: u64| crate::refcount::load(alloc.stack(), o).unwrap(); + assert_eq!(load(strong_off), 1); + assert_eq!(load(weak_off), 1); + + let rc = unsafe { + BStackRc::::from_raw(BStackRef::from_range(data), Some(ctrl), &alloc) + }; + let rc2 = rc.try_clone().unwrap(); + assert_eq!(load(strong_off), 2); + let weak = rc.downgrade().unwrap(); + assert_eq!(load(weak_off), 2); + + drop(rc2); + // Last strong drop: frees the data block AND recursively its owned child, + // then releases the phantom weak (2 -> 1); control survives. + drop(rc); + assert_eq!(load(strong_off), 0); + assert_eq!(load(weak_off), 1); + + assert!(weak.upgrade().unwrap().is_none()); + drop(weak); // frees the control block + + // With leaf + data + control all freed (and coalesced, since they were + // allocated consecutively), the lowest slot is reclaimable only if the owned + // child was actually recursively freed — otherwise leaf's slot would still be + // live and a fresh alloc would land higher. + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// -------------------------------------------------------------------------- +// #[bstack_strong] — parent drop dispatches through BStackShared to the child +// -------------------------------------------------------------------------- + +#[bstack_block(rc, weak)] +struct MacroStrongChild { + val: u32, +} + +#[bstack_block] +struct MacroStrongParent { + #[bstack_strong] + s: MacroStrongChild, +} + +#[test] +fn macro_strong_child() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let child_data_size = size_of::<::OnDisk>() as u64; + let child_ctrl_size = size_of::<::Control>() as u64; + let parent_size = size_of::<::OnDisk>() as u64; + + let child = alloc_block(&alloc, MacroStrongChild::eightcc(), child_data_size).unwrap(); + let child_ctrl = alloc_control(&alloc, ctrl_tag(), child, child_ctrl_size).unwrap(); + let strong_off = child_ctrl.start() + layout::CTRL_STRONG_OFFSET; + // A second, keep-alive strong owner besides the parent's `s` field. + crate::refcount::fetch_add(alloc.stack(), strong_off, 1).unwrap(); // strong = 2 + + let parent = alloc_block(&alloc, MacroStrongParent::eightcc(), parent_size).unwrap(); + // `s` is the first user field, right after the header. + alloc + .stack() + .set( + parent.start() + layout::HEADER_SIZE, + child.start().to_le_bytes(), + ) + .unwrap(); + + // Dropping the parent runs its generated teardown, which dispatches through + // BStackShared::drop_strong_ref to decrement the child's strong count. + let owned = unsafe { + BStackOwned::from_raw( + ::from_range(parent), + &alloc, + ) + }; + drop(owned); + assert_eq!(crate::refcount::load(alloc.stack(), strong_off).unwrap(), 1); // child survives + + // Release the keep-alive: strong -> 0 frees the child data + control block. + MacroStrongChild::drop_strong_ref(unsafe { BStackRef::from_range(child) }, &alloc).unwrap(); + let reused = alloc_block(&alloc, MacroStrongChild::eightcc(), child_data_size).unwrap(); + assert_eq!(reused.start(), child.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// -------------------------------------------------------------------------- +// Generated `new` constructors + field accessors +// -------------------------------------------------------------------------- + +#[test] +fn macro_new_and_accessors() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Plain-block constructor: allocates and writes the whole payload. + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + assert_eq!(leaf.handle().val(stack).unwrap(), 42); + + // Owned child is consumed by the parent constructor (ownership transferred). + let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); + assert_eq!(parent.handle().tag(stack).unwrap(), 7); + + // Accessor resolves the owned-ref field to the child handle; reading its own + // field proves the child pointer was wired correctly. + let child = parent.handle().child(stack).unwrap(); + assert_eq!(child.val(stack).unwrap(), 42); + + // Dropping the parent recursively frees the child then itself (no panic / + // error swallowed by Drop); recursion correctness is covered elsewhere. + drop(parent); +} + +#[test] +fn macro_new_rc_weak() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // (rc, weak) constructor allocates the data block AND wires a control block. + let leaf = MacroLeaf::new(&alloc, 99).unwrap(); + let rc = MacroShared::new(&alloc, leaf).unwrap(); + + // Traverse through the shared handle to the owned child and read it. + assert_eq!(rc.handle().child(stack).unwrap().val(stack).unwrap(), 99); + + // Full shared lifecycle on a constructor-built block. + let rc2 = rc.try_clone().unwrap(); + let weak = rc.downgrade().unwrap(); + drop(rc2); + drop(rc); + assert!(weak.upgrade().unwrap().is_none()); + drop(weak); +} + +// -------------------------------------------------------------------------- +// #[bstack_weak] field — constructor (null init), setter, upgrade accessor, and +// sound teardown when the target's data is freed first (the cycle case). +// -------------------------------------------------------------------------- + +#[bstack_block(rc, weak)] +struct WNode { + #[bstack_weak] + back: WNode, + val: u32, +} + +#[test] +fn macro_weak_field_cycle() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + // Constructor works for a weak-field block; `back` starts null. + let a = WNode::new(&alloc, 1).unwrap(); + let b = WNode::new(&alloc, 2).unwrap(); + let a_data = a.handle().range().start(); // lowest allocation + + // Setter wires b.back -> a as a weak reference. + b.handle().set_back(&alloc, a.downgrade().unwrap()).unwrap(); + + // Upgrade accessor resolves the live target. + let up = b.handle().back(&alloc).unwrap().expect("a is alive"); + assert_eq!(up.handle().val(alloc.stack()).unwrap(), 1); + drop(up); + + // Drop the strong owner `a` first: its DATA block is freed, but its control + // block survives because b.back still holds a weak count. + drop(a); + + // The weak field can no longer upgrade — and reaching this did NOT read a's + // freed data block, because the field stores a's control offset. + assert!(b.handle().back(&alloc).unwrap().is_none()); + + // Dropping `b` releases b.back's weak on a's control block (freeing it), then + // frees b. No use-after-free of a's data. + drop(b); + + // Everything (a data+control, b data+control) is freed and coalesced, so the + // lowest slot — a's — is reclaimed. + let reused = alloc_block( + &alloc, + WNode::eightcc(), + size_of::<::OnDisk>() as u64, + ) + .unwrap(); + assert_eq!(reused.start(), a_data); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// -------------------------------------------------------------------------- +// bstack_move! — destructure an owned block into its field handles +// -------------------------------------------------------------------------- + +#[test] +fn macro_bstack_move() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 55).unwrap(); + let leaf_off = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); + + // Move the fields out: owned child -> BStackOwned, tag -> u32. + let (child, tag) = bstack_move!(parent).unwrap(); + assert_eq!(tag, 7); + + // Ownership of the child transferred (same allocation), and it is still live + // because bstack_move! frees only the parent shell. + assert_eq!(child.handle().range().start(), leaf_off); + assert_eq!(child.handle().val(stack).unwrap(), 55); + + // Dropping the moved-out child frees the leaf. With the parent shell already + // freed, both slots coalesce and the lowest (leaf's) is reclaimed. + drop(child); + let reused = alloc_block( + &alloc, + MacroLeaf::eightcc(), + size_of::<::OnDisk>() as u64, + ) + .unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// A plain block whose fields reference shared blocks (both `(rc, weak)`). +#[bstack_block] +struct MoveHolder { + #[bstack_strong] + s: MacroStrongChild, + #[bstack_weak] + w: WNode, + n: u32, +} + +#[test] +fn macro_bstack_move_shared() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let sc = MacroStrongChild::new(&alloc, 88).unwrap(); // BStackRc, strong = 1 + let wt = WNode::new(&alloc, 3).unwrap(); // the weak target + + // The strong field consumes `sc` (transferring its strong count); the weak + // field is wired after construction. + let holder = MoveHolder::new(&alloc, sc, 5).unwrap(); + holder + .handle() + .set_w(&alloc, wt.downgrade().unwrap()) + .unwrap(); + + // Move every field out: strong -> BStackRc, weak -> Option, pod. + let (moved_s, moved_w, n) = bstack_move!(holder).unwrap(); + assert_eq!(n, 5); + + // The strong field came back as a live BStackRc. + assert_eq!(moved_s.handle().val(stack).unwrap(), 88); + + // The weak field came back as Some(weak) and still upgrades (target alive). + let up = moved_w + .as_ref() + .unwrap() + .upgrade() + .unwrap() + .expect("wt alive"); + assert_eq!(up.handle().val(stack).unwrap(), 3); + drop(up); + + // Clean teardown across the moved-out handles. + drop(moved_s); // frees the strong child + drop(moved_w); // releases the weak on wt's control block + drop(wt); // frees wt (data + control) +} From e88d6c7d47675fa1d3dec253c0b5f914ce3035e9 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:09:40 -0700 Subject: [PATCH 011/140] Make 8CC more sophiscated --- bstack_raii/derive/src/block.rs | 266 +++++++++++++++++++++++++++++--- bstack_raii/derive/src/lib.rs | 58 ++++--- bstack_raii/src/layout.rs | 10 +- bstack_raii/src/tests.rs | 101 +++++++++++- 4 files changed, 380 insertions(+), 55 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 2d6b28c..ba58605 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -14,7 +14,7 @@ use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::parse::Parser; use syn::punctuated::Punctuated; -use syn::{Error, Fields, Ident, ItemStruct, Token, Type}; +use syn::{Error, Expr, ExprLit, Fields, Ident, ItemStruct, Lit, Meta, Token, Type}; /// The block mode from the attribute arguments. #[derive(Clone, Copy, PartialEq)] @@ -39,7 +39,8 @@ enum Kind { } pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result { - let mode = parse_mode(attr)?; + let attr = parse_attr(attr)?; + let mode = attr.mode; if !input.generics.params.is_empty() { return Err(Error::new_spanned( @@ -243,7 +244,48 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } - let tag = name.to_string(); + // EightCC tags: readable prefix over a hash of `crate ++ type_name`. The + // control tag uses the same hash with the prefix lowercased. + let type_name = name.to_string(); + let crate_name = std::env::var("CARGO_PKG_NAME").unwrap_or_default(); + let hash = fnv1a64(&format!("{crate_name}\0{type_name}")); + let data_prefix = attr.tag.as_ref().map_or_else( + || auto_prefix(&type_name), + |t| t.bytes().collect::>(), + ); + let ctrl_prefix = attr.ctrl_tag.as_ref().map_or_else( + || { + data_prefix + .iter() + .map(u8::to_ascii_lowercase) + .collect::>() + }, + |t| t.bytes().collect::>(), + ); + let data_tag = build_tag(hash, &data_prefix); + let ctrl_tag = build_tag(hash, &ctrl_prefix); + let data_eightcc = eightcc_expr(&data_tag.bytes); + let ctrl_eightcc = eightcc_expr(&ctrl_tag.bytes); + + // Overlong `tag =` / `ctrl_tag =` overrides warn (unless silenced) + truncate. + let overlong_warning = if (data_tag.truncated || ctrl_tag.truncated) && !attr.allow_long { + let warn_fn = format_ident!("__bstack_tag_overlong_{}", name); + let msg = format!( + "#[bstack_block] on `{type_name}`: a tag override longer than 8 bytes was truncated; \ + add `allow_long_tag` to silence" + ); + quote! { + #[doc(hidden)] + #[allow(dead_code, non_snake_case)] + fn #warn_fn() { + #[deprecated(note = #msg)] + fn overlong_tag() {} + overlong_tag(); + } + } + } else { + quote!() + }; // BStackShared / BStackWeakable / control block for the refcounted modes. let shared_impl = match mode { @@ -317,10 +359,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let constructor = constructor( - name, vis, &on_disk, mode, + &ctrl_eightcc, &ctor_params, &ctor_preps, &ctor_inits, @@ -378,7 +420,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result impl ::bstack_raii::BStackCast for #name { fn eightcc() -> ::bstack_raii::EightCC { - ::bstack_raii::EightCC::from_name(#tag) + #data_eightcc } } @@ -416,6 +458,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #shared_impl #weakable_items #move_impl + #overlong_warning }) } @@ -520,10 +563,10 @@ fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Ident /// Assemble the `new` constructor. fn constructor( - name: &Ident, vis: &syn::Visibility, on_disk: &Ident, mode: Mode, + ctrl_eightcc: &TokenStream, params: &[TokenStream], preps: &[TokenStream], inits: &[TokenStream], @@ -556,11 +599,10 @@ fn constructor( }) }, Mode::RcWeak => { - let ctrl_tag = format!("{name}Ref"); quote! { let __ctrl = match ::bstack_raii::alloc_control( allocator, - ::bstack_raii::EightCC::from_name(#ctrl_tag), + #ctrl_eightcc, __data, ::core::mem::size_of::<::Control>() as u64, ) { @@ -624,25 +666,199 @@ fn child_range_stmt(fname: &Ident, fty: &Type, body: TokenStream) -> TokenStream } } -/// Parse the attribute arguments into a [`Mode`]: ``, `rc`, or `rc, weak`. -fn parse_mode(attr: TokenStream) -> syn::Result { - if attr.is_empty() { - return Ok(Mode::Plain); +/// Parsed `#[bstack_block(...)]` arguments. +struct Attr { + mode: Mode, + /// Explicit data-block tag prefix (`tag = "..."`). + tag: Option, + /// Explicit control-block tag prefix (`ctrl_tag = "..."`). + ctrl_tag: Option, + /// Suppress the overlong-tag warning (`allow_long_tag`). + allow_long: bool, +} + +/// Parse `rc`, `weak`, `tag = "..."`, `ctrl_tag = "..."`, `allow_long_tag` in any +/// order. +fn parse_attr(attr: TokenStream) -> syn::Result { + let (mut rc, mut weak) = (false, false); + let (mut tag, mut ctrl_tag, mut allow_long) = (None, None, false); + + if !attr.is_empty() { + let metas = Punctuated::::parse_terminated.parse2(attr)?; + for meta in metas { + match &meta { + Meta::Path(p) => match ident_of(p).as_deref() { + Some("rc") => rc = true, + Some("weak") => weak = true, + Some("allow_long_tag") => allow_long = true, + _ => return Err(Error::new_spanned(&meta, unknown_opt())), + }, + Meta::NameValue(nv) => { + let value = match &nv.value { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => s.value(), + other => { + return Err(Error::new_spanned(other, "expected a string literal")); + } + }; + match ident_of(&nv.path).as_deref() { + Some("tag") => tag = Some(value), + Some("ctrl_tag") => ctrl_tag = Some(value), + _ => return Err(Error::new_spanned(&meta, unknown_opt())), + } + } + _ => return Err(Error::new_spanned(&meta, unknown_opt())), + } + } } - let parser = Punctuated::::parse_terminated; - let idents: Vec = parser - .parse2(attr)? - .into_iter() - .map(|i| i.to_string()) - .collect(); - match idents.as_slice() { - [rc] if rc == "rc" => Ok(Mode::Rc), - [rc, weak] if rc == "rc" && weak == "weak" => Ok(Mode::RcWeak), - _ => Err(Error::new( - Span::call_site(), - "expected `#[bstack_block]`, `#[bstack_block(rc)]`, or `#[bstack_block(rc, weak)]`", - )), + + let mode = match (rc, weak) { + (false, false) => Mode::Plain, + (true, false) => Mode::Rc, + (true, true) => Mode::RcWeak, + (false, true) => { + return Err(Error::new( + Span::call_site(), + "`weak` requires `rc` (use `rc, weak`)", + )); + } + }; + Ok(Attr { + mode, + tag, + ctrl_tag, + allow_long, + }) +} + +fn ident_of(path: &syn::Path) -> Option { + path.get_ident().map(|i| i.to_string()) +} + +fn unknown_opt() -> &'static str { + "expected `rc`, `weak`, `tag = \"...\"`, `ctrl_tag = \"...\"`, or `allow_long_tag`" +} + +// --------------------------------------------------------------------------- +// EightCC tag generation +// +// An 8-byte tag = a readable ASCII prefix (2–5 auto, or a `tag =` override) over +// the first N bytes, followed by the tail of a 64-bit FNV-1a hash of +// `crate_name ++ "\0" ++ type_name`. Every tail byte has its high bit set so it +// lands in the non-printable range and can't be mistaken for the prefix. The +// control-block tag is the same, with the prefix lowercased. See the +// `#[bstack_block]` docs. +// --------------------------------------------------------------------------- + +/// The value passed to `EightCC::new([..])` plus whether the prefix was longer +/// than 8 bytes (and hence truncated). +struct Tag { + bytes: [u8; 8], + truncated: bool, +} + +fn fnv1a64(s: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in s.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); } + h +} + +/// Compose a tag from a hash and a readable prefix. Prefix bytes overwrite the +/// (high-bit-set) hash bytes from the front; > 8 prefix bytes are truncated. +fn build_tag(hash: u64, prefix: &[u8]) -> Tag { + let mut bytes = hash.to_le_bytes(); + for b in bytes.iter_mut() { + *b |= 0x80; + } + let truncated = prefix.len() > 8; + let n = prefix.len().min(8); + bytes[..n].copy_from_slice(&prefix[..n]); + Tag { bytes, truncated } +} + +fn is_ascii_vowel(b: u8) -> bool { + matches!( + b.to_ascii_uppercase(), + b'A' | b'E' | b'I' | b'O' | b'U' | b'Y' + ) +} + +/// Split a type name into words on camel-case boundaries and separators. +fn split_words(name: &str) -> Vec { + let chars: Vec = name.chars().collect(); + let mut words = Vec::new(); + let mut cur = String::new(); + for (i, &c) in chars.iter().enumerate() { + if !c.is_alphanumeric() { + if !cur.is_empty() { + words.push(std::mem::take(&mut cur)); + } + continue; + } + let boundary = !cur.is_empty() + && ((c.is_uppercase() && chars[i - 1].is_lowercase()) + || (c.is_uppercase() + && chars[i - 1].is_uppercase() + && chars.get(i + 1).is_some_and(|n| n.is_lowercase()))); + if boundary { + words.push(std::mem::take(&mut cur)); + } + cur.push(c); + } + if !cur.is_empty() { + words.push(cur); + } + words +} + +/// Auto-derive a 2–5 byte uppercase prefix from a type name: initials of the +/// words if there are ≥ 2, else the de-voweled single word. +fn auto_prefix(name: &str) -> Vec { + let words = split_words(name); + let prefix: Vec = if words.len() >= 2 { + words + .iter() + .filter_map(|w| w.bytes().next()) + .map(|b| b.to_ascii_uppercase()) + .take(5) + .collect() + } else { + let letters: Vec = words + .first() + .map(|w| { + w.bytes() + .filter(u8::is_ascii_alphanumeric) + .map(|b| b.to_ascii_uppercase()) + .collect() + }) + .unwrap_or_default(); + let mut v = Vec::new(); + for (i, &b) in letters.iter().enumerate() { + // Keep the first letter always; drop vowels from the rest. + if i == 0 || !is_ascii_vowel(b) { + v.push(b); + } + if v.len() == 5 { + break; + } + } + // Fall back to the first two letters if de-voweling left < 2. + if v.len() < 2 { + v = letters.into_iter().take(2).collect(); + } + v + }; + prefix +} + +/// Emit `::bstack_raii::EightCC::new([..])` from tag bytes. +fn eightcc_expr(bytes: &[u8; 8]) -> TokenStream { + let bytes = bytes.iter(); + quote!(::bstack_raii::EightCC::new([#(#bytes),*])) } /// Classify a field by its ownership annotation. diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index d0e5055..d6afbf1 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -10,11 +10,6 @@ //! transferring ownership of every field out as a tuple of typed handles. //! * [`bstack_cast`] — function-like macro. Direction-inferring typed/untyped //! handle conversion. -//! -//! Everything below is a scaffold: the parsing/validation/codegen bodies are the -//! work to be filled in. The signatures and the emitted-shape contracts are -//! fixed so the runtime crate and downstream callers can be developed in -//! parallel. use proc_macro::TokenStream; @@ -22,26 +17,41 @@ mod block; /// `#[bstack_block]` — generate the on-disk layout and typed handle machinery. /// -/// Accepts optional mode arguments: `#[bstack_block]`, `#[bstack_block(rc)]`, or -/// `#[bstack_block(rc, weak)]`. +/// # Arguments +/// +/// `#[bstack_block(rc)]` / `#[bstack_block(rc, weak)]` select the refcount mode. +/// `tag = "…"` / `ctrl_tag = "…"` override the generated tags (see below), and +/// `allow_long_tag` silences the truncation warning for an over-long override. +/// All are optional and may appear in any order. +/// +/// # Generated items (for `struct X { .. }`) +/// +/// * `struct X(BStackRange)` — the typed handle, and `struct XOnDisk` +/// (`#[repr(C, packed)]`, `Pod`) — the on-disk payload. `#[bstack_owned]` / +/// `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` fields lower to a +/// `u64` offset; un-annotated fields are stored inline (and must be `Pod`). +/// `(rc)` injects an inline `refcount`; `(rc, weak)` injects a `ctrl` +/// back-pointer and emits an `XOnDiskRef` control block. +/// * `impl BStackCast / BStackBlock / BStackDrop`, plus `BStackShared` +/// (`rc` / `rc, weak`), `BStackWeakable` (`rc, weak`), and `BStackMove` +/// (plain blocks — the `bstack_move!` target). +/// * Field accessors, `set_` for weak fields, and a `new` constructor. +/// +/// # EightCC tag generation +/// +/// Each block's [`EightCC`](../bstack_raii/struct.EightCC.html) is an 8-byte tag +/// = a **readable ASCII prefix** over the first bytes, followed by the tail of a +/// **64-bit FNV-1a hash** of `crate_name ++ "\0" ++ type_name` (little-endian). +/// Every hash-tail byte has its high bit set, so it lands in the non-printable +/// range and reads as clearly-not-a-name in a hex dump. The hash keeps distinct +/// types apart even when their prefixes collide, and is deterministic (stable +/// across builds/versions) so it is safe as on-disk ABI. /// -/// Must generate, for an input `struct X { .. }`: -/// 1. `struct XOnDisk` — `#[repr(C, packed)]`, `header: BlockHeader` first, then -/// each field lowered per its annotation: -/// * `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / -/// `#[bstack_ref]` → `BStackRef` (validate exactly one annotation on -/// each non-POD field). -/// * un-annotated field → stored inline; must be `bytemuck::Pod` (reject -/// otherwise at expansion time). -/// * `(rc)` injects `refcount: AtomicU64` after the header; `(rc, weak)` -/// instead injects `ctrl: BStackRef` and emits a separate -/// `struct XOnDiskRef` control block (`strong`, `weak`, back-pointer). -/// 2. Field accessor methods on `X` that `read_into` a buffer and read the field. -/// 3. `impl BStackDrop for X` — post-order: one child-handle `.bstack_drop(allocator)?` -/// per non-`#[bstack_ref]`, non-POD field, then `dealloc_range` of the block. -/// 4. `impl BStackCast for X` with an `EightCC` derived from the type name. -/// 5. `impl BStackWeakable for X { type Control = XOnDiskRef; }` only for -/// `(rc, weak)`. +/// The prefix is derived from the type name: initials of the camel-case words +/// (≥ 2 words), or the de-voweled single word, clamped to 2–5 bytes. Override it +/// with `tag = "PREFIX"` (0–8 bytes; fewer than 8 leaves room for hash, exactly +/// 8 is a fully manual tag, over 8 warns and truncates). The control block's tag +/// is the data tag with its prefix **lowercased**, or an explicit `ctrl_tag`. #[proc_macro_attribute] pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as syn::ItemStruct); diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index 5a1b9a8..f74be33 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -8,9 +8,13 @@ use bytemuck::{Pod, Zeroable}; /// An 8-byte type tag stored in every [`BlockHeader`]. /// /// Used instead of a 4-byte `FourCC` because `bstack` offsets are 64-bit, so -/// 8-byte alignment is natural. The `#[bstack_block]` macro derives it from the -/// block type's name via [`EightCC::from_name`]; [`crate::BStackCast`] compares -/// it during safe downcasts. +/// 8-byte alignment is natural. [`crate::BStackCast`] compares it during safe +/// downcasts, so it must be unique per block type. +/// +/// The `#[bstack_block]` macro generates it as a readable ASCII prefix followed +/// by the high-bit-set tail of a hash of the crate + type name (so distinct +/// types stay distinct even with a shared prefix) — see the macro docs. +/// [`EightCC::from_name`] is the simpler truncating form, for manual use. #[repr(transparent)] #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Pod, Zeroable)] pub struct EightCC(pub [u8; 8]); diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index a80b01d..ca4d388 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -225,9 +225,9 @@ fn rc_weak_lifecycle() { } /// Many threads hammering `try_clone` + `drop` on a shared strong handle. Each -/// iteration is a balanced +1/-1 on `strong`, and the main handle keeps `strong` -/// >= 1 throughout (so no teardown races). If the on-disk RMW were not atomic -/// under contention, lost updates would leave the final count off. +/// iteration is a balanced +1/-1 on `strong`, and the main handle keeps at least +/// one strong reference throughout (so no teardown races). If the on-disk RMW +/// were not atomic under contention, lost updates would leave the final count off. #[test] fn concurrent_clone_drop() { const THREADS: usize = 8; @@ -657,3 +657,98 @@ fn macro_bstack_move_shared() { drop(moved_w); // releases the weak on wt's control block drop(wt); // frees wt (data + control) } + +// -------------------------------------------------------------------------- +// EightCC tag generation: readable prefix + non-printable hash tail +// -------------------------------------------------------------------------- + +#[bstack_block] +struct SomeAbstractThing { + x: u32, +} + +#[bstack_block] +struct ABlock { + x: u32, +} + +#[bstack_block(tag = "OVR")] +struct Overridden { + x: u32, +} + +#[bstack_block(rc, weak)] +struct TagCtrl { + x: u32, +} + +// Same forced prefix, different type names → hash tails must differ. +#[bstack_block(tag = "SAME")] +struct SameA { + x: u32, +} +#[bstack_block(tag = "SAME")] +struct SameB { + x: u32, +} + +// Overlong override is truncated to 8 bytes (warning silenced). +#[bstack_block(tag = "TOOLONGTAG12", allow_long_tag)] +struct Truncated { + x: u32, +} + +#[test] +fn macro_tag_generation() { + // CamelCase initials, and the tail is the high-bit (non-printable) hash. + let t = SomeAbstractThing::eightcc().0; + assert_eq!(&t[0..3], b"SAT"); + assert!(t[3..].iter().all(|&b| b & 0x80 != 0)); + + // Two-word initials. + assert_eq!(&ABlock::eightcc().0[0..2], b"AB"); + + // Manual prefix override. + let o = Overridden::eightcc().0; + assert_eq!(&o[0..3], b"OVR"); + assert!(o[3..].iter().all(|&b| b & 0x80 != 0)); + + // Same prefix, different names → identical prefix, different hash tails. + let a = SameA::eightcc().0; + let b = SameB::eightcc().0; + assert_eq!(&a[0..4], b"SAME"); + assert_eq!(&b[0..4], b"SAME"); + assert_ne!(a[4..], b[4..]); + + // Overlong override truncated to the first 8 bytes. + assert_eq!(&Truncated::eightcc().0, b"TOOLONGT"); +} + +#[test] +fn macro_control_tag_is_lowercased() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let rc = TagCtrl::new(&alloc, 1).unwrap(); + let data_off = rc.handle().range().start(); + + // data.__bstack_ctrl (offset 16) -> control block offset. + let mut buf = [0u8; 8]; + stack + .get_into(data_off + layout::CTRL_BACKPTR_OFFSET, &mut buf) + .unwrap(); + let ctrl_off = u64::from_le_bytes(buf); + + // Control block's header tag lives at ctrl_off + 8 (after size: u64). + let mut ctrl_tag = [0u8; 8]; + stack.get_into(ctrl_off + 8, &mut ctrl_tag).unwrap(); + + let data_tag = TagCtrl::eightcc().0; // prefix "TC" + assert_eq!(&data_tag[0..2], b"TC"); + // Control tag = data tag with the prefix lowercased, same hash tail. + assert_eq!(&ctrl_tag[0..2], b"tc"); + assert_eq!(ctrl_tag[2..], data_tag[2..]); + + drop(rc); +} From 0403b139594e26955014d72664df594e15cab3a4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:23:38 -0700 Subject: [PATCH 012/140] Add bstack_cast --- bstack_raii/derive/src/block.rs | 14 +++++++ bstack_raii/derive/src/cast.rs | 68 ++++++++++++++++++++++++++++++++ bstack_raii/derive/src/lib.rs | 24 +++++++----- bstack_raii/src/cast.rs | 69 +++++++++++++++++++++++++++++++++ bstack_raii/src/lib.rs | 4 +- bstack_raii/src/tests.rs | 48 +++++++++++++++++++++-- 6 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 bstack_raii/derive/src/cast.rs create mode 100644 bstack_raii/src/cast.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index ba58605..8565233 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -452,6 +452,20 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result impl #name { #(#accessors)* #(#setters)* + + /// Borrow this block as an untyped slice (infallible upcast). + #vis fn as_slice<'__s>( + &self, + stack: &'__s ::bstack_raii::BStack, + ) -> ::bstack_raii::BStackSlice<'__s> { + unsafe { + ::bstack_raii::BStackSlice::from_raw_range( + stack, + ::bstack_raii::BStackBlock::range(self), + ) + } + } + #constructor } diff --git a/bstack_raii/derive/src/cast.rs b/bstack_raii/derive/src/cast.rs new file mode 100644 index 0000000..54ad0cb --- /dev/null +++ b/bstack_raii/derive/src/cast.rs @@ -0,0 +1,68 @@ +//! Implementation of the `bstack_cast!(expr as Target)` macro. +//! +//! A function-like macro can't observe the surrounding `let x: T = …` +//! annotation, so the target type is given explicitly with `as`, and the +//! direction is chosen from the target's tokens: +//! +//! * `expr as BStackOwnedSlice` — owned upcast → `BStackOwned::into_slice` +//! * `expr as BStackOwned` — owned downcast → `BStackCastInto::cast_into::` +//! * `slice as X` (a block type) — borrowed downcast → `BStackCastAs::cast_as::` +//! +//! The borrowed upcast (`X` → `BStackSlice`) needs a stack, so it is the +//! generated `handle.as_slice(stack)` method rather than a `bstack_cast!` form. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Error, ExprCast, GenericArgument, PathArguments, Type}; + +pub fn expand(input: TokenStream) -> syn::Result { + let cast: ExprCast = syn::parse2(input).map_err(|_| { + Error::new( + proc_macro2::Span::call_site(), + "bstack_cast! expects `expr as Target` (e.g. `bstack_cast!(slice as BStackOwned)`)", + ) + })?; + let expr = &cast.expr; + let ty = &*cast.ty; + + let Type::Path(tp) = ty else { + return Err(Error::new_spanned( + ty, + "bstack_cast!: target must be a type path", + )); + }; + let seg = tp.path.segments.last().expect("non-empty path"); + + let tokens = match seg.ident.to_string().as_str() { + "BStackOwnedSlice" => quote!(::bstack_raii::BStackOwned::into_slice(#expr)), + "BStackSlice" => { + return Err(Error::new_spanned( + ty, + "bstack_cast! can't build a borrowed slice (it needs a stack); \ + use `handle.as_slice(stack)` instead", + )); + } + "BStackOwned" => { + let inner = first_type_arg(seg)?; + quote!(::bstack_raii::BStackCastInto::cast_into::<#inner>(#expr)) + } + // A concrete block type: borrowed downcast off a `BStackSlice`. + _ => quote!(::bstack_raii::BStackCastAs::cast_as::<#ty>(&#expr)), + }; + Ok(tokens) +} + +/// Extract `X` from a `BStackOwned` target. +fn first_type_arg(seg: &syn::PathSegment) -> syn::Result<&Type> { + if let PathArguments::AngleBracketed(ab) = &seg.arguments { + for arg in &ab.args { + if let GenericArgument::Type(t) = arg { + return Ok(t); + } + } + } + Err(Error::new_spanned( + seg, + "expected `BStackOwned` for an owned downcast", + )) +} diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index d6afbf1..6092d3b 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -14,6 +14,7 @@ use proc_macro::TokenStream; mod block; +mod cast; /// `#[bstack_block]` — generate the on-disk layout and typed handle machinery. /// @@ -78,16 +79,19 @@ pub fn bstack_move(input: TokenStream) -> TokenStream { quote::quote!(::bstack_raii::BStackMove::bstack_move(#expr)).into() } -/// `bstack_cast!(handle)` — type-checked handle conversion, direction inferred -/// from the target type. +/// `bstack_cast!(expr as Target)` — type-checked handle conversion. The target +/// is given explicitly (a function-like macro can't read a `let` annotation) and +/// selects the direction: /// -/// Emits `.cast_into::()` / `.into_slice()` (owned) or `.cast_as::()` / -/// `.as_slice()` (borrowed) depending on whether the target is a concrete -/// `#[bstack_block]` type (downcast) or a `BStackOwnedSlice` / `BStackSlice` -/// (upcast). See RAII.md "`bstack_cast!`". +/// * `owned as BStackOwnedSlice` — owned upcast (infallible). +/// * `slice as BStackOwned` — owned downcast → `io::Result, _>>`. +/// * `slice as X` — borrowed downcast off a `BStackSlice` → `io::Result>`. +/// +/// The borrowed upcast is the generated `handle.as_slice(stack)` method. #[proc_macro] -pub fn bstack_cast(_input: TokenStream) -> TokenStream { - "::core::todo!(\"bstack_cast! not yet implemented\")" - .parse() - .unwrap() +pub fn bstack_cast(input: TokenStream) -> TokenStream { + match cast::expand(input.into()) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } } diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs new file mode 100644 index 0000000..94acbd6 --- /dev/null +++ b/bstack_raii/src/cast.rs @@ -0,0 +1,69 @@ +//! Typed ↔ untyped handle conversion — the runtime behind `bstack_cast!`. +//! +//! Upcasts (typed → untyped) are infallible. Downcasts (untyped → typed) check +//! the block's [`EightCC`] tag against the target type's and are fallible. The +//! borrowed upcast (`X` → [`BStackSlice`]) is a generated `X::as_slice(stack)` +//! method, since a bare handle carries no stack. + +use std::io; + +use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; + +use crate::block::{BStackBlock, BStackCast}; +use crate::layout::EightCC; +use crate::owned::BStackOwned; + +/// Byte offset of the `tag` within a [`crate::BlockHeader`] (`size: u64` first). +const TAG_OFFSET: u64 = 8; + +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { + /// Upcast to the untyped owned slice, discarding type info (infallible). + /// + /// Consumes the handle without running its disk-level `Drop`; the returned + /// slice owns the allocation. + pub fn into_slice(self) -> BStackOwnedSlice<'a, A> { + let (inner, allocator) = self.into_raw_parts(); + unsafe { BStackOwnedSlice::from_raw_range(allocator, inner.range()) } + } +} + +/// Downcast an owned slice to a typed owned handle by checking the block tag. +pub trait BStackCastInto<'a, A: BStackOwnedSliceAllocator>: Sized { + /// `Ok(Ok(owned))` on a tag match; `Ok(Err(self))` on mismatch (ownership is + /// handed back so the caller can try another type); `Err` on an I/O failure + /// reading the header. + fn cast_into(self) -> io::Result, Self>>; +} + +impl<'a, A: BStackOwnedSliceAllocator> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { + fn cast_into(self) -> io::Result, Self>> { + let mut tag = [0u8; 8]; + self.read_range_into(TAG_OFFSET, &mut tag)?; + if EightCC(tag) != T::eightcc() { + return Ok(Err(self)); + } + let allocator = self.allocator(); + let range = self.as_range(); + Ok(Ok(unsafe { + BStackOwned::from_raw(T::from_range(range), allocator) + })) + } +} + +/// Downcast a borrowed slice to a typed handle by checking the block tag. +pub trait BStackCastAs<'a> { + /// `Some(handle)` on a tag match, `None` on mismatch, `Err` on an I/O + /// failure reading the header. + fn cast_as(&self) -> io::Result>; +} + +impl<'a> BStackCastAs<'a> for BStackSlice<'a> { + fn cast_as(&self) -> io::Result> { + let mut tag = [0u8; 8]; + self.read_range_into(TAG_OFFSET, &mut tag)?; + if EightCC(tag) != T::eightcc() { + return Ok(None); + } + Ok(Some(T::from_range(self.as_range()))) + } +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 1c59659..2861665 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -49,6 +49,7 @@ extern crate self as bstack_raii; mod block; +mod cast; mod clone; mod construct; mod handle; @@ -63,6 +64,7 @@ mod teardown; mod tests; pub use block::{BStackBlock, BStackCast, BStackMove, BStackShared, BStackWeakable}; +pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::TryClone; pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; @@ -75,7 +77,7 @@ pub use teardown::{BStackDrop, dealloc_range}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream // crates need not depend on `bstack` or `bytemuck` directly. -pub use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; +pub use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, BStackSlice}; pub use bytemuck::{Pod, Zeroable}; // Re-exported whole so generated code can call `::bstack_raii::bytemuck::bytes_of`. pub use bytemuck; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index ca4d388..4dc522c 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,9 +14,9 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - BStackBlock, BStackCast, BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, - BStackWeakable, EightCC, TryClone, alloc_block, alloc_control, bstack_block, bstack_move, - dealloc_range, + BStackBlock, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, BStackOwned, BStackRc, + BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, alloc_block, alloc_control, + bstack_block, bstack_cast, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -752,3 +752,45 @@ fn macro_control_tag_is_lowercased() { drop(rc); } + +// -------------------------------------------------------------------------- +// bstack_cast! + cast methods — typed <-> untyped conversion +// -------------------------------------------------------------------------- + +#[test] +fn macro_cast() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 9).unwrap(); + + // Borrowed: upcast via the generated `as_slice`, downcast via method + macro. + let sl = leaf.handle().as_slice(stack); + assert_eq!( + sl.cast_as::() + .unwrap() + .unwrap() + .val(stack) + .unwrap(), + 9 + ); + assert!(sl.cast_as::().unwrap().is_none()); // wrong tag + assert!(bstack_cast!(sl as MacroLeaf).unwrap().is_some()); + assert!(bstack_cast!(sl as MacroParent).unwrap().is_none()); + + // Owned upcast (macro), then a wrong-type downcast hands the slice back. + let slice = bstack_cast!(leaf as BStackOwnedSlice); + let slice = match slice.cast_into::().unwrap() { + Ok(_) => panic!("tag should not match"), + Err(s) => s, + }; + + // Correct owned downcast (macro) round-trips to the typed handle. + let owned = bstack_cast!(slice as BStackOwned) + .unwrap() + .ok() + .unwrap(); + assert_eq!(owned.handle().val(stack).unwrap(), 9); + drop(owned); // frees the leaf +} From 6ef61d2274c1432714891a2c87c5f42bb699c278 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:34:44 -0700 Subject: [PATCH 013/140] Minor fix --- bstack_raii/derive/src/block.rs | 15 +++++++++------ bstack_raii/src/block.rs | 24 +++++++++++++++--------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 8565233..8fc6cc1 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -372,14 +372,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // their fields may be any kind, including strong/weak. let move_impl = if mode == Mode::Plain { quote! { - impl<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> - ::bstack_raii::BStackMove for ::bstack_raii::BStackOwned<'__mv, #name, __A> - { - type Fields = ( #(#mv_types,)* ); - fn bstack_move(self) -> ::std::io::Result { + // Implemented on the block type (local downstream) so the orphan rule + // is satisfied; `bstack_move!` selects it from the argument's type. + impl ::bstack_raii::BStackMove for #name { + type Fields<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> = + ( #(#mv_types,)* ); + fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + owned: ::bstack_raii::BStackOwned<'__mv, Self, __A>, + ) -> ::std::io::Result> { // Take the inner handle out (defusing the owned Drop) and read // the payload before freeing anything. - let (__inner, __alloc) = self.into_raw_parts(); + let (__inner, __alloc) = owned.into_raw_parts(); let __stack = __alloc.stack(); let __range = ::bstack_raii::BStackBlock::range(&__inner); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index 3d4bc86..009c5a0 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -10,6 +10,7 @@ use bstack::{BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; use crate::layout::EightCC; +use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::teardown::BStackDrop; @@ -37,16 +38,21 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// Destructure an owned block into its typed field handles. /// -/// Implemented for `BStackOwned` by `#[bstack_block]` (plain blocks only) -/// and invoked by the `bstack_move!` macro. It transfers ownership of every -/// field out — owned children as `BStackOwned`, refs as `BStackRef`, POD by -/// value — and frees only the parent shell. Not implemented for `(rc)` / -/// `(rc, weak)` blocks, nor (yet) for blocks with `#[bstack_strong]` / -/// `#[bstack_weak]` fields. -pub trait BStackMove: Sized { +/// Implemented on the block type `X` by `#[bstack_block]` (plain blocks only) +/// and invoked by the `bstack_move!` macro, which selects the impl from the +/// argument's `BStackOwned` type. It transfers ownership of every field +/// out — owned children as `BStackOwned`, strong as `BStackRc`, weak as +/// `Option`, refs as `BStackRef`, POD by value — and frees only the +/// parent shell. Not implemented for `(rc)` / `(rc, weak)` blocks. +/// +/// The impl lives on `X` (a local type in the caller's crate) rather than on +/// `BStackOwned` so it satisfies the orphan rule downstream. +pub trait BStackMove: BStackBlock { /// The tuple of field handles produced, in field-declaration order. - type Fields; - fn bstack_move(self) -> io::Result; + type Fields<'a, A: BStackOwnedSliceAllocator>; + fn bstack_move<'a, A: BStackOwnedSliceAllocator>( + owned: BStackOwned<'a, Self, A>, + ) -> io::Result>; } /// Implemented by refcounted blocks (`#[bstack_block(rc)]` and From 470e4fd4d759cb29a7dc1c9cdd695487dd20b7ad Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:34:51 -0700 Subject: [PATCH 014/140] Add session example --- bstack_raii/examples/sessions.rs | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 bstack_raii/examples/sessions.rs diff --git a/bstack_raii/examples/sessions.rs b/bstack_raii/examples/sessions.rs new file mode 100644 index 0000000..dc6387e --- /dev/null +++ b/bstack_raii/examples/sessions.rs @@ -0,0 +1,118 @@ +//! # Shared, reference-counted persistent objects (`bstack_raii`) +//! +//! `std::rc::Rc` / `Weak` live and die with the process. `bstack_raii` gives the +//! same ownership model — shared strong handles, non-owning weak handles, and +//! automatic cleanup when the last owner is dropped — but backed by a single, +//! crash-safe file, so the object graph *and its reference counts* survive a +//! restart. +//! +//! This models an app that persists login `Session`s. Many sessions share one +//! `Config` (reference-counted on disk). The config is freed automatically the +//! instant the last session referencing it drops, and a `Monitor` holds a weak +//! handle to observe it without keeping it alive. +//! +//! Run with: `cargo run --example sessions` + +use std::io; + +use bstack::FirstFitBStackAllocator; +// `BStack` / `BStackAllocator` / `BStackBlock` / `BStackRange` are re-exported by +// `bstack_raii`, so a downstream crate only depends on `bstack_raii`. +use bstack_raii::{BStack, BStackAllocator, BStackBlock, BStackRange, TryClone, bstack_block}; + +/// A shared, reference-counted configuration. `(rc, weak)` makes it refcounted +/// and weak-observable on disk. +#[bstack_block(rc, weak)] +struct Config { + version: u64, + flags: u64, +} + +/// A session that owns a *strong* reference to a shared `Config`. +#[bstack_block] +struct Session { + id: u64, + #[bstack_strong] + config: Config, +} + +/// Shared ownership with automatic, refcount-driven cleanup. +fn shared_ownership_demo(path: &std::path::Path) -> io::Result<()> { + let alloc = FirstFitBStackAllocator::new(BStack::open(path)?)?; + let stack = alloc.stack(); + + // One config, shared by three sessions. Each `Session::new` *consumes* a + // clone, so the on-disk strong count climbs to three. + let config = Config::new(&alloc, 3, 0b1010)?; + let mut sessions = Vec::new(); + for id in 0u64..3 { + sessions.push(Session::new(&alloc, id, config.try_clone()?)?); + } + + // A monitor observes the config without owning it. + let monitor = config.downgrade()?; + drop(config); // the three sessions still keep it alive + + // Read the shared config through any session's generated accessor. + let cfg = sessions[0].handle().config(stack)?; + println!( + "shared config: version {}, flags {:#06b} (held by {} sessions)", + cfg.version(stack)?, + cfg.flags(stack)?, + sessions.len(), + ); + + // Close sessions one at a time; the config stays alive until the last drops. + while let Some(session) = sessions.pop() { + drop(session); + let still_alive = monitor.upgrade()?.is_some(); + println!("closed a session -> shared config still alive: {still_alive}"); + } + + // The last session is gone, so the shared config was reclaimed automatically — + // no manual free, no leak, no dangling weak handle. + assert!(monitor.upgrade()?.is_none()); + println!("last session closed -> shared config freed automatically"); + + drop(monitor); // releases the (now-unreferenced) control block + Ok(()) +} + +/// Durability: the typed block survives closing and reopening the file. +fn durability_demo(path: &std::path::Path) -> io::Result<()> { + // Write a config, then simulate the process exiting while still holding it: + // `mem::forget` skips the handle's destructor, so the on-disk block (and its + // refcount) are left intact — exactly what a real exit or crash leaves behind. + let saved: BStackRange = { + let alloc = FirstFitBStackAllocator::new(BStack::open(path)?)?; + let config = Config::new(&alloc, 7, 0b1)?; + let range = config.handle().range(); + std::mem::forget(config); + range + // allocator dropped here -> the file is flushed and closed + }; + + // A later run reopens the same file and reads the persisted block back. + let alloc = FirstFitBStackAllocator::new(BStack::open(path)?)?; + let cfg = ::from_range(saved); + println!( + "after reopen: config version {} (persisted across the close/reopen)", + cfg.version(alloc.stack())?, + ); + Ok(()) +} + +fn main() -> io::Result<()> { + let path = std::env::temp_dir().join("bstack_raii_sessions.bstack"); + let _ = std::fs::remove_file(&path); + + println!("== shared ownership + automatic cleanup =="); + shared_ownership_demo(&path)?; + + let _ = std::fs::remove_file(&path); + println!("\n== durability across a reopen =="); + durability_demo(&path)?; + + let _ = std::fs::remove_file(&path); + Ok(()) +} From ef8a16d4df5c483557656a46e8d5cf1636496792 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:47:18 -0700 Subject: [PATCH 015/140] Better readme --- bstack_raii/README.md | 325 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 294 insertions(+), 31 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 40aea16..0687671 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -1,48 +1,311 @@ # bstack_raii -A typed, RAII-style ownership, lifetime, and on-disk-layout layer over the -[`bstack`](https://github.com/williamwutq/bstack) allocation primitives -(`BStackRange`, `BStackSlice`, `BStackOwnedSlice`). It decouples disk-level -destruction (`BStackDrop`) from Rust's process-scoped `Drop`, giving persistent -storage the ergonomics of C++ `unique_ptr` / `shared_ptr` / `weak_ptr`. +Typed, RAII-style ownership for persistent objects — `Rc`/`Weak` semantics that +survive a process restart or crash, backed by a single [`bstack`] file. -The full design is in [`RAII.md`](../RAII.md) at the repository root. This crate -is its implementation. +`std::rc::Rc` and `Weak` live and die with the process. `bstack_raii` gives you +the same model — shared strong handles, non-owning weak handles, and automatic +cleanup when the last owner drops — but the object graph *and its reference +counts* are stored on disk, crash-safely. You define blocks as ordinary structs; +the `#[bstack_block]` macro generates the on-disk layout, typed accessors, +constructors, recursive teardown, and refcounting. -## Why a separate crate (not a `bstack` feature) +It is a thin, typed layer over the mainline `bstack` allocator +(`BStackRange` / `BStackSlice` / `BStackOwnedSlice`), which already provides the +atomicity, crash-safety, and single-ownership guarantees. This crate adds the +object model on top. -`bstack` keeps stable features ABI-stable. The RAII layer introduces a large, -not-yet-stable ABI surface (block layouts, control blocks, refcounting), so it -lives outside the `bstack` package until it settles. +> **Status:** feature-complete and tested, but the on-disk ABI is not yet +> stable. Developed inside the [`bstack`] repository; not yet published to +> crates.io. -## Layout +## Contents +- [Quick start](#quick-start) +- [Concepts](#concepts) +- [Defining blocks](#defining-blocks) +- [Field ownership](#field-ownership) +- [Handles](#handles) +- [Shared ownership & weak references](#shared-ownership--weak-references) +- [Moving fields out: `bstack_move!`](#moving-fields-out-bstack_move) +- [Casting: `bstack_cast!`](#casting-bstack_cast) +- [Type tags (`EightCC`)](#type-tags-eightcc) +- [How it works on disk](#how-it-works-on-disk) +- [Limitations](#limitations) + +## Quick start + +Add both crates (`bstack` supplies the allocator; `bstack_raii` the object +layer): + +```toml +[dependencies] +bstack_raii = { git = "https://github.com/williamwutq/bstack" } +bstack = "0.4" ``` -bstack_raii/ # runtime: traits + on-disk header + handle types - src/lib.rs - derive/ # proc-macro crate: #[bstack_block], bstack_move!, bstack_cast! - src/lib.rs + +```rust +use std::io; +use bstack::FirstFitBStackAllocator; +use bstack_raii::{BStack, BStackAllocator, TryClone, bstack_block}; + +// A shared, reference-counted, weak-observable block. +#[bstack_block(rc, weak)] +struct Config { + version: u64, + flags: u64, +} + +// A block that owns a *strong* reference to a shared Config. +#[bstack_block] +struct Session { + id: u64, + #[bstack_strong] + config: Config, +} + +fn main() -> io::Result<()> { + let alloc = FirstFitBStackAllocator::new(BStack::open("app.bstack")?)?; + let stack = alloc.stack(); + + let config = Config::new(&alloc, 3, 0b1010)?; // BStackRc, strong = 1 + let session = Session::new(&alloc, 0, config.try_clone()?)?; // strong = 2 + + // Read fields through generated accessors. + let cfg = session.handle().config(stack)?; // -> a Config handle + println!("v{} flags {:#b}", cfg.version(stack)?, cfg.flags(stack)?); + + drop(config); // strong = 1 — the session still owns it + drop(session); // strong = 0 — Config is freed from disk automatically + Ok(()) +} ``` -`bstack_raii` re-exports the macros, so downstream code depends only on -`bstack_raii`. It is a **self-contained cargo workspace**: the outer `bstack` -repo has no `[workspace]`, so root-level `cargo` commands never build this crate, -and `bstack`'s CI is configured to ignore `bstack_raii/**`. +A fuller walk-through (shared ownership, weak observers, durability across a +reopen) is in [`examples/sessions.rs`](examples/sessions.rs): +`cargo run --example sessions`. -## Status +## Concepts -Scaffold. Traits, on-disk header, typed-reference and child-handle types, and -the three proc-macro entry points are stubbed with their final signatures and -generation contracts; the bodies (marked `todo!()` / `TODO`) are the work ahead. +A **block** is a fixed-size record on disk. You write it as an ordinary struct +and annotate it with `#[bstack_block]`; the macro generates a parallel +`#[repr(C, packed)]` on-disk layout plus all the machinery to work with it. -## Dependency on bstack +Three block **modes**: -`bstack` 0.4.0 is feature-complete but not yet on crates.io, so this crate -depends on the GitHub source: +| Mode | Meaning | +|-----------------------------|----------------------------------------------------------| +| `#[bstack_block]` | Plain, exclusively owned (like `Box`). | +| `#[bstack_block(rc)]` | Reference-counted, inline count (like `Rc`, no `Weak`). | +| `#[bstack_block(rc, weak)]` | Refcounted **and** weak-observable (like `Rc` + `Weak`). | -```toml -bstack = { git = "https://github.com/williamwutq/bstack", features = ["alloc", "set", "atomic"] } +Every non-POD field carries an ownership annotation that decides how it is torn +down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, etc.) are +stored inline and copied by value. + +## Defining blocks + +```rust +#[bstack_block] +struct Node { + #[bstack_owned] // this block exclusively owns the child + payload: Payload, + #[bstack_strong] // a shared, refcounted reference + shared: SharedThing, + #[bstack_weak] // a non-owning back-pointer (may dangle) + parent: Node, + #[bstack_ref] // a raw reference, no ownership semantics + sibling: Node, + tag: u32, // POD, stored inline +} +``` + +For each block the macro generates: + +- a typed handle `struct Node(BStackRange)` and its `NodeOnDisk` payload; +- a `new(...)` **constructor** that allocates and wires the block; +- **accessors** — `node.field(stack)` for each field; +- **`set_`** setters for `#[bstack_weak]` fields; +- recursive **teardown** (`BStackDrop`), casting, and (for `rc` / `rc, weak`) + the control block and refcount machinery. + +## Field ownership + +| Annotation | Child kind required | On drop | `bstack_move!` yields | +|--------------------|------------------------|------------------------------------|-------------------------| +| `#[bstack_owned]` | any block | recursively frees the child | `BStackOwned` | +| `#[bstack_strong]` | `(rc)` or `(rc, weak)` | decrements refcount; frees at zero | `BStackRc` | +| `#[bstack_weak]` | `(rc, weak)` | decrements weak count only | `Option>` | +| `#[bstack_ref]` | any block | nothing | `BStackRef` | +| *(none)* — POD | `Pod` type | nothing (inline) | the value | + +Ownership rules are enforced at compile time: a `#[bstack_weak]` field whose +target isn't `(rc, weak)`, or a non-`Pod` field with no annotation, is a +compile error. + +## Handles + +The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, +`Copy`, and the thing you read fields through. The *owning* wrappers carry an +allocator and run teardown on `Drop`: + +| Handle | Ownership | Notes | +|----------------------|----------------------------------|--------------------------------------------| +| `X` (the block type) | none (borrowed view) | `x.field(stack)`; get from `.handle()` | +| `BStackOwned` | exclusive | frees the block (recursively) on `Drop` | +| `BStackRc` | shared strong | `try_clone`, `downgrade`; frees at count 0 | +| `BStackWeak` | none (keeps control block alive) | `try_clone`, `upgrade` | +| `BStackRef` | none (raw offset) | resolve manually | + +Get the read handle from a wrapper with `.handle()`: + +```rust +let owned: BStackOwned = Node::new(&alloc, /* … */)?; +let value = owned.handle().tag(stack)?; // read a field +``` + +Construction (`new`) consumes the children it takes ownership of: + +- `#[bstack_owned] p: P` → parameter `p: BStackOwned

` +- `#[bstack_strong] s: S` → parameter `s: BStackRc` +- `#[bstack_ref] r: R` → parameter `r: BStackRef` +- POD `t: u32` → parameter `t: u32` +- `#[bstack_weak]` → **not** a parameter; starts null, wired later with + `set_` (see below). + +## Shared ownership & weak references + +`BStackRc` is the on-disk `Rc`. Because duplicating it must atomically bump an +on-disk counter (which can fail with I/O), cloning is the fallible +[`TryClone`] trait rather than `Clone`: + +```rust +let a = Config::new(&alloc, 1, 0)?; // BStackRc, strong = 1 +let b = a.try_clone()?; // strong = 2 +let w = a.downgrade()?; // BStackWeak, weak observer +drop(a); drop(b); // strong = 0 — Config's data is freed +assert!(w.upgrade()?.is_none()); // upgrade fails: the object is gone +``` + +`BStackWeak` never keeps the data alive, only the small control block, so +`upgrade()` is a sound liveness check (atomic CAS on the strong count). + +**Weak fields** are for back-pointers and cycles, where you can't supply the +target at construction. They start null and are wired afterward; the accessor is +an *upgrade*: + +```rust +#[bstack_block(rc, weak)] +struct WNode { + #[bstack_weak] + back: WNode, + val: u32, +} + +let a = WNode::new(&alloc, 1)?; // BStackRc +let b = WNode::new(&alloc, 2)?; +b.handle().set_back(&alloc, a.downgrade()?)?; // wire b.back -> a (weak) + +if let Some(a2) = b.handle().back(&alloc)? { // upgrade the weak field + println!("a is still alive: {}", a2.handle().val(stack)?); +} ``` -Pin a `branch`/`rev`/`tag` (or switch to `path = ".."` for local iteration) once -the RAII primitives land on a stable ref. +A weak field stores its target's *control-block* offset, so dropping the strong +owner first and then the holder is sound — no use-after-free of freed data. + +## Moving fields out: `bstack_move!` + +`bstack_move!` destructures a `BStackOwned` (plain blocks only), transferring +ownership of every field out as a tuple and freeing only the parent shell: + +```rust +#[bstack_block] +struct Pair { + #[bstack_owned] left: Leaf, + #[bstack_strong] shared: Thing, + right: u32, +} + +let pair: BStackOwned = /* … */; +let (left, shared, right) = bstack_move!(pair)?; +// ^BStackOwned ^BStackRc ^u32 +``` + +The children stay live on disk; you now own them independently. + +## Casting: `bstack_cast!` + +Convert between typed handles and the untyped `bstack` primitives. Upcasts are +infallible; downcasts check the block's tag. Because a function-like macro can't +read a `let x: T = …` annotation, the target is given explicitly with `as`: + +```rust +use bstack_raii::{BStackCastAs, BStackCastInto}; // the cast methods + +let owned: BStackOwned = /* … */; + +let slice = bstack_cast!(owned as BStackOwnedSlice); // upcast (infallible) + +match bstack_cast!(slice as BStackOwned)? { // owned downcast + Ok(node) => { /* tag matched */ } + Err(slice) => { /* tag mismatch — slice handed back */ } +} + +let view = node.handle().as_slice(stack); // borrowed upcast +let maybe: Option = bstack_cast!(view as Node)?; // borrowed downcast +``` + +The equivalent methods (`into_slice`, `cast_into::`, `cast_as::`, +`as_slice`) can also be called directly. + +## Type tags (`EightCC`) + +Each block gets an 8-byte tag used as the downcast discriminant. It is a +**readable prefix** over a **hash tail**: camel-case initials (or a de-voweled +single word), followed by the high-bit-set tail of a 64-bit hash of the crate + +type name — so distinct types stay distinct even when their prefixes collide, and +the tag is deterministic and stable across builds. Override it if you want a +documented, fixed on-disk tag: + +```rust +#[bstack_block(rc, tag = "ORDLINE")] // explicit data tag +struct OrderLine { /* … */ } +``` + +`ctrl_tag = "…"` overrides the control-block tag (default: the data tag, +lowercased). An override longer than 8 bytes is truncated with a compile warning; +`allow_long_tag` silences it. + +## How it works on disk + +Every block begins with a 16-byte `BlockHeader { size: u64, tag: EightCC }`. +References between blocks are stored as `u64` offsets; a target's length is +recovered from its compile-time `size_of::()`. + +- **`(rc)`** injects an inline `refcount` after the header. +- **`(rc, weak)`** splits into a *data block* (with a back-pointer to its control + block) and a separate *control block* holding `strong` / `weak` counters and a + forward pointer. The data block is reclaimed when `strong` hits zero; the small + control block persists until `weak` also hits zero — exactly like `Arc`/`Weak`. + +Refcount updates are single-lock read-modify-writes on `bstack` (crash-atomic, +no spin loop). All operations are durable and speak `std::io::Result`. + +## Limitations + +- **Fixed-size blocks.** A block's on-disk size equals its `OnDisk` struct size; + there are no variable-length arrays or inline slices. Model collections as + linked blocks. +- **No nullable owned/strong fields.** `#[bstack_owned]` / `#[bstack_strong]` + fields must always be present (they're required constructor parameters). Only + `#[bstack_weak]` fields may be null/unset. +- **No generic block types**, and non-`Pod` fields must carry an annotation. +- The on-disk **ABI is not yet stable**. + +## License + +MIT (same as `bstack`). + +[`bstack`]: https://github.com/williamwutq/bstack +[`TryClone`]: src/clone.rs From 69879ab13756cb782a80e9a243330926773ac7ef Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 02:57:21 -0700 Subject: [PATCH 016/140] bstack_move! for Rc --- bstack_raii/README.md | 20 +++++++-- bstack_raii/derive/src/block.rs | 8 ++-- bstack_raii/derive/src/lib.rs | 2 +- bstack_raii/src/block.rs | 33 +++++++++----- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/owned.rs | 10 +++++ bstack_raii/src/refcount.rs | 7 +++ bstack_raii/src/shared.rs | 51 +++++++++++++++++++++- bstack_raii/src/tests.rs | 76 +++++++++++++++++++++++++++++++++ 9 files changed, 189 insertions(+), 22 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 0687671..0e60b54 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -216,8 +216,11 @@ owner first and then the holder is sound — no use-after-free of freed data. ## Moving fields out: `bstack_move!` -`bstack_move!` destructures a `BStackOwned` (plain blocks only), transferring -ownership of every field out as a tuple and freeing only the parent shell: +`bstack_move!` destructures a handle into its fields, transferring ownership of +each out as a tuple and freeing only the parent *shell* — the children stay live +on disk, now owned independently. + +On a **`BStackOwned`** it is infallible (a unique owner): ```rust #[bstack_block] @@ -232,7 +235,18 @@ let (left, shared, right) = bstack_move!(pair)?; // ^BStackOwned ^BStackRc ^u32 ``` -The children stay live on disk; you now own them independently. +On a **`BStackRc`** (an `(rc)` or `(rc, weak)` block) it is a `try_unwrap`: it +succeeds only when this handle is the **sole strong owner** (an atomic +`strong: 1 → 0`), otherwise it hands the handle back. A weak observer does *not* +block the move — afterward its `upgrade()` just returns `None`. + +```rust +let rc: BStackRc = /* … */; +match bstack_move!(rc)? { + Ok((left, shared, right)) => { /* we were the only owner */ } + Err(rc) => { /* someone else still holds it */ } +} +``` ## Casting: `bstack_cast!` diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 8fc6cc1..bcfb47d 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -368,9 +368,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &ctor_inits, ); - // `bstack_move!` is defined for plain blocks (not rc / rc,weak themselves); - // their fields may be any kind, including strong/weak. - let move_impl = if mode == Mode::Plain { + // The field destructure is generated for every mode: plain blocks use it via + // `BStackOwned` (infallible), rc / rc,weak via `BStackRc::try_move`. + let move_impl = { quote! { // Implemented on the block type (local downstream) so the orphan rule // is satisfied; `bstack_move!` selects it from the argument's type. @@ -395,8 +395,6 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } } - } else { - quote!() }; Ok(quote! { diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 6092d3b..22b3042 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -76,7 +76,7 @@ pub fn bstack_move(input: TokenStream) -> TokenStream { // `BStackMove` impl on `BStackOwned`; here we just invoke it, letting // type inference select the block's impl from the argument's type. let expr = syn::parse_macro_input!(input as syn::Expr); - quote::quote!(::bstack_raii::BStackMove::bstack_move(#expr)).into() + quote::quote!(::bstack_raii::BStackMoveExpr::bstack_move(#expr)).into() } /// `bstack_cast!(expr as Target)` — type-checked handle conversion. The target diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index 009c5a0..8cbe7ad 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -36,17 +36,16 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { fn range(&self) -> BStackRange; } -/// Destructure an owned block into its typed field handles. +/// The per-block field destructure behind `bstack_move!`: read every field, then +/// free only the parent *shell* (the children stay live on disk). Ownership is +/// transferred out — owned children as `BStackOwned`, strong as `BStackRc`, weak +/// as `Option`, refs as `BStackRef`, POD by value. /// -/// Implemented on the block type `X` by `#[bstack_block]` (plain blocks only) -/// and invoked by the `bstack_move!` macro, which selects the impl from the -/// argument's `BStackOwned` type. It transfers ownership of every field -/// out — owned children as `BStackOwned`, strong as `BStackRc`, weak as -/// `Option`, refs as `BStackRef`, POD by value — and frees only the -/// parent shell. Not implemented for `(rc)` / `(rc, weak)` blocks. -/// -/// The impl lives on `X` (a local type in the caller's crate) rather than on -/// `BStackOwned` so it satisfies the orphan rule downstream. +/// Generated on the block type `X` (a local type downstream, so it satisfies the +/// orphan rule) for **all** modes. It is the shared core used by both the owned +/// and the `Rc` `bstack_move!` paths in [`BStackMoveExpr`]; it does not touch +/// refcounts or the control block, so the caller must have already established +/// that the shell may be freed. pub trait BStackMove: BStackBlock { /// The tuple of field handles produced, in field-declaration order. type Fields<'a, A: BStackOwnedSliceAllocator>; @@ -55,6 +54,20 @@ pub trait BStackMove: BStackBlock { ) -> io::Result>; } +/// The `bstack_move!` entry point, dispatched on the wrapper handle type. +/// +/// * `BStackOwned` — infallible; `Output = io::Result`. +/// * `BStackRc` — a `try_unwrap`: `Output = io::Result>`, +/// succeeding only when this handle is the **sole strong owner** (else the +/// handle is returned in `Err`). +/// +/// The macro emits `BStackMoveExpr::bstack_move(expr)` and lets inference select +/// the impl from the argument's type. +pub trait BStackMoveExpr { + type Output; + fn bstack_move(self) -> Self::Output; +} + /// Implemented by refcounted blocks (`#[bstack_block(rc)]` and /// `#[bstack_block(rc, weak)]`), i.e. any block that can be the target of a /// `#[bstack_strong]` field. diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 2861665..168e360 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -63,7 +63,9 @@ mod teardown; #[cfg(test)] mod tests; -pub use block::{BStackBlock, BStackCast, BStackMove, BStackShared, BStackWeakable}; +pub use block::{ + BStackBlock, BStackCast, BStackMove, BStackMoveExpr, BStackShared, BStackWeakable, +}; pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::TryClone; pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index bc39306..e8861ea 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -6,9 +6,11 @@ //! which defuses this `Drop` so no parallel destruction path exists. use core::mem::ManuallyDrop; +use std::io; use bstack::BStackOwnedSliceAllocator; +use crate::block::{BStackMove, BStackMoveExpr}; use crate::teardown::BStackDrop; /// An owned, allocator-bound handle to a block whose `Drop` recursively frees it @@ -60,3 +62,11 @@ impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for BStackOwned<'a, T let _ = inner.bstack_drop(self.allocator); } } + +impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackOwned<'a, T, A> { + // A unique owner: the destructure is always valid. + type Output = io::Result>; + fn bstack_move(self) -> Self::Output { + T::bstack_move(self) + } +} diff --git a/bstack_raii/src/refcount.rs b/bstack_raii/src/refcount.rs index b0bae4f..836aa37 100644 --- a/bstack_raii/src/refcount.rs +++ b/bstack_raii/src/refcount.rs @@ -30,6 +30,13 @@ fn underflow_err() -> io::Error { io::Error::new(io::ErrorKind::InvalidData, "refcount underflow") } +/// Compare-and-swap the counter at `offset`: set it to `new` iff it currently +/// equals `expected`. Returns whether the swap happened. The atomic "try-unwrap" +/// primitive behind [`crate::BStackRc::try_move`]. +pub fn cas(stack: &BStack, offset: u64, expected: u64, new: u64) -> io::Result { + stack.cas(offset, expected.to_le_bytes(), new.to_le_bytes()) +} + /// Load the current value of the counter at `offset` (little-endian). Read-only, /// so it takes only `get_into` (no lock upgrade, no write-back). pub fn load(stack: &BStack, offset: u64) -> io::Result { diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 1d63154..5565034 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -5,13 +5,14 @@ use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; -use crate::block::{BStackBlock, BStackWeakable}; +use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; use crate::handle::{StrongRef, WeakRef, strong_release_ctrl}; use crate::layout; +use crate::owned::BStackOwned; use crate::refcount; use crate::reference::BStackRef; -use crate::teardown::BStackDrop; +use crate::teardown::{BStackDrop, dealloc_range}; /// A shared, refcounted, allocator-bound handle. /// @@ -124,6 +125,52 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> Drop for BStackRc<'a, T, } } +impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { + /// `Rc::try_unwrap` + destructure: if this handle is the **sole strong + /// owner**, move every field out (freeing only the data shell) and return + /// them; otherwise hand the handle back in `Err`. + /// + /// The check-and-take is an atomic CAS `strong: 1 -> 0`, so a concurrent + /// clone or `upgrade` makes it fail cleanly rather than tearing a shared + /// block apart. Works for both `(rc)` (inline count) and `(rc, weak)` (the + /// control block's phantom weak is released, freeing it if no weak handles + /// remain). This is what `bstack_move!` calls on a `BStackRc`. + pub fn try_move(self) -> io::Result, Self>> { + let allocator = self.allocator; + let stack = allocator.stack(); + let strong_off = self.strong_offset(); + + // Atomic try-unwrap: succeed only if the strong count is exactly 1. + if !refcount::cas(stack, strong_off, 1, 0)? { + return Ok(Err(self)); + } + + // Strong is now 0 — no concurrent upgrade can revive the data block, so + // it is safe to move the fields out and free the data shell. + let (data, ctrl) = self.into_raw(); + let owned = unsafe { + BStackOwned::from_raw(::from_range(data.into_range()), allocator) + }; + let fields = T::bstack_move(owned)?; + + // `(rc, weak)`: release the phantom weak; free the control block at zero. + if let Some(ctrl) = ctrl { + let weak_off = ctrl.start() + layout::CTRL_WEAK_OFFSET; + if refcount::fetch_sub(stack, weak_off, 1)? == 1 { + unsafe { dealloc_range(allocator, ctrl)? }; + } + } + Ok(Ok(fields)) + } +} + +impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackRc<'a, T, A> { + type Output = io::Result, Self>>; + fn bstack_move(self) -> Self::Output { + self.try_move() + } +} + /// A non-owning weak handle to an `(rc, weak)` block's control block. /// /// Obtained from [`BStackRc::downgrade`] or [`TryClone::try_clone`]. It keeps the diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 4dc522c..f89b8d7 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -794,3 +794,79 @@ fn macro_cast() { assert_eq!(owned.handle().val(stack).unwrap(), 9); drop(owned); // frees the leaf } + +// -------------------------------------------------------------------------- +// bstack_move! on a BStackRc — try_unwrap-style, solo strong owner only +// -------------------------------------------------------------------------- + +#[bstack_block(rc)] +struct RcHolder { + #[bstack_owned] + leaf: MacroLeaf, + n: u32, +} + +#[bstack_block(rc, weak)] +struct RcwHolder { + #[bstack_owned] + leaf: MacroLeaf, + n: u32, +} + +#[test] +fn macro_bstack_move_rc() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 5).unwrap(); + let leaf_off = leaf.handle().range().start(); + let rc = RcHolder::new(&alloc, leaf, 7).unwrap(); // BStackRc, strong = 1 + + // A second strong owner blocks the move. + let clone = rc.try_clone().unwrap(); // strong = 2 + let rc = match bstack_move!(rc).unwrap() { + Ok(_) => panic!("must not move a shared block"), + Err(rc) => rc, // handed back, untouched + }; + drop(clone); // strong = 1 — now the sole owner + + // Sole owner: the move succeeds and transfers the owned child out. + let (moved_leaf, n) = bstack_move!(rc).unwrap().ok().expect("sole owner"); + assert_eq!(n, 7); + assert_eq!(moved_leaf.handle().val(stack).unwrap(), 5); + + // Only the RcHolder shell was freed; the child is still live. Dropping it + // reclaims the last block, so the lowest slot (the leaf's) comes back. + drop(moved_leaf); + let reused = alloc_block( + &alloc, + MacroLeaf::eightcc(), + size_of::<::OnDisk>() as u64, + ) + .unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[test] +fn macro_bstack_move_rc_weak() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 9).unwrap(); + let rc = RcwHolder::new(&alloc, leaf, 3).unwrap(); + let weak = rc.downgrade().unwrap(); // a weak observer does NOT block the move + + // Sole *strong* owner: move succeeds even with a weak outstanding. + let (moved_leaf, n) = bstack_move!(rc).unwrap().ok().expect("sole strong owner"); + assert_eq!(n, 3); + assert_eq!(moved_leaf.handle().val(stack).unwrap(), 9); + + // The data block is gone, so the weak can no longer upgrade. + assert!(weak.upgrade().unwrap().is_none()); + + drop(moved_leaf); // frees the moved-out child + drop(weak); // frees the now-unreferenced control block +} From 8d6efd3736daf23a2e30d173234e7564e1e3df02 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 03:24:10 -0700 Subject: [PATCH 017/140] Support Option nullability --- bstack_raii/README.md | 32 ++- bstack_raii/derive/src/block.rs | 427 +++++++++++++++++++------------- bstack_raii/derive/src/lib.rs | 1 + bstack_raii/src/tests.rs | 51 ++++ 4 files changed, 342 insertions(+), 169 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 0e60b54..500af88 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -103,6 +103,14 @@ Every non-POD field carries an ownership annotation that decides how it is torn down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, etc.) are stored inline and copied by value. +> **Requires a real allocator.** This layer needs a `bstack` allocator that +> actually frees (`dealloc`) and reserves offset 0 for its own metadata — e.g. +> `FirstFitBStackAllocator`, `SlabBStackAllocator`, `GhostTreeBstackAllocator`. +> **Do not use `LinearBStackAllocator`**: it's a bump allocator whose `dealloc` +> is a no-op (so teardown frees nothing), and it can hand out offset 0 (which +> would break the `Option` niche). RAII over a non-freeing allocator doesn't +> make sense anyway. + ## Defining blocks ```rust @@ -143,6 +151,24 @@ Ownership rules are enforced at compile time: a `#[bstack_weak]` field whose target isn't `(rc, weak)`, or a non-`Pod` field with no annotation, is a compile error. +### Nullable references: `Option` + +Wrap a reference field in `Option` to make it nullable — on disk it's still a +single `u64`, with `0 == None` (no allocation ever lives at offset 0, so it's a +free niche, no tag byte): + +```rust +#[bstack_block] +struct Node { + #[bstack_owned] left: Option, // may be absent + #[bstack_strong] shared: Option, +} +``` + +The accessor then returns `io::Result>`, the constructor takes +`Option>` / `Option>`, and `bstack_move!` +yields `Option<…>`. (`#[bstack_weak]` fields are already nullable by nature.) + ## Handles The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, @@ -311,10 +337,10 @@ no spin loop). All operations are durable and speak `std::io::Result`. - **Fixed-size blocks.** A block's on-disk size equals its `OnDisk` struct size; there are no variable-length arrays or inline slices. Model collections as linked blocks. -- **No nullable owned/strong fields.** `#[bstack_owned]` / `#[bstack_strong]` - fields must always be present (they're required constructor parameters). Only - `#[bstack_weak]` fields may be null/unset. +- **Requires a freeing allocator** that reserves offset 0 — not + `LinearBStackAllocator` (see [Concepts](#concepts)). - **No generic block types**, and non-`Pod` fields must carry an annotation. +- No enums or variable-length fields yet (planned). - The on-disk **ABI is not yet stable**. ## License diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index bcfb47d..7d3fa7f 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -14,7 +14,10 @@ use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote}; use syn::parse::Parser; use syn::punctuated::Punctuated; -use syn::{Error, Expr, ExprLit, Fields, Ident, ItemStruct, Lit, Meta, Token, Type}; +use syn::{ + Error, Expr, ExprLit, Fields, GenericArgument, Ident, ItemStruct, Lit, Meta, PathArguments, + Token, Type, +}; /// The block mode from the attribute arguments. #[derive(Clone, Copy, PartialEq)] @@ -87,65 +90,58 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result for field in fields { let fname = field.ident.as_ref().expect("named field"); - let fty = &field.ty; let kind = classify(field)?; + // `Option` makes a reference field nullable: `0` on disk == `None` + // (no allocation ever lives at offset 0). The annotation applies to Inner. + let (inner_ty, nullable) = match option_inner(&field.ty) { + Some(inner) => (inner, true), + None => (&field.ty, false), + }; + if nullable && kind == Kind::Pod { + return Err(Error::new_spanned( + &field.ty, + "Option is only supported on #[bstack_owned] / #[bstack_strong] / \ + #[bstack_weak] / #[bstack_ref] fields", + )); + } - // On-disk lowering + teardown. + // On-disk lowering. match kind { Kind::Pod => { - on_disk_fields.push(quote!(#fname: #fty,)); - pod_types.push(fty); + on_disk_fields.push(quote!(#fname: #inner_ty,)); + pod_types.push(inner_ty); } _ => on_disk_fields.push(quote!(#fname: u64,)), } + + // Teardown. match kind { Kind::Owned => drop_stmts.push(child_range_stmt( fname, - fty, - quote! { - ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; - }, + inner_ty, + nullable, + quote!(::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?;), )), Kind::Strong => drop_stmts.push(child_range_stmt( fname, - fty, - quote! { - <#fty as ::bstack_raii::BStackShared>::drop_strong_ref(__child, allocator)?; - }, + inner_ty, + nullable, + quote!(<#inner_ty as ::bstack_raii::BStackShared>::drop_strong_ref(__child, allocator)?;), )), - // Weak fields store the child's *control-block* offset (sound even if - // the target's data is already freed), and may be null (0 = unset). - Kind::Weak => drop_stmts.push(quote! { - { - let __off = __on_disk.#fname; - if __off != 0 { - let __ctrl = unsafe { - ::bstack_raii::BStackRef::< - <#fty as ::bstack_raii::BStackWeakable>::Control - >::from_range(::bstack_raii::BStackRange::new( - __off, - ::core::mem::size_of::< - <#fty as ::bstack_raii::BStackWeakable>::Control - >() as u64, - )) - }; - ::bstack_raii::WeakRef::<#fty>(__ctrl).bstack_drop(allocator)?; - } - } - }), + Kind::Weak => drop_stmts.push(weak_drop_stmt(fname, inner_ty)), Kind::Ref | Kind::Pod => {} } // Accessor. - accessors.push(accessor(vis, fname, fty, &on_disk, kind)); + accessors.push(accessor(vis, fname, inner_ty, &on_disk, kind, nullable)); - // Constructor pieces. Weak fields are not constructor parameters — they - // start null and are wired afterwards via the generated `set_`. + // Constructor. Weak fields are not parameters — they start null and are + // wired afterwards via the generated `set_`. if kind == Kind::Weak { ctor_inits.push(quote!(#fname: 0u64,)); - setters.push(weak_setter(vis, fname, fty, &on_disk)); + setters.push(weak_setter(vis, fname, inner_ty, &on_disk)); } else { - let (param, prep, init) = ctor_field(fname, fty, kind); + let (param, prep, init) = ctor_field(fname, inner_ty, kind, nullable); ctor_params.push(param); ctor_preps.push(prep); ctor_inits.push(init); @@ -155,93 +151,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // then reconstruct the transferred handle after. let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); - match kind { - Kind::Owned => { - mv_types.push(quote!(::bstack_raii::BStackOwned<'__mv, #fty, __A>)); - mv_recon.push(quote! { - unsafe { - ::bstack_raii::BStackOwned::from_raw( - <#fty as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new( - #cap, - ::core::mem::size_of::< - <#fty as ::bstack_raii::BStackBlock>::OnDisk - >() as u64, - ), - ), - __alloc, - ) - } - }); - } - Kind::Ref => { - mv_types.push(quote!(::bstack_raii::BStackRef<#fty>)); - mv_recon.push(quote! { - unsafe { - ::bstack_raii::BStackRef::<#fty>::from_range( - ::bstack_raii::BStackRange::new( - #cap, - ::core::mem::size_of::< - <#fty as ::bstack_raii::BStackBlock>::OnDisk - >() as u64, - ), - ) - } - }); - } - Kind::Pod => { - mv_types.push(quote!(#fty)); - mv_recon.push(quote!(#cap)); - } - Kind::Strong => { - // Rebuild a BStackRc, dispatching through BStackShared so the - // child's kind (rc vs rc,weak) picks up the control block if any. - mv_types.push(quote!(::bstack_raii::BStackRc<'__mv, #fty, __A>)); - mv_recon.push(quote! { - { - let __data = unsafe { - ::bstack_raii::BStackRef::<#fty>::from_range( - ::bstack_raii::BStackRange::new( - #cap, - ::core::mem::size_of::< - <#fty as ::bstack_raii::BStackBlock>::OnDisk - >() as u64, - ), - ) - }; - let (__d, __c) = - <#fty as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; - unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } - } - }); - } - Kind::Weak => { - // The field holds the child's control offset directly; rebuild a - // BStackWeak, or None if the field was never set (0). - mv_types.push(quote! { - ::core::option::Option<::bstack_raii::BStackWeak<'__mv, #fty, __A>> - }); - mv_recon.push(quote! { - if #cap == 0 { - ::core::option::Option::None - } else { - let __ctrl = unsafe { - ::bstack_raii::BStackRef::< - <#fty as ::bstack_raii::BStackWeakable>::Control - >::from_range(::bstack_raii::BStackRange::new( - #cap, - ::core::mem::size_of::< - <#fty as ::bstack_raii::BStackWeakable>::Control - >() as u64, - )) - }; - ::core::option::Option::Some( - unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } - ) - } - }); - } - } + let (mv_ty, mv_rc) = move_field(&cap, inner_ty, kind, nullable); + mv_types.push(mv_ty); + mv_recon.push(mv_rc); } // EightCC tags: readable prefix over a hash of `crate ++ type_name`. The @@ -477,13 +389,33 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }) } -/// Generate the reader method for one field. +/// Return `Some(Inner)` if `ty` is `Option`. +fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { + return None; + }; + let seg = tp.path.segments.last()?; + if seg.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(ab) = &seg.arguments else { + return None; + }; + match ab.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// Generate the reader method for one field. `nullable` (an `Option<_>` field) +/// makes ref accessors return `Option`, treating a `0` offset as `None`. fn accessor( vis: &syn::Visibility, fname: &Ident, - fty: &Type, + inner_ty: &Type, on_disk: &Ident, kind: Kind, + nullable: bool, ) -> TokenStream { // Weak fields hold a control offset; the accessor attempts a live upgrade. if kind == Kind::Weak { @@ -492,7 +424,7 @@ fn accessor( &self, allocator: &'__u __A, ) -> ::std::io::Result< - ::core::option::Option<::bstack_raii::BStackRc<'__u, #fty, __A>> + ::core::option::Option<::bstack_raii::BStackRc<'__u, #inner_ty, __A>> > { let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; ::bstack_raii::upgrade_weak_field(allocator, __field) @@ -505,58 +437,195 @@ fn accessor( let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; }; if kind == Kind::Pod { - quote! { - #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#fty> { + return quote! { + #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { #read ::std::result::Result::Ok(__od.#fname) } + }; + } + // Owned/strong/ref field: resolve the stored data offset to the handle. + let resolve = quote! { + <#inner_ty as ::bstack_raii::BStackBlock>::from_range(::bstack_raii::BStackRange::new( + __od.#fname, + ::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + )) + }; + if nullable { + quote! { + #vis fn #fname( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<::core::option::Option<#inner_ty>> { + #read + if __od.#fname == 0 { + ::std::result::Result::Ok(::core::option::Option::None) + } else { + ::std::result::Result::Ok(::core::option::Option::Some(#resolve)) + } + } } } else { - // Owned/strong/ref field: resolve the stored data offset to the handle. quote! { - #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#fty> { + #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { #read - let __range = ::bstack_raii::BStackRange::new( - __od.#fname, - ::core::mem::size_of::<<#fty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, - ); - ::std::result::Result::Ok(<#fty as ::bstack_raii::BStackBlock>::from_range(__range)) + ::std::result::Result::Ok(#resolve) } } } } /// Generate `(param, prep, init)` for one constructor field. Not called for -/// `#[bstack_weak]` fields. -fn ctor_field(fname: &Ident, fty: &Type, kind: Kind) -> (TokenStream, TokenStream, TokenStream) { - match kind { - Kind::Pod => (quote!(#fname: #fty,), quote!(), quote!(#fname: #fname,)), +/// `#[bstack_weak]` fields. `nullable` fields take an `Option` (None => 0). +fn ctor_field( + fname: &Ident, + inner_ty: &Type, + kind: Kind, + nullable: bool, +) -> (TokenStream, TokenStream, TokenStream) { + // The prep body that turns a consumed handle into its `u64` offset. + let (handle_ty, to_offset): (TokenStream, TokenStream) = match kind { + Kind::Pod => return (quote!(#fname: #inner_ty,), quote!(), quote!(#fname: #fname,)), Kind::Owned => ( - quote!(#fname: ::bstack_raii::BStackOwned<'__ctor, #fty, __A>,), + quote!(::bstack_raii::BStackOwned<'__ctor, #inner_ty, __A>), + quote!({ + let (__h, _) = __handle.into_raw_parts(); + ::bstack_raii::BStackBlock::range(&__h).start() + }), + ), + Kind::Strong => ( + quote!(::bstack_raii::BStackRc<'__ctor, #inner_ty, __A>), + quote!({ + let (__d, _) = __handle.into_raw(); + __d.into_range().start() + }), + ), + Kind::Ref => ( + quote!(::bstack_raii::BStackRef<#inner_ty>), + quote!(__handle.into_range().start()), + ), + Kind::Weak => unreachable!("weak fields are wired via set_, not the constructor"), + }; + if nullable { + ( + quote!(#fname: ::core::option::Option<#handle_ty>,), quote! { - let #fname: u64 = { - let (__h, _) = #fname.into_raw_parts(); - ::bstack_raii::BStackBlock::range(&__h).start() + let #fname: u64 = match #fname { + ::core::option::Option::Some(__handle) => #to_offset, + ::core::option::Option::None => 0u64, }; }, quote!(#fname: #fname,), + ) + } else { + ( + quote!(#fname: #handle_ty,), + quote! { let #fname: u64 = { let __handle = #fname; #to_offset }; }, + quote!(#fname: #fname,), + ) + } +} + +/// Build one `bstack_move!` field: its type in the result tuple and the +/// expression that reconstructs it from the captured offset `cap`. +fn move_field(cap: &Ident, inner_ty: &Type, kind: Kind, nullable: bool) -> (TokenStream, TokenStream) { + let size_od = + quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); + match kind { + Kind::Pod => (quote!(#inner_ty), quote!(#cap)), + // Weak is inherently nullable and stores the control offset. + Kind::Weak => { + let ty = quote! { + ::core::option::Option<::bstack_raii::BStackWeak<'__mv, #inner_ty, __A>> + }; + let recon = quote! { + if #cap == 0 { + ::core::option::Option::None + } else { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::< + <#inner_ty as ::bstack_raii::BStackWeakable>::Control + >::from_range(::bstack_raii::BStackRange::new( + #cap, + ::core::mem::size_of::< + <#inner_ty as ::bstack_raii::BStackWeakable>::Control + >() as u64, + )) + }; + ::core::option::Option::Some( + unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } + ) + } + }; + (ty, recon) + } + Kind::Owned => wrap_move( + quote!(::bstack_raii::BStackOwned<'__mv, #inner_ty, __A>), + quote! { + unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#inner_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(#cap, #size_od), + ), + __alloc, + ) + } + }, + cap, + nullable, ), - Kind::Strong => ( - quote!(#fname: ::bstack_raii::BStackRc<'__ctor, #fty, __A>,), + Kind::Ref => wrap_move( + quote!(::bstack_raii::BStackRef<#inner_ty>), quote! { - let #fname: u64 = { - let (__d, _) = #fname.into_raw(); - __d.into_range().start() - }; + unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range( + ::bstack_raii::BStackRange::new(#cap, #size_od), + ) + } }, - quote!(#fname: #fname,), + cap, + nullable, ), - Kind::Ref => ( - quote!(#fname: ::bstack_raii::BStackRef<#fty>,), - quote!(), - quote!(#fname: #fname.into_range().start(),), + Kind::Strong => wrap_move( + quote!(::bstack_raii::BStackRc<'__mv, #inner_ty, __A>), + quote! { + { + let __data = unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range( + ::bstack_raii::BStackRange::new(#cap, #size_od), + ) + }; + let (__d, __c) = + <#inner_ty as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } + } + }, + cap, + nullable, ), - Kind::Weak => unreachable!("weak fields are wired via set_, not the constructor"), + } +} + +/// Wrap a move field's type/expr in `Option` when the field is nullable. +fn wrap_move( + ty: TokenStream, + build: TokenStream, + cap: &Ident, + nullable: bool, +) -> (TokenStream, TokenStream) { + if nullable { + ( + quote!(::core::option::Option<#ty>), + quote! { + if #cap == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some(#build) + } + }, + ) + } else { + (ty, build) } } @@ -665,18 +734,44 @@ fn constructor( } } -/// Build a teardown statement that resolves a child field's `u64` offset into a -/// typed `BStackRef<#fty>` bound to `__child`, then runs `body`. -fn child_range_stmt(fname: &Ident, fty: &Type, body: TokenStream) -> TokenStream { +/// Build an owned/strong teardown statement: resolve the child field's `u64` +/// offset into a typed `BStackRef<#inner_ty>` bound to `__child`, then run +/// `body`. A `nullable` field guards on a non-zero offset. +fn child_range_stmt(fname: &Ident, inner_ty: &Type, nullable: bool, body: TokenStream) -> TokenStream { + let core = quote! { + let __range = ::bstack_raii::BStackRange::new( + __off, + ::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + ); + let __child = unsafe { ::bstack_raii::BStackRef::<#inner_ty>::from_range(__range) }; + #body + }; + if nullable { + quote! { { let __off = __on_disk.#fname; if __off != 0 { #core } } } + } else { + quote! { { let __off = __on_disk.#fname; #core } } + } +} + +/// Teardown statement for a `#[bstack_weak]` field, which stores the child's +/// control-block offset (`0` = unset). +fn weak_drop_stmt(fname: &Ident, inner_ty: &Type) -> TokenStream { quote! { { let __off = __on_disk.#fname; - let __range = ::bstack_raii::BStackRange::new( - __off, - ::core::mem::size_of::<<#fty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, - ); - let __child = unsafe { ::bstack_raii::BStackRef::<#fty>::from_range(__range) }; - #body + if __off != 0 { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::< + <#inner_ty as ::bstack_raii::BStackWeakable>::Control + >::from_range(::bstack_raii::BStackRange::new( + __off, + ::core::mem::size_of::< + <#inner_ty as ::bstack_raii::BStackWeakable>::Control + >() as u64, + )) + }; + ::bstack_raii::WeakRef::<#inner_ty>(__ctrl).bstack_drop(allocator)?; + } } } } diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 22b3042..29b804a 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -31,6 +31,7 @@ mod cast; /// (`#[repr(C, packed)]`, `Pod`) — the on-disk payload. `#[bstack_owned]` / /// `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` fields lower to a /// `u64` offset; un-annotated fields are stored inline (and must be `Pod`). +/// Wrapping a reference field in `Option` makes it nullable (`0 == None`). /// `(rc)` injects an inline `refcount`; `(rc, weak)` injects a `ctrl` /// back-pointer and emits an `XOnDiskRef` control block. /// * `impl BStackCast / BStackBlock / BStackDrop`, plus `BStackShared` diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index f89b8d7..e2e07f5 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -870,3 +870,54 @@ fn macro_bstack_move_rc_weak() { drop(moved_leaf); // frees the moved-out child drop(weak); // frees the now-unreferenced control block } + +// -------------------------------------------------------------------------- +// Option — nullable reference fields (0 == None) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct OptHolder { + #[bstack_owned] + child: Option, + n: u32, +} + +#[test] +fn macro_option_owned() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Some: constructor takes Option>, accessor returns Option. + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let leaf_off = leaf.handle().range().start(); + let holder = OptHolder::new(&alloc, Some(leaf), 7).unwrap(); + assert_eq!(holder.handle().n(stack).unwrap(), 7); + let got = holder.handle().child(stack).unwrap(); + assert_eq!(got.unwrap().val(stack).unwrap(), 42); + + // bstack_move! yields Option>. + let (moved_child, n) = bstack_move!(holder).unwrap(); + assert_eq!(n, 7); + assert_eq!( + moved_child.as_ref().unwrap().handle().val(stack).unwrap(), + 42 + ); + drop(moved_child); // frees the leaf + + // The leaf + holder shell are both freed; the lowest slot (leaf's) returns. + let reused = alloc_block( + &alloc, + MacroLeaf::eightcc(), + size_of::<::OnDisk>() as u64, + ) + .unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; + + // None: no child, accessor is None, teardown skips the null field cleanly. + let empty = OptHolder::new(&alloc, None, 9).unwrap(); + assert_eq!(empty.handle().n(stack).unwrap(), 9); + assert!(empty.handle().child(stack).unwrap().is_none()); + drop(empty); +} From 8e63467480a54d7179f96ecd7581efec5601cf2a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 04:01:32 -0700 Subject: [PATCH 018/140] on disk Vec --- bstack_raii/README.md | 4 + bstack_raii/derive/src/block.rs | 22 +++- bstack_raii/src/lib.rs | 2 + bstack_raii/src/tests.rs | 44 ++++++++ bstack_raii/src/vec.rs | 173 ++++++++++++++++++++++++++++++++ 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 bstack_raii/src/vec.rs diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 500af88..d6c1920 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -110,6 +110,10 @@ stored inline and copied by value. > is a no-op (so teardown frees nothing), and it can hand out offset 0 (which > would break the `Option` niche). RAII over a non-freeing allocator doesn't > make sense anyway. +> +> For growable fields (`Vec` / `String`), prefer a **realloc-safe** allocator: +> growth reallocates the backing block, and a torn realloc under a poorly-behaved +> allocator can corrupt it. `FirstFitBStackAllocator` is realloc-safe. ## Defining blocks diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 7d3fa7f..3a08c96 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -485,7 +485,13 @@ fn ctor_field( ) -> (TokenStream, TokenStream, TokenStream) { // The prep body that turns a consumed handle into its `u64` offset. let (handle_ty, to_offset): (TokenStream, TokenStream) = match kind { - Kind::Pod => return (quote!(#fname: #inner_ty,), quote!(), quote!(#fname: #fname,)), + Kind::Pod => { + return ( + quote!(#fname: #inner_ty,), + quote!(), + quote!(#fname: #fname,), + ); + } Kind::Owned => ( quote!(::bstack_raii::BStackOwned<'__ctor, #inner_ty, __A>), quote!({ @@ -528,7 +534,12 @@ fn ctor_field( /// Build one `bstack_move!` field: its type in the result tuple and the /// expression that reconstructs it from the captured offset `cap`. -fn move_field(cap: &Ident, inner_ty: &Type, kind: Kind, nullable: bool) -> (TokenStream, TokenStream) { +fn move_field( + cap: &Ident, + inner_ty: &Type, + kind: Kind, + nullable: bool, +) -> (TokenStream, TokenStream) { let size_od = quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); match kind { @@ -737,7 +748,12 @@ fn constructor( /// Build an owned/strong teardown statement: resolve the child field's `u64` /// offset into a typed `BStackRef<#inner_ty>` bound to `__child`, then run /// `body`. A `nullable` field guards on a non-zero offset. -fn child_range_stmt(fname: &Ident, inner_ty: &Type, nullable: bool, body: TokenStream) -> TokenStream { +fn child_range_stmt( + fname: &Ident, + inner_ty: &Type, + nullable: bool, + body: TokenStream, +) -> TokenStream { let core = quote! { let __range = ::bstack_raii::BStackRange::new( __off, diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 168e360..6eebdbd 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -59,6 +59,7 @@ mod refcount; mod reference; mod shared; mod teardown; +mod vec; #[cfg(test)] mod tests; @@ -75,6 +76,7 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{BStackDrop, dealloc_range}; +pub use vec::BStackVec; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e2e07f5..66b9f70 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -921,3 +921,47 @@ fn macro_option_owned() { assert!(empty.handle().child(stack).unwrap().is_none()); drop(empty); } + +// -------------------------------------------------------------------------- +// BStackVec — persistent growable POD vector via the descriptor indirection +// -------------------------------------------------------------------------- + +#[test] +fn bstack_vec_grow_and_free() { + use crate::BStackVec; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + // Build from a slice, read it back. + let mut v = BStackVec::::from_slice(&alloc, b"hello").unwrap(); + assert_eq!(v.len().unwrap(), 5); + assert_eq!(v.to_vec().unwrap(), b"hello"); + + // The stable identity is the descriptor. + let desc = v.descriptor(); + + // Grow past capacity: the data block reallocs/moves, but the descriptor + // (and hence the field pointer) is unchanged. + for &b in b", world!" { + v.push(b).unwrap(); + } + assert_eq!(v.descriptor(), desc); // identity stable across growth + assert_eq!(v.to_vec().unwrap(), b"hello, world!"); + assert_eq!(v.len().unwrap(), 13); + + // Free the data block + descriptor. + v.bstack_drop().unwrap(); + + // Allocator is healthy afterwards: a fresh vector round-trips. + let v2 = BStackVec::::from_slice(&alloc, b"again").unwrap(); + assert_eq!(v2.to_vec().unwrap(), b"again"); + v2.bstack_drop().unwrap(); + + // A larger POD element type also works (unaligned reads). + let mut nums = BStackVec::::from_slice(&alloc, &[1u32, 2, 3]).unwrap(); + nums.push(4).unwrap(); + assert_eq!(nums.to_vec().unwrap(), vec![1u32, 2, 3, 4]); + assert_eq!(nums.len().unwrap(), 4); + nums.bstack_drop().unwrap(); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs new file mode 100644 index 0000000..6bbf156 --- /dev/null +++ b/bstack_raii/src/vec.rs @@ -0,0 +1,173 @@ +//! [`BStackVec`]: a persistent, growable POD vector reachable through a +//! **fixed-size** field. +//! +//! A block field can only store a fixed-size value, but a vector's backing store +//! must be able to grow — and `BStackByteVec` **moves** its block on realloc. The +//! fix is one level of indirection: +//! +//! ```text +//! parent field (u64) ── points to ──▶ descriptor block (fixed, never moves) +//! │ { data_off, data_size } +//! └── points to ──▶ BStackByteVec data +//! block (may realloc/move) +//! ``` +//! +//! The **descriptor** is a fixed 16-byte block that holds the current offset and +//! size of the data block. When the data grows and moves, only the descriptor's +//! pointer is rewritten; the parent's pointer to the descriptor is stable. So a +//! `BStackVec` field is identified by its descriptor offset, which never changes. +//! +//! Elements are `bytemuck::Pod`; bytes are stored/read unaligned, so any element +//! type works. `u8` (i.e. `Vec` / `String` fields) is the common case. +//! +//! > **Growth reallocates**, so use a realloc-safe allocator (see the crate +//! > docs) to avoid corruption on a torn realloc. + +use core::marker::PhantomData; +use std::io; + +use bstack::{BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::Pod; + +use crate::teardown::dealloc_range; + +/// Byte size of a descriptor block: `{ data_off: u64, data_size: u64 }`. +pub(crate) const DESCRIPTOR_SIZE: u64 = 16; + +/// A persistent, growable vector of POD elements, addressed by a stable +/// descriptor block. Backs `#[bstack_owned] Vec` / `String` fields. +pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { + /// The descriptor block (`data_off`, `data_size`) — a stable identity. + desc: BStackRange, + allocator: &'a A, + _marker: PhantomData T>, +} + +impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { + /// Reconstruct a handle from its descriptor block (e.g. a field accessor). + /// + /// # Safety + /// `desc` must point at a live descriptor block written by this type. + pub unsafe fn from_descriptor(desc: BStackRange, allocator: &'a A) -> Self { + Self { + desc, + allocator, + _marker: PhantomData, + } + } + + /// The descriptor block's range — the vector's stable on-disk identity. + pub fn descriptor(&self) -> BStackRange { + self.desc + } + + fn read_desc(&self) -> io::Result<(u64, u64)> { + let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; + self.allocator + .stack() + .get_into(self.desc.start(), &mut buf)?; + let off = u64::from_le_bytes(buf[0..8].try_into().unwrap()); + let size = u64::from_le_bytes(buf[8..16].try_into().unwrap()); + Ok((off, size)) + } + + fn write_desc(&self, data_off: u64, data_size: u64) -> io::Result<()> { + let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; + buf[0..8].copy_from_slice(&data_off.to_le_bytes()); + buf[8..16].copy_from_slice(&data_size.to_le_bytes()); + self.allocator.stack().set(self.desc.start(), buf) + } + + /// Reconstruct the `BStackByteVec` over the current data block. + fn bytes(&self) -> io::Result> { + let (off, size) = self.read_desc()?; + let block = unsafe { + BStackOwnedSlice::from_raw_range(self.allocator, BStackRange::new(off, size)) + }; + Ok(unsafe { BStackByteVec::from_raw_block(block) }) + } + + /// Free the data block and the descriptor. Consumes the handle. + pub fn bstack_drop(self) -> io::Result<()> { + let (off, size) = self.read_desc()?; + unsafe { + dealloc_range(self.allocator, BStackRange::new(off, size))?; + dealloc_range(self.allocator, self.desc)?; + } + Ok(()) + } +} + +impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { + /// Create a vector holding `data`, allocating the data block then a + /// descriptor pointing at it. + pub fn from_slice(allocator: &'a A, data: &[T]) -> io::Result { + let data_range = BStackByteVec::from_slice(bytemuck::cast_slice(data), allocator)? + .into_raw_block() + .as_range(); + + let mut desc = match allocator.alloc(DESCRIPTOR_SIZE) { + Ok(d) => d, + Err(e) => { + let _ = unsafe { dealloc_range(allocator, data_range) }; + return Err(e); + } + }; + let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; + buf[0..8].copy_from_slice(&data_range.start().to_le_bytes()); + buf[8..16].copy_from_slice(&data_range.len().to_le_bytes()); + if let Err(e) = desc.write_range(0, buf) { + let _ = allocator.dealloc(desc); + let _ = unsafe { dealloc_range(allocator, data_range) }; + return Err(e); + } + Ok(Self { + desc: desc.as_range(), + allocator, + _marker: PhantomData, + }) + } + + /// Create an empty vector. + pub fn new(allocator: &'a A) -> io::Result { + Self::from_slice(allocator, &[]) + } + + /// Number of elements. + pub fn len(&self) -> io::Result { + Ok(self.bytes()?.len()? / core::mem::size_of::() as u64) + } + + /// Whether the vector is empty. + pub fn is_empty(&self) -> io::Result { + Ok(self.len()? == 0) + } + + /// Read all elements into a `Vec` (unaligned reads, so any `T` is fine). + pub fn to_vec(&self) -> io::Result> { + let bytes = self.bytes()?.read_bytes()?; + let esz = core::mem::size_of::(); + Ok(bytes + .chunks_exact(esz) + .map(bytemuck::pod_read_unaligned::) + .collect()) + } + + /// Append an element, growing the data block if needed (which may move it — + /// the descriptor is rewritten to follow). + pub fn push(&mut self, value: T) -> io::Result<()> { + let (off, size) = self.read_desc()?; + let block = unsafe { + BStackOwnedSlice::from_raw_range(self.allocator, BStackRange::new(off, size)) + }; + let mut bytevec = unsafe { BStackByteVec::from_raw_block(block) }; + for &b in bytemuck::bytes_of(&value) { + bytevec.push(b)?; + } + let new_range = bytevec.into_raw_block().as_range(); + if new_range.start() != off || new_range.len() != size { + self.write_desc(new_range.start(), new_range.len())?; + } + Ok(()) + } +} From 94327c7055786383541a102744fb75f2fb32502f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 04:05:14 -0700 Subject: [PATCH 019/140] Macros for vec --- bstack_raii/derive/src/block.rs | 112 ++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 62 ++++++++++++++++++ bstack_raii/src/vec.rs | 10 +-- 3 files changed, 180 insertions(+), 4 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 3a08c96..ebca750 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -91,6 +91,32 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result for field in fields { let fname = field.ident.as_ref().expect("named field"); let kind = classify(field)?; + + // `Vec` / `String` fields: a fixed-size descriptor offset on disk, a + // `BStackVec` at runtime. Handled entirely here. + if let Some(vinfo) = vec_field(&field.ty) { + if kind != Kind::Owned { + return Err(Error::new_spanned( + &field.ty, + "`Vec` / `String` fields must be annotated `#[bstack_owned]`", + )); + } + let elem = &vinfo.elem; + on_disk_fields.push(quote!(#fname: u64,)); + drop_stmts.push(vec_drop_stmt(fname, elem)); + accessors.push(vec_accessor(vis, fname, elem, &on_disk)); + let (param, prep, init) = vec_ctor(fname, &vinfo); + ctor_params.push(param); + ctor_preps.push(prep); + ctor_inits.push(init); + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + let (mv_ty, mv_rc) = vec_move(&cap, elem); + mv_types.push(mv_ty); + mv_recon.push(mv_rc); + continue; + } + // `Option` makes a reference field nullable: `0` on disk == `None` // (no allocation ever lives at offset 0). The annotation applies to Inner. let (inner_ty, nullable) = match option_inner(&field.ty) { @@ -389,6 +415,92 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }) } +/// A `Vec` / `String` field: its element type (tokens) and whether it's a +/// `String` (so the constructor takes `&str`). +struct VecInfo { + elem: TokenStream, + is_string: bool, +} + +/// Detect `Vec` / `String` field types. +fn vec_field(ty: &Type) -> Option { + let Type::Path(tp) = ty else { + return None; + }; + let seg = tp.path.segments.last()?; + if seg.ident == "String" { + return Some(VecInfo { elem: quote!(u8), is_string: true }); + } + if seg.ident == "Vec" { + if let PathArguments::AngleBracketed(ab) = &seg.arguments { + if let Some(GenericArgument::Type(inner)) = ab.args.first() { + return Some(VecInfo { elem: quote!(#inner), is_string: false }); + } + } + } + None +} + +/// Teardown for an owned `Vec` / `String` field: free its `BStackVec` (data + +/// descriptor blocks). +fn vec_drop_stmt(fname: &Ident, elem: &TokenStream) -> TokenStream { + quote! { + { + unsafe { + ::bstack_raii::BStackVec::<#elem, __A>::from_descriptor(__on_disk.#fname, allocator) + .bstack_drop()?; + } + } + } +} + +/// Accessor for a `Vec` / `String` field: resolve the descriptor offset to a +/// `BStackVec` handle. Takes the allocator (the vector's ops need it). +fn vec_accessor(vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, on_disk: &Ident) -> TokenStream { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { + let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let mut __buf = [0u8; 8]; + allocator.stack().get_into(__field, &mut __buf)?; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackVec::from_descriptor(u64::from_le_bytes(__buf), allocator) + }) + } + } +} + +/// Constructor `(param, prep, init)` for a `Vec` / `String` field: build the +/// `BStackVec` from the passed data and store its descriptor offset. +fn vec_ctor(fname: &Ident, vinfo: &VecInfo) -> (TokenStream, TokenStream, TokenStream) { + let elem = &vinfo.elem; + let (param_ty, data): (TokenStream, TokenStream) = if vinfo.is_string { + (quote!(&str), quote!(#fname.as_bytes())) + } else { + (quote!(&[#elem]), quote!(#fname)) + }; + ( + quote!(#fname: #param_ty,), + quote! { + let #fname: u64 = + ::bstack_raii::BStackVec::<#elem, __A>::from_slice(allocator, #data)? + .descriptor() + .start(); + }, + quote!(#fname: #fname,), + ) +} + +/// `bstack_move!` field for a `Vec` / `String`: yield the `BStackVec` handle. +fn vec_move(cap: &Ident, elem: &TokenStream) -> (TokenStream, TokenStream) { + ( + quote!(::bstack_raii::BStackVec<'__mv, #elem, __A>), + quote!(unsafe { ::bstack_raii::BStackVec::from_descriptor(#cap, __alloc) }), + ) +} + /// Return `Some(Inner)` if `ty` is `Option`. fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(tp) = ty else { diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 66b9f70..fc09fce 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -965,3 +965,65 @@ fn bstack_vec_grow_and_free() { assert_eq!(nums.len().unwrap(), 4); nums.bstack_drop().unwrap(); } + +// -------------------------------------------------------------------------- +// Vec / String fields (POD elements) via BStackVec +// -------------------------------------------------------------------------- + +#[bstack_block] +struct Record { + #[bstack_owned] + name: String, + #[bstack_owned] + tags: Vec, + id: u64, +} + +#[test] +fn macro_vec_string_fields() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Constructor takes `&str` for String and `&[T]` for Vec. + let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42).unwrap(); + assert_eq!(rec.handle().id(stack).unwrap(), 42); + + // Accessors return BStackVec handles (take the allocator). + assert_eq!(rec.handle().name(&alloc).unwrap().to_vec().unwrap(), b"hello"); + assert_eq!(rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3]); + + // Mutate through the handle: the field points at the stable descriptor, so + // growth (even if the data block moves) is visible on the next read. + let mut tags = rec.handle().tags(&alloc).unwrap(); + tags.push(4).unwrap(); + assert_eq!( + rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3, 4], + ); + + // Dropping the record frees both vectors (data + descriptor) and the record. + drop(rec); + + // Allocator is healthy: a fresh record round-trips. + let rec2 = Record::new(&alloc, "again", &[9u32], 1).unwrap(); + assert_eq!(rec2.handle().name(&alloc).unwrap().to_vec().unwrap(), b"again"); + assert_eq!(rec2.handle().tags(&alloc).unwrap().to_vec().unwrap(), vec![9u32]); + drop(rec2); +} + +#[test] +fn macro_vec_bstack_move() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let rec = Record::new(&alloc, "movable", &[7u32, 8], 5).unwrap(); + // bstack_move! yields the BStackVec handles + the POD. + let (name, tags, id) = bstack_move!(rec).unwrap(); + assert_eq!(id, 5); + assert_eq!(name.to_vec().unwrap(), b"movable"); + assert_eq!(tags.to_vec().unwrap(), vec![7u32, 8]); + // The vectors are now independently owned; free them. + name.bstack_drop().unwrap(); + tags.bstack_drop().unwrap(); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 6bbf156..bde8da0 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -44,13 +44,15 @@ pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { } impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { - /// Reconstruct a handle from its descriptor block (e.g. a field accessor). + /// Reconstruct a handle from its descriptor block offset (e.g. a field + /// accessor, which stores just that offset). /// /// # Safety - /// `desc` must point at a live descriptor block written by this type. - pub unsafe fn from_descriptor(desc: BStackRange, allocator: &'a A) -> Self { + /// `desc_off` must be the offset of a live descriptor block written by this + /// type. + pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { Self { - desc, + desc: BStackRange::new(desc_off, DESCRIPTOR_SIZE), allocator, _marker: PhantomData, } From a239d20c0fcbdf263fc78384c856f5a025b345c2 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 04:16:51 -0700 Subject: [PATCH 020/140] Allow & but warn and documenting variable length additions --- bstack_raii/README.md | 37 +++++++- bstack_raii/derive/src/block.rs | 145 +++++++++++++++++++++++++++----- bstack_raii/derive/src/lib.rs | 8 +- bstack_raii/src/tests.rs | 22 +++-- 4 files changed, 180 insertions(+), 32 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index d6c1920..ec3d95b 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -173,6 +173,39 @@ The accessor then returns `io::Result>`, the constructor takes `Option>` / `Option>`, and `bstack_move!` yields `Option<…>`. (`#[bstack_weak]` fields are already nullable by nature.) +### Variable-length: `Vec` and `String` + +An `#[bstack_owned] Vec` (POD `T`) or `String` field stores a growable +sequence. On disk the field is a fixed-size pointer to a small **descriptor** +block, which in turn points to the (growable, reallocating) data block — so the +field stays fixed-size while the data can grow and move: + +```rust +#[bstack_block] +struct Record { + #[bstack_owned] name: String, + #[bstack_owned] tags: Vec, + id: u64, +} + +let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42)?; // &str / &[T] / value +let mut tags = rec.handle().tags(&alloc)?; // a BStackVec handle +tags.push(4)?; // grows in place, visible on re-read +assert_eq!(rec.handle().tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); +``` + +The accessor returns a [`BStackVec`] handle (`len` / `to_vec` / `push`); the +constructor takes `&str` / `&[T]`; `bstack_move!` yields the `BStackVec`. Freeing +the block frees the data + descriptor. Elements must be `Pod` — `Vec` +(vectors of blocks), `#[bstack_ref] Vec`, and `Option>` are not +supported yet. + +### Ergonomic reference coercion + +For convenience, a field written `&T` is coerced to owned `T` (and `&str` to +`String`) with a compile warning — so a stray reference doesn't fail to compile, +but you're nudged to write the owned type. + ## Handles The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, @@ -319,7 +352,8 @@ struct OrderLine { /* … */ } `ctrl_tag = "…"` overrides the control-block tag (default: the data tag, lowercased). An override longer than 8 bytes is truncated with a compile warning; -`allow_long_tag` silences it. +`#[bstack_block(allow(overlong_tag))]` silences it (as does the reference-coercion +warning's `allow(coerced_ref)`, or a real `#[allow(deprecated)]` on the struct). ## How it works on disk @@ -353,3 +387,4 @@ MIT (same as `bstack`). [`bstack`]: https://github.com/williamwutq/bstack [`TryClone`]: src/clone.rs +[`BStackVec`]: src/vec.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index ebca750..1512895 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -87,14 +87,34 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let mut mv_caps = Vec::new(); let mut mv_types = Vec::new(); let mut mv_recon = Vec::new(); + // Whether any field was written `&T` (coerced to owned `T`, with a warning). + let mut ref_coerced = false; for field in fields { let fname = field.ident.as_ref().expect("named field"); let kind = classify(field)?; - // `Vec` / `String` fields: a fixed-size descriptor offset on disk, a - // `BStackVec` at runtime. Handled entirely here. - if let Some(vinfo) = vec_field(&field.ty) { + // Ergonomic: `&T` is coerced to owned `T` (and `&str` to `String`), with + // a warning. `eff_ty` is the type after stripping a leading `&`. + let eff_ty: &Type = match &field.ty { + Type::Reference(r) => &r.elem, + other => other, + }; + if matches!(&field.ty, Type::Reference(_)) { + ref_coerced = true; + } + + // `Vec` / `String` fields (and `&str` → `String`): a fixed-size + // descriptor offset on disk, a `BStackVec` at runtime. Handled here. + let vinfo = if is_str(eff_ty) { + Some(VecInfo { + elem: quote!(u8), + is_string: true, + }) + } else { + vec_field(eff_ty) + }; + if let Some(vinfo) = vinfo { if kind != Kind::Owned { return Err(Error::new_spanned( &field.ty, @@ -119,9 +139,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // `Option` makes a reference field nullable: `0` on disk == `None` // (no allocation ever lives at offset 0). The annotation applies to Inner. - let (inner_ty, nullable) = match option_inner(&field.ty) { + let (inner_ty, nullable) = match option_inner(eff_ty) { Some(inner) => (inner, true), - None => (&field.ty, false), + None => (eff_ty, false), }; if nullable && kind == Kind::Pod { return Err(Error::new_spanned( @@ -205,12 +225,18 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let data_eightcc = eightcc_expr(&data_tag.bytes); let ctrl_eightcc = eightcc_expr(&ctrl_tag.bytes); + // The warnings use the `deprecated` mechanism, so a real `#[allow(deprecated)]` + // on the struct also silences them (in addition to the `allow(...)` args). + let allow_deprecated = input.attrs.iter().any(is_allow_deprecated); + let allow_overlong = attr.allow_overlong || allow_deprecated; + let allow_coerced_ref = attr.allow_coerced_ref || allow_deprecated; + // Overlong `tag =` / `ctrl_tag =` overrides warn (unless silenced) + truncate. - let overlong_warning = if (data_tag.truncated || ctrl_tag.truncated) && !attr.allow_long { + let overlong_warning = if (data_tag.truncated || ctrl_tag.truncated) && !allow_overlong { let warn_fn = format_ident!("__bstack_tag_overlong_{}", name); let msg = format!( "#[bstack_block] on `{type_name}`: a tag override longer than 8 bytes was truncated; \ - add `allow_long_tag` to silence" + add `allow(overlong_tag)` to silence" ); quote! { #[doc(hidden)] @@ -225,6 +251,27 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result quote!() }; + // A `&T` field is coerced to owned `T` (`&str` to `String`); warn once. + let ref_warning = if ref_coerced && !allow_coerced_ref { + let warn_fn = format_ident!("__bstack_ref_coerced_{}", name); + let msg = format!( + "#[bstack_block] on `{type_name}`: a `&T` field was coerced to owned `T` \ + (and `&str` to `String`); write the owned type directly, or add \ + `allow(coerced_ref)` to silence" + ); + quote! { + #[doc(hidden)] + #[allow(dead_code, non_snake_case)] + fn #warn_fn() { + #[deprecated(note = #msg)] + fn ref_coerced() {} + ref_coerced(); + } + } + } else { + quote!() + }; + // BStackShared / BStackWeakable / control block for the refcounted modes. let shared_impl = match mode { Mode::Plain => quote!(), @@ -412,6 +459,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #weakable_items #move_impl #overlong_warning + #ref_warning }) } @@ -422,6 +470,11 @@ struct VecInfo { is_string: bool, } +/// Whether `ty` is the `str` type. +fn is_str(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == "str")) +} + /// Detect `Vec` / `String` field types. fn vec_field(ty: &Type) -> Option { let Type::Path(tp) = ty else { @@ -429,14 +482,19 @@ fn vec_field(ty: &Type) -> Option { }; let seg = tp.path.segments.last()?; if seg.ident == "String" { - return Some(VecInfo { elem: quote!(u8), is_string: true }); + return Some(VecInfo { + elem: quote!(u8), + is_string: true, + }); } - if seg.ident == "Vec" { - if let PathArguments::AngleBracketed(ab) = &seg.arguments { - if let Some(GenericArgument::Type(inner)) = ab.args.first() { - return Some(VecInfo { elem: quote!(#inner), is_string: false }); - } - } + if seg.ident == "Vec" + && let PathArguments::AngleBracketed(ab) = &seg.arguments + && let Some(GenericArgument::Type(inner)) = ab.args.first() + { + return Some(VecInfo { + elem: quote!(#inner), + is_string: false, + }); } None } @@ -456,7 +514,12 @@ fn vec_drop_stmt(fname: &Ident, elem: &TokenStream) -> TokenStream { /// Accessor for a `Vec` / `String` field: resolve the descriptor offset to a /// `BStackVec` handle. Takes the allocator (the vector's ops need it). -fn vec_accessor(vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, on_disk: &Ident) -> TokenStream { +fn vec_accessor( + vis: &syn::Visibility, + fname: &Ident, + elem: &TokenStream, + on_disk: &Ident, +) -> TokenStream { quote! { #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, @@ -911,15 +974,20 @@ struct Attr { tag: Option, /// Explicit control-block tag prefix (`ctrl_tag = "..."`). ctrl_tag: Option, - /// Suppress the overlong-tag warning (`allow_long_tag`). - allow_long: bool, + /// Suppress the overlong-tag warning (`allow(overlong_tag)`). + allow_overlong: bool, + /// Suppress the reference-coercion warning (`allow(coerced_ref)`). + allow_coerced_ref: bool, } -/// Parse `rc`, `weak`, `tag = "..."`, `ctrl_tag = "..."`, `allow_long_tag` in any -/// order. +/// Parse `rc`, `weak`, `tag = "..."`, `ctrl_tag = "..."`, and +/// `allow(overlong_tag | coerced_ref | deprecated)` in any order. fn parse_attr(attr: TokenStream) -> syn::Result { let (mut rc, mut weak) = (false, false); - let (mut tag, mut ctrl_tag, mut allow_long) = (None, None, false); + let mut tag = None; + let mut ctrl_tag = None; + let mut allow_overlong = false; + let mut allow_coerced_ref = false; if !attr.is_empty() { let metas = Punctuated::::parse_terminated.parse2(attr)?; @@ -928,7 +996,6 @@ fn parse_attr(attr: TokenStream) -> syn::Result { Meta::Path(p) => match ident_of(p).as_deref() { Some("rc") => rc = true, Some("weak") => weak = true, - Some("allow_long_tag") => allow_long = true, _ => return Err(Error::new_spanned(&meta, unknown_opt())), }, Meta::NameValue(nv) => { @@ -946,6 +1013,29 @@ fn parse_attr(attr: TokenStream) -> syn::Result { _ => return Err(Error::new_spanned(&meta, unknown_opt())), } } + // `allow(overlong_tag, coerced_ref, deprecated)` — suppress warnings. + Meta::List(list) if list.path.is_ident("allow") => { + let lints = + list.parse_args_with(Punctuated::::parse_terminated)?; + for lint in lints { + match lint.to_string().as_str() { + "overlong_tag" => allow_overlong = true, + "coerced_ref" => allow_coerced_ref = true, + // The warnings use the `deprecated` mechanism, so + // `allow(deprecated)` covers all of them. + "deprecated" => { + allow_overlong = true; + allow_coerced_ref = true; + } + _ => { + return Err(Error::new_spanned( + &lint, + "expected `overlong_tag`, `coerced_ref`, or `deprecated`", + )); + } + } + } + } _ => return Err(Error::new_spanned(&meta, unknown_opt())), } } @@ -966,7 +1056,8 @@ fn parse_attr(attr: TokenStream) -> syn::Result { mode, tag, ctrl_tag, - allow_long, + allow_overlong, + allow_coerced_ref, }) } @@ -974,8 +1065,16 @@ fn ident_of(path: &syn::Path) -> Option { path.get_ident().map(|i| i.to_string()) } +/// Whether a struct attribute is `#[allow(.., deprecated, ..)]`. +fn is_allow_deprecated(attr: &syn::Attribute) -> bool { + attr.path().is_ident("allow") + && attr + .parse_args_with(Punctuated::::parse_terminated) + .is_ok_and(|lints| lints.iter().any(|l| l == "deprecated")) +} + fn unknown_opt() -> &'static str { - "expected `rc`, `weak`, `tag = \"...\"`, `ctrl_tag = \"...\"`, or `allow_long_tag`" + "expected `rc`, `weak`, `tag = \"...\"`, `ctrl_tag = \"...\"`, or `allow(...)`" } // --------------------------------------------------------------------------- diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 29b804a..8535ea9 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -21,9 +21,11 @@ mod cast; /// # Arguments /// /// `#[bstack_block(rc)]` / `#[bstack_block(rc, weak)]` select the refcount mode. -/// `tag = "…"` / `ctrl_tag = "…"` override the generated tags (see below), and -/// `allow_long_tag` silences the truncation warning for an over-long override. -/// All are optional and may appear in any order. +/// `tag = "…"` / `ctrl_tag = "…"` override the generated tags (see below). +/// `allow(overlong_tag)` / `allow(coerced_ref)` silence the corresponding +/// warnings (and a real `#[allow(deprecated)]` on the struct silences both, +/// since the warnings use the deprecation mechanism). All are optional and may +/// appear in any order. /// /// # Generated items (for `struct X { .. }`) /// diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index fc09fce..fdbf0c8 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -693,7 +693,7 @@ struct SameB { } // Overlong override is truncated to 8 bytes (warning silenced). -#[bstack_block(tag = "TOOLONGTAG12", allow_long_tag)] +#[bstack_block(tag = "TOOLONGTAG12", allow(overlong_tag))] struct Truncated { x: u32, } @@ -990,8 +990,14 @@ fn macro_vec_string_fields() { assert_eq!(rec.handle().id(stack).unwrap(), 42); // Accessors return BStackVec handles (take the allocator). - assert_eq!(rec.handle().name(&alloc).unwrap().to_vec().unwrap(), b"hello"); - assert_eq!(rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3]); + assert_eq!( + rec.handle().name(&alloc).unwrap().to_vec().unwrap(), + b"hello" + ); + assert_eq!( + rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3] + ); // Mutate through the handle: the field points at the stable descriptor, so // growth (even if the data block moves) is visible on the next read. @@ -1007,8 +1013,14 @@ fn macro_vec_string_fields() { // Allocator is healthy: a fresh record round-trips. let rec2 = Record::new(&alloc, "again", &[9u32], 1).unwrap(); - assert_eq!(rec2.handle().name(&alloc).unwrap().to_vec().unwrap(), b"again"); - assert_eq!(rec2.handle().tags(&alloc).unwrap().to_vec().unwrap(), vec![9u32]); + assert_eq!( + rec2.handle().name(&alloc).unwrap().to_vec().unwrap(), + b"again" + ); + assert_eq!( + rec2.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![9u32] + ); drop(rec2); } From ec895ce978e872b6b37698366f39ce94ef7a3563 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 09:01:49 -0700 Subject: [PATCH 021/140] First Drop refactor --- bstack_raii/derive/src/block.rs | 8 +- bstack_raii/src/lib.rs | 6 +- bstack_raii/src/owned.rs | 62 +++++++++---- bstack_raii/src/shared.rs | 157 ++++++++++++++++++-------------- bstack_raii/src/tests.rs | 52 ++++++++++- bstack_raii/src/vec.rs | 42 +++++++-- 6 files changed, 222 insertions(+), 105 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 1512895..86dac07 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -501,13 +501,11 @@ fn vec_field(ty: &Type) -> Option { /// Teardown for an owned `Vec` / `String` field: free its `BStackVec` (data + /// descriptor blocks). -fn vec_drop_stmt(fname: &Ident, elem: &TokenStream) -> TokenStream { +fn vec_drop_stmt(fname: &Ident, _elem: &TokenStream) -> TokenStream { quote! { { - unsafe { - ::bstack_raii::BStackVec::<#elem, __A>::from_descriptor(__on_disk.#fname, allocator) - .bstack_drop()?; - } + use ::bstack_raii::BStackDrop as _; + ::bstack_raii::VecRef::from_descriptor(__on_disk.#fname).bstack_drop(allocator)?; } } } diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 6eebdbd..77de205 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -22,7 +22,7 @@ //! | [`refcount`] | Little-endian atomic CAS ops over on-disk `u64` counters. | //! | [`clone`] | [`TryClone`]: fallible clone for handles that touch disk. | //! | [`handle`] | Without-allocator inner handles: [`OwnedRef`], [`StrongRef`], [`StrongWeakRef`], [`WeakRef`]. | -//! | [`owned`] | [`BStackOwned`]: with-allocator unique handle. | +//! | [`owned`] | [`AutoDrop`]: the RAII guard bridging `BStackDrop` to `Drop`, and its [`BStackOwned`] alias. | //! | [`shared`] | [`BStackRc`] + [`BStackWeak`]: with-allocator shared handles.| //! //! ## Conventions fixed by the ABI @@ -72,11 +72,11 @@ pub use clone::TryClone; pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; -pub use owned::BStackOwned; +pub use owned::{AutoDrop, BStackOwned}; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{BStackDrop, dealloc_range}; -pub use vec::BStackVec; +pub use vec::{BStackVec, VecRef}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index e8861ea..c9388a8 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -1,11 +1,19 @@ -//! [`BStackOwned`]: the with-allocator unique handle. +//! [`AutoDrop`]: the generic RAII guard bridging [`BStackDrop`] to Rust `Drop`. //! -//! A newtype over `(ManuallyDrop, &'a A)`. Rust's `Drop` takes the inner `T` -//! out and calls [`BStackDrop::bstack_drop`]; errors are swallowed, matching the -//! contract of `Drop`. `bstack_move!` consumes it via [`BStackOwned::into_raw_parts`], -//! which defuses this `Drop` so no parallel destruction path exists. +//! On-disk teardown ([`BStackDrop::bstack_drop`]) is fallible and needs an +//! allocator, so it can't live directly in a `Drop` impl. `AutoDrop` pairs a +//! `BStackDrop` handle with its allocator and runs the teardown on scope exit +//! (swallowing the error, matching the contract of `Drop`). It is the *one* place +//! that calls `bstack_drop` from a `Drop` impl — every allocator-bound handle +//! that wants automatic cleanup is either an `AutoDrop` (as [`BStackOwned`] is) +//! or embeds one, rather than hand-writing its own `Drop`. +//! +//! Without wrapping in `AutoDrop`, a bare `BStackDrop` handle frees nothing on +//! its own: its `bstack_drop` is invoked explicitly, or runs as a child of some +//! parent block's recursive teardown. use core::mem::ManuallyDrop; +use core::ops::Deref; use std::io; use bstack::BStackOwnedSliceAllocator; @@ -13,15 +21,25 @@ use bstack::BStackOwnedSliceAllocator; use crate::block::{BStackMove, BStackMoveExpr}; use crate::teardown::BStackDrop; -/// An owned, allocator-bound handle to a block whose `Drop` recursively frees it -/// on disk via [`BStackDrop`]. -pub struct BStackOwned<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { +/// A guard that runs [`BStackDrop::bstack_drop`] on its inner handle when it goes +/// out of scope, bridging fallible on-disk teardown to Rust's `Drop`. +/// +/// It is a newtype over `(ManuallyDrop, &'a A)`. `bstack_move!` and the raw +/// accessors defuse it via [`into_raw_parts`](Self::into_raw_parts) so no +/// parallel destruction path exists. +pub struct AutoDrop<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { inner: ManuallyDrop, allocator: &'a A, } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { - /// Wrap an inner handle and allocator into an owned handle. +/// An owned, allocator-bound handle to a block whose `Drop` recursively frees it +/// on disk. Just an [`AutoDrop`] over the block type itself (whose +/// [`BStackDrop`] is the recursive free), so a fresh block hands back an +/// auto-freeing handle with no bespoke `Drop`. +pub type BStackOwned<'a, T, A> = AutoDrop<'a, T, A>; + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> AutoDrop<'a, T, A> { + /// Pair an inner handle with its allocator into an auto-dropping guard. /// /// # Safety /// The caller asserts `inner` describes a live allocation owned by @@ -34,11 +52,11 @@ impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { } /// Split into the raw inner handle and allocator **without** running the - /// disk-level `Drop`. This is the destructuring entry point `bstack_move!` - /// uses; the caller takes over responsibility for the allocation. + /// disk-level `Drop`. The caller takes over responsibility for the + /// allocation (e.g. `bstack_move!`, which frees only the parent shell). pub fn into_raw_parts(self) -> (T, &'a A) { - // Wrapping `self` in ManuallyDrop prevents our own `Drop` from running, - // so `bstack_drop` is not called; then move the inner `T` out. + // Wrapping `self` in ManuallyDrop defuses our own `Drop`, so + // `bstack_drop` is not called; then move the inner `T` out. let mut me = ManuallyDrop::new(self); let inner = unsafe { ManuallyDrop::take(&mut me.inner) }; (inner, me.allocator) @@ -49,21 +67,29 @@ impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { self.allocator } - /// Borrow the underlying typed handle, e.g. to call generated field - /// accessors: `owned.handle().field(stack)`. + /// Borrow the underlying handle, e.g. to call generated field accessors: + /// `owned.handle().field(stack)`. pub fn handle(&self) -> &T { &self.inner } } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for BStackOwned<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Deref for AutoDrop<'a, T, A> { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for AutoDrop<'a, T, A> { fn drop(&mut self) { let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; + // Errors are swallowed, matching the contract of Rust's `Drop`. let _ = inner.bstack_drop(self.allocator); } } -impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackOwned<'a, T, A> { +impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for AutoDrop<'a, T, A> { // A unique owner: the destructure is always valid. type Output = io::Result>; fn bstack_move(self) -> Self::Output { diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 5565034..d86be8b 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -1,4 +1,9 @@ //! [`BStackRc`] + [`BStackWeak`]: the with-allocator shared handles. +//! +//! Neither hand-writes a `Drop`. Each embeds an [`AutoDrop`] over a +//! without-allocator *drop core* ([`StrongCore`] / [`WeakRef`]) whose +//! [`BStackDrop`] performs the refcount release; the embedded guard runs it on +//! scope exit. use core::mem::size_of; use std::io; @@ -9,14 +14,36 @@ use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; use crate::handle::{StrongRef, WeakRef, strong_release_ctrl}; use crate::layout; -use crate::owned::BStackOwned; +use crate::owned::{AutoDrop, BStackOwned}; use crate::refcount; use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; +/// The without-allocator drop core of a [`BStackRc`]: the data ref plus the +/// optional control-block range. +/// +/// `ctrl` distinguishes the two block kinds at runtime — `None` for a plain +/// `(rc)` block (inline refcount), `Some(range)` for an `(rc, weak)` block +/// (control block). Its [`BStackDrop`] is the strong release, so a `BStackRc`'s +/// embedded [`AutoDrop`] runs it automatically and the handle needs no +/// hand-written `Drop`. +pub(crate) struct StrongCore { + data: BStackRef, + ctrl: Option, +} + +impl BStackDrop for StrongCore { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + match self.ctrl { + None => StrongRef(self.data).bstack_drop(allocator), + Some(ctrl) => strong_release_ctrl::(allocator, self.data.into_range(), ctrl), + } + } +} + /// A shared, refcounted, allocator-bound handle. /// -/// Serves **both** block kinds. `ctrl` distinguishes them at runtime: +/// Serves **both** block kinds via its [`StrongCore`]'s runtime `ctrl`: /// * `None` — a plain `#[bstack_block(rc)]` block, whose refcount lives inline /// in the data block at [`layout::RC_REFCOUNT_OFFSET`]. /// * `Some(range)` — an `#[bstack_block(rc, weak)]` block, whose `strong`/`weak` @@ -25,17 +52,13 @@ use crate::teardown::{BStackDrop, dealloc_range}; /// Carrying this as a runtime `Option` (rather than a type-level split via an /// associated `Strong` handle) keeps `BStackRc<'a, T, A>`'s public signature /// fixed at three parameters; a zero-cost representation can replace it later -/// without breaking callers. Freeing at zero reuses [`StrongRef`] (the `None` -/// path) or [`strong_release_ctrl`] (the `Some` path) — the latter needs only -/// `T: BStackBlock`, so `BStackRc` need not bound `T: BStackWeakable`. +/// without breaking callers. /// /// **Invariant:** for a `T: BStackWeakable` block, `ctrl` is always `Some` — such /// blocks are only ever constructed through the control-block paths /// ([`BStackWeak::upgrade`], `bstack_move!`). `downgrade` relies on this. pub struct BStackRc<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { - data: BStackRef, - ctrl: Option, - allocator: &'a A, + inner: AutoDrop<'a, StrongCore, A>, } impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { @@ -53,31 +76,41 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { allocator: &'a A, ) -> Self { Self { - data, - ctrl, - allocator, + inner: unsafe { AutoDrop::from_raw(StrongCore { data, ctrl }, allocator) }, } } + fn data(&self) -> BStackRef { + self.inner.handle().data + } + + fn ctrl(&self) -> Option { + self.inner.handle().ctrl + } + + fn allocator(&self) -> &'a A { + self.inner.allocator() + } + /// The underlying typed handle, e.g. to call generated field accessors: /// `rc.handle().field(stack)`. Cheap: it just re-wraps the data ref and does /// not touch the refcount. pub fn handle(&self) -> T { - ::from_range(self.data.into_range()) + ::from_range(self.data().into_range()) } /// Consume the handle into its raw parts **without** decrementing the strong /// count — the count is transferred to the caller (e.g. into a parent's /// `#[bstack_strong]` field). `ctrl` is `Some` for `(rc, weak)` blocks. pub fn into_raw(self) -> (BStackRef, Option) { - let me = core::mem::ManuallyDrop::new(self); - (me.data, me.ctrl) + let (core, _) = self.inner.into_raw_parts(); + (core.data, core.ctrl) } /// Byte offset of the strong counter for this handle's block kind. fn strong_offset(&self) -> u64 { - match self.ctrl { - None => self.data.into_range().start() + layout::RC_REFCOUNT_OFFSET, + match self.ctrl() { + None => self.data().into_range().start() + layout::RC_REFCOUNT_OFFSET, Some(ctrl) => ctrl.start() + layout::CTRL_STRONG_OFFSET, } } @@ -85,12 +118,10 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { - refcount::fetch_add(self.allocator.stack(), self.strong_offset(), 1)?; - Ok(Self { - data: self.data, - ctrl: self.ctrl, - allocator: self.allocator, - }) + refcount::fetch_add(self.allocator().stack(), self.strong_offset(), 1)?; + // SAFETY: the fetch_add above established the strong count this clone + // accounts for. + Ok(unsafe { Self::from_raw(self.data(), self.ctrl(), self.allocator()) }) } } @@ -103,25 +134,13 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { pub fn downgrade(&self) -> io::Result> { // Invariant: a weakable block's `BStackRc` always carries a control ref. let ctrl_range = self - .ctrl + .ctrl() .expect("BStackRc always has a control block"); let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; - refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; + refcount::fetch_add(self.allocator().stack(), weak_off, 1)?; let ctrl = unsafe { BStackRef::::from_range(ctrl_range) }; - Ok(BStackWeak { - ctrl, - allocator: self.allocator, - }) - } -} - -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> Drop for BStackRc<'a, T, A> { - fn drop(&mut self) { - // Errors are swallowed, matching the contract of Rust's `Drop`. - let _ = match self.ctrl { - None => StrongRef(self.data).bstack_drop(self.allocator), - Some(ctrl) => strong_release_ctrl::(self.allocator, self.data.into_range(), ctrl), - }; + // SAFETY: the fetch_add above established the weak count this handle holds. + Ok(unsafe { BStackWeak::from_raw(ctrl, self.allocator()) }) } } @@ -136,9 +155,8 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { /// control block's phantom weak is released, freeing it if no weak handles /// remain). This is what `bstack_move!` calls on a `BStackRc`. pub fn try_move(self) -> io::Result, Self>> { - let allocator = self.allocator; - let stack = allocator.stack(); let strong_off = self.strong_offset(); + let stack = self.allocator().stack(); // Atomic try-unwrap: succeed only if the strong count is exactly 1. if !refcount::cas(stack, strong_off, 1, 0)? { @@ -146,8 +164,10 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { } // Strong is now 0 — no concurrent upgrade can revive the data block, so - // it is safe to move the fields out and free the data shell. - let (data, ctrl) = self.into_raw(); + // it is safe to move the fields out and free the data shell. Defuse the + // embedded guard so it does not double-free. + let (core, allocator) = self.inner.into_raw_parts(); + let StrongCore { data, ctrl } = core; let owned = unsafe { BStackOwned::from_raw(::from_range(data.into_range()), allocator) }; @@ -156,7 +176,7 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { // `(rc, weak)`: release the phantom weak; free the control block at zero. if let Some(ctrl) = ctrl { let weak_off = ctrl.start() + layout::CTRL_WEAK_OFFSET; - if refcount::fetch_sub(stack, weak_off, 1)? == 1 { + if refcount::fetch_sub(allocator.stack(), weak_off, 1)? == 1 { unsafe { dealloc_range(allocator, ctrl)? }; } } @@ -175,10 +195,10 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackR /// /// Obtained from [`BStackRc::downgrade`] or [`TryClone::try_clone`]. It keeps the /// control block alive (so [`upgrade`](BStackWeak::upgrade) can check liveness) -/// but never pins the data block. +/// but never pins the data block. Its drop core is a [`WeakRef`], whose +/// [`BStackDrop`] decrements `ctrl.weak` and frees the control block at zero. pub struct BStackWeak<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { - ctrl: BStackRef, - allocator: &'a A, + inner: AutoDrop<'a, WeakRef, A>, } impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { @@ -188,22 +208,33 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { /// `ctrl` must describe a live control block owned by `allocator`, and this /// handle must account for a weak count the caller has already established. pub unsafe fn from_raw(ctrl: BStackRef, allocator: &'a A) -> Self { - Self { ctrl, allocator } + Self { + inner: unsafe { AutoDrop::from_raw(WeakRef(ctrl), allocator) }, + } + } + + fn ctrl(&self) -> BStackRef { + self.inner.handle().0 + } + + fn allocator(&self) -> &'a A { + self.inner.allocator() } /// Consume the handle into its raw control ref **without** decrementing the /// weak count — the count is transferred to the caller. pub fn into_raw(self) -> BStackRef { - let me = core::mem::ManuallyDrop::new(self); - me.ctrl + let (weak, _) = self.inner.into_raw_parts(); + weak.0 } /// Attempt to promote to a strong handle. Succeeds iff `ctrl.strong` is /// currently non-zero (CAS-increment-if-nonzero), reading `ctrl.x` to recover /// the data ref. Returns `None` if the data block is already gone. pub fn upgrade(&self) -> io::Result>> { - let stack = self.allocator.stack(); - let ctrl_range = self.ctrl.into_range(); + let allocator = self.allocator(); + let stack = allocator.stack(); + let ctrl_range = self.ctrl().into_range(); let strong_off = ctrl_range.start() + layout::CTRL_STRONG_OFFSET; if refcount::increment_if_nonzero(stack, strong_off)?.is_none() { return Ok(None); @@ -214,28 +245,18 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { stack.get_into(data_pos, &mut bytes)?; let data_range = BStackRange::new(u64::from_le_bytes(bytes), size_of::() as u64); let data = unsafe { BStackRef::::from_range(data_range) }; - Ok(Some(BStackRc { - data, - ctrl: Some(ctrl_range), - allocator: self.allocator, + // SAFETY: the increment above claimed the strong count this handle holds. + Ok(Some(unsafe { + BStackRc::from_raw(data, Some(ctrl_range), allocator) })) } } impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWeak<'a, T, A> { fn try_clone(&self) -> io::Result { - let weak_off = self.ctrl.into_range().start() + layout::CTRL_WEAK_OFFSET; - refcount::fetch_add(self.allocator.stack(), weak_off, 1)?; - Ok(Self { - ctrl: self.ctrl, - allocator: self.allocator, - }) - } -} - -impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> Drop for BStackWeak<'a, T, A> { - fn drop(&mut self) { - // Decrement ctrl.weak; free the control block at zero. Errors swallowed. - let _ = WeakRef::(self.ctrl).bstack_drop(self.allocator); + let weak_off = self.ctrl().into_range().start() + layout::CTRL_WEAK_OFFSET; + refcount::fetch_add(self.allocator().stack(), weak_off, 1)?; + // SAFETY: the fetch_add above established the weak count this clone holds. + Ok(unsafe { Self::from_raw(self.ctrl(), self.allocator()) }) } } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index fdbf0c8..1931e91 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,9 +14,9 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - BStackBlock, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, BStackOwned, BStackRc, - BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, alloc_block, alloc_control, - bstack_block, bstack_cast, bstack_move, dealloc_range, + AutoDrop, BStackBlock, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, BStackOwned, + BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, alloc_block, + alloc_control, bstack_block, bstack_cast, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -346,6 +346,52 @@ fn macro_recursive_drop() { unsafe { dealloc_range(&alloc, reused).unwrap() }; } +// -------------------------------------------------------------------------- +// AutoDrop: the RAII guard vs. bare / manual teardown +// -------------------------------------------------------------------------- + +#[test] +fn autodrop_guard_frees_on_scope_exit() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let size = size_of::<::OnDisk>() as u64; + + let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), size).unwrap(); + let handle = ::from_range(leaf); + + // Wrapping a bare `BStackDrop` handle in `AutoDrop` makes it free on scope + // exit — the single, reusable auto-drop mechanism. + let guard = unsafe { AutoDrop::from_raw(handle, &alloc) }; + drop(guard); + + // The slot is reclaimed: the guard's `Drop` ran the teardown. + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), size).unwrap(); + assert_eq!(reused.start(), leaf.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[test] +fn bare_handle_frees_only_when_asked() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let size = size_of::<::OnDisk>() as u64; + + let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), size).unwrap(); + let handle = ::from_range(leaf); + + // A bare handle is `Copy` and owns nothing — holding one triggers no + // teardown, so the block stays live and the next alloc lands elsewhere. + let other = alloc_block(&alloc, MacroLeaf::eightcc(), size).unwrap(); + assert_ne!(other.start(), leaf.start()); + + // Teardown is explicit: invoke `bstack_drop` directly (the "otherwise" path). + handle.bstack_drop(&alloc).unwrap(); + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), size).unwrap(); + assert_eq!(reused.start(), leaf.start()); + unsafe { dealloc_range(&alloc, reused).unwrap() }; + unsafe { dealloc_range(&alloc, other).unwrap() }; +} + // -------------------------------------------------------------------------- // #[bstack_block(rc, weak)] macro — control block + recursive owned child // -------------------------------------------------------------------------- diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index bde8da0..c9d763d 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -29,11 +29,40 @@ use std::io; use bstack::{BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; -use crate::teardown::dealloc_range; +use crate::teardown::{BStackDrop, dealloc_range}; /// Byte size of a descriptor block: `{ data_off: u64, data_size: u64 }`. pub(crate) const DESCRIPTOR_SIZE: u64 = 16; +/// The without-allocator drop core of a [`BStackVec`]: just its descriptor +/// range. Its [`BStackDrop`] frees the data block and then the descriptor, so a +/// vector field's teardown (and [`crate::AutoDrop`]) frees it uniformly with the +/// other handle kinds — without carrying the element type or an allocator. +#[derive(Clone, Copy)] +pub struct VecRef(pub BStackRange); + +impl VecRef { + /// Build from a descriptor block offset (its length is the fixed descriptor + /// size). + pub fn from_descriptor(desc_off: u64) -> Self { + VecRef(BStackRange::new(desc_off, DESCRIPTOR_SIZE)) + } +} + +impl BStackDrop for VecRef { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; + allocator.stack().get_into(self.0.start(), &mut buf)?; + let off = u64::from_le_bytes(buf[0..8].try_into().unwrap()); + let size = u64::from_le_bytes(buf[8..16].try_into().unwrap()); + unsafe { + dealloc_range(allocator, BStackRange::new(off, size))?; + dealloc_range(allocator, self.0)?; + } + Ok(()) + } +} + /// A persistent, growable vector of POD elements, addressed by a stable /// descriptor block. Backs `#[bstack_owned] Vec` / `String` fields. pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { @@ -89,14 +118,11 @@ impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { Ok(unsafe { BStackByteVec::from_raw_block(block) }) } - /// Free the data block and the descriptor. Consumes the handle. + /// Free the data block and the descriptor. Consumes the handle. Delegates to + /// the without-allocator [`VecRef`] core, the same teardown a vector field + /// runs during its parent's recursive drop. pub fn bstack_drop(self) -> io::Result<()> { - let (off, size) = self.read_desc()?; - unsafe { - dealloc_range(self.allocator, BStackRange::new(off, size))?; - dealloc_range(self.allocator, self.desc)?; - } - Ok(()) + VecRef(self.desc).bstack_drop(self.allocator) } } From 2e5d4b86a158cef99d6c50887e561bb8dd85e57d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 11 Jul 2026 09:14:12 -0700 Subject: [PATCH 022/140] Drop refactor 2 --- bstack_raii/derive/src/block.rs | 19 +++-- bstack_raii/derive/src/cast.rs | 2 +- bstack_raii/derive/src/lib.rs | 57 +++++++++++---- bstack_raii/examples/sessions.rs | 16 +++-- bstack_raii/src/block.rs | 6 +- bstack_raii/src/cast.rs | 31 ++++---- bstack_raii/src/lib.rs | 8 +-- bstack_raii/src/owned.rs | 120 +++++++++++++++---------------- bstack_raii/src/shared.rs | 11 ++- bstack_raii/src/teardown.rs | 71 ++++++++++++++++++ bstack_raii/src/tests.rs | 63 ++++++++-------- 11 files changed, 255 insertions(+), 149 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 86dac07..16c1d86 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -363,11 +363,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result type Fields<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> = ( #(#mv_types,)* ); fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( - owned: ::bstack_raii::BStackOwned<'__mv, Self, __A>, + owned: ::bstack_raii::BStackOwned, + __alloc: &'__mv __A, ) -> ::std::io::Result> { - // Take the inner handle out (defusing the owned Drop) and read - // the payload before freeing anything. - let (__inner, __alloc) = owned.into_raw_parts(); + // Unwrap the ownership marker and read the payload before + // freeing anything. + let __inner = owned.into_inner(); let __stack = __alloc.stack(); let __range = ::bstack_raii::BStackBlock::range(&__inner); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; @@ -666,9 +667,9 @@ fn ctor_field( ); } Kind::Owned => ( - quote!(::bstack_raii::BStackOwned<'__ctor, #inner_ty, __A>), + quote!(::bstack_raii::BStackOwned<#inner_ty>), quote!({ - let (__h, _) = __handle.into_raw_parts(); + let __h = __handle.into_inner(); ::bstack_raii::BStackBlock::range(&__h).start() }), ), @@ -744,14 +745,13 @@ fn move_field( (ty, recon) } Kind::Owned => wrap_move( - quote!(::bstack_raii::BStackOwned<'__mv, #inner_ty, __A>), + quote!(::bstack_raii::BStackOwned<#inner_ty>), quote! { unsafe { ::bstack_raii::BStackOwned::from_raw( <#inner_ty as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new(#cap, #size_od), ), - __alloc, ) } }, @@ -845,7 +845,7 @@ fn constructor( Mode::RcWeak => quote!(__bstack_ctrl: 0u64,), }; let ret = match mode { - Mode::Plain => quote!(::bstack_raii::BStackOwned<'__ctor, Self, __A>), + Mode::Plain => quote!(::bstack_raii::BStackOwned), _ => quote!(::bstack_raii::BStackRc<'__ctor, Self, __A>), }; let finish = match mode { @@ -853,7 +853,6 @@ fn constructor( ::std::result::Result::Ok(unsafe { ::bstack_raii::BStackOwned::from_raw( ::from_range(__data), - allocator, ) }) }, diff --git a/bstack_raii/derive/src/cast.rs b/bstack_raii/derive/src/cast.rs index 54ad0cb..c58bea2 100644 --- a/bstack_raii/derive/src/cast.rs +++ b/bstack_raii/derive/src/cast.rs @@ -34,7 +34,7 @@ pub fn expand(input: TokenStream) -> syn::Result { let seg = tp.path.segments.last().expect("non-empty path"); let tokens = match seg.ident.to_string().as_str() { - "BStackOwnedSlice" => quote!(::bstack_raii::BStackOwned::into_slice(#expr)), + "BStackOwnedSlice" => quote!((#expr).into_slice()), "BStackSlice" => { return Err(Error::new_spanned( ty, diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 8535ea9..6ad819b 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -65,21 +65,54 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { } } -/// `bstack_move!(x)` — transfer every field out of a `BStackOwned`. +/// `bstack_move!(handle)` / `bstack_move!(owned, allocator)` — transfer every +/// field out of a block, freeing only the parent shell. /// -/// Expands to a block that reads each field's `BStackRef`/POD value from -/// `XOnDisk` (capturing `ctrl` refs for `(rc, weak)` / weak fields first), -/// `dealloc_range`s the parent shell only, then reconstructs typed handles with -/// the allocator attached and returns them as a `Result<(..), io::Error>` tuple. -/// Callable only on `BStackOwned`; not defined for `(rc)` / `(rc, weak)` -/// blocks. See RAII.md "`bstack_move!`". +/// Reads each field's `BStackRef`/POD value from `XOnDisk` (capturing `ctrl` +/// refs for `(rc, weak)` / weak fields first), `dealloc_range`s the parent shell +/// only, then reconstructs typed handles and returns them as a +/// `Result<(..), io::Error>` tuple. Two forms select where the allocator comes +/// from: +/// +/// * `bstack_move!(handle)` — for an allocator-carrying handle: a `BStackRc` +/// (a `try_unwrap`, sole-owner only) or an `AutoDrop`-wrapped owned handle. +/// Dispatched through [`BStackMoveExpr`], inferring the impl from the type. +/// * `bstack_move!(owned, allocator)` — for a **bare** `BStackOwned`, which +/// carries no allocator; the allocator is supplied explicitly (symmetric with +/// `owned.bstack_drop(allocator)`). +/// +/// See RAII.md "`bstack_move!`". #[proc_macro] pub fn bstack_move(input: TokenStream) -> TokenStream { - // The per-field destructuring is generated by `#[bstack_block]` as a - // `BStackMove` impl on `BStackOwned`; here we just invoke it, letting - // type inference select the block's impl from the argument's type. - let expr = syn::parse_macro_input!(input as syn::Expr); - quote::quote!(::bstack_raii::BStackMoveExpr::bstack_move(#expr)).into() + use syn::parse::Parser; + use syn::punctuated::Punctuated; + + let parser = Punctuated::::parse_terminated; + let args = match parser.parse(input) { + Ok(a) => a, + Err(e) => return e.to_compile_error().into(), + }; + let mut it = args.into_iter(); + let ts = match (it.next(), it.next(), it.next()) { + // One arg: dispatch through the wrapper's own `BStackMoveExpr` (Rc or an + // `AutoDrop`-wrapped owned handle), inferring the block impl from its type. + (Some(expr), None, None) => { + quote::quote!(::bstack_raii::BStackMoveExpr::bstack_move(#expr)) + } + // Two args: a bare `BStackOwned` plus its allocator. + (Some(owned), Some(alloc), None) => { + quote::quote!(::bstack_raii::BStackMove::bstack_move(#owned, #alloc)) + } + _ => { + return syn::Error::new( + proc_macro2::Span::call_site(), + "bstack_move! takes `handle` or `owned, allocator`", + ) + .to_compile_error() + .into(); + } + }; + ts.into() } /// `bstack_cast!(expr as Target)` — type-checked handle conversion. The target diff --git a/bstack_raii/examples/sessions.rs b/bstack_raii/examples/sessions.rs index dc6387e..7aa795a 100644 --- a/bstack_raii/examples/sessions.rs +++ b/bstack_raii/examples/sessions.rs @@ -18,7 +18,9 @@ use std::io; use bstack::FirstFitBStackAllocator; // `BStack` / `BStackAllocator` / `BStackBlock` / `BStackRange` are re-exported by // `bstack_raii`, so a downstream crate only depends on `bstack_raii`. -use bstack_raii::{BStack, BStackAllocator, BStackBlock, BStackRange, TryClone, bstack_block}; +use bstack_raii::{ + BStack, BStackAllocator, BStackBlock, BStackDrop, BStackRange, TryClone, bstack_block, +}; /// A shared, reference-counted configuration. `(rc, weak)` makes it refcounted /// and weak-observable on disk. @@ -62,15 +64,19 @@ fn shared_ownership_demo(path: &std::path::Path) -> io::Result<()> { sessions.len(), ); - // Close sessions one at a time; the config stays alive until the last drops. + // Close sessions one at a time. A `Session` is a *uniquely owned* block, so + // its teardown is explicit (`bstack_drop`) — dropping the handle alone would + // persist it, which is what you want for a durable root. Each close releases + // the session's strong reference to the shared config. while let Some(session) = sessions.pop() { - drop(session); + session.bstack_drop(&alloc)?; let still_alive = monitor.upgrade()?.is_some(); println!("closed a session -> shared config still alive: {still_alive}"); } - // The last session is gone, so the shared config was reclaimed automatically — - // no manual free, no leak, no dangling weak handle. + // The last session released the last strong reference, so the *shared* config + // was reclaimed automatically by its refcount — no manual free of the config, + // no leak, no dangling weak handle. assert!(monitor.upgrade()?.is_none()); println!("last session closed -> shared config freed automatically"); diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index 8cbe7ad..949da4d 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -46,11 +46,15 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// and the `Rc` `bstack_move!` paths in [`BStackMoveExpr`]; it does not touch /// refcounts or the control block, so the caller must have already established /// that the shell may be freed. +/// +/// Takes the (bare, allocator-less) [`BStackOwned`] handle plus an explicit +/// allocator, since neither the owned handle nor the block type carries one. pub trait BStackMove: BStackBlock { /// The tuple of field handles produced, in field-declaration order. type Fields<'a, A: BStackOwnedSliceAllocator>; fn bstack_move<'a, A: BStackOwnedSliceAllocator>( - owned: BStackOwned<'a, Self, A>, + owned: BStackOwned, + allocator: &'a A, ) -> io::Result>; } diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs index 94acbd6..6f9d6b8 100644 --- a/bstack_raii/src/cast.rs +++ b/bstack_raii/src/cast.rs @@ -12,41 +12,46 @@ use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; use crate::block::{BStackBlock, BStackCast}; use crate::layout::EightCC; use crate::owned::BStackOwned; +use crate::teardown::AutoDrop; /// Byte offset of the `tag` within a [`crate::BlockHeader`] (`size: u64` first). const TAG_OFFSET: u64 = 8; -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackOwned<'a, T, A> { - /// Upcast to the untyped owned slice, discarding type info (infallible). +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> AutoDrop<'a, BStackOwned, A> { + /// Upcast an auto-freeing owned handle to the untyped owned slice, discarding + /// type info (infallible). /// - /// Consumes the handle without running its disk-level `Drop`; the returned - /// slice owns the allocation. + /// Consumes the guard without running its disk-level `Drop`; the returned + /// slice owns the allocation. (A bare `BStackOwned` carries no allocator, + /// so wrap it — `owned.auto(alloc)` — before upcasting to a slice.) pub fn into_slice(self) -> BStackOwnedSlice<'a, A> { - let (inner, allocator) = self.into_raw_parts(); - unsafe { BStackOwnedSlice::from_raw_range(allocator, inner.range()) } + let (owned, allocator) = self.into_raw_parts(); + let range = owned.into_inner().range(); + unsafe { BStackOwnedSlice::from_raw_range(allocator, range) } } } -/// Downcast an owned slice to a typed owned handle by checking the block tag. +/// Downcast an owned slice to a typed (bare) owned handle by checking the block +/// tag. The result carries no allocator; free it with `owned.bstack_drop(alloc)` +/// or wrap it via `owned.auto(alloc)`. pub trait BStackCastInto<'a, A: BStackOwnedSliceAllocator>: Sized { /// `Ok(Ok(owned))` on a tag match; `Ok(Err(self))` on mismatch (ownership is /// handed back so the caller can try another type); `Err` on an I/O failure /// reading the header. - fn cast_into(self) -> io::Result, Self>>; + fn cast_into(self) -> io::Result, Self>>; } impl<'a, A: BStackOwnedSliceAllocator> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { - fn cast_into(self) -> io::Result, Self>> { + fn cast_into(self) -> io::Result, Self>> { let mut tag = [0u8; 8]; self.read_range_into(TAG_OFFSET, &mut tag)?; if EightCC(tag) != T::eightcc() { return Ok(Err(self)); } - let allocator = self.allocator(); + // `as_range` consumes the owned slice, defusing its own free; ownership + // of the block transfers to the returned bare handle. let range = self.as_range(); - Ok(Ok(unsafe { - BStackOwned::from_raw(T::from_range(range), allocator) - })) + Ok(Ok(unsafe { BStackOwned::from_raw(T::from_range(range)) })) } } diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 77de205..803b9e2 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -17,12 +17,12 @@ //! |----------------|-------------------------------------------------------------| //! | [`layout`] | On-disk primitives: [`EightCC`], [`BlockHeader`] (both Pod). | //! | [`reference`] | [`BStackRef`]: typed range wrapper + buffered `OnDisk` read. | -//! | [`teardown`] | [`BStackDrop`] trait + [`dealloc_range`] helper. | +//! | [`teardown`] | [`BStackDrop`] trait, [`AutoDrop`] RAII guard, [`dealloc_range`]. | //! | [`block`] | Block-type contracts: [`BStackCast`], [`BStackBlock`], [`BStackWeakable`]. | //! | [`refcount`] | Little-endian atomic CAS ops over on-disk `u64` counters. | //! | [`clone`] | [`TryClone`]: fallible clone for handles that touch disk. | //! | [`handle`] | Without-allocator inner handles: [`OwnedRef`], [`StrongRef`], [`StrongWeakRef`], [`WeakRef`]. | -//! | [`owned`] | [`AutoDrop`]: the RAII guard bridging `BStackDrop` to `Drop`, and its [`BStackOwned`] alias. | +//! | [`owned`] | [`BStackOwned`]: the without-allocator, uniquely-owned block handle. | //! | [`shared`] | [`BStackRc`] + [`BStackWeak`]: with-allocator shared handles.| //! //! ## Conventions fixed by the ABI @@ -72,10 +72,10 @@ pub use clone::TryClone; pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; -pub use owned::{AutoDrop, BStackOwned}; +pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; -pub use teardown::{BStackDrop, dealloc_range}; +pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackVec, VecRef}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index c9388a8..8df388e 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -1,98 +1,90 @@ -//! [`AutoDrop`]: the generic RAII guard bridging [`BStackDrop`] to Rust `Drop`. +//! [`BStackOwned`]: a without-allocator, uniquely-owned block handle. //! -//! On-disk teardown ([`BStackDrop::bstack_drop`]) is fallible and needs an -//! allocator, so it can't live directly in a `Drop` impl. `AutoDrop` pairs a -//! `BStackDrop` handle with its allocator and runs the teardown on scope exit -//! (swallowing the error, matching the contract of `Drop`). It is the *one* place -//! that calls `bstack_drop` from a `Drop` impl — every allocator-bound handle -//! that wants automatic cleanup is either an `AutoDrop` (as [`BStackOwned`] is) -//! or embeds one, rather than hand-writing its own `Drop`. +//! `BStackOwned` is an *ownership marker* over an inner [`BStackDrop`] handle +//! (typically a `#[bstack_block]` type). Its own [`BStackDrop`] recursively frees +//! the block — but, being a bare handle, it frees **nothing on scope exit**: +//! teardown is explicit ([`bstack_drop`](BStackDrop::bstack_drop)) or automatic +//! only once wrapped in an [`AutoDrop`] (`owned.auto(alloc)`), whose Rust `Drop` +//! runs it. This keeps a persistent root from being silently deleted when its +//! handle drops. //! -//! Without wrapping in `AutoDrop`, a bare `BStackDrop` handle frees nothing on -//! its own: its `bstack_drop` is invoked explicitly, or runs as a child of some -//! parent block's recursive teardown. +//! `X::new(..)` and `bstack_move!`'d owned children hand back a bare +//! `BStackOwned`; the caller decides when (and whether) it dies. -use core::mem::ManuallyDrop; use core::ops::Deref; use std::io; use bstack::BStackOwnedSliceAllocator; use crate::block::{BStackMove, BStackMoveExpr}; -use crate::teardown::BStackDrop; +use crate::teardown::{AutoDrop, BStackDrop}; -/// A guard that runs [`BStackDrop::bstack_drop`] on its inner handle when it goes -/// out of scope, bridging fallible on-disk teardown to Rust's `Drop`. +/// A uniquely-owned handle to a block: an ownership marker over an inner +/// [`BStackDrop`] handle whose teardown recursively frees the block on disk. /// -/// It is a newtype over `(ManuallyDrop, &'a A)`. `bstack_move!` and the raw -/// accessors defuse it via [`into_raw_parts`](Self::into_raw_parts) so no -/// parallel destruction path exists. -pub struct AutoDrop<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { - inner: ManuallyDrop, - allocator: &'a A, -} - -/// An owned, allocator-bound handle to a block whose `Drop` recursively frees it -/// on disk. Just an [`AutoDrop`] over the block type itself (whose -/// [`BStackDrop`] is the recursive free), so a fresh block hands back an -/// auto-freeing handle with no bespoke `Drop`. -pub type BStackOwned<'a, T, A> = AutoDrop<'a, T, A>; +/// Carries no allocator (unlike an [`AutoDrop`]-wrapped handle), so it never +/// frees itself on `Drop`. Wrap it via [`auto`](Self::auto) for RAII, or free it +/// explicitly with [`BStackDrop::bstack_drop`]. +pub struct BStackOwned(T); -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> AutoDrop<'a, T, A> { - /// Pair an inner handle with its allocator into an auto-dropping guard. +impl BStackOwned { + /// Mark `inner` as uniquely owned. /// /// # Safety - /// The caller asserts `inner` describes a live allocation owned by - /// `allocator` and that no other handle will also free it. - pub unsafe fn from_raw(inner: T, allocator: &'a A) -> Self { - Self { - inner: ManuallyDrop::new(inner), - allocator, - } - } - - /// Split into the raw inner handle and allocator **without** running the - /// disk-level `Drop`. The caller takes over responsibility for the - /// allocation (e.g. `bstack_move!`, which frees only the parent shell). - pub fn into_raw_parts(self) -> (T, &'a A) { - // Wrapping `self` in ManuallyDrop defuses our own `Drop`, so - // `bstack_drop` is not called; then move the inner `T` out. - let mut me = ManuallyDrop::new(self); - let inner = unsafe { ManuallyDrop::take(&mut me.inner) }; - (inner, me.allocator) + /// The caller asserts `inner` describes a live allocation that no other + /// handle will also free. + pub unsafe fn from_raw(inner: T) -> Self { + BStackOwned(inner) } - /// The allocator this handle is bound to. - pub fn allocator(&self) -> &'a A { - self.allocator + /// Unwrap to the inner handle, dropping the ownership marker without freeing + /// anything (the caller takes over responsibility). Used to read a child's + /// offset when transferring it into a parent field. + pub fn into_inner(self) -> T { + self.0 } - /// Borrow the underlying handle, e.g. to call generated field accessors: + /// Borrow the inner handle, e.g. to call generated field accessors: /// `owned.handle().field(stack)`. pub fn handle(&self) -> &T { - &self.inner + &self.0 + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping + /// the returned value runs this handle's recursive teardown. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: a `BStackOwned` asserts sole ownership of a live block at + // construction, exactly the invariant `AutoDrop::from_raw` requires. + unsafe { AutoDrop::from_raw(self, allocator) } } } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Deref for AutoDrop<'a, T, A> { +impl Deref for BStackOwned { type Target = T; fn deref(&self) -> &T { - &self.inner + &self.0 } } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for AutoDrop<'a, T, A> { - fn drop(&mut self) { - let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; - // Errors are swallowed, matching the contract of Rust's `Drop`. - let _ = inner.bstack_drop(self.allocator); +impl BStackDrop for BStackOwned { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // Recursively free the owned block (and its children) via the inner + // handle's teardown. + self.0.bstack_drop(allocator) } } -impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for AutoDrop<'a, T, A> { - // A unique owner: the destructure is always valid. - type Output = io::Result>; +/// `bstack_move!` on an `AutoDrop`-wrapped owned handle: defuse the guard and +/// destructure via the block's [`BStackMove`], passing the recovered allocator. +/// +/// (A *bare* `BStackOwned` carries no allocator, so it is moved with the +/// explicit two-argument form `bstack_move!(owned, allocator)` instead.) +impl<'a, X: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr + for AutoDrop<'a, BStackOwned, A> +{ + type Output = io::Result>; fn bstack_move(self) -> Self::Output { - T::bstack_move(self) + let (owned, allocator) = self.into_raw_parts(); + X::bstack_move(owned, allocator) } } diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index d86be8b..4357721 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -14,10 +14,10 @@ use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; use crate::handle::{StrongRef, WeakRef, strong_release_ctrl}; use crate::layout; -use crate::owned::{AutoDrop, BStackOwned}; +use crate::owned::BStackOwned; use crate::refcount; use crate::reference::BStackRef; -use crate::teardown::{BStackDrop, dealloc_range}; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; /// The without-allocator drop core of a [`BStackRc`]: the data ref plus the /// optional control-block range. @@ -168,10 +168,9 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { // embedded guard so it does not double-free. let (core, allocator) = self.inner.into_raw_parts(); let StrongCore { data, ctrl } = core; - let owned = unsafe { - BStackOwned::from_raw(::from_range(data.into_range()), allocator) - }; - let fields = T::bstack_move(owned)?; + let owned = + unsafe { BStackOwned::from_raw(::from_range(data.into_range())) }; + let fields = T::bstack_move(owned, allocator)?; // `(rc, weak)`: release the phantom weak; free the control block at zero. if let Some(ctrl) = ctrl { diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index d741a14..d882a3a 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -5,6 +5,8 @@ //! types in [`crate::handle`]. It takes `self` (a *without-allocator* handle) //! plus an explicit allocator, so it is generic over all handle-like types. +use core::mem::ManuallyDrop; +use core::ops::Deref; use std::io; use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; @@ -39,3 +41,72 @@ pub unsafe fn dealloc_range( unsafe { BStackOwnedSlice::from_raw_range(allocator, range) }; allocator.dealloc(owned).map_err(|e| e.source) } + +/// A guard that runs [`BStackDrop::bstack_drop`] on its inner handle when it goes +/// out of scope, bridging fallible on-disk teardown to Rust's `Drop`. +/// +/// It is the *one* place that calls `bstack_drop` from a `Drop` impl: every +/// allocator-bound handle that wants automatic cleanup is (or embeds) an +/// `AutoDrop`, rather than hand-writing its own `Drop`. A bare [`BStackDrop`] +/// handle that is not wrapped frees nothing on its own — its `bstack_drop` is +/// invoked explicitly, or runs as a child of a parent block's recursive +/// teardown. +/// +/// It is a newtype over `(ManuallyDrop, &'a A)`; `bstack_move!` and the raw +/// accessors defuse it via [`into_raw_parts`](Self::into_raw_parts) so no +/// parallel destruction path exists. +pub struct AutoDrop<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { + inner: ManuallyDrop, + allocator: &'a A, +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> AutoDrop<'a, T, A> { + /// Pair an inner handle with its allocator into an auto-dropping guard. + /// + /// # Safety + /// The caller asserts `inner` describes a live allocation owned by + /// `allocator` and that no other handle will also free it. + pub unsafe fn from_raw(inner: T, allocator: &'a A) -> Self { + Self { + inner: ManuallyDrop::new(inner), + allocator, + } + } + + /// Split into the raw inner handle and allocator **without** running the + /// disk-level `Drop`. The caller takes over responsibility for the + /// allocation (e.g. `bstack_move!`, which frees only the parent shell). + pub fn into_raw_parts(self) -> (T, &'a A) { + // Wrapping `self` in ManuallyDrop defuses our own `Drop`, so + // `bstack_drop` is not called; then move the inner `T` out. + let mut me = ManuallyDrop::new(self); + let inner = unsafe { ManuallyDrop::take(&mut me.inner) }; + (inner, me.allocator) + } + + /// The allocator this handle is bound to. + pub fn allocator(&self) -> &'a A { + self.allocator + } + + /// Borrow the underlying handle, e.g. to call generated field accessors: + /// `owned.handle().field(stack)`. + pub fn handle(&self) -> &T { + &self.inner + } +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Deref for AutoDrop<'a, T, A> { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } +} + +impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for AutoDrop<'a, T, A> { + fn drop(&mut self) { + let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; + // Errors are swallowed, matching the contract of Rust's `Drop`. + let _ = inner.bstack_drop(self.allocator); + } +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 1931e91..44a9ec9 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -334,10 +334,9 @@ fn macro_recursive_drop() { ) .unwrap(); - // Own the parent; dropping it must recursively free the child, then itself. - let owned = - unsafe { BStackOwned::from_raw(::from_range(parent), &alloc) }; - drop(owned); + // Own the parent; freeing it must recursively free the child, then itself. + let owned = unsafe { BStackOwned::from_raw(::from_range(parent)) }; + owned.bstack_drop(&alloc).unwrap(); // The child's slot (allocated first, so the lowest offset) is reclaimed — // proof the generated `bstack_drop` recursed into the owned child. @@ -496,15 +495,11 @@ fn macro_strong_child() { ) .unwrap(); - // Dropping the parent runs its generated teardown, which dispatches through + // Freeing the parent runs its generated teardown, which dispatches through // BStackShared::drop_strong_ref to decrement the child's strong count. - let owned = unsafe { - BStackOwned::from_raw( - ::from_range(parent), - &alloc, - ) - }; - drop(owned); + let owned = + unsafe { BStackOwned::from_raw(::from_range(parent)) }; + owned.bstack_drop(&alloc).unwrap(); assert_eq!(crate::refcount::load(alloc.stack(), strong_off).unwrap(), 1); // child survives // Release the keep-alive: strong -> 0 frees the child data + control block. @@ -537,9 +532,9 @@ fn macro_new_and_accessors() { let child = parent.handle().child(stack).unwrap(); assert_eq!(child.val(stack).unwrap(), 42); - // Dropping the parent recursively frees the child then itself (no panic / - // error swallowed by Drop); recursion correctness is covered elsewhere. - drop(parent); + // Freeing the parent recursively frees the child then itself; recursion + // correctness is covered elsewhere. + parent.bstack_drop(&alloc).unwrap(); } #[test] @@ -633,7 +628,8 @@ fn macro_bstack_move() { let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); // Move the fields out: owned child -> BStackOwned, tag -> u32. - let (child, tag) = bstack_move!(parent).unwrap(); + // A bare owned handle carries no allocator, so pass it explicitly. + let (child, tag) = bstack_move!(parent, &alloc).unwrap(); assert_eq!(tag, 7); // Ownership of the child transferred (same allocation), and it is still live @@ -641,9 +637,9 @@ fn macro_bstack_move() { assert_eq!(child.handle().range().start(), leaf_off); assert_eq!(child.handle().val(stack).unwrap(), 55); - // Dropping the moved-out child frees the leaf. With the parent shell already + // Freeing the moved-out child frees the leaf. With the parent shell already // freed, both slots coalesce and the lowest (leaf's) is reclaimed. - drop(child); + child.bstack_drop(&alloc).unwrap(); let reused = alloc_block( &alloc, MacroLeaf::eightcc(), @@ -682,7 +678,7 @@ fn macro_bstack_move_shared() { .unwrap(); // Move every field out: strong -> BStackRc, weak -> Option, pod. - let (moved_s, moved_w, n) = bstack_move!(holder).unwrap(); + let (moved_s, moved_w, n) = bstack_move!(holder, &alloc).unwrap(); assert_eq!(n, 5); // The strong field came back as a live BStackRc. @@ -825,20 +821,21 @@ fn macro_cast() { assert!(bstack_cast!(sl as MacroLeaf).unwrap().is_some()); assert!(bstack_cast!(sl as MacroParent).unwrap().is_none()); - // Owned upcast (macro), then a wrong-type downcast hands the slice back. - let slice = bstack_cast!(leaf as BStackOwnedSlice); + // Owned upcast (macro) — a bare owned handle is wrapped (`auto`) to attach an + // allocator first — then a wrong-type downcast hands the slice back. + let slice = bstack_cast!(leaf.auto(&alloc) as BStackOwnedSlice); let slice = match slice.cast_into::().unwrap() { Ok(_) => panic!("tag should not match"), Err(s) => s, }; - // Correct owned downcast (macro) round-trips to the typed handle. + // Correct owned downcast (macro) round-trips to the typed (bare) handle. let owned = bstack_cast!(slice as BStackOwned) .unwrap() .ok() .unwrap(); assert_eq!(owned.handle().val(stack).unwrap(), 9); - drop(owned); // frees the leaf + owned.bstack_drop(&alloc).unwrap(); // frees the leaf } // -------------------------------------------------------------------------- @@ -882,9 +879,9 @@ fn macro_bstack_move_rc() { assert_eq!(n, 7); assert_eq!(moved_leaf.handle().val(stack).unwrap(), 5); - // Only the RcHolder shell was freed; the child is still live. Dropping it + // Only the RcHolder shell was freed; the child is still live. Freeing it // reclaims the last block, so the lowest slot (the leaf's) comes back. - drop(moved_leaf); + moved_leaf.bstack_drop(&alloc).unwrap(); let reused = alloc_block( &alloc, MacroLeaf::eightcc(), @@ -913,7 +910,7 @@ fn macro_bstack_move_rc_weak() { // The data block is gone, so the weak can no longer upgrade. assert!(weak.upgrade().unwrap().is_none()); - drop(moved_leaf); // frees the moved-out child + moved_leaf.bstack_drop(&alloc).unwrap(); // frees the moved-out child drop(weak); // frees the now-unreferenced control block } @@ -943,13 +940,13 @@ fn macro_option_owned() { assert_eq!(got.unwrap().val(stack).unwrap(), 42); // bstack_move! yields Option>. - let (moved_child, n) = bstack_move!(holder).unwrap(); + let (moved_child, n) = bstack_move!(holder, &alloc).unwrap(); assert_eq!(n, 7); assert_eq!( moved_child.as_ref().unwrap().handle().val(stack).unwrap(), 42 ); - drop(moved_child); // frees the leaf + moved_child.unwrap().bstack_drop(&alloc).unwrap(); // frees the leaf // The leaf + holder shell are both freed; the lowest slot (leaf's) returns. let reused = alloc_block( @@ -965,7 +962,7 @@ fn macro_option_owned() { let empty = OptHolder::new(&alloc, None, 9).unwrap(); assert_eq!(empty.handle().n(stack).unwrap(), 9); assert!(empty.handle().child(stack).unwrap().is_none()); - drop(empty); + empty.bstack_drop(&alloc).unwrap(); } // -------------------------------------------------------------------------- @@ -1054,8 +1051,8 @@ fn macro_vec_string_fields() { vec![1u32, 2, 3, 4], ); - // Dropping the record frees both vectors (data + descriptor) and the record. - drop(rec); + // Freeing the record frees both vectors (data + descriptor) and the record. + rec.bstack_drop(&alloc).unwrap(); // Allocator is healthy: a fresh record round-trips. let rec2 = Record::new(&alloc, "again", &[9u32], 1).unwrap(); @@ -1067,7 +1064,7 @@ fn macro_vec_string_fields() { rec2.handle().tags(&alloc).unwrap().to_vec().unwrap(), vec![9u32] ); - drop(rec2); + rec2.bstack_drop(&alloc).unwrap(); } #[test] @@ -1077,7 +1074,7 @@ fn macro_vec_bstack_move() { let rec = Record::new(&alloc, "movable", &[7u32, 8], 5).unwrap(); // bstack_move! yields the BStackVec handles + the POD. - let (name, tags, id) = bstack_move!(rec).unwrap(); + let (name, tags, id) = bstack_move!(rec, &alloc).unwrap(); assert_eq!(id, 5); assert_eq!(name.to_vec().unwrap(), b"movable"); assert_eq!(tags.to_vec().unwrap(), vec![7u32, 8]); From d033b320d0de275267db761a262a9d4d9ac6f390 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 20 Jul 2026 21:55:02 -0700 Subject: [PATCH 023/140] Add vec implementation --- bstack_raii/README.md | 131 ++++++++--- bstack_raii/derive/src/block.rs | 155 ++++++++++++- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 208 ++++++++++++++++- bstack_raii/src/vec.rs | 382 ++++++++++++++++++++++++++++++++ 5 files changed, 832 insertions(+), 46 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index ec3d95b..f6a083c 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -25,7 +25,7 @@ object model on top. - [Concepts](#concepts) - [Defining blocks](#defining-blocks) - [Field ownership](#field-ownership) -- [Handles](#handles) +- [Handles & lifetimes](#handles--lifetimes) - [Shared ownership & weak references](#shared-ownership--weak-references) - [Moving fields out: `bstack_move!`](#moving-fields-out-bstack_move) - [Casting: `bstack_cast!`](#casting-bstack_cast) @@ -47,7 +47,7 @@ bstack = "0.4" ```rust use std::io; use bstack::FirstFitBStackAllocator; -use bstack_raii::{BStack, BStackAllocator, TryClone, bstack_block}; +use bstack_raii::{BStack, BStackAllocator, BStackDrop, TryClone, bstack_block}; // A shared, reference-counted, weak-observable block. #[bstack_block(rc, weak)] @@ -75,12 +75,19 @@ fn main() -> io::Result<()> { let cfg = session.handle().config(stack)?; // -> a Config handle println!("v{} flags {:#b}", cfg.version(stack)?, cfg.flags(stack)?); - drop(config); // strong = 1 — the session still owns it - drop(session); // strong = 0 — Config is freed from disk automatically + drop(config); // strong = 1 — the session still owns it (Rc: auto-decrement) + session.bstack_drop(&alloc)?; // strong = 0 — Config freed automatically by its refcount Ok(()) } ``` +> **Owned vs. shared teardown.** A `Session` is a *uniquely owned* block, so its +> handle (`BStackOwned`) frees **nothing on `Drop`** — you free it +> explicitly with `bstack_drop`, so a persistent root is never silently deleted +> when a handle goes out of scope. A shared `Config` handle (`BStackRc`) *does* +> auto-manage its refcount on `Drop` (like `std::rc::Rc`). See +> [Handles & lifetimes](#handles--lifetimes). + A fuller walk-through (shared ownership, weak observers, durability across a reopen) is in [`examples/sessions.rs`](examples/sessions.rs): `cargo run --example sessions`. @@ -183,8 +190,8 @@ field stays fixed-size while the data can grow and move: ```rust #[bstack_block] struct Record { - #[bstack_owned] name: String, - #[bstack_owned] tags: Vec, + name: String, // POD vectors are un-annotated + tags: Vec, id: u64, } @@ -195,10 +202,49 @@ assert_eq!(rec.handle().tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); ``` The accessor returns a [`BStackVec`] handle (`len` / `to_vec` / `push`); the -constructor takes `&str` / `&[T]`; `bstack_move!` yields the `BStackVec`. Freeing -the block frees the data + descriptor. Elements must be `Pod` — `Vec` -(vectors of blocks), `#[bstack_ref] Vec`, and `Option>` are not -supported yet. +constructor takes `&str` / `&[T]`; `bstack_move!` yields the `BStackVec`. The +descriptor + data array are always owned by the enclosing struct, so freeing the +block frees them. + +#### Vectors of blocks: `#[bstack_owned/strong/weak/ref] Vec` + +When the elements are `#[bstack_block]` values, the field annotation states the +**elements'** ownership (the descriptor + offset array are still owned by the +struct). The vector stores each element's offset; the annotation decides what +happens to the elements on teardown — mirroring single-field annotations: + +| Field | Element handle | Accessor type | On the struct's teardown | +|------------------------------------|----------------------|------------------------|---------------------------------| +| `Vec` / `String` *(un-annotated)* | POD value (`T: Pod`) | `BStackVec` | frees array + descriptor | +| `#[bstack_owned] Vec` | `BStackOwned` | `BStackBlockVec`| recursively frees every child | +| `#[bstack_strong] Vec` | `BStackRc` | `BStackStrongVec`| releases each strong ref (frees at 0) | +| `#[bstack_weak] Vec` | `BStackWeak` | `BStackWeakVec` | releases each weak ref | +| `#[bstack_ref] Vec` | `BStackRef` | `BStackRefVec` | frees array + descriptor only | + +```rust +#[bstack_block] +struct Tree { + #[bstack_owned] kids: Vec, // Tree owns each Leaf + label: u32, +} + +let kids = vec![Leaf::new(&alloc, 10)?, Leaf::new(&alloc, 20)?]; +let tree = Tree::new(&alloc, kids, 7)?; // ctor takes Vec> +let v = tree.handle().kids(&alloc)?; // a BStackBlockVec +assert_eq!(v.get(1)?.unwrap().val(stack)?, 20); +tree.bstack_drop(&alloc)?; // recursively frees every child +``` + +The constructor takes a `Vec` of the corresponding element handle; the accessor +returns the vector handle (`len` / `to_vec` / `get`; `BStackWeakVec` has +`upgrade(i)`; each has a `push_*`). Because the annotation *is* what marks +block elements, an **un-annotated** `Vec` is always POD and requires `T: Pod`. + +**Sharing a vector** between two structs isn't done by pointing both at the same +descriptor (a descriptor has a single owner). Instead, wrap the vector in its own +`#[bstack_block]` and share *that* block with `#[bstack_strong]` / `#[bstack_ref]`. + +Still unsupported: `Option>`. ### Ergonomic reference coercion @@ -206,27 +252,36 @@ For convenience, a field written `&T` is coerced to owned `T` (and `&str` to `String`) with a compile warning — so a stray reference doesn't fail to compile, but you're nudged to write the owned type. -## Handles +## Handles & lifetimes The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, -`Copy`, and the thing you read fields through. The *owning* wrappers carry an -allocator and run teardown on `Drop`: - -| Handle | Ownership | Notes | -|----------------------|----------------------------------|--------------------------------------------| -| `X` (the block type) | none (borrowed view) | `x.field(stack)`; get from `.handle()` | -| `BStackOwned` | exclusive | frees the block (recursively) on `Drop` | -| `BStackRc` | shared strong | `try_clone`, `downgrade`; frees at count 0 | -| `BStackWeak` | none (keeps control block alive) | `try_clone`, `upgrade` | -| `BStackRef` | none (raw offset) | resolve manually | - -Get the read handle from a wrapper with `.handle()`: +`Copy`, and the thing you read fields through. Ownership wrappers layer on top: + +| Handle | Allocator | Ownership | Teardown | +|----------------------|-----------|----------------------------------|-------------------------------------------------------------------| +| `X` (the block type) | no | none (borrowed view / bare ref) | `x.bstack_drop(alloc)?` — explicit only | +| `BStackOwned` | no | exclusive (ownership marker) | `owned.bstack_drop(alloc)?` — **nothing on `Drop`** | +| `AutoDrop` | yes | RAII guard over any `BStackDrop` | runs `bstack_drop` on Rust `Drop` | +| `BStackRc` | yes | shared strong | `try_clone` / `downgrade`; **auto-decrements on `Drop`**, frees at 0 | +| `BStackWeak` | yes | none (keeps control block alive) | `try_clone` / `upgrade`; auto-decrements weak on `Drop` | +| `BStackRef` | no | none (raw offset) | none | + +**Owned is manual; shared is automatic.** A uniquely-owned `BStackOwned` +carries no allocator and frees **nothing** when its handle drops — so a +persistent root is never silently deleted by going out of scope. You free it +explicitly, or wrap it in an `AutoDrop` guard for RAII: ```rust let owned: BStackOwned = Node::new(&alloc, /* … */)?; -let value = owned.handle().tag(stack)?; // read a field +let value = owned.handle().tag(stack)?; // read a field (or `owned.tag(stack)?` via Deref) + +owned.bstack_drop(&alloc)?; // free it now, explicitly … +// … or: let _guard = owned.auto(&alloc); // RAII — freed when `_guard` drops ``` +Shared handles (`BStackRc` / `BStackWeak`) *do* manage their reference counts +automatically on `Drop`, exactly like `std::rc::Rc` / `Weak`. + Construction (`new`) consumes the children it takes ownership of: - `#[bstack_owned] p: P` → parameter `p: BStackOwned

` @@ -283,7 +338,9 @@ owner first and then the holder is sound — no use-after-free of freed data. each out as a tuple and freeing only the parent *shell* — the children stay live on disk, now owned independently. -On a **`BStackOwned`** it is infallible (a unique owner): +On a **`BStackOwned`** it is infallible (a unique owner). Because a bare +owned handle carries no allocator, pass one — `bstack_move!(owned, &alloc)` +(symmetric with `owned.bstack_drop(&alloc)`): ```rust #[bstack_block] @@ -294,7 +351,7 @@ struct Pair { } let pair: BStackOwned = /* … */; -let (left, shared, right) = bstack_move!(pair)?; +let (left, shared, right) = bstack_move!(pair, &alloc)?; // ^BStackOwned ^BStackRc ^u32 ``` @@ -303,6 +360,9 @@ succeeds only when this handle is the **sole strong owner** (an atomic `strong: 1 → 0`), otherwise it hands the handle back. A weak observer does *not* block the move — afterward its `upgrade()` just returns `None`. +An allocator-carrying handle — a `BStackRc`, or a `BStackOwned` wrapped as +`owned.auto(&alloc)` — takes the single-argument form (the allocator rides along): + ```rust let rc: BStackRc = /* … */; match bstack_move!(rc)? { @@ -322,9 +382,10 @@ use bstack_raii::{BStackCastAs, BStackCastInto}; // the cast methods let owned: BStackOwned = /* … */; -let slice = bstack_cast!(owned as BStackOwnedSlice); // upcast (infallible) +// Upcast needs an allocator, so wrap the bare owned handle first (`auto`): +let slice = bstack_cast!(owned.auto(&alloc) as BStackOwnedSlice); // infallible -match bstack_cast!(slice as BStackOwned)? { // owned downcast +match bstack_cast!(slice as BStackOwned)? { // owned downcast (bare handle) Ok(node) => { /* tag matched */ } Err(slice) => { /* tag mismatch — slice handed back */ } } @@ -372,13 +433,15 @@ no spin loop). All operations are durable and speak `std::io::Result`. ## Limitations -- **Fixed-size blocks.** A block's on-disk size equals its `OnDisk` struct size; - there are no variable-length arrays or inline slices. Model collections as - linked blocks. +- **Fixed-size block payloads.** A block's `OnDisk` struct is fixed-size — no + *inline* variable-length arrays or slices. Growable data lives out-of-line via + the descriptor indirection: `Vec` / `String` (POD) and + `#[bstack_owned/strong/weak/ref] Vec` (block elements) are supported; + `Option>` is not yet. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). - **No generic block types**, and non-`Pod` fields must carry an annotation. -- No enums or variable-length fields yet (planned). +- No enums yet (planned). - The on-disk **ABI is not yet stable**. ## License @@ -388,3 +451,7 @@ MIT (same as `bstack`). [`bstack`]: https://github.com/williamwutq/bstack [`TryClone`]: src/clone.rs [`BStackVec`]: src/vec.rs +[`BStackBlockVec`]: src/vec.rs +[`BStackStrongVec`]: src/vec.rs +[`BStackWeakVec`]: src/vec.rs +[`BStackRefVec`]: src/vec.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 16c1d86..8cfef34 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -115,23 +115,81 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result vec_field(eff_ty) }; if let Some(vinfo) = vinfo { - if kind != Kind::Owned { + let elem = &vinfo.elem; + on_disk_fields.push(quote!(#fname: u64,)); + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + + // `String` is always POD bytes; a block annotation on it is meaningless. + if vinfo.is_string && kind != Kind::Pod { return Err(Error::new_spanned( &field.ty, - "`Vec` / `String` fields must be annotated `#[bstack_owned]`", + "`String` is always POD; remove the ownership annotation", )); } - let elem = &vinfo.elem; - on_disk_fields.push(quote!(#fname: u64,)); - drop_stmts.push(vec_drop_stmt(fname, elem)); - accessors.push(vec_accessor(vis, fname, elem, &on_disk)); - let (param, prep, init) = vec_ctor(fname, &vinfo); + + // The annotation states the *elements'* relationship (the descriptor + // + array is always owned by this struct regardless). No annotation => + // POD elements (byte storage, requiring `T: Pod`). + let (drop_s, acc, ctor, mv) = match kind { + Kind::Pod => ( + vec_drop_stmt(fname, elem), + vec_accessor(vis, fname, elem, &on_disk), + vec_ctor(fname, &vinfo), + vec_move(&cap, elem), + ), + Kind::Owned => ( + block_vec_drop_stmt(fname, quote!(BStackBlockVec), elem), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackBlockVec)), + block_vec_ctor( + fname, + elem, + quote!(BStackBlockVec), + quote!(::bstack_raii::BStackOwned<#elem>), + ), + block_vec_move(&cap, elem, quote!(BStackBlockVec)), + ), + Kind::Strong => ( + block_vec_drop_stmt(fname, quote!(BStackStrongVec), elem), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackStrongVec)), + block_vec_ctor( + fname, + elem, + quote!(BStackStrongVec), + quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + ), + block_vec_move(&cap, elem, quote!(BStackStrongVec)), + ), + Kind::Weak => ( + block_vec_drop_stmt(fname, quote!(BStackWeakVec), elem), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackWeakVec)), + block_vec_ctor( + fname, + elem, + quote!(BStackWeakVec), + quote!(::bstack_raii::BStackWeak<'__ctor, #elem, __A>), + ), + block_vec_move(&cap, elem, quote!(BStackWeakVec)), + ), + Kind::Ref => ( + block_vec_drop_stmt(fname, quote!(BStackRefVec), elem), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackRefVec)), + block_vec_ctor( + fname, + elem, + quote!(BStackRefVec), + quote!(::bstack_raii::BStackRef<#elem>), + ), + block_vec_move(&cap, elem, quote!(BStackRefVec)), + ), + }; + drop_stmts.push(drop_s); + accessors.push(acc); + let (param, prep, init) = ctor; ctor_params.push(param); ctor_preps.push(prep); ctor_inits.push(init); - let cap = format_ident!("__cap_{}", fname); - mv_caps.push(quote!(let #cap = __od.#fname;)); - let (mv_ty, mv_rc) = vec_move(&cap, elem); + let (mv_ty, mv_rc) = mv; mv_types.push(mv_ty); mv_recon.push(mv_rc); continue; @@ -465,7 +523,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } /// A `Vec` / `String` field: its element type (tokens) and whether it's a -/// `String` (so the constructor takes `&str`). +/// `String` (so the constructor takes `&str`). Whether the elements are POD +/// (byte storage) or blocks (offset storage) is decided by the field's ownership +/// annotation, not by inspecting the element type. struct VecInfo { elem: TokenStream, is_string: bool, @@ -563,6 +623,79 @@ fn vec_move(cap: &Ident, elem: &TokenStream) -> (TokenStream, TokenStream) { ) } +// Block-element vectors (`#[bstack_owned/strong/weak/ref] Vec`) all share +// the same descriptor-indirected offset-array storage and a uniform +// codegen-facing API (`from_descriptor` / `from_handles` / `descriptor` / +// `bstack_drop`); only the runtime type (`vec_ty`) and the element-handle type +// differ per element relationship. These helpers are parameterized over both. + +/// Teardown for a block-element `Vec` field: run the vector's own +/// `bstack_drop` (per-element release + free the offset array + descriptor). +fn block_vec_drop_stmt(fname: &Ident, vec_ty: TokenStream, elem: &TokenStream) -> TokenStream { + quote! { + { + unsafe { + ::bstack_raii::#vec_ty::<#elem, __A>::from_descriptor(__on_disk.#fname, allocator) + .bstack_drop()?; + } + } + } +} + +/// Accessor for a block-element `Vec` field: resolve the descriptor offset +/// to the vector handle. Takes the allocator (its ops need it). +fn block_vec_accessor( + vis: &syn::Visibility, + fname: &Ident, + elem: &TokenStream, + on_disk: &Ident, + vec_ty: TokenStream, +) -> TokenStream { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { + let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let mut __buf = [0u8; 8]; + allocator.stack().get_into(__field, &mut __buf)?; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::#vec_ty::from_descriptor(u64::from_le_bytes(__buf), allocator) + }) + } + } +} + +/// Constructor `(param, prep, init)` for a block-element `Vec` field: +/// build the vector from a `Vec` of element handles (each consumed) and store its +/// descriptor offset. +fn block_vec_ctor( + fname: &Ident, + elem: &TokenStream, + vec_ty: TokenStream, + handle_ty: TokenStream, +) -> (TokenStream, TokenStream, TokenStream) { + ( + quote!(#fname: ::std::vec::Vec<#handle_ty>,), + quote! { + let #fname: u64 = + ::bstack_raii::#vec_ty::<#elem, __A>::from_handles(allocator, #fname)? + .descriptor() + .start(); + }, + quote!(#fname: #fname,), + ) +} + +/// `bstack_move!` field for a block-element `Vec`: yield the vector handle +/// (now independently owned). +fn block_vec_move(cap: &Ident, elem: &TokenStream, vec_ty: TokenStream) -> (TokenStream, TokenStream) { + ( + quote!(::bstack_raii::#vec_ty<'__mv, #elem, __A>), + quote!(unsafe { ::bstack_raii::#vec_ty::from_descriptor(#cap, __alloc) }), + ) +} + /// Return `Some(Inner)` if `ty` is `Option`. fn option_inner(ty: &Type) -> Option<&Type> { let Type::Path(tp) = ty else { diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 803b9e2..9a01ae7 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -76,7 +76,7 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; -pub use vec::{BStackVec, VecRef}; +pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecRef}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 44a9ec9..d8c7ff4 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1015,9 +1015,8 @@ fn bstack_vec_grow_and_free() { #[bstack_block] struct Record { - #[bstack_owned] + // POD vectors are un-annotated (an annotation would mean block elements). name: String, - #[bstack_owned] tags: Vec, id: u64, } @@ -1082,3 +1081,208 @@ fn macro_vec_bstack_move() { name.bstack_drop().unwrap(); tags.bstack_drop().unwrap(); } + +// -------------------------------------------------------------------------- +// #[bstack_owned] Vec — a vector of owned block children +// -------------------------------------------------------------------------- + +#[bstack_block] +struct Tree { + #[bstack_owned] + kids: Vec, + label: u32, +} + +#[test] +fn macro_owned_block_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Allocate three owned leaves, then a Tree that owns them. + let kids = vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + MacroLeaf::new(&alloc, 30).unwrap(), + ]; + let first_off = kids[0].handle().range().start(); // lowest allocation + let tree = Tree::new(&alloc, kids, 7).unwrap(); + assert_eq!(tree.handle().label(stack).unwrap(), 7); + + // Accessor resolves to a BStackBlockVec; read the children back. + let v = tree.handle().kids(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 3); + let vals: Vec = v + .to_vec() + .unwrap() + .iter() + .map(|k| k.val(stack).unwrap()) + .collect(); + assert_eq!(vals, vec![10, 20, 30]); + assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20); + assert!(v.get(3).unwrap().is_none()); + + // Freeing the tree recursively frees every owned child, plus the offset + // array and descriptor. The lowest child slot returns as proof. + tree.bstack_drop(&alloc).unwrap(); + let reused = alloc_block( + &alloc, + MacroLeaf::eightcc(), + size_of::<::OnDisk>() as u64, + ) + .unwrap(); + assert_eq!(reused.start(), first_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[test] +fn macro_owned_block_vec_move() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let kids = vec![ + MacroLeaf::new(&alloc, 1).unwrap(), + MacroLeaf::new(&alloc, 2).unwrap(), + ]; + let tree = Tree::new(&alloc, kids, 9).unwrap(); + + // bstack_move! transfers the vector out (children stay live); only the Tree + // shell is freed. + let (kids_vec, label) = bstack_move!(tree, &alloc).unwrap(); + assert_eq!(label, 9); + assert_eq!(kids_vec.len().unwrap(), 2); + assert_eq!(kids_vec.get(0).unwrap().unwrap().val(stack).unwrap(), 1); + + // The moved-out vector is independently owned; free it (children + arrays). + kids_vec.bstack_drop().unwrap(); +} + +// -------------------------------------------------------------------------- +// #[bstack_strong] / #[bstack_weak] / #[bstack_ref] Vec — block-element +// vectors whose annotation states the *elements'* ownership +// -------------------------------------------------------------------------- + +#[bstack_block] +struct StrongList { + #[bstack_strong] + items: Vec, + n: u32, +} + +/// Read a block's strong count via its data-block `ctrl` back-pointer. +fn strong_of(stack: &BStack, data_off: u64) -> u64 { + let mut buf = [0u8; 8]; + stack + .get_into(data_off + layout::CTRL_BACKPTR_OFFSET, &mut buf) + .unwrap(); + let ctrl = u64::from_le_bytes(buf); + crate::refcount::load(stack, ctrl + layout::CTRL_STRONG_OFFSET).unwrap() +} + +#[test] +fn macro_strong_block_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 100).unwrap(); // BStackRc, strong = 1 + let b = MacroStrongChild::new(&alloc, 200).unwrap(); + let a_clone = a.try_clone().unwrap(); // a strong = 2 + let a_data = a_clone.handle().range().start(); + + // The strong vector consumes each Rc, transferring its strong count. + let list = StrongList::new(&alloc, vec![a, b], 3).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); // list + a_clone + + let v = list.handle().items(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 2); + assert_eq!(v.get(0).unwrap().unwrap().val(stack).unwrap(), 100); + + // Freeing the list releases every element's strong ref: `b` (sole owner) is + // freed; `a` survives via `a_clone`. + list.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 1); // a_clone only + + assert_eq!(a_clone.handle().val(stack).unwrap(), 100); + drop(a_clone); // a freed now +} + +#[bstack_block] +struct WeakList { + #[bstack_weak] + watchers: Vec, + n: u32, +} + +#[test] +fn macro_weak_block_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 1).unwrap(); // strong owner + let b = MacroStrongChild::new(&alloc, 2).unwrap(); + + // The weak vector consumes each downgraded weak handle. + let list = WeakList::new( + &alloc, + vec![a.downgrade().unwrap(), b.downgrade().unwrap()], + 5, + ) + .unwrap(); + + let v = list.handle().watchers(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 2); + + // Upgrade element 0 while `a` is alive. + let up = v.upgrade(0).unwrap().expect("a alive"); + assert_eq!(up.handle().val(stack).unwrap(), 1); + drop(up); + + // Drop `a`'s data block: element 0 can no longer upgrade (sound — the vector + // stores control offsets, not freed data offsets). + drop(a); + let v = list.handle().watchers(&alloc).unwrap(); + assert!(v.upgrade(0).unwrap().is_none()); + assert!(v.upgrade(1).unwrap().is_some()); // b still alive + + // Teardown releases each weak count (freeing control blocks at zero). + list.bstack_drop(&alloc).unwrap(); + drop(b); +} + +#[bstack_block] +struct RefList { + #[bstack_ref] + links: Vec, + n: u32, +} + +#[test] +fn macro_ref_block_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Standalone leaves owned by us; the list only references them. + let a = MacroLeaf::new(&alloc, 7).unwrap(); + let b = MacroLeaf::new(&alloc, 8).unwrap(); + let refs = vec![ + unsafe { BStackRef::from_range(a.handle().range()) }, + unsafe { BStackRef::from_range(b.handle().range()) }, + ]; + let list = RefList::new(&alloc, refs, 9).unwrap(); + + let v = list.handle().links(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 2); + assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 8); + + // Freeing the list frees only the offset array + descriptor, not the targets. + list.bstack_drop(&alloc).unwrap(); + assert_eq!(a.handle().val(stack).unwrap(), 7); // still alive + assert_eq!(b.handle().val(stack).unwrap(), 8); + + a.bstack_drop(&alloc).unwrap(); + b.bstack_drop(&alloc).unwrap(); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index c9d763d..f79a3e9 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -24,11 +24,17 @@ //! > docs) to avoid corruption on a torn realloc. use core::marker::PhantomData; +use core::mem::size_of; use std::io; use bstack::{BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; +use crate::block::{BStackBlock, BStackShared, BStackWeakable}; +use crate::handle::WeakRef; +use crate::owned::BStackOwned; +use crate::reference::BStackRef; +use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; /// Byte size of a descriptor block: `{ data_off: u64, data_size: u64 }`. @@ -92,6 +98,11 @@ impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { self.desc } + /// The allocator this vector is bound to. + pub fn allocator(&self) -> &'a A { + self.allocator + } + fn read_desc(&self) -> io::Result<(u64, u64)> { let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; self.allocator @@ -199,3 +210,374 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { Ok(()) } } + +/// A persistent, growable vector of **owned block children**, addressed by a +/// stable descriptor. +/// +/// Like [`BStackVec`], but each element is a `u64` offset to a +/// separately-allocated `#[bstack_block]` child that this vector *owns*: the +/// backing data block stores the child offsets, and dropping the vector +/// recursively frees every child (post-order) plus the offset array and the +/// descriptor. Backs `#[bstack_owned] Vec` fields. +/// +/// > Like [`BStackVec`], growth reallocates the offset array, so use a +/// > realloc-safe allocator. +pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { + /// The offset array, stored as a POD `u64` vector behind the same descriptor + /// indirection. Its identity (the descriptor) is the field's stable pointer. + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> { + /// Reconstruct a handle from its descriptor block offset. + /// + /// # Safety + /// `desc_off` must be the offset of a live descriptor block written by this + /// type, over an array of offsets to live `T` blocks this vector owns. + pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + Self { + offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + _marker: PhantomData, + } + } + + /// The descriptor block's range — the vector's stable on-disk identity. + pub fn descriptor(&self) -> BStackRange { + self.offsets.descriptor() + } + + /// Number of elements. + pub fn len(&self) -> io::Result { + self.offsets.len() + } + + /// Whether the vector is empty. + pub fn is_empty(&self) -> io::Result { + self.offsets.is_empty() + } + + /// The child block's range, recovered from a stored offset and `T`'s fixed + /// on-disk size. + fn elem_range(off: u64) -> BStackRange { + BStackRange::new(off, size_of::() as u64) + } + + /// Read all element handles (non-owning views; the vector still owns them). + pub fn to_vec(&self) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .into_iter() + .map(|off| T::from_range(Self::elem_range(off))) + .collect()) + } + + /// The element at index `i`, or `None` if out of range. + pub fn get(&self, i: u64) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .get(i as usize) + .map(|&off| T::from_range(Self::elem_range(off)))) + } + + /// Build from a list of owned children (each consumed, its ownership moved + /// into the vector). + pub fn from_handles(allocator: &'a A, children: Vec>) -> io::Result { + let offs: Vec = children + .into_iter() + .map(|c| c.into_inner().range().start()) + .collect(); + Ok(Self { + offsets: BStackVec::from_slice(allocator, &offs)?, + _marker: PhantomData, + }) + } + + /// Create an empty vector. + pub fn new(allocator: &'a A) -> io::Result { + Self::from_handles(allocator, Vec::new()) + } + + /// Append an owned child, transferring its ownership into the vector (the + /// offset array may realloc/move; the descriptor follows). + pub fn push_owned(&mut self, child: BStackOwned) -> io::Result<()> { + let off = child.into_inner().range().start(); + self.offsets.push(off) + } + + /// Recursively free every owned child (post-order), then the offset array and + /// the descriptor. Consumes the handle. + pub fn bstack_drop(self) -> io::Result<()> { + let allocator = self.offsets.allocator(); + for off in self.offsets.to_vec()? { + T::from_range(Self::elem_range(off)).bstack_drop(allocator)?; + } + self.offsets.bstack_drop() + } +} + +/// A persistent, growable vector of **strong references** to shared block +/// children (`(rc)` / `(rc, weak)` blocks), behind a stable descriptor. +/// +/// Each element holds one strong reference (contributes 1 to the child's strong +/// count); dropping the vector releases every one (freeing a child when its count +/// hits zero) and frees the offset array + descriptor. Backs +/// `#[bstack_strong] Vec` fields. +pub struct BStackStrongVec<'a, T: BStackShared, A: BStackOwnedSliceAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A> { + /// # Safety + /// `desc_off` must be a live descriptor over an array of data offsets to + /// live `T` blocks, each accounting for one strong reference this vector owns. + pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + Self { + offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + _marker: PhantomData, + } + } + + /// The vector's stable on-disk identity. + pub fn descriptor(&self) -> BStackRange { + self.offsets.descriptor() + } + + /// Number of elements. + pub fn len(&self) -> io::Result { + self.offsets.len() + } + + /// Whether the vector is empty. + pub fn is_empty(&self) -> io::Result { + self.offsets.is_empty() + } + + fn elem_range(off: u64) -> BStackRange { + BStackRange::new(off, size_of::() as u64) + } + + /// Read all element handles (non-owning views — the vector still holds the + /// strong references). + pub fn to_vec(&self) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .into_iter() + .map(|off| T::from_range(Self::elem_range(off))) + .collect()) + } + + /// The element at index `i`, or `None` if out of range. + pub fn get(&self, i: u64) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .get(i as usize) + .map(|&off| T::from_range(Self::elem_range(off)))) + } + + /// Build from a list of strong handles (each consumed, its strong count moved + /// into the vector). + pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { + let offs: Vec = elems + .into_iter() + .map(|rc| { + let (data, _ctrl) = rc.into_raw(); + data.into_range().start() + }) + .collect(); + Ok(Self { + offsets: BStackVec::from_slice(allocator, &offs)?, + _marker: PhantomData, + }) + } + + /// Append a strong reference (consumed, its count moved into the vector). + pub fn push_strong(&mut self, elem: BStackRc<'a, T, A>) -> io::Result<()> { + let (data, _ctrl) = elem.into_raw(); + self.offsets.push(data.into_range().start()) + } + + /// Release every strong reference (freeing children that reach zero), then + /// free the offset array and descriptor. Consumes the handle. + pub fn bstack_drop(self) -> io::Result<()> { + let allocator = self.offsets.allocator(); + for off in self.offsets.to_vec()? { + let data = unsafe { BStackRef::::from_range(Self::elem_range(off)) }; + T::drop_strong_ref(data, allocator)?; + } + self.offsets.bstack_drop() + } +} + +/// A persistent, growable vector of **weak references** to `(rc, weak)` block +/// children, behind a stable descriptor. +/// +/// Each element holds one weak reference (a stored control-block offset). +/// Dropping the vector releases every weak count (freeing a control block when +/// it reaches zero) and frees the offset array + descriptor. Backs +/// `#[bstack_weak] Vec` fields. +pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A> { + /// # Safety + /// `desc_off` must be a live descriptor over an array of control-block + /// offsets, each accounting for one weak reference this vector owns. + pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + Self { + offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + _marker: PhantomData, + } + } + + /// The vector's stable on-disk identity. + pub fn descriptor(&self) -> BStackRange { + self.offsets.descriptor() + } + + /// Number of elements. + pub fn len(&self) -> io::Result { + self.offsets.len() + } + + /// Whether the vector is empty. + pub fn is_empty(&self) -> io::Result { + self.offsets.is_empty() + } + + fn ctrl_ref(off: u64) -> BStackRef { + unsafe { BStackRef::from_range(BStackRange::new(off, size_of::() as u64)) } + } + + /// Attempt to upgrade the element at index `i` to a strong handle. `None` if + /// out of range, or if the target's strong count has already reached zero. + pub fn upgrade(&self, i: u64) -> io::Result>> { + let offs = self.offsets.to_vec()?; + let Some(&off) = offs.get(i as usize) else { + return Ok(None); + }; + // Borrow a weak over the element's control ref just long enough to + // upgrade; consume it via `into_raw` so the vector's own weak count is + // untouched. + let allocator = self.offsets.allocator(); + let weak = unsafe { BStackWeak::from_raw(Self::ctrl_ref(off), allocator) }; + let result = weak.upgrade(); + let _ = weak.into_raw(); + result + } + + /// Build from a list of weak handles (each consumed, its weak count moved + /// into the vector). + pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { + let offs: Vec = elems + .into_iter() + .map(|w| w.into_raw().into_range().start()) + .collect(); + Ok(Self { + offsets: BStackVec::from_slice(allocator, &offs)?, + _marker: PhantomData, + }) + } + + /// Append a weak reference (consumed, its count moved into the vector). + pub fn push_weak(&mut self, elem: BStackWeak<'a, T, A>) -> io::Result<()> { + self.offsets.push(elem.into_raw().into_range().start()) + } + + /// Release every weak reference (freeing control blocks that reach zero), + /// then free the offset array and descriptor. Consumes the handle. + pub fn bstack_drop(self) -> io::Result<()> { + let allocator = self.offsets.allocator(); + for off in self.offsets.to_vec()? { + WeakRef::(Self::ctrl_ref(off)).bstack_drop(allocator)?; + } + self.offsets.bstack_drop() + } +} + +/// A persistent, growable vector of **raw references** to block children, behind +/// a stable descriptor. +/// +/// Elements carry no ownership: dropping the vector frees only the offset array +/// and descriptor, never the targets. Backs `#[bstack_ref] Vec` fields. +pub struct BStackRefVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { + /// # Safety + /// `desc_off` must be a live descriptor over an array of offsets to `T` + /// blocks (which this vector does not own). + pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + Self { + offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + _marker: PhantomData, + } + } + + /// The vector's stable on-disk identity. + pub fn descriptor(&self) -> BStackRange { + self.offsets.descriptor() + } + + /// Number of elements. + pub fn len(&self) -> io::Result { + self.offsets.len() + } + + /// Whether the vector is empty. + pub fn is_empty(&self) -> io::Result { + self.offsets.is_empty() + } + + fn elem_range(off: u64) -> BStackRange { + BStackRange::new(off, size_of::() as u64) + } + + /// Read all element handles (non-owning views). + pub fn to_vec(&self) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .into_iter() + .map(|off| T::from_range(Self::elem_range(off))) + .collect()) + } + + /// The element at index `i`, or `None` if out of range. + pub fn get(&self, i: u64) -> io::Result> { + Ok(self + .offsets + .to_vec()? + .get(i as usize) + .map(|&off| T::from_range(Self::elem_range(off)))) + } + + /// Build from a list of raw references. + pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { + let offs: Vec = elems.into_iter().map(|r| r.into_range().start()).collect(); + Ok(Self { + offsets: BStackVec::from_slice(allocator, &offs)?, + _marker: PhantomData, + }) + } + + /// Append a raw reference. + pub fn push_ref(&mut self, elem: BStackRef) -> io::Result<()> { + self.offsets.push(elem.into_range().start()) + } + + /// Free only the offset array and descriptor (elements are not owned). + /// Consumes the handle. + pub fn bstack_drop(self) -> io::Result<()> { + self.offsets.bstack_drop() + } +} From 30c28b6788b47b7aa2676c762973dc955e6b0b77 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 20 Jul 2026 22:29:13 -0700 Subject: [PATCH 024/140] Optimize vec by reducing a level of indirection enabled by unique ownership of the vec descriptor, thus enable its elimination. --- bstack_raii/README.md | 50 +++-- bstack_raii/derive/src/block.rs | 79 ++++--- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 14 +- bstack_raii/src/vec.rs | 368 ++++++++++++++++---------------- 5 files changed, 261 insertions(+), 252 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index f6a083c..98cd277 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -182,10 +182,11 @@ yields `Option<…>`. (`#[bstack_weak]` fields are already nullable by nature.) ### Variable-length: `Vec` and `String` -An `#[bstack_owned] Vec` (POD `T`) or `String` field stores a growable -sequence. On disk the field is a fixed-size pointer to a small **descriptor** -block, which in turn points to the (growable, reallocating) data block — so the -field stays fixed-size while the data can grow and move: +A `Vec` (POD `T`) or `String` field stores a growable sequence. On disk the +field holds a fixed-size **descriptor** — `{ data_off, data_size }` stored +*inline* — pointing at the (growable, reallocating) data block. The field stays +fixed-size while the data grows and moves; because the struct uniquely owns the +vector, the descriptor needs no separate block: ```rust #[bstack_block] @@ -197,14 +198,19 @@ struct Record { let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42)?; // &str / &[T] / value let mut tags = rec.handle().tags(&alloc)?; // a BStackVec handle -tags.push(4)?; // grows in place, visible on re-read +tags.push(4)?; // grows; rewrites the inline descriptor assert_eq!(rec.handle().tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); ``` The accessor returns a [`BStackVec`] handle (`len` / `to_vec` / `push`); the -constructor takes `&str` / `&[T]`; `bstack_move!` yields the `BStackVec`. The -descriptor + data array are always owned by the enclosing struct, so freeing the -block frees them. +constructor takes `&str` / `&[T]`; freeing the block frees the data (the inline +descriptor goes with the struct). A field handle rewrites the inline descriptor +when a push reallocates. + +A vector **not** resident in a field — built by `BStackVec::from_slice` or handed +out by `bstack_move!` — is *detached*: it carries its descriptor in memory and +frees only its data block on `bstack_drop`. It becomes persistent when written +into a struct field (which stamps the inline descriptor). #### Vectors of blocks: `#[bstack_owned/strong/weak/ref] Vec` @@ -213,13 +219,13 @@ When the elements are `#[bstack_block]` values, the field annotation states the struct). The vector stores each element's offset; the annotation decides what happens to the elements on teardown — mirroring single-field annotations: -| Field | Element handle | Accessor type | On the struct's teardown | -|------------------------------------|----------------------|------------------------|---------------------------------| -| `Vec` / `String` *(un-annotated)* | POD value (`T: Pod`) | `BStackVec` | frees array + descriptor | -| `#[bstack_owned] Vec` | `BStackOwned` | `BStackBlockVec`| recursively frees every child | -| `#[bstack_strong] Vec` | `BStackRc` | `BStackStrongVec`| releases each strong ref (frees at 0) | -| `#[bstack_weak] Vec` | `BStackWeak` | `BStackWeakVec` | releases each weak ref | -| `#[bstack_ref] Vec` | `BStackRef` | `BStackRefVec` | frees array + descriptor only | +| Field | Element handle | Accessor type | On the struct's teardown | +|--------------------------------------|----------------------|--------------------------|-------------------------------------------------------| +| `Vec` / `String` *(un-annotated)* | POD value (`T: Pod`) | `BStackVec` | frees the data block | +| `#[bstack_owned] Vec` | `BStackOwned` | `BStackBlockVec` | recursively frees every child, then the offset array | +| `#[bstack_strong] Vec` | `BStackRc` | `BStackStrongVec` | releases each strong ref (frees at 0), then the array | +| `#[bstack_weak] Vec` | `BStackWeak` | `BStackWeakVec` | releases each weak ref, then the array | +| `#[bstack_ref] Vec` | `BStackRef` | `BStackRefVec` | frees the offset array only | ```rust #[bstack_block] @@ -257,14 +263,14 @@ but you're nudged to write the owned type. The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, `Copy`, and the thing you read fields through. Ownership wrappers layer on top: -| Handle | Allocator | Ownership | Teardown | -|----------------------|-----------|----------------------------------|-------------------------------------------------------------------| -| `X` (the block type) | no | none (borrowed view / bare ref) | `x.bstack_drop(alloc)?` — explicit only | -| `BStackOwned` | no | exclusive (ownership marker) | `owned.bstack_drop(alloc)?` — **nothing on `Drop`** | -| `AutoDrop` | yes | RAII guard over any `BStackDrop` | runs `bstack_drop` on Rust `Drop` | +| Handle | Allocator | Ownership | Teardown | +|----------------------|-----------|----------------------------------|----------------------------------------------------------------------| +| `X` (the block type) | no | none (borrowed view / bare ref) | `x.bstack_drop(alloc)?` — explicit only | +| `BStackOwned` | no | exclusive (ownership marker) | `owned.bstack_drop(alloc)?` — **nothing on `Drop`** | +| `AutoDrop` | yes | RAII guard over any `BStackDrop` | runs `bstack_drop` on Rust `Drop` | | `BStackRc` | yes | shared strong | `try_clone` / `downgrade`; **auto-decrements on `Drop`**, frees at 0 | -| `BStackWeak` | yes | none (keeps control block alive) | `try_clone` / `upgrade`; auto-decrements weak on `Drop` | -| `BStackRef` | no | none (raw offset) | none | +| `BStackWeak` | yes | none (keeps control block alive) | `try_clone` / `upgrade`; auto-decrements weak on `Drop` | +| `BStackRef` | no | none (raw offset) | none | **Owned is manual; shared is automatic.** A uniquely-owned `BStackOwned` carries no allocator and frees **nothing** when its handle drops — so a diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 8cfef34..fba3acf 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -116,7 +116,8 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; if let Some(vinfo) = vinfo { let elem = &vinfo.elem; - on_disk_fields.push(quote!(#fname: u64,)); + // The descriptor lives inline in the field (no descriptor block). + on_disk_fields.push(quote!(#fname: ::bstack_raii::VecDesc,)); let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); @@ -560,19 +561,20 @@ fn vec_field(ty: &Type) -> Option { None } -/// Teardown for an owned `Vec` / `String` field: free its `BStackVec` (data + -/// descriptor blocks). -fn vec_drop_stmt(fname: &Ident, _elem: &TokenStream) -> TokenStream { +/// Teardown for a POD `Vec` / `String` field: free the vector's data block +/// (the inline descriptor is freed with the enclosing struct's block). +fn vec_drop_stmt(fname: &Ident, elem: &TokenStream) -> TokenStream { quote! { { - use ::bstack_raii::BStackDrop as _; - ::bstack_raii::VecRef::from_descriptor(__on_disk.#fname).bstack_drop(allocator)?; + ::bstack_raii::BStackVec::<#elem, __A>::from_desc(__on_disk.#fname, allocator) + .bstack_drop()?; } } } -/// Accessor for a `Vec` / `String` field: resolve the descriptor offset to a -/// `BStackVec` handle. Takes the allocator (the vector's ops need it). +/// Accessor for a `Vec` / `String` field: read the inline descriptor at the +/// field's location into a `BStackVec` handle. Takes the allocator (the vector's +/// ops need it). fn vec_accessor( vis: &syn::Visibility, fname: &Ident, @@ -585,17 +587,13 @@ fn vec_accessor( allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; - let mut __buf = [0u8; 8]; - allocator.stack().get_into(__field, &mut __buf)?; - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackVec::from_descriptor(u64::from_le_bytes(__buf), allocator) - }) + unsafe { ::bstack_raii::BStackVec::from_field(__field, allocator) } } } } -/// Constructor `(param, prep, init)` for a `Vec` / `String` field: build the -/// `BStackVec` from the passed data and store its descriptor offset. +/// Constructor `(param, prep, init)` for a `Vec` / `String` field: allocate +/// the data block and store its descriptor inline in the field. fn vec_ctor(fname: &Ident, vinfo: &VecInfo) -> (TokenStream, TokenStream, TokenStream) { let elem = &vinfo.elem; let (param_ty, data): (TokenStream, TokenStream) = if vinfo.is_string { @@ -606,44 +604,42 @@ fn vec_ctor(fname: &Ident, vinfo: &VecInfo) -> (TokenStream, TokenStream, TokenS ( quote!(#fname: #param_ty,), quote! { - let #fname: u64 = + let #fname: ::bstack_raii::VecDesc = ::bstack_raii::BStackVec::<#elem, __A>::from_slice(allocator, #data)? - .descriptor() - .start(); + .descriptor(); }, quote!(#fname: #fname,), ) } -/// `bstack_move!` field for a `Vec` / `String`: yield the `BStackVec` handle. +/// `bstack_move!` field for a `Vec` / `String`: yield a detached `BStackVec` +/// carrying the inline descriptor (captured from the parent before it is freed). fn vec_move(cap: &Ident, elem: &TokenStream) -> (TokenStream, TokenStream) { ( quote!(::bstack_raii::BStackVec<'__mv, #elem, __A>), - quote!(unsafe { ::bstack_raii::BStackVec::from_descriptor(#cap, __alloc) }), + quote!(::bstack_raii::BStackVec::from_desc(#cap, __alloc)), ) } // Block-element vectors (`#[bstack_owned/strong/weak/ref] Vec`) all share -// the same descriptor-indirected offset-array storage and a uniform -// codegen-facing API (`from_descriptor` / `from_handles` / `descriptor` / +// the same inline-descriptor offset-array storage and a uniform codegen-facing +// API (`from_field` / `from_desc` / `from_handles` / `descriptor` / // `bstack_drop`); only the runtime type (`vec_ty`) and the element-handle type // differ per element relationship. These helpers are parameterized over both. /// Teardown for a block-element `Vec` field: run the vector's own -/// `bstack_drop` (per-element release + free the offset array + descriptor). +/// `bstack_drop` (per-element release + free the offset array). fn block_vec_drop_stmt(fname: &Ident, vec_ty: TokenStream, elem: &TokenStream) -> TokenStream { quote! { { - unsafe { - ::bstack_raii::#vec_ty::<#elem, __A>::from_descriptor(__on_disk.#fname, allocator) - .bstack_drop()?; - } + ::bstack_raii::#vec_ty::<#elem, __A>::from_desc(__on_disk.#fname, allocator) + .bstack_drop()?; } } } -/// Accessor for a block-element `Vec` field: resolve the descriptor offset -/// to the vector handle. Takes the allocator (its ops need it). +/// Accessor for a block-element `Vec` field: read the inline descriptor at +/// the field's location into the vector handle. Takes the allocator. fn block_vec_accessor( vis: &syn::Visibility, fname: &Ident, @@ -657,18 +653,14 @@ fn block_vec_accessor( allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; - let mut __buf = [0u8; 8]; - allocator.stack().get_into(__field, &mut __buf)?; - ::std::result::Result::Ok(unsafe { - ::bstack_raii::#vec_ty::from_descriptor(u64::from_le_bytes(__buf), allocator) - }) + unsafe { ::bstack_raii::#vec_ty::from_field(__field, allocator) } } } } /// Constructor `(param, prep, init)` for a block-element `Vec` field: /// build the vector from a `Vec` of element handles (each consumed) and store its -/// descriptor offset. +/// descriptor inline in the field. fn block_vec_ctor( fname: &Ident, elem: &TokenStream, @@ -678,21 +670,24 @@ fn block_vec_ctor( ( quote!(#fname: ::std::vec::Vec<#handle_ty>,), quote! { - let #fname: u64 = + let #fname: ::bstack_raii::VecDesc = ::bstack_raii::#vec_ty::<#elem, __A>::from_handles(allocator, #fname)? - .descriptor() - .start(); + .descriptor(); }, quote!(#fname: #fname,), ) } -/// `bstack_move!` field for a block-element `Vec`: yield the vector handle -/// (now independently owned). -fn block_vec_move(cap: &Ident, elem: &TokenStream, vec_ty: TokenStream) -> (TokenStream, TokenStream) { +/// `bstack_move!` field for a block-element `Vec`: yield a detached vector +/// handle carrying the inline descriptor (now independently owned). +fn block_vec_move( + cap: &Ident, + elem: &TokenStream, + vec_ty: TokenStream, +) -> (TokenStream, TokenStream) { ( quote!(::bstack_raii::#vec_ty<'__mv, #elem, __A>), - quote!(unsafe { ::bstack_raii::#vec_ty::from_descriptor(#cap, __alloc) }), + quote!(::bstack_raii::#vec_ty::from_desc(#cap, __alloc)), ) } diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 9a01ae7..cb1bf7a 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -76,7 +76,7 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; -pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecRef}; +pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index d8c7ff4..16bba3b 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -976,24 +976,22 @@ fn bstack_vec_grow_and_free() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - // Build from a slice, read it back. + // Build a detached vector from a slice, read it back. let mut v = BStackVec::::from_slice(&alloc, b"hello").unwrap(); assert_eq!(v.len().unwrap(), 5); assert_eq!(v.to_vec().unwrap(), b"hello"); - // The stable identity is the descriptor. - let desc = v.descriptor(); - - // Grow past capacity: the data block reallocs/moves, but the descriptor - // (and hence the field pointer) is unchanged. + // A detached vector carries its descriptor in memory: as it grows, the + // descriptor tracks the (reallocating) data block. + let before = v.descriptor().data_size; for &b in b", world!" { v.push(b).unwrap(); } - assert_eq!(v.descriptor(), desc); // identity stable across growth + assert!(v.descriptor().data_size >= before); // block tracks growth assert_eq!(v.to_vec().unwrap(), b"hello, world!"); assert_eq!(v.len().unwrap(), 13); - // Free the data block + descriptor. + // Free the data block (there is no descriptor block). v.bstack_drop().unwrap(); // Allocator is healthy afterwards: a fresh vector round-trips. diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index f79a3e9..e32e21d 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -1,24 +1,27 @@ -//! [`BStackVec`]: a persistent, growable POD vector reachable through a -//! **fixed-size** field. +//! Persistent, growable vectors reached through a **fixed-size** field, with the +//! descriptor stored **inline** in the owning struct. //! //! A block field can only store a fixed-size value, but a vector's backing store -//! must be able to grow — and `BStackByteVec` **moves** its block on realloc. The -//! fix is one level of indirection: +//! must grow — and `BStackByteVec` **moves** its block on realloc. The fix is a +//! small [`VecDesc`] (`{ data_off, data_size }`) that names the current data +//! block; the field stores it, and on growth only the descriptor is rewritten: //! //! ```text -//! parent field (u64) ── points to ──▶ descriptor block (fixed, never moves) -//! │ { data_off, data_size } -//! └── points to ──▶ BStackByteVec data -//! block (may realloc/move) +//! struct field: [ data_off, data_size ] ── points to ──▶ data block (may realloc/move) //! ``` //! -//! The **descriptor** is a fixed 16-byte block that holds the current offset and -//! size of the data block. When the data grows and moves, only the descriptor's -//! pointer is rewritten; the parent's pointer to the descriptor is stable. So a -//! `BStackVec` field is identified by its descriptor offset, which never changes. +//! Because a struct **uniquely owns** its vector, the descriptor lives *inline* +//! in the field — there is no separate descriptor block, no extra indirection or +//! allocation. A vector not resident in a field (built by [`BStackVec::from_slice`] +//! or handed out by `bstack_move!`) carries its descriptor **in memory** in the +//! handle, and becomes persistent only when written into a field (which stamps +//! the inline descriptor). A field handle remembers its inline location and +//! rewrites it whenever a push reallocates. //! -//! Elements are `bytemuck::Pod`; bytes are stored/read unaligned, so any element -//! type works. `u8` (i.e. `Vec` / `String` fields) is the common case. +//! Elements are `bytemuck::Pod`; bytes are stored/read unaligned. `u8` (`Vec` +//! / `String` fields) is the common case. Block-element vectors +//! ([`BStackBlockVec`] / [`BStackStrongVec`] / [`BStackWeakVec`] / +//! [`BStackRefVec`]) store a `u64` offset per element in the same way. //! //! > **Growth reallocates**, so use a realloc-safe allocator (see the crate //! > docs) to avoid corruption on a torn realloc. @@ -27,8 +30,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; -use bytemuck::Pod; +use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; use crate::handle::WeakRef; @@ -37,65 +40,87 @@ use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; -/// Byte size of a descriptor block: `{ data_off: u64, data_size: u64 }`. -pub(crate) const DESCRIPTOR_SIZE: u64 = 16; - -/// The without-allocator drop core of a [`BStackVec`]: just its descriptor -/// range. Its [`BStackDrop`] frees the data block and then the descriptor, so a -/// vector field's teardown (and [`crate::AutoDrop`]) frees it uniformly with the -/// other handle kinds — without carrying the element type or an allocator. -#[derive(Clone, Copy)] -pub struct VecRef(pub BStackRange); +/// The inline, fixed-size descriptor of a persistent vector: the current offset +/// and byte size of its (reallocating) data block. +/// +/// Stored **inline** in the owning struct's field — there is no separate +/// descriptor block, since the struct uniquely owns the vector. `Pod`, so it +/// embeds directly in a generated `XOnDisk`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Pod, Zeroable)] +pub struct VecDesc { + pub data_off: u64, + pub data_size: u64, +} -impl VecRef { - /// Build from a descriptor block offset (its length is the fixed descriptor - /// size). - pub fn from_descriptor(desc_off: u64) -> Self { - VecRef(BStackRange::new(desc_off, DESCRIPTOR_SIZE)) - } +/// Read a [`VecDesc`] from an absolute on-disk offset (its inline field location). +fn read_vecdesc(stack: &BStack, loc: u64) -> io::Result { + let mut buf = [0u8; size_of::()]; + stack.get_into(loc, &mut buf)?; + Ok(VecDesc { + data_off: u64::from_le_bytes(buf[0..8].try_into().unwrap()), + data_size: u64::from_le_bytes(buf[8..16].try_into().unwrap()), + }) } -impl BStackDrop for VecRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { - let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; - allocator.stack().get_into(self.0.start(), &mut buf)?; - let off = u64::from_le_bytes(buf[0..8].try_into().unwrap()); - let size = u64::from_le_bytes(buf[8..16].try_into().unwrap()); - unsafe { - dealloc_range(allocator, BStackRange::new(off, size))?; - dealloc_range(allocator, self.0)?; - } - Ok(()) - } +/// Write a [`VecDesc`] to an absolute on-disk offset (its inline field location). +fn write_vecdesc(stack: &BStack, loc: u64, desc: VecDesc) -> io::Result<()> { + let mut buf = [0u8; size_of::()]; + buf[0..8].copy_from_slice(&desc.data_off.to_le_bytes()); + buf[8..16].copy_from_slice(&desc.data_size.to_le_bytes()); + stack.set(loc, buf) } -/// A persistent, growable vector of POD elements, addressed by a stable -/// descriptor block. Backs `#[bstack_owned] Vec` / `String` fields. +/// A persistent, growable vector of POD elements. Backs un-annotated `Vec` +/// (`T: Pod`) / `String` fields. +/// +/// The handle carries the descriptor in memory (`data`), plus the inline field +/// location to persist it to (`writeback`) when field-resident — `None` for a +/// detached vector (from [`from_slice`](Self::from_slice) or `bstack_move!`). pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { - /// The descriptor block (`data_off`, `data_size`) — a stable identity. - desc: BStackRange, + /// The current data block range (the live descriptor). + data: BStackRange, + /// Where to persist descriptor changes on realloc (the inline field). `None` + /// for a detached vector (in-memory descriptor only). + writeback: Option, allocator: &'a A, _marker: PhantomData T>, } impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { - /// Reconstruct a handle from its descriptor block offset (e.g. a field - /// accessor, which stores just that offset). + /// Reconstruct a **field-resident** handle from its inline descriptor's + /// absolute on-disk location (what a field accessor passes). Reads the + /// current descriptor and remembers the location for write-back. /// /// # Safety - /// `desc_off` must be the offset of a live descriptor block written by this - /// type. - pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + /// `loc` must be the offset of a live inline [`VecDesc`] written by this type. + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + let desc = read_vecdesc(allocator.stack(), loc)?; + Ok(Self { + data: BStackRange::new(desc.data_off, desc.data_size), + writeback: Some(BStackRange::new(loc, size_of::() as u64)), + allocator, + _marker: PhantomData, + }) + } + + /// Reconstruct a **detached** handle from a descriptor value (no write-back; + /// the descriptor lives only in memory). Used by `bstack_move!`. + pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { - desc: BStackRange::new(desc_off, DESCRIPTOR_SIZE), + data: BStackRange::new(desc.data_off, desc.data_size), + writeback: None, allocator, _marker: PhantomData, } } - /// The descriptor block's range — the vector's stable on-disk identity. - pub fn descriptor(&self) -> BStackRange { - self.desc + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { + VecDesc { + data_off: self.data.start(), + data_size: self.data.len(), + } } /// The allocator this vector is bound to. @@ -103,78 +128,50 @@ impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { self.allocator } - fn read_desc(&self) -> io::Result<(u64, u64)> { - let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; - self.allocator - .stack() - .get_into(self.desc.start(), &mut buf)?; - let off = u64::from_le_bytes(buf[0..8].try_into().unwrap()); - let size = u64::from_le_bytes(buf[8..16].try_into().unwrap()); - Ok((off, size)) - } - - fn write_desc(&self, data_off: u64, data_size: u64) -> io::Result<()> { - let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; - buf[0..8].copy_from_slice(&data_off.to_le_bytes()); - buf[8..16].copy_from_slice(&data_size.to_le_bytes()); - self.allocator.stack().set(self.desc.start(), buf) + /// Persist the current descriptor to the inline field, if field-resident. + fn persist(&self) -> io::Result<()> { + if let Some(loc) = self.writeback { + write_vecdesc(self.allocator.stack(), loc.start(), self.descriptor())?; + } + Ok(()) } /// Reconstruct the `BStackByteVec` over the current data block. fn bytes(&self) -> io::Result> { - let (off, size) = self.read_desc()?; - let block = unsafe { - BStackOwnedSlice::from_raw_range(self.allocator, BStackRange::new(off, size)) - }; + let block = unsafe { BStackOwnedSlice::from_raw_range(self.allocator, self.data) }; Ok(unsafe { BStackByteVec::from_raw_block(block) }) } - /// Free the data block and the descriptor. Consumes the handle. Delegates to - /// the without-allocator [`VecRef`] core, the same teardown a vector field - /// runs during its parent's recursive drop. + /// Free the data block. Consumes the handle. (There is no descriptor block; + /// a field's inline descriptor is freed with the owning struct's block.) pub fn bstack_drop(self) -> io::Result<()> { - VecRef(self.desc).bstack_drop(self.allocator) + unsafe { dealloc_range(self.allocator, self.data) } } } impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { - /// Create a vector holding `data`, allocating the data block then a - /// descriptor pointing at it. + /// Create a **detached** vector holding `data`, allocating only the data + /// block. It becomes persistent when written into a struct field. pub fn from_slice(allocator: &'a A, data: &[T]) -> io::Result { let data_range = BStackByteVec::from_slice(bytemuck::cast_slice(data), allocator)? .into_raw_block() .as_range(); - - let mut desc = match allocator.alloc(DESCRIPTOR_SIZE) { - Ok(d) => d, - Err(e) => { - let _ = unsafe { dealloc_range(allocator, data_range) }; - return Err(e); - } - }; - let mut buf = [0u8; DESCRIPTOR_SIZE as usize]; - buf[0..8].copy_from_slice(&data_range.start().to_le_bytes()); - buf[8..16].copy_from_slice(&data_range.len().to_le_bytes()); - if let Err(e) = desc.write_range(0, buf) { - let _ = allocator.dealloc(desc); - let _ = unsafe { dealloc_range(allocator, data_range) }; - return Err(e); - } Ok(Self { - desc: desc.as_range(), + data: data_range, + writeback: None, allocator, _marker: PhantomData, }) } - /// Create an empty vector. + /// Create an empty detached vector. pub fn new(allocator: &'a A) -> io::Result { Self::from_slice(allocator, &[]) } /// Number of elements. pub fn len(&self) -> io::Result { - Ok(self.bytes()?.len()? / core::mem::size_of::() as u64) + Ok(self.bytes()?.len()? / size_of::() as u64) } /// Whether the vector is empty. @@ -185,7 +182,7 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { /// Read all elements into a `Vec` (unaligned reads, so any `T` is fine). pub fn to_vec(&self) -> io::Result> { let bytes = self.bytes()?.read_bytes()?; - let esz = core::mem::size_of::(); + let esz = size_of::(); Ok(bytes .chunks_exact(esz) .map(bytemuck::pod_read_unaligned::) @@ -193,57 +190,54 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { } /// Append an element, growing the data block if needed (which may move it — - /// the descriptor is rewritten to follow). + /// the inline descriptor is rewritten to follow, if field-resident). pub fn push(&mut self, value: T) -> io::Result<()> { - let (off, size) = self.read_desc()?; - let block = unsafe { - BStackOwnedSlice::from_raw_range(self.allocator, BStackRange::new(off, size)) - }; - let mut bytevec = unsafe { BStackByteVec::from_raw_block(block) }; + let mut bytevec = self.bytes()?; for &b in bytemuck::bytes_of(&value) { bytevec.push(b)?; } - let new_range = bytevec.into_raw_block().as_range(); - if new_range.start() != off || new_range.len() != size { - self.write_desc(new_range.start(), new_range.len())?; - } - Ok(()) + self.data = bytevec.into_raw_block().as_range(); + self.persist() } } -/// A persistent, growable vector of **owned block children**, addressed by a -/// stable descriptor. -/// -/// Like [`BStackVec`], but each element is a `u64` offset to a -/// separately-allocated `#[bstack_block]` child that this vector *owns*: the -/// backing data block stores the child offsets, and dropping the vector -/// recursively frees every child (post-order) plus the offset array and the -/// descriptor. Backs `#[bstack_owned] Vec` fields. +// --------------------------------------------------------------------------- +// Block-element vectors: one `u64` offset per element, stored the same way. The +// field annotation states the elements' ownership; the descriptor + offset array +// are always owned by the enclosing struct. +// --------------------------------------------------------------------------- + +/// A persistent, growable vector of **owned block children**. /// -/// > Like [`BStackVec`], growth reallocates the offset array, so use a -/// > realloc-safe allocator. +/// Each element is a `u64` offset to a separately-allocated `#[bstack_block]` +/// child this vector *owns*; dropping the vector recursively frees every child +/// (post-order) plus the offset array. Backs `#[bstack_owned] Vec` fields. pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { - /// The offset array, stored as a POD `u64` vector behind the same descriptor - /// indirection. Its identity (the descriptor) is the field's stable pointer. offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> { - /// Reconstruct a handle from its descriptor block offset. - /// /// # Safety - /// `desc_off` must be the offset of a live descriptor block written by this - /// type, over an array of offsets to live `T` blocks this vector owns. - pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + /// `loc` must be a live inline descriptor over an array of data offsets to + /// live `T` blocks this vector owns. + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. + pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { - offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + offsets: BStackVec::from_desc(desc, allocator), _marker: PhantomData, } } - /// The descriptor block's range — the vector's stable on-disk identity. - pub fn descriptor(&self) -> BStackRange { + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { self.offsets.descriptor() } @@ -257,8 +251,6 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> self.offsets.is_empty() } - /// The child block's range, recovered from a stored offset and `T`'s fixed - /// on-disk size. fn elem_range(off: u64) -> BStackRange { BStackRange::new(off, size_of::() as u64) } @@ -282,8 +274,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> .map(|&off| T::from_range(Self::elem_range(off)))) } - /// Build from a list of owned children (each consumed, its ownership moved - /// into the vector). + /// Build a detached vector from a list of owned children (each consumed). pub fn from_handles(allocator: &'a A, children: Vec>) -> io::Result { let offs: Vec = children .into_iter() @@ -295,20 +286,18 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> }) } - /// Create an empty vector. + /// Create an empty detached vector. pub fn new(allocator: &'a A) -> io::Result { Self::from_handles(allocator, Vec::new()) } - /// Append an owned child, transferring its ownership into the vector (the - /// offset array may realloc/move; the descriptor follows). + /// Append an owned child, transferring its ownership into the vector. pub fn push_owned(&mut self, child: BStackOwned) -> io::Result<()> { - let off = child.into_inner().range().start(); - self.offsets.push(off) + self.offsets.push(child.into_inner().range().start()) } - /// Recursively free every owned child (post-order), then the offset array and - /// the descriptor. Consumes the handle. + /// Recursively free every owned child (post-order), then the offset array. + /// Consumes the handle. pub fn bstack_drop(self) -> io::Result<()> { let allocator = self.offsets.allocator(); for off in self.offsets.to_vec()? { @@ -319,12 +308,11 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> } /// A persistent, growable vector of **strong references** to shared block -/// children (`(rc)` / `(rc, weak)` blocks), behind a stable descriptor. +/// children (`(rc)` / `(rc, weak)` blocks). /// -/// Each element holds one strong reference (contributes 1 to the child's strong -/// count); dropping the vector releases every one (freeing a child when its count -/// hits zero) and frees the offset array + descriptor. Backs -/// `#[bstack_strong] Vec` fields. +/// Each element holds one strong reference; dropping the vector releases every +/// one (freeing a child when its count hits zero) and frees the offset array. +/// Backs `#[bstack_strong] Vec` fields. pub struct BStackStrongVec<'a, T: BStackShared, A: BStackOwnedSliceAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, @@ -332,17 +320,25 @@ pub struct BStackStrongVec<'a, T: BStackShared, A: BStackOwnedSliceAllocator> { impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A> { /// # Safety - /// `desc_off` must be a live descriptor over an array of data offsets to + /// `loc` must be a live inline descriptor over an array of data offsets to /// live `T` blocks, each accounting for one strong reference this vector owns. - pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. + pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { - offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + offsets: BStackVec::from_desc(desc, allocator), _marker: PhantomData, } } - /// The vector's stable on-disk identity. - pub fn descriptor(&self) -> BStackRange { + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { self.offsets.descriptor() } @@ -380,8 +376,8 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A .map(|&off| T::from_range(Self::elem_range(off)))) } - /// Build from a list of strong handles (each consumed, its strong count moved - /// into the vector). + /// Build a detached vector from a list of strong handles (each consumed, its + /// strong count moved into the vector). pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { let offs: Vec = elems .into_iter() @@ -403,7 +399,7 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A } /// Release every strong reference (freeing children that reach zero), then - /// free the offset array and descriptor. Consumes the handle. + /// free the offset array. Consumes the handle. pub fn bstack_drop(self) -> io::Result<()> { let allocator = self.offsets.allocator(); for off in self.offsets.to_vec()? { @@ -415,12 +411,12 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A } /// A persistent, growable vector of **weak references** to `(rc, weak)` block -/// children, behind a stable descriptor. +/// children. /// /// Each element holds one weak reference (a stored control-block offset). /// Dropping the vector releases every weak count (freeing a control block when -/// it reaches zero) and frees the offset array + descriptor. Backs -/// `#[bstack_weak] Vec` fields. +/// it reaches zero) and frees the offset array. Backs `#[bstack_weak] Vec` +/// fields. pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, @@ -428,17 +424,25 @@ pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A> { /// # Safety - /// `desc_off` must be a live descriptor over an array of control-block + /// `loc` must be a live inline descriptor over an array of control-block /// offsets, each accounting for one weak reference this vector owns. - pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. + pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { - offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + offsets: BStackVec::from_desc(desc, allocator), _marker: PhantomData, } } - /// The vector's stable on-disk identity. - pub fn descriptor(&self) -> BStackRange { + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { self.offsets.descriptor() } @@ -473,8 +477,8 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A result } - /// Build from a list of weak handles (each consumed, its weak count moved - /// into the vector). + /// Build a detached vector from a list of weak handles (each consumed, its + /// weak count moved into the vector). pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { let offs: Vec = elems .into_iter() @@ -492,7 +496,7 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A } /// Release every weak reference (freeing control blocks that reach zero), - /// then free the offset array and descriptor. Consumes the handle. + /// then free the offset array. Consumes the handle. pub fn bstack_drop(self) -> io::Result<()> { let allocator = self.offsets.allocator(); for off in self.offsets.to_vec()? { @@ -502,11 +506,10 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A } } -/// A persistent, growable vector of **raw references** to block children, behind -/// a stable descriptor. +/// A persistent, growable vector of **raw references** to block children. /// -/// Elements carry no ownership: dropping the vector frees only the offset array -/// and descriptor, never the targets. Backs `#[bstack_ref] Vec` fields. +/// Elements carry no ownership: dropping the vector frees only the offset array, +/// never the targets. Backs `#[bstack_ref] Vec` fields. pub struct BStackRefVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, @@ -514,17 +517,25 @@ pub struct BStackRefVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { /// # Safety - /// `desc_off` must be a live descriptor over an array of offsets to `T` + /// `loc` must be a live inline descriptor over an array of offsets to `T` /// blocks (which this vector does not own). - pub unsafe fn from_descriptor(desc_off: u64, allocator: &'a A) -> Self { + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. + pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { - offsets: unsafe { BStackVec::from_descriptor(desc_off, allocator) }, + offsets: BStackVec::from_desc(desc, allocator), _marker: PhantomData, } } - /// The vector's stable on-disk identity. - pub fn descriptor(&self) -> BStackRange { + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { self.offsets.descriptor() } @@ -561,7 +572,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { .map(|&off| T::from_range(Self::elem_range(off)))) } - /// Build from a list of raw references. + /// Build a detached vector from a list of raw references. pub fn from_handles(allocator: &'a A, elems: Vec>) -> io::Result { let offs: Vec = elems.into_iter().map(|r| r.into_range().start()).collect(); Ok(Self { @@ -575,8 +586,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { self.offsets.push(elem.into_range().start()) } - /// Free only the offset array and descriptor (elements are not owned). - /// Consumes the handle. + /// Free only the offset array (elements are not owned). Consumes the handle. pub fn bstack_drop(self) -> io::Result<()> { self.offsets.bstack_drop() } From 0f10732385000e2ae66baedca5637b9b021da11b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 20 Jul 2026 22:56:08 -0700 Subject: [PATCH 025/140] Vecs and String now support Option --- bstack_raii/derive/src/block.rs | 293 ++++++++++++++++++++++---------- bstack_raii/src/tests.rs | 53 ++++++ bstack_raii/src/vec.rs | 75 ++++++++ 3 files changed, 331 insertions(+), 90 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index fba3acf..c419083 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -104,15 +104,24 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ref_coerced = true; } - // `Vec` / `String` fields (and `&str` → `String`): a fixed-size - // descriptor offset on disk, a `BStackVec` at runtime. Handled here. - let vinfo = if is_str(eff_ty) { + // `Option` makes a field nullable (`0` on disk == `None`, since no + // allocation ever lives at offset 0). Strip it first, so the inner type — + // which may itself be a `Vec` / `String` — is what we classify. + let (inner_ty, nullable) = match option_inner(eff_ty) { + Some(inner) => (inner, true), + None => (eff_ty, false), + }; + + // `Vec` / `String` (and `&str` → `String`): an inline descriptor on + // disk, a `BStackVec` at runtime. A nullable vec uses the `data_off == 0` + // niche. Handled here. + let vinfo = if is_str(inner_ty) { Some(VecInfo { elem: quote!(u8), is_string: true, }) } else { - vec_field(eff_ty) + vec_field(inner_ty) }; if let Some(vinfo) = vinfo { let elem = &vinfo.elem; @@ -134,54 +143,58 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // POD elements (byte storage, requiring `T: Pod`). let (drop_s, acc, ctor, mv) = match kind { Kind::Pod => ( - vec_drop_stmt(fname, elem), - vec_accessor(vis, fname, elem, &on_disk), - vec_ctor(fname, &vinfo), - vec_move(&cap, elem), + vec_drop_stmt(fname, elem, nullable), + vec_accessor(vis, fname, elem, &on_disk, nullable), + vec_ctor(fname, &vinfo, nullable), + vec_move(&cap, elem, nullable), ), Kind::Owned => ( - block_vec_drop_stmt(fname, quote!(BStackBlockVec), elem), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackBlockVec)), + block_vec_drop_stmt(fname, quote!(BStackBlockVec), elem, nullable), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackBlockVec), nullable), block_vec_ctor( fname, elem, quote!(BStackBlockVec), quote!(::bstack_raii::BStackOwned<#elem>), + nullable, ), - block_vec_move(&cap, elem, quote!(BStackBlockVec)), + block_vec_move(&cap, elem, quote!(BStackBlockVec), nullable), ), Kind::Strong => ( - block_vec_drop_stmt(fname, quote!(BStackStrongVec), elem), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackStrongVec)), + block_vec_drop_stmt(fname, quote!(BStackStrongVec), elem, nullable), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackStrongVec), nullable), block_vec_ctor( fname, elem, quote!(BStackStrongVec), quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + nullable, ), - block_vec_move(&cap, elem, quote!(BStackStrongVec)), + block_vec_move(&cap, elem, quote!(BStackStrongVec), nullable), ), Kind::Weak => ( - block_vec_drop_stmt(fname, quote!(BStackWeakVec), elem), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackWeakVec)), + block_vec_drop_stmt(fname, quote!(BStackWeakVec), elem, nullable), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackWeakVec), nullable), block_vec_ctor( fname, elem, quote!(BStackWeakVec), quote!(::bstack_raii::BStackWeak<'__ctor, #elem, __A>), + nullable, ), - block_vec_move(&cap, elem, quote!(BStackWeakVec)), + block_vec_move(&cap, elem, quote!(BStackWeakVec), nullable), ), Kind::Ref => ( - block_vec_drop_stmt(fname, quote!(BStackRefVec), elem), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackRefVec)), + block_vec_drop_stmt(fname, quote!(BStackRefVec), elem, nullable), + block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackRefVec), nullable), block_vec_ctor( fname, elem, quote!(BStackRefVec), quote!(::bstack_raii::BStackRef<#elem>), + nullable, ), - block_vec_move(&cap, elem, quote!(BStackRefVec)), + block_vec_move(&cap, elem, quote!(BStackRefVec), nullable), ), }; drop_stmts.push(drop_s); @@ -196,12 +209,6 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } - // `Option` makes a reference field nullable: `0` on disk == `None` - // (no allocation ever lives at offset 0). The annotation applies to Inner. - let (inner_ty, nullable) = match option_inner(eff_ty) { - Some(inner) => (inner, true), - None => (eff_ty, false), - }; if nullable && kind == Kind::Pod { return Err(Error::new_spanned( &field.ty, @@ -562,133 +569,239 @@ fn vec_field(ty: &Type) -> Option { } /// Teardown for a POD `Vec` / `String` field: free the vector's data block -/// (the inline descriptor is freed with the enclosing struct's block). -fn vec_drop_stmt(fname: &Ident, elem: &TokenStream) -> TokenStream { - quote! { - { - ::bstack_raii::BStackVec::<#elem, __A>::from_desc(__on_disk.#fname, allocator) - .bstack_drop()?; - } +/// (the inline descriptor is freed with the enclosing struct's block). A nullable +/// field frees nothing when the descriptor is the `0` niche. +fn vec_drop_stmt(fname: &Ident, elem: &TokenStream, nullable: bool) -> TokenStream { + let free = quote! { + ::bstack_raii::BStackVec::<#elem, __A>::from_desc(__on_disk.#fname, allocator) + .bstack_drop()?; + }; + if nullable { + quote! { { if __on_disk.#fname.data_off != 0 { #free } } } + } else { + quote! { { #free } } } } /// Accessor for a `Vec` / `String` field: read the inline descriptor at the -/// field's location into a `BStackVec` handle. Takes the allocator (the vector's -/// ops need it). +/// field's location into a `BStackVec` handle. A nullable field returns +/// `Option<_>` (`None` for the `0` niche). fn vec_accessor( vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, on_disk: &Ident, + nullable: bool, ) -> TokenStream { - quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( - &self, - allocator: &'__v __A, - ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { - let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; - unsafe { ::bstack_raii::BStackVec::from_field(__field, allocator) } + let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); + if nullable { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result< + ::core::option::Option<::bstack_raii::BStackVec<'__v, #elem, __A>> + > { + unsafe { ::bstack_raii::BStackVec::from_field_opt(#field, allocator) } + } + } + } else { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { + unsafe { ::bstack_raii::BStackVec::from_field(#field, allocator) } + } } } } /// Constructor `(param, prep, init)` for a `Vec` / `String` field: allocate -/// the data block and store its descriptor inline in the field. -fn vec_ctor(fname: &Ident, vinfo: &VecInfo) -> (TokenStream, TokenStream, TokenStream) { +/// the data block and store its descriptor inline. A nullable field takes an +/// `Option` (`None` => the `0` niche, no allocation). +fn vec_ctor(fname: &Ident, vinfo: &VecInfo, nullable: bool) -> (TokenStream, TokenStream, TokenStream) { let elem = &vinfo.elem; - let (param_ty, data): (TokenStream, TokenStream) = if vinfo.is_string { - (quote!(&str), quote!(#fname.as_bytes())) + let base_param: TokenStream = if vinfo.is_string { + quote!(&str) } else { - (quote!(&[#elem]), quote!(#fname)) + quote!(&[#elem]) }; - ( - quote!(#fname: #param_ty,), + // The byte slice passed to `from_slice`, given the source binding `b`. + let data_of = |b: TokenStream| -> TokenStream { + if vinfo.is_string { + quote!(#b.as_bytes()) + } else { + b + } + }; + let prep = if nullable { + let some_data = data_of(quote!(__d)); + quote! { + let #fname: ::bstack_raii::VecDesc = match #fname { + ::core::option::Option::Some(__d) => + ::bstack_raii::BStackVec::<#elem, __A>::from_slice(allocator, #some_data)? + .descriptor(), + ::core::option::Option::None => ::core::default::Default::default(), + }; + } + } else { + let data = data_of(quote!(#fname)); quote! { let #fname: ::bstack_raii::VecDesc = ::bstack_raii::BStackVec::<#elem, __A>::from_slice(allocator, #data)? .descriptor(); - }, - quote!(#fname: #fname,), - ) + } + }; + let param = if nullable { + quote!(#fname: ::core::option::Option<#base_param>,) + } else { + quote!(#fname: #base_param,) + }; + (param, prep, quote!(#fname: #fname,)) } /// `bstack_move!` field for a `Vec` / `String`: yield a detached `BStackVec` /// carrying the inline descriptor (captured from the parent before it is freed). -fn vec_move(cap: &Ident, elem: &TokenStream) -> (TokenStream, TokenStream) { - ( - quote!(::bstack_raii::BStackVec<'__mv, #elem, __A>), - quote!(::bstack_raii::BStackVec::from_desc(#cap, __alloc)), - ) +/// Nullable yields `Option<_>`. +fn vec_move(cap: &Ident, elem: &TokenStream, nullable: bool) -> (TokenStream, TokenStream) { + let ty = quote!(::bstack_raii::BStackVec<'__mv, #elem, __A>); + let build = quote!(::bstack_raii::BStackVec::from_desc(#cap, __alloc)); + wrap_vec_move(ty, build, cap, nullable) } // Block-element vectors (`#[bstack_owned/strong/weak/ref] Vec`) all share // the same inline-descriptor offset-array storage and a uniform codegen-facing -// API (`from_field` / `from_desc` / `from_handles` / `descriptor` / -// `bstack_drop`); only the runtime type (`vec_ty`) and the element-handle type -// differ per element relationship. These helpers are parameterized over both. +// API (`from_field` / `from_field_opt` / `from_desc` / `from_handles` / +// `descriptor` / `bstack_drop`); only the runtime type (`vec_ty`) and the +// element-handle type differ per element relationship. These helpers are +// parameterized over both. + +/// Wrap a vector move field's type/expr in `Option` when the field is nullable +/// (the `data_off == 0` niche == `None`). +fn wrap_vec_move( + ty: TokenStream, + build: TokenStream, + cap: &Ident, + nullable: bool, +) -> (TokenStream, TokenStream) { + if nullable { + ( + quote!(::core::option::Option<#ty>), + quote! { + if #cap.data_off != 0 { + ::core::option::Option::Some(#build) + } else { + ::core::option::Option::None + } + }, + ) + } else { + (ty, build) + } +} /// Teardown for a block-element `Vec` field: run the vector's own -/// `bstack_drop` (per-element release + free the offset array). -fn block_vec_drop_stmt(fname: &Ident, vec_ty: TokenStream, elem: &TokenStream) -> TokenStream { - quote! { - { - ::bstack_raii::#vec_ty::<#elem, __A>::from_desc(__on_disk.#fname, allocator) - .bstack_drop()?; - } +/// `bstack_drop` (per-element release + free the offset array). Nullable frees +/// nothing for the `0` niche. +fn block_vec_drop_stmt( + fname: &Ident, + vec_ty: TokenStream, + elem: &TokenStream, + nullable: bool, +) -> TokenStream { + let free = quote! { + ::bstack_raii::#vec_ty::<#elem, __A>::from_desc(__on_disk.#fname, allocator) + .bstack_drop()?; + }; + if nullable { + quote! { { if __on_disk.#fname.data_off != 0 { #free } } } + } else { + quote! { { #free } } } } /// Accessor for a block-element `Vec` field: read the inline descriptor at -/// the field's location into the vector handle. Takes the allocator. +/// the field's location into the vector handle. Nullable returns `Option<_>`. fn block_vec_accessor( vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, on_disk: &Ident, vec_ty: TokenStream, + nullable: bool, ) -> TokenStream { - quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( - &self, - allocator: &'__v __A, - ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { - let __field = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; - unsafe { ::bstack_raii::#vec_ty::from_field(__field, allocator) } + let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); + if nullable { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result< + ::core::option::Option<::bstack_raii::#vec_ty<'__v, #elem, __A>> + > { + unsafe { ::bstack_raii::#vec_ty::from_field_opt(#field, allocator) } + } + } + } else { + quote! { + #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { + unsafe { ::bstack_raii::#vec_ty::from_field(#field, allocator) } + } } } } /// Constructor `(param, prep, init)` for a block-element `Vec` field: /// build the vector from a `Vec` of element handles (each consumed) and store its -/// descriptor inline in the field. +/// descriptor inline. Nullable takes an `Option>` (`None` => the niche). fn block_vec_ctor( fname: &Ident, elem: &TokenStream, vec_ty: TokenStream, handle_ty: TokenStream, + nullable: bool, ) -> (TokenStream, TokenStream, TokenStream) { - ( - quote!(#fname: ::std::vec::Vec<#handle_ty>,), - quote! { - let #fname: ::bstack_raii::VecDesc = - ::bstack_raii::#vec_ty::<#elem, __A>::from_handles(allocator, #fname)? - .descriptor(); - }, - quote!(#fname: #fname,), - ) + if nullable { + ( + quote!(#fname: ::core::option::Option<::std::vec::Vec<#handle_ty>>,), + quote! { + let #fname: ::bstack_raii::VecDesc = match #fname { + ::core::option::Option::Some(__v) => + ::bstack_raii::#vec_ty::<#elem, __A>::from_handles(allocator, __v)? + .descriptor(), + ::core::option::Option::None => ::core::default::Default::default(), + }; + }, + quote!(#fname: #fname,), + ) + } else { + ( + quote!(#fname: ::std::vec::Vec<#handle_ty>,), + quote! { + let #fname: ::bstack_raii::VecDesc = + ::bstack_raii::#vec_ty::<#elem, __A>::from_handles(allocator, #fname)? + .descriptor(); + }, + quote!(#fname: #fname,), + ) + } } /// `bstack_move!` field for a block-element `Vec`: yield a detached vector -/// handle carrying the inline descriptor (now independently owned). +/// handle carrying the inline descriptor. Nullable yields `Option<_>`. fn block_vec_move( cap: &Ident, elem: &TokenStream, vec_ty: TokenStream, + nullable: bool, ) -> (TokenStream, TokenStream) { - ( - quote!(::bstack_raii::#vec_ty<'__mv, #elem, __A>), - quote!(::bstack_raii::#vec_ty::from_desc(#cap, __alloc)), - ) + let ty = quote!(::bstack_raii::#vec_ty<'__mv, #elem, __A>); + let build = quote!(::bstack_raii::#vec_ty::from_desc(#cap, __alloc)); + wrap_vec_move(ty, build, cap, nullable) } /// Return `Some(Inner)` if `ty` is `Option`. diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 16bba3b..2e4b26b 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1284,3 +1284,56 @@ fn macro_ref_block_vec() { a.bstack_drop(&alloc).unwrap(); b.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Option> / Option — nullable vectors via the data_off==0 niche +// -------------------------------------------------------------------------- + +#[bstack_block] +struct OptVec { + tags: Option>, + name: Option, + id: u64, +} + +#[test] +fn macro_option_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Some: constructor takes Option<&[T]> / Option<&str>; accessors resolve. + let a = OptVec::new(&alloc, Some(&[1u32, 2, 3][..]), Some("hi"), 7).unwrap(); + assert_eq!(a.handle().id(stack).unwrap(), 7); + assert_eq!( + a.handle() + .tags(&alloc) + .unwrap() + .expect("some") + .to_vec() + .unwrap(), + vec![1u32, 2, 3] + ); + assert_eq!( + a.handle() + .name(&alloc) + .unwrap() + .expect("some") + .to_vec() + .unwrap(), + b"hi" + ); + + // bstack_move! yields Option>; free the moved-out vectors. + let (tags, name, id) = bstack_move!(a, &alloc).unwrap(); + assert_eq!(id, 7); + tags.unwrap().bstack_drop().unwrap(); + name.unwrap().bstack_drop().unwrap(); + + // None: `0` niche — accessors are None, teardown frees nothing extra. + let b = OptVec::new(&alloc, None, None, 9).unwrap(); + assert_eq!(b.handle().id(stack).unwrap(), 9); + assert!(b.handle().tags(&alloc).unwrap().is_none()); + assert!(b.handle().name(&alloc).unwrap().is_none()); + b.bstack_drop(&alloc).unwrap(); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index e32e21d..567e04b 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -104,6 +104,25 @@ impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { }) } + /// Like [`from_field`](Self::from_field), but for a nullable field: a + /// `data_off` of `0` (the offset-0 niche, since no allocation lives there) + /// reads as `None`. Backs `Option>` accessors. + /// + /// # Safety + /// As [`from_field`](Self::from_field). + pub unsafe fn from_field_opt(loc: u64, allocator: &'a A) -> io::Result> { + let desc = read_vecdesc(allocator.stack(), loc)?; + if desc.data_off == 0 { + return Ok(None); + } + Ok(Some(Self { + data: BStackRange::new(desc.data_off, desc.data_size), + writeback: Some(BStackRange::new(loc, size_of::() as u64)), + allocator, + _marker: PhantomData, + })) + } + /// Reconstruct a **detached** handle from a descriptor value (no write-back; /// the descriptor lives only in memory). Used by `bstack_move!`. pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { @@ -228,6 +247,20 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> }) } + /// Like [`from_field`](Self::from_field), but nullable — `None` when the + /// inline descriptor is the offset-0 niche. Backs `Option>`. + /// + /// # Safety + /// As [`from_field`](Self::from_field). + pub unsafe fn from_field_opt(loc: u64, allocator: &'a A) -> io::Result> { + Ok( + unsafe { BStackVec::from_field_opt(loc, allocator)? }.map(|offsets| Self { + offsets, + _marker: PhantomData, + }), + ) + } + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { @@ -329,6 +362,20 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A }) } + /// Like [`from_field`](Self::from_field), but nullable — `None` when the + /// inline descriptor is the offset-0 niche. Backs `Option>`. + /// + /// # Safety + /// As [`from_field`](Self::from_field). + pub unsafe fn from_field_opt(loc: u64, allocator: &'a A) -> io::Result> { + Ok( + unsafe { BStackVec::from_field_opt(loc, allocator)? }.map(|offsets| Self { + offsets, + _marker: PhantomData, + }), + ) + } + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { @@ -433,6 +480,20 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A }) } + /// Like [`from_field`](Self::from_field), but nullable — `None` when the + /// inline descriptor is the offset-0 niche. Backs `Option>`. + /// + /// # Safety + /// As [`from_field`](Self::from_field). + pub unsafe fn from_field_opt(loc: u64, allocator: &'a A) -> io::Result> { + Ok( + unsafe { BStackVec::from_field_opt(loc, allocator)? }.map(|offsets| Self { + offsets, + _marker: PhantomData, + }), + ) + } + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { @@ -526,6 +587,20 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { }) } + /// Like [`from_field`](Self::from_field), but nullable — `None` when the + /// inline descriptor is the offset-0 niche. Backs `Option>`. + /// + /// # Safety + /// As [`from_field`](Self::from_field). + pub unsafe fn from_field_opt(loc: u64, allocator: &'a A) -> io::Result> { + Ok( + unsafe { BStackVec::from_field_opt(loc, allocator)? }.map(|offsets| Self { + offsets, + _marker: PhantomData, + }), + ) + } + /// Reconstruct a detached handle from a descriptor value. Used by `bstack_move!`. pub fn from_desc(desc: VecDesc, allocator: &'a A) -> Self { Self { From bc5cffc0f005eb5118ffbb79aa1a1b1a4c860dd7 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 20 Jul 2026 22:56:24 -0700 Subject: [PATCH 026/140] Clairfying docs for detachment --- bstack_raii/README.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 98cd277..5067e4d 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -210,7 +210,15 @@ when a push reallocates. A vector **not** resident in a field — built by `BStackVec::from_slice` or handed out by `bstack_move!` — is *detached*: it carries its descriptor in memory and frees only its data block on `bstack_drop`. It becomes persistent when written -into a struct field (which stamps the inline descriptor). +into a struct field (which stamps the inline descriptor) — the general +[moved-out-values-are-unrooted](#moving-fields-out-bstack_move) rule. + +Wrapping a vector field in `Option` makes it nullable — `Option>` / +`Option` (and `Option<#[bstack_owned] Vec>`, etc.). On disk it is +the same inline descriptor with the `data_off == 0` niche as `None` (distinct +from an *empty* present vector, whose data block is at a non-zero offset). The +constructor takes `Option<&[T]>` / `Option<&str>` / `Option>`, the +accessor returns `Option<_>`, and `bstack_move!` yields `Option<_>`. #### Vectors of blocks: `#[bstack_owned/strong/weak/ref] Vec` @@ -250,8 +258,6 @@ block elements, an **un-annotated** `Vec` is always POD and requires `T: Pod` descriptor (a descriptor has a single owner). Instead, wrap the vector in its own `#[bstack_block]` and share *that* block with `#[bstack_strong]` / `#[bstack_ref]`. -Still unsupported: `Option>`. - ### Ergonomic reference coercion For convenience, a field written `&T` is coerced to owned `T` (and `&str` to @@ -377,6 +383,13 @@ match bstack_move!(rc)? { } ``` +> **Moved-out values are unrooted.** A handle produced by `bstack_move!` — like +> one from `X::new` — is detached from any persistent structure. Its block still +> lives on disk, but it is reachable *only* through your in-memory handle: if the +> program ends without re-attaching it (storing it into another block's field) or +> freeing it (`bstack_drop`), it becomes unreachable garbage. Persistence comes +> from being reachable through a struct, not from having been moved out. + ## Casting: `bstack_cast!` Convert between typed handles and the untyped `bstack` primitives. Upcasts are @@ -441,9 +454,9 @@ no spin loop). All operations are durable and speak `std::io::Result`. - **Fixed-size block payloads.** A block's `OnDisk` struct is fixed-size — no *inline* variable-length arrays or slices. Growable data lives out-of-line via - the descriptor indirection: `Vec` / `String` (POD) and - `#[bstack_owned/strong/weak/ref] Vec` (block elements) are supported; - `Option>` is not yet. + an inline descriptor: `Vec` / `String` (POD), + `#[bstack_owned/strong/weak/ref] Vec` (block elements), and their + `Option<…>` (nullable) forms are all supported. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). - **No generic block types**, and non-`Pod` fields must carry an annotation. From d057392ea7fdf884931ff5beb71abc88e7052981 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 21 Jul 2026 18:49:24 -0700 Subject: [PATCH 027/140] Add bstack enum --- bstack_raii/README.md | 54 ++- bstack_raii/derive/src/block.rs | 623 +++++++++++++++++++++++++++++++- bstack_raii/derive/src/lib.rs | 29 ++ bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 225 +++++++++++- 5 files changed, 926 insertions(+), 7 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 5067e4d..af76135 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -25,6 +25,7 @@ object model on top. - [Concepts](#concepts) - [Defining blocks](#defining-blocks) - [Field ownership](#field-ownership) +- [Enums: `#[bstack_enum]`](#enums-bstack_enum) - [Handles & lifetimes](#handles--lifetimes) - [Shared ownership & weak references](#shared-ownership--weak-references) - [Moving fields out: `bstack_move!`](#moving-fields-out-bstack_move) @@ -264,6 +265,54 @@ For convenience, a field written `&T` is coerced to owned `T` (and `&str` to `String`) with a compile warning — so a stray reference doesn't fail to compile, but you're nudged to write the owned type. +## Enums: `#[bstack_enum]` + +A `#[bstack_enum]` lowers a Rust `enum` to a **tagged-union block**: a 1-byte +discriminant plus a payload area sized to the largest variant. Each variant is +**unit** (no data), a **POD** newtype `V(P)` (`P: Pod`, stored inline), or an +annotated newtype whose annotation states the *variant's* relationship, exactly +like a struct field — `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / +`#[bstack_ref]` (each a `u64` offset to the child / control block). + +```rust +#[bstack_enum] +enum Node { + Empty, // unit + Num(u32), // POD, inline + #[bstack_ref] Link(Leaf), // borrowed reference (frees nothing) + #[bstack_owned] Child(Leaf), // owned child (freed on teardown) + #[bstack_strong] Shared(Thing), // a strong ref (Thing is (rc)/(rc, weak)) + #[bstack_weak] Watch(Thing), // a weak ref (Thing is (rc, weak)) +} + +let leaf = Leaf::new(&alloc, 7)?; +let node = Node::new(&alloc, NodeInit::Child(leaf))?; // construct a variant +match node.handle().read(&alloc)? { // read / match the current one + NodeView::Child(c) => assert_eq!(c.val(stack)?, 7), + _ => {} +} +node.bstack_drop(&alloc)?; // frees the owned child too +``` + +The macro generates the handle `Node`, plus **`NodeInit`** (construction input: +POD by value, `#[bstack_owned]` → `BStackOwned`, `#[bstack_strong]` → +`BStackRc`, `#[bstack_weak]` → `BStackWeak`, `#[bstack_ref]` → +`BStackRef`) and **`NodeView`** (the read result: POD by value, owned/ref +children as borrowed handles, a weak variant *upgraded* to `Option>`). +`read` takes the allocator (a weak variant upgrades through it). Teardown matches +the discriminant and releases the variant's reference — recursively freeing an +owned child, decrementing a strong/weak count, and nothing for a ref. + +Like a struct, an enum has **modes**: `#[bstack_enum]` is owned, while +`#[bstack_enum(rc)]` / `#[bstack_enum(rc, weak)]` make the enum itself +reference-counted / weak-observable — `new` then returns a `BStackRc` (with +`try_clone` / `downgrade` / `upgrade`), and the enum can be a `#[bstack_strong]` / +`#[bstack_weak]` field of a struct. + +An enum is a block, so it is **always referenced** — store it as a field of a +struct (inline embedding isn't supported). Struct and multi-field tuple variants +aren't supported. + ## Handles & lifetimes The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, @@ -460,7 +509,10 @@ no spin loop). All operations are durable and speak `std::io::Result`. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). - **No generic block types**, and non-`Pod` fields must carry an annotation. -- No enums yet (planned). +- **Enums** ([`#[bstack_enum]`](#enums-bstack_enum)) support unit / POD / + `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` + variants in all three modes (owned / `(rc)` / `(rc, weak)`). Struct / + multi-field tuple variants, and `bstack_move!` on an enum, are not done. - The on-disk **ABI is not yet stable**. ## License diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index c419083..cf0d448 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1436,10 +1436,11 @@ fn eightcc_expr(bytes: &[u8; 8]) -> TokenStream { quote!(::bstack_raii::EightCC::new([#(#bytes),*])) } -/// Classify a field by its ownership annotation. -fn classify(field: &syn::Field) -> syn::Result { +/// Classify by ownership annotation among a set of attributes (a field's or an +/// enum variant's). No annotation => `Pod`. +fn classify_attrs(attrs: &[syn::Attribute]) -> syn::Result { let mut found: Option = None; - for attr in &field.attrs { + for attr in attrs { let Some(id) = attr.path().get_ident() else { continue; }; @@ -1453,10 +1454,624 @@ fn classify(field: &syn::Field) -> syn::Result { if found.is_some() { return Err(Error::new_spanned( attr, - "a field may carry at most one bstack ownership annotation", + "at most one bstack ownership annotation is allowed here", )); } found = Some(kind); } Ok(found.unwrap_or(Kind::Pod)) } + +/// Classify a struct field by its ownership annotation. +fn classify(field: &syn::Field) -> syn::Result { + classify_attrs(&field.attrs) +} + +// =========================================================================== +// #[bstack_enum] — a tagged union block +// =========================================================================== +// +// An enum lowers to a fixed-size block: a 1-byte discriminant plus a payload +// area sized to the largest variant. Each variant is either a unit (no payload), +// a POD newtype `V(P)` (bytes stored inline), or an annotated newtype +// `#[bstack_owned]`/`#[bstack_ref]` `V(T)` (a `u64` offset to a child block). +// Construction goes through a generated `EInit` input enum + `E::new`; reading +// through a generated `EView` + `E::read`; teardown matches the discriminant and +// frees the owned child, if any. + +/// Implementation of the `#[bstack_enum]` attribute macro (plain/owned mode). +pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { + let attr = parse_attr(attr)?; + let mode = attr.mode; + if !input.generics.params.is_empty() { + return Err(Error::new_spanned( + &input.generics, + "#[bstack_enum] does not support generic enums", + )); + } + if input.variants.len() > 256 { + return Err(Error::new_spanned( + &input.variants, + "#[bstack_enum] supports at most 256 variants (1-byte discriminant)", + )); + } + + let name = &input.ident; + let vis = &input.vis; + let on_disk = format_ident!("{}OnDisk", name); + let control = format_ident!("{}OnDiskRef", name); + let init = format_ident!("{}Init", name); + let view = format_ident!("{}View", name); + + let mut init_variants = Vec::new(); + let mut view_variants = Vec::new(); + let mut new_arms = Vec::new(); + let mut read_arms = Vec::new(); + let mut drop_arms = Vec::new(); + let mut payload_sizes = Vec::new(); + let mut pod_types: Vec = Vec::new(); + let mut needs_payload = false; + // A strong/weak variant makes `EInit` generic over `<'e, A>`; a weak variant + // also makes `EView` generic (its read upgrades to a `BStackRc`). + let mut has_shared = false; + let mut has_weak = false; + + for (i, variant) in input.variants.iter().enumerate() { + let disc = i as u8; + let vname = &variant.ident; + let kind = classify_attrs(&variant.attrs)?; + + // The child block's range recovered from a stored offset (owned / ref). + let child_from_off = |ty: &Type| { + quote! { + <#ty as ::bstack_raii::BStackBlock>::from_range(::bstack_raii::BStackRange::new( + u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::core::mem::size_of::<<#ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + )) + } + }; + + match &variant.fields { + Fields::Unit => { + if kind != Kind::Pod { + return Err(Error::new_spanned( + variant, + "a unit variant carries no data, so it cannot have an ownership annotation", + )); + } + init_variants.push(quote!(#vname,)); + view_variants.push(quote!(#vname,)); + new_arms.push(quote!(#init::#vname => (#disc, [0u8; Self::__PAYLOAD]),)); + read_arms.push(quote!(#disc => #view::#vname,)); + } + Fields::Unnamed(f) if f.unnamed.len() == 1 => { + needs_payload = true; + let ty = &f.unnamed.first().unwrap().ty; + match kind { + Kind::Pod => { + pod_types.push(ty.clone()); + payload_sizes.push(quote!(::core::mem::size_of::<#ty>())); + init_variants.push(quote!(#vname(#ty),)); + view_variants.push(quote!(#vname(#ty),)); + new_arms.push(quote! { + #init::#vname(__v) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..::core::mem::size_of::<#ty>()] + .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__v)); + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => #view::#vname( + ::bstack_raii::bytemuck::pod_read_unaligned::<#ty>( + &__pl[..::core::mem::size_of::<#ty>()], + ) + ), + }); + } + Kind::Owned => { + payload_sizes.push(quote!(8usize)); + let child = child_from_off(ty); + init_variants.push(quote!(#vname(::bstack_raii::BStackOwned<#ty>),)); + view_variants.push(quote!(#vname(#ty),)); + new_arms.push(quote! { + #init::#vname(__v) => { + let __h = __v.into_inner(); + let __off = ::bstack_raii::BStackBlock::range(&__h).start(); + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..8].copy_from_slice(&__off.to_le_bytes()); + (#disc, __pl) + } + }); + read_arms.push(quote!(#disc => #view::#vname(#child),)); + drop_arms.push(quote! { + #disc => { + let __child = unsafe { + ::bstack_raii::BStackRef::<#ty>::from_range( + ::bstack_raii::BStackRange::new( + u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::core::mem::size_of::< + <#ty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ) + }; + ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; + } + }); + } + Kind::Ref => { + payload_sizes.push(quote!(8usize)); + let child = child_from_off(ty); + init_variants.push(quote!(#vname(::bstack_raii::BStackRef<#ty>),)); + view_variants.push(quote!(#vname(#ty),)); + new_arms.push(quote! { + #init::#vname(__v) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..8].copy_from_slice(&__v.into_range().start().to_le_bytes()); + (#disc, __pl) + } + }); + read_arms.push(quote!(#disc => #view::#vname(#child),)); + // A raw reference owns nothing: no teardown. + } + Kind::Strong => { + has_shared = true; + payload_sizes.push(quote!(8usize)); + let child = child_from_off(ty); + // A strong variant stores the child's DATA offset and holds + // one strong reference (like a `#[bstack_strong]` field). + init_variants + .push(quote!(#vname(::bstack_raii::BStackRc<'__e, #ty, __A>),)); + view_variants.push(quote!(#vname(#ty),)); + new_arms.push(quote! { + #init::#vname(__v) => { + let (__data, _ctrl) = __v.into_raw(); + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..8].copy_from_slice(&__data.into_range().start().to_le_bytes()); + (#disc, __pl) + } + }); + read_arms.push(quote!(#disc => #view::#vname(#child),)); + drop_arms.push(quote! { + #disc => { + let __data = unsafe { + ::bstack_raii::BStackRef::<#ty>::from_range( + ::bstack_raii::BStackRange::new( + u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::core::mem::size_of::< + <#ty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ) + }; + <#ty as ::bstack_raii::BStackShared>::drop_strong_ref( + __data, allocator, + )?; + } + }); + } + Kind::Weak => { + has_shared = true; + has_weak = true; + payload_sizes.push(quote!(8usize)); + let ctrl_ref = quote! { + unsafe { + ::bstack_raii::BStackRef::< + <#ty as ::bstack_raii::BStackWeakable>::Control + >::from_range(::bstack_raii::BStackRange::new( + u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::core::mem::size_of::< + <#ty as ::bstack_raii::BStackWeakable>::Control + >() as u64, + )) + } + }; + // A weak variant stores the child's CONTROL offset and holds + // one weak reference (like a `#[bstack_weak]` field). + init_variants + .push(quote!(#vname(::bstack_raii::BStackWeak<'__e, #ty, __A>),)); + view_variants.push(quote! { + #vname(::core::option::Option< + ::bstack_raii::BStackRc<'__e, #ty, __A> + >), + }); + new_arms.push(quote! { + #init::#vname(__v) => { + let __ctrl = __v.into_raw(); + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..8].copy_from_slice(&__ctrl.into_range().start().to_le_bytes()); + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => { + // Borrow a weak over the stored control ref just long + // enough to upgrade; consume it via `into_raw` so the + // variant's own weak count is untouched. + let __w = unsafe { + ::bstack_raii::BStackWeak::<#ty, __A>::from_raw(#ctrl_ref, allocator) + }; + let __up = __w.upgrade()?; + let _ = __w.into_raw(); + #view::#vname(__up) + } + }); + drop_arms.push(quote! { + #disc => { + ::bstack_raii::WeakRef::<#ty>(#ctrl_ref).bstack_drop(allocator)?; + } + }); + } + } + } + _ => { + return Err(Error::new_spanned( + &variant.fields, + "#[bstack_enum] variants must be unit or single-field tuple `V(T)` \ + (struct and multi-field tuple variants are not supported)", + )); + } + } + } + + // EightCC tag: readable prefix over a hash of `crate ++ type_name` (as structs). + let type_name = name.to_string(); + let crate_name = std::env::var("CARGO_PKG_NAME").unwrap_or_default(); + let hash = fnv1a64(&format!("{crate_name}\0{type_name}")); + let prefix = attr + .tag + .as_ref() + .map_or_else(|| auto_prefix(&type_name), |t| t.bytes().collect::>()); + let tag = build_tag(hash, &prefix); + let eightcc = eightcc_expr(&tag.bytes); + + // Control-block tag (rc, weak): the data tag with its prefix lowercased, or a + // `ctrl_tag` override. + let ctrl_prefix = attr.ctrl_tag.as_ref().map_or_else( + || prefix.iter().map(u8::to_ascii_lowercase).collect::>(), + |t| t.bytes().collect::>(), + ); + let ctrl_tag = build_tag(hash, &ctrl_prefix); + let ctrl_eightcc = eightcc_expr(&ctrl_tag.bytes); + + // Refcount / control machinery, mirroring the struct rc modes: an injected + // field after the header, `BStackShared` (rc / rc,weak), and (rc, weak) a + // control block + `BStackWeakable`. `new` returns `BStackRc` for rc modes. + let injected_ondisk = match mode { + Mode::Plain => quote!(), + Mode::Rc => quote!(__bstack_refcount: u64,), + Mode::RcWeak => quote!(__bstack_ctrl: u64,), + }; + let injected_init = match mode { + Mode::Plain => quote!(), + Mode::Rc => quote!(__bstack_refcount: 1u64,), + Mode::RcWeak => quote!(__bstack_ctrl: 0u64,), + }; + let new_ret = match mode { + Mode::Plain => quote!(::bstack_raii::BStackOwned), + _ => quote!(::bstack_raii::BStackRc<'__e, Self, __A>), + }; + let new_finish = match mode { + Mode::Plain => quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__data), + ) + }) + }, + Mode::Rc => quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::None, + allocator, + ) + }) + }, + Mode::RcWeak => quote! { + let __ctrl = match ::bstack_raii::alloc_control( + allocator, + #ctrl_eightcc, + __data, + ::core::mem::size_of::<::Control>() as u64, + ) { + ::std::result::Result::Ok(__c) => __c, + ::std::result::Result::Err(__e) => { + let _ = unsafe { ::bstack_raii::dealloc_range(allocator, __data) }; + return ::std::result::Result::Err(__e); + } + }; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::Some(__ctrl), + allocator, + ) + }) + }, + }; + let shared_impl = match mode { + Mode::Plain => quote!(), + Mode::Rc => quote! { + impl ::bstack_raii::BStackShared for #name { + fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + ::bstack_raii::StrongRef(data).bstack_drop(allocator) + } + fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + _allocator: &__A, + ) -> ::std::io::Result<( + ::bstack_raii::BStackRef, + ::core::option::Option<::bstack_raii::BStackRange>, + )> { + ::std::result::Result::Ok((data, ::core::option::Option::None)) + } + } + }, + Mode::RcWeak => quote! { + impl ::bstack_raii::BStackShared for #name { + fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + ::bstack_raii::StrongWeakRef::from_disk(data, allocator)? + .bstack_drop(allocator) + } + fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + data: ::bstack_raii::BStackRef, + allocator: &__A, + ) -> ::std::io::Result<( + ::bstack_raii::BStackRef, + ::core::option::Option<::bstack_raii::BStackRange>, + )> { + let __swr = ::bstack_raii::StrongWeakRef::from_disk(data, allocator)?; + ::std::result::Result::Ok(( + __swr.0, + ::core::option::Option::Some(__swr.1.into_range()), + )) + } + } + }, + }; + let weakable_items = if mode == Mode::RcWeak { + quote! { + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #control { + __bstack_header: ::bstack_raii::BlockHeader, + __bstack_strong: u64, + __bstack_weak: u64, + __bstack_x: u64, + } + unsafe impl ::bstack_raii::Zeroable for #control {} + unsafe impl ::bstack_raii::Pod for #control {} + + impl ::bstack_raii::BStackWeakable for #name { + type Control = #control; + } + } + } else { + quote!() + }; + + let allow_deprecated = input.attrs.iter().any(is_allow_deprecated); + let ctrl_truncated = mode == Mode::RcWeak && ctrl_tag.truncated; + let overlong_warning = if (tag.truncated || ctrl_truncated) + && !(attr.allow_overlong || allow_deprecated) + { + let warn_fn = format_ident!("__bstack_tag_overlong_{}", name); + let msg = format!( + "#[bstack_enum] on `{type_name}`: a tag override longer than 8 bytes was truncated; \ + add `allow(overlong_tag)` to silence" + ); + quote! { + #[doc(hidden)] + #[allow(dead_code, non_snake_case)] + fn #warn_fn() { + #[deprecated(note = #msg)] + fn overlong_tag() {} + overlong_tag(); + } + } + } else { + quote!() + }; + + // `read` / `bstack_drop` only need the payload bytes when a variant carries one. + let read_payload = if needs_payload { + quote!(let __pl = __od.__bstack_payload;) + } else { + quote!() + }; + let drop_body = if drop_arms.is_empty() { + quote!() + } else { + quote! { + let __stack = allocator.stack(); + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let __disc = __od.__bstack_disc; + let __pl = __od.__bstack_payload; + match __disc { + #(#drop_arms)* + _ => {} + } + } + }; + + // `EInit` is generic over `<'e, A>` when a variant holds a strong/weak + // reference; `EView` only when a weak variant makes `read` upgrade. + let init_generics = if has_shared { + quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) + } else { + quote!() + }; + let init_ty = if has_shared { + quote!(#init<'__e, __A>) + } else { + quote!(#init) + }; + let view_generics = if has_weak { + quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) + } else { + quote!() + }; + let view_ty = if has_weak { + quote!(#view<'__e, __A>) + } else { + quote!(#view) + }; + + Ok(quote! { + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #name(::bstack_raii::BStackRange); + + impl #name { + /// The payload area size (bytes) — the max over all variants. + #[doc(hidden)] + pub const __PAYLOAD: usize = { + let __s = [0usize #(, #payload_sizes)*]; + let mut __m = 0usize; + let mut __i = 0usize; + while __i < __s.len() { + if __s[__i] > __m { + __m = __s[__i]; + } + __i += 1; + } + __m + }; + } + + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #on_disk { + __bstack_header: ::bstack_raii::BlockHeader, + #injected_ondisk + __bstack_disc: u8, + __bstack_payload: [u8; #name::__PAYLOAD], + } + unsafe impl ::bstack_raii::Zeroable for #on_disk {} + unsafe impl ::bstack_raii::Pod for #on_disk {} + + const _: fn() = || { + fn __assert_pod<__T: ::bstack_raii::Pod>() {} + #( __assert_pod::<#pod_types>(); )* + }; + + /// Input for [`new`](#name::new): the variant to create, with its payload. + #vis enum #init #init_generics { + #(#init_variants)* + } + + /// The result of [`read`](#name::read): the current variant, with POD + /// values by value, owned/ref children as borrowed handles, and a weak + /// variant upgraded to `Option`. + #vis enum #view #view_generics { + #(#view_variants)* + } + + impl ::bstack_raii::BStackCast for #name { + fn eightcc() -> ::bstack_raii::EightCC { + #eightcc + } + } + + impl ::bstack_raii::BStackBlock for #name { + type OnDisk = #on_disk; + fn from_range(range: ::bstack_raii::BStackRange) -> Self { + #name(range) + } + fn range(&self) -> ::bstack_raii::BStackRange { + self.0 + } + } + + impl ::bstack_raii::BStackDrop for #name { + fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + self, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + #drop_body + unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } + } + } + + impl #name { + /// Allocate a new enum block holding `init`'s variant + payload. + #vis fn new<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__e __A, + init: #init_ty, + ) -> ::std::io::Result<#new_ret> { + let (__disc, __payload): (u8, [u8; Self::__PAYLOAD]) = match init { + #(#new_arms)* + }; + let __on_disk = #on_disk { + __bstack_header: ::bstack_raii::BlockHeader { + size: ::core::mem::size_of::<#on_disk>() as u64, + tag: ::eightcc(), + }, + #injected_init + __bstack_disc: __disc, + __bstack_payload: __payload, + }; + let mut __slice = allocator.alloc(::core::mem::size_of::<#on_disk>() as u64)?; + let __data = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) + { + let _ = allocator.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + #new_finish + } + + /// Read the current variant. Takes the allocator (a weak variant's + /// read upgrades through it; other variants just read the block). + #vis fn read<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__e __A, + ) -> ::std::io::Result<#view_ty> { + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk = *__r.read_on_disk(allocator.stack(), &mut __buf)?; + let __disc = __od.__bstack_disc; + #read_payload + ::std::result::Result::Ok(match __disc { + #(#read_arms)* + _ => { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, + "bstack_enum: invalid discriminant", + )); + } + }) + } + + /// Borrow this block as an untyped slice (infallible upcast). + #vis fn as_slice<'__s>( + &self, + stack: &'__s ::bstack_raii::BStack, + ) -> ::bstack_raii::BStackSlice<'__s> { + unsafe { + ::bstack_raii::BStackSlice::from_raw_range( + stack, + ::bstack_raii::BStackBlock::range(self), + ) + } + } + } + + #shared_impl + #weakable_items + #overlong_warning + }) +} diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 6ad819b..9ee5418 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -65,6 +65,35 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { } } +/// `#[bstack_enum]` — a tagged-union block. +/// +/// Lowers an `enum` to a fixed-size block: a 1-byte discriminant plus a payload +/// area sized to the largest variant. Variants may be **unit** (no data), a +/// **POD** newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype +/// `#[bstack_owned] V(T)` / `#[bstack_ref] V(T)` (a `u64` offset to a child +/// block, freed / not freed on teardown accordingly). Only single-field tuple +/// variants are allowed (no struct or multi-field variants). +/// +/// Generates: +/// * `struct E(BStackRange)` (the handle) and its `EOnDisk` payload. +/// * `enum EInit` (construction input) and `enum EView` (read result — POD by +/// value, child blocks as borrowed handles). +/// * `impl BStackCast / BStackBlock / BStackDrop`, plus `E::new(alloc, EInit)`, +/// `E::read(stack) -> EView`, and `E::as_slice`. +/// +/// An enum is always **referenced** (it is a block; store it as a +/// `#[bstack_owned]` / `#[bstack_ref]` field of a struct — inline embedding is +/// not supported). `#[bstack_enum(rc)]` / `(rc, weak)` and +/// `#[bstack_strong]` / `#[bstack_weak]` variants are not yet implemented. +#[proc_macro_attribute] +pub fn bstack_enum(args: TokenStream, item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as syn::ItemEnum); + match block::expand_enum(args.into(), input) { + Ok(ts) => ts.into(), + Err(e) => e.to_compile_error().into(), + } +} + /// `bstack_move!(handle)` / `bstack_move!(owned, allocator)` — transfer every /// field out of a block, freeing only the parent shell. /// diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index cb1bf7a..97042e4 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -87,4 +87,4 @@ pub use bytemuck::{Pod, Zeroable}; pub use bytemuck; // Procedural macros, re-exported so downstream depends only on `bstack_raii`. -pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_move}; +pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 2e4b26b..70ba7cf 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -16,7 +16,7 @@ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBlock, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, alloc_block, - alloc_control, bstack_block, bstack_cast, bstack_move, dealloc_range, + alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -1337,3 +1337,226 @@ fn macro_option_vec() { assert!(b.handle().name(&alloc).unwrap().is_none()); b.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// #[bstack_enum] — a tagged-union block (unit / POD / owned / ref variants) +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum Node { + Empty, + Num(u32), + #[bstack_ref] + Link(MacroLeaf), + #[bstack_owned] + Child(MacroLeaf), +} + +#[test] +fn macro_enum_basic() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + // Unit variant. + let e = Node::new(&alloc, NodeInit::Empty).unwrap(); + assert!(matches!(e.handle().read(&alloc).unwrap(), NodeView::Empty)); + e.bstack_drop(&alloc).unwrap(); + + // POD variant: value stored inline, read back. + let e = Node::new(&alloc, NodeInit::Num(42)).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Num(n) => assert_eq!(n, 42), + _ => panic!("expected Num"), + } + e.bstack_drop(&alloc).unwrap(); + + // Owned variant: the enum owns the child; dropping it recursively frees it. + let leaf = MacroLeaf::new(&alloc, 7).unwrap(); + let leaf_off = leaf.handle().range().start(); + let e = Node::new(&alloc, NodeInit::Child(leaf)).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 7), + _ => panic!("expected Child"), + } + e.bstack_drop(&alloc).unwrap(); + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf_off); // child slot reclaimed => teardown recursed + unsafe { dealloc_range(&alloc, reused).unwrap() }; + + // Ref variant: references a leaf it does NOT own; dropping the enum leaves it. + let keep = MacroLeaf::new(&alloc, 9).unwrap(); + let link = unsafe { BStackRef::from_range(keep.handle().range()) }; + let e = Node::new(&alloc, NodeInit::Link(link)).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Link(l) => assert_eq!(l.val(stack).unwrap(), 9), + _ => panic!("expected Link"), + } + e.bstack_drop(&alloc).unwrap(); + assert_eq!(keep.handle().val(stack).unwrap(), 9); // still alive + keep.bstack_drop(&alloc).unwrap(); +} + +// An enum used as an owned field of a struct — enums compose as referenced blocks. +#[bstack_block] +struct EnumHolder { + #[bstack_owned] + node: Node, + tag: u32, +} + +#[test] +fn macro_enum_as_field() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + let leaf = MacroLeaf::new(&alloc, 5).unwrap(); + let leaf_off = leaf.handle().range().start(); + let node = Node::new(&alloc, NodeInit::Child(leaf)).unwrap(); + let holder = EnumHolder::new(&alloc, node, 3).unwrap(); + assert_eq!(holder.handle().tag(stack).unwrap(), 3); + + // Traverse struct -> enum -> owned child. + let node = holder.handle().node(stack).unwrap(); + match node.read(&alloc).unwrap() { + NodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 5), + _ => panic!("expected Child"), + } + + // Freeing the struct recursively frees the enum and its owned child. + holder.bstack_drop(&alloc).unwrap(); + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +// -------------------------------------------------------------------------- +// #[bstack_enum(rc)] / (rc, weak) — refcounted / weak-observable enum blocks +// -------------------------------------------------------------------------- + +#[bstack_enum(rc)] +enum RcNode { + Empty, + Val(u32), + #[bstack_owned] + Child(MacroLeaf), +} + +#[test] +fn macro_enum_rc() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + let leaf = MacroLeaf::new(&alloc, 4).unwrap(); + let leaf_off = leaf.handle().range().start(); + let rc = RcNode::new(&alloc, RcNodeInit::Child(leaf)).unwrap(); // BStackRc, strong = 1 + let rc2 = rc.try_clone().unwrap(); // strong = 2 + + match rc.handle().read(&alloc).unwrap() { + RcNodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 4), + _ => panic!("expected Child"), + } + + drop(rc); // strong = 1 — still alive + drop(rc2); // strong = 0 — frees the enum block AND its owned child + + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf_off); // child reclaimed => teardown recursed + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[bstack_enum(rc, weak)] +enum RcwNode { + Nil, + #[bstack_owned] + One(MacroLeaf), +} + +#[test] +fn macro_enum_rc_weak() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 8).unwrap(); + let rc = RcwNode::new(&alloc, RcwNodeInit::One(leaf)).unwrap(); // BStackRc, strong = 1 + let weak = rc.downgrade().unwrap(); + + match rc.handle().read(&alloc).unwrap() { + RcwNodeView::One(c) => assert_eq!(c.val(stack).unwrap(), 8), + _ => panic!("expected One"), + } + + // Upgrade succeeds while the strong owner is alive. + let up = weak.upgrade().unwrap().expect("alive"); + assert!(matches!( + up.handle().read(&alloc).unwrap(), + RcwNodeView::One(_) + )); + drop(up); + + // Last strong drop frees the data block (and its owned child); control + // survives while a weak handle remains, so upgrade now fails. + drop(rc); + assert!(weak.upgrade().unwrap().is_none()); + drop(weak); // frees the control block +} + +// -------------------------------------------------------------------------- +// #[bstack_strong] / #[bstack_weak] enum variants — a variant holding a shared +// or weak reference (MacroStrongChild is #[bstack_block(rc, weak)]). +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum Cell { + Nil, + #[bstack_strong] + Shared(MacroStrongChild), + #[bstack_weak] + Watch(MacroStrongChild), +} + +#[test] +fn macro_enum_strong_weak_variants() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Strong variant: consumes a BStackRc, the enum holds one strong reference. + let child = MacroStrongChild::new(&alloc, 11).unwrap(); // strong = 1 + let keep = child.try_clone().unwrap(); // strong = 2 (observe after the enum drops) + let cell = Cell::new(&alloc, CellInit::Shared(child)).unwrap(); // consumes child's ref + match cell.handle().read(&alloc).unwrap() { + CellView::Shared(c) => assert_eq!(c.val(stack).unwrap(), 11), + _ => panic!("expected Shared"), + } + cell.bstack_drop(&alloc).unwrap(); // releases the enum's strong ref (strong = 1) + assert_eq!(keep.handle().val(stack).unwrap(), 11); // still alive + drop(keep); // strong = 0 — freed + + // Weak variant: consumes a BStackWeak; reading upgrades it. + let owner = MacroStrongChild::new(&alloc, 22).unwrap(); // strong owner + let cell = Cell::new(&alloc, CellInit::Watch(owner.downgrade().unwrap())).unwrap(); + match cell.handle().read(&alloc).unwrap() { + CellView::Watch(Some(up)) => assert_eq!(up.handle().val(stack).unwrap(), 22), + _ => panic!("expected a live Watch"), + } + + // Drop the strong owner: the weak variant can no longer upgrade. + drop(owner); + assert!(matches!( + cell.handle().read(&alloc).unwrap(), + CellView::Watch(None) + )); + cell.bstack_drop(&alloc).unwrap(); // releases the enum's weak ref (frees control) + + // The Nil unit variant still works alongside the shared ones. + let cell = Cell::new(&alloc, CellInit::Nil).unwrap(); + assert!(matches!(cell.handle().read(&alloc).unwrap(), CellView::Nil)); + cell.bstack_drop(&alloc).unwrap(); +} From 531c2d3e299fa8e37a8eba74a27bbbeb8478909a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 21 Jul 2026 19:38:18 -0700 Subject: [PATCH 028/140] bstack_move and bstack_cast implementations for enum --- bstack_raii/README.md | 34 +++++-- bstack_raii/derive/src/block.rs | 168 +++++++++++++++++++++++--------- bstack_raii/derive/src/lib.rs | 26 ++--- bstack_raii/src/tests.rs | 135 +++++++++++++++++++++++-- 4 files changed, 288 insertions(+), 75 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index af76135..6f1d204 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -294,14 +294,16 @@ match node.handle().read(&alloc)? { // read / match the curr node.bstack_drop(&alloc)?; // frees the owned child too ``` -The macro generates the handle `Node`, plus **`NodeInit`** (construction input: -POD by value, `#[bstack_owned]` → `BStackOwned`, `#[bstack_strong]` → -`BStackRc`, `#[bstack_weak]` → `BStackWeak`, `#[bstack_ref]` → -`BStackRef`) and **`NodeView`** (the read result: POD by value, owned/ref -children as borrowed handles, a weak variant *upgraded* to `Option>`). -`read` takes the allocator (a weak variant upgrades through it). Teardown matches -the discriminant and releases the variant's reference — recursively freeing an -owned child, decrementing a strong/weak count, and nothing for a ref. +The macro generates the handle `Node` plus two companion enums. **`NodeInit`** +is the in-memory *owned* form (POD by value, `#[bstack_owned]` → `BStackOwned`, +`#[bstack_strong]` → `BStackRc`, `#[bstack_weak]` → `BStackWeak`, +`#[bstack_ref]` → `BStackRef`) — the **same** type you pass to `new` and get +back from `bstack_move!` (construction and destructuring are duals). **`NodeView`** +is the read result (POD by value, owned/ref children as borrowed handles, a weak +variant *upgraded* to `Option>`); `read` takes the allocator (a weak +variant upgrades through it). Teardown matches the discriminant and releases the +variant's reference — recursively freeing an owned child, decrementing a +strong/weak count, and nothing for a ref. Like a struct, an enum has **modes**: `#[bstack_enum]` is owned, while `#[bstack_enum(rc)]` / `#[bstack_enum(rc, weak)]` make the enum itself @@ -309,6 +311,18 @@ reference-counted / weak-observable — `new` then returns a `BStackRc` (with `try_clone` / `downgrade` / `upgrade`), and the enum can be a `#[bstack_strong]` / `#[bstack_weak]` field of a struct. +`bstack_move!` and `bstack_cast!` work on enums too. Moving destructures the +active variant, freeing the enum shell and handing the payload out through +`NodeInit` (each child moved out as an owned handle): + +```rust +match bstack_move!(node, &alloc)? { // owned enum: `bstack_move!(node, &alloc)` + NodeInit::Child(owned) => { /* owned: BStackOwned — you now own it */ } + _ => {} +} +let n: Option = bstack_cast!(slice as Node)?; // tag-checked, like a struct +``` + An enum is a block, so it is **always referenced** — store it as a field of a struct (inline embedding isn't supported). Struct and multi-field tuple variants aren't supported. @@ -511,8 +525,8 @@ no spin loop). All operations are durable and speak `std::io::Result`. - **No generic block types**, and non-`Pod` fields must carry an annotation. - **Enums** ([`#[bstack_enum]`](#enums-bstack_enum)) support unit / POD / `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` - variants in all three modes (owned / `(rc)` / `(rc, weak)`). Struct / - multi-field tuple variants, and `bstack_move!` on an enum, are not done. + variants in all three modes (owned / `(rc)` / `(rc, weak)`), plus `bstack_move!` + and `bstack_cast!`. Struct / multi-field tuple variants aren't supported. - The on-disk **ABI is not yet stable**. ## License diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index cf0d448..7630d09 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1475,7 +1475,7 @@ fn classify(field: &syn::Field) -> syn::Result { // area sized to the largest variant. Each variant is either a unit (no payload), // a POD newtype `V(P)` (bytes stored inline), or an annotated newtype // `#[bstack_owned]`/`#[bstack_ref]` `V(T)` (a `u64` offset to a child block). -// Construction goes through a generated `EInit` input enum + `E::new`; reading +// Construction goes through a generated `EData` input enum + `E::new`; reading // through a generated `EView` + `E::read`; teardown matches the discriminant and // frees the owned child, if any. @@ -1500,18 +1500,22 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = Vec::new(); let mut needs_payload = false; - // A strong/weak variant makes `EInit` generic over `<'e, A>`; a weak variant + // A strong/weak variant makes `EData` generic over `<'e, A>`; a weak variant // also makes `EView` generic (its read upgrades to a `BStackRc`). let mut has_shared = false; let mut has_weak = false; @@ -1530,6 +1534,15 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result` over the child block, recovered from a stored offset. + let child_ref = |ty: &Type| { + quote! { + ::bstack_raii::BStackRef::<#ty>::from_range(::bstack_raii::BStackRange::new( + u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::core::mem::size_of::<<#ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, + )) + } + }; match &variant.fields { Fields::Unit => { @@ -1539,10 +1552,11 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result (#disc, [0u8; Self::__PAYLOAD]),)); + new_arms.push(quote!(#data::#vname => (#disc, [0u8; Self::__PAYLOAD]),)); read_arms.push(quote!(#disc => #view::#vname,)); + move_arms.push(quote!(#disc => #data::#vname,)); } Fields::Unnamed(f) if f.unnamed.len() == 1 => { needs_payload = true; @@ -1551,31 +1565,31 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { pod_types.push(ty.clone()); payload_sizes.push(quote!(::core::mem::size_of::<#ty>())); - init_variants.push(quote!(#vname(#ty),)); + data_variants.push(quote!(#vname(#ty),)); view_variants.push(quote!(#vname(#ty),)); new_arms.push(quote! { - #init::#vname(__v) => { + #data::#vname(__v) => { let mut __pl = [0u8; Self::__PAYLOAD]; __pl[..::core::mem::size_of::<#ty>()] .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__v)); (#disc, __pl) } }); - read_arms.push(quote! { - #disc => #view::#vname( - ::bstack_raii::bytemuck::pod_read_unaligned::<#ty>( - &__pl[..::core::mem::size_of::<#ty>()], - ) - ), - }); + let read_pod = quote! { + ::bstack_raii::bytemuck::pod_read_unaligned::<#ty>( + &__pl[..::core::mem::size_of::<#ty>()], + ) + }; + read_arms.push(quote!(#disc => #view::#vname(#read_pod),)); + move_arms.push(quote!(#disc => #data::#vname(#read_pod),)); } Kind::Owned => { payload_sizes.push(quote!(8usize)); let child = child_from_off(ty); - init_variants.push(quote!(#vname(::bstack_raii::BStackOwned<#ty>),)); + data_variants.push(quote!(#vname(::bstack_raii::BStackOwned<#ty>),)); view_variants.push(quote!(#vname(#ty),)); new_arms.push(quote! { - #init::#vname(__v) => { + #data::#vname(__v) => { let __h = __v.into_inner(); let __off = ::bstack_raii::BStackBlock::range(&__h).start(); let mut __pl = [0u8; Self::__PAYLOAD]; @@ -1584,6 +1598,11 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result #view::#vname(#child),)); + move_arms.push(quote! { + #disc => #data::#vname(unsafe { + ::bstack_raii::BStackOwned::from_raw(#child) + }), + }); drop_arms.push(quote! { #disc => { let __child = unsafe { @@ -1603,16 +1622,18 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { payload_sizes.push(quote!(8usize)); let child = child_from_off(ty); - init_variants.push(quote!(#vname(::bstack_raii::BStackRef<#ty>),)); + let cref = child_ref(ty); + data_variants.push(quote!(#vname(::bstack_raii::BStackRef<#ty>),)); view_variants.push(quote!(#vname(#ty),)); new_arms.push(quote! { - #init::#vname(__v) => { + #data::#vname(__v) => { let mut __pl = [0u8; Self::__PAYLOAD]; __pl[..8].copy_from_slice(&__v.into_range().start().to_le_bytes()); (#disc, __pl) } }); read_arms.push(quote!(#disc => #view::#vname(#child),)); + move_arms.push(quote!(#disc => #data::#vname(unsafe { #cref }),)); // A raw reference owns nothing: no teardown. } Kind::Strong => { @@ -1621,11 +1642,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result),)); view_variants.push(quote!(#vname(#ty),)); new_arms.push(quote! { - #init::#vname(__v) => { + #data::#vname(__v) => { let (__data, _ctrl) = __v.into_raw(); let mut __pl = [0u8; Self::__PAYLOAD]; __pl[..8].copy_from_slice(&__data.into_range().start().to_le_bytes()); @@ -1633,18 +1655,21 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result #view::#vname(#child),)); + // Move: rebuild a `BStackRc` (transferring the strong ref) + // via `strong_parts` — exactly like a `#[bstack_strong]` field. + move_arms.push(quote! { + #disc => { + let __data = unsafe { #cref }; + let (__d, __c) = + <#ty as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + #data::#vname(unsafe { + ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) + }) + } + }); drop_arms.push(quote! { #disc => { - let __data = unsafe { - ::bstack_raii::BStackRef::<#ty>::from_range( - ::bstack_raii::BStackRange::new( - u64::from_le_bytes(__pl[..8].try_into().unwrap()), - ::core::mem::size_of::< - <#ty as ::bstack_raii::BStackBlock>::OnDisk - >() as u64, - ), - ) - }; + let __data = unsafe { #cref }; <#ty as ::bstack_raii::BStackShared>::drop_strong_ref( __data, allocator, )?; @@ -1669,7 +1694,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result),)); view_variants.push(quote! { #vname(::core::option::Option< @@ -1677,7 +1702,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result), }); new_arms.push(quote! { - #init::#vname(__v) => { + #data::#vname(__v) => { let __ctrl = __v.into_raw(); let mut __pl = [0u8; Self::__PAYLOAD]; __pl[..8].copy_from_slice(&__ctrl.into_range().start().to_le_bytes()); @@ -1697,6 +1722,13 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result #data::#vname(unsafe { + ::bstack_raii::BStackWeak::from_raw(#ctrl_ref, __alloc) + }), + }); drop_arms.push(quote! { #disc => { ::bstack_raii::WeakRef::<#ty>(#ctrl_ref).bstack_drop(allocator)?; @@ -1906,17 +1938,17 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result` when a variant holds a strong/weak + // `EData` is generic over `<'e, A>` when a variant holds a strong/weak // reference; `EView` only when a weak variant makes `read` upgrade. - let init_generics = if has_shared { + let data_generics = if has_shared { quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) } else { quote!() }; - let init_ty = if has_shared { - quote!(#init<'__e, __A>) + let data_ty = if has_shared { + quote!(#data<'__e, __A>) } else { - quote!(#init) + quote!(#data) }; let view_generics = if has_weak { quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) @@ -1928,6 +1960,20 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result) + } else { + quote!(#data) + }; + // `bstack_move!` frees the enum shell, then rebuilds the active variant's + // payload as an owned handle. + let move_payload = if needs_payload { + quote!(let __pl = __od.__bstack_payload;) + } else { + quote!() + }; Ok(quote! { #[derive(::core::clone::Clone, ::core::marker::Copy)] @@ -1966,9 +2012,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result(); )* }; - /// Input for [`new`](#name::new): the variant to create, with its payload. - #vis enum #init #init_generics { - #(#init_variants)* + /// The in-memory owned form of the enum's payload — POD by value and + /// each child/reference as an owned handle (owned → `BStackOwned`, + /// strong → `BStackRc`, weak → `BStackWeak`, ref → `BStackRef`). + /// + /// The *same* type is passed to [`new`](#name::new) to construct a variant + /// and returned by `bstack_move!` to destructure one (they are duals). + #vis enum #data #data_generics { + #(#data_variants)* } /// The result of [`read`](#name::read): the current variant, with POD @@ -2006,12 +2057,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( allocator: &'__e __A, - init: #init_ty, + data: #data_ty, ) -> ::std::io::Result<#new_ret> { - let (__disc, __payload): (u8, [u8; Self::__PAYLOAD]) = match init { + let (__disc, __payload): (u8, [u8; Self::__PAYLOAD]) = match data { #(#new_arms)* }; let __on_disk = #on_disk { @@ -2072,6 +2123,35 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = #move_fields_ty; + fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + owned: ::bstack_raii::BStackOwned, + __alloc: &'__mv __A, + ) -> ::std::io::Result> { + let __inner = owned.into_inner(); + let __range = ::bstack_raii::BStackBlock::range(&__inner); + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; + let __od: #on_disk = *__r.read_on_disk(__alloc.stack(), &mut __buf)?; + let __disc = __od.__bstack_disc; + #move_payload + let __result = match __disc { + #(#move_arms)* + _ => { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, + "bstack_enum: invalid discriminant", + )); + } + }; + // Free the enum shell only; the moved-out payload stays live. + unsafe { ::bstack_raii::dealloc_range(__alloc, __range)?; } + ::std::result::Result::Ok(__result) + } + } + #overlong_warning }) } diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 9ee5418..69a99d3 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -70,21 +70,25 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { /// Lowers an `enum` to a fixed-size block: a 1-byte discriminant plus a payload /// area sized to the largest variant. Variants may be **unit** (no data), a /// **POD** newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype -/// `#[bstack_owned] V(T)` / `#[bstack_ref] V(T)` (a `u64` offset to a child -/// block, freed / not freed on teardown accordingly). Only single-field tuple -/// variants are allowed (no struct or multi-field variants). +/// `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` +/// `V(T)` (a `u64` offset to the child / control block, released on teardown per +/// the annotation). Only single-field tuple variants are allowed (no struct or +/// multi-field variants). All three modes are supported (`#[bstack_enum]`, +/// `(rc)`, `(rc, weak)`). /// /// Generates: /// * `struct E(BStackRange)` (the handle) and its `EOnDisk` payload. -/// * `enum EInit` (construction input) and `enum EView` (read result — POD by -/// value, child blocks as borrowed handles). -/// * `impl BStackCast / BStackBlock / BStackDrop`, plus `E::new(alloc, EInit)`, -/// `E::read(stack) -> EView`, and `E::as_slice`. +/// * `enum EData` — the in-memory owned form: passed to `new` **and** returned by +/// `bstack_move!` (construction and destructuring are duals), and `enum EView` +/// — the read result (POD by value, owned/ref children as borrowed handles, a +/// weak variant upgraded to `Option`). +/// * `impl BStackCast / BStackBlock / BStackDrop / BStackMove`, plus +/// `E::new(alloc, EData)`, `E::read(alloc) -> EView`, and `E::as_slice`. +/// `bstack_move!` and `bstack_cast!` work on enums as on structs. /// -/// An enum is always **referenced** (it is a block; store it as a -/// `#[bstack_owned]` / `#[bstack_ref]` field of a struct — inline embedding is -/// not supported). `#[bstack_enum(rc)]` / `(rc, weak)` and -/// `#[bstack_strong]` / `#[bstack_weak]` variants are not yet implemented. +/// An enum is always **referenced** (it is a block; store it as a field of a +/// struct — inline embedding is not supported). Struct / multi-field tuple +/// variants are not supported. #[proc_macro_attribute] pub fn bstack_enum(args: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as syn::ItemEnum); diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 70ba7cf..5a24714 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1360,12 +1360,12 @@ fn macro_enum_basic() { let leaf_size = size_of::<::OnDisk>() as u64; // Unit variant. - let e = Node::new(&alloc, NodeInit::Empty).unwrap(); + let e = Node::new(&alloc, NodeData::Empty).unwrap(); assert!(matches!(e.handle().read(&alloc).unwrap(), NodeView::Empty)); e.bstack_drop(&alloc).unwrap(); // POD variant: value stored inline, read back. - let e = Node::new(&alloc, NodeInit::Num(42)).unwrap(); + let e = Node::new(&alloc, NodeData::Num(42)).unwrap(); match e.handle().read(&alloc).unwrap() { NodeView::Num(n) => assert_eq!(n, 42), _ => panic!("expected Num"), @@ -1375,7 +1375,7 @@ fn macro_enum_basic() { // Owned variant: the enum owns the child; dropping it recursively frees it. let leaf = MacroLeaf::new(&alloc, 7).unwrap(); let leaf_off = leaf.handle().range().start(); - let e = Node::new(&alloc, NodeInit::Child(leaf)).unwrap(); + let e = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); match e.handle().read(&alloc).unwrap() { NodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 7), _ => panic!("expected Child"), @@ -1388,7 +1388,7 @@ fn macro_enum_basic() { // Ref variant: references a leaf it does NOT own; dropping the enum leaves it. let keep = MacroLeaf::new(&alloc, 9).unwrap(); let link = unsafe { BStackRef::from_range(keep.handle().range()) }; - let e = Node::new(&alloc, NodeInit::Link(link)).unwrap(); + let e = Node::new(&alloc, NodeData::Link(link)).unwrap(); match e.handle().read(&alloc).unwrap() { NodeView::Link(l) => assert_eq!(l.val(stack).unwrap(), 9), _ => panic!("expected Link"), @@ -1415,7 +1415,7 @@ fn macro_enum_as_field() { let leaf = MacroLeaf::new(&alloc, 5).unwrap(); let leaf_off = leaf.handle().range().start(); - let node = Node::new(&alloc, NodeInit::Child(leaf)).unwrap(); + let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); let holder = EnumHolder::new(&alloc, node, 3).unwrap(); assert_eq!(holder.handle().tag(stack).unwrap(), 3); @@ -1454,7 +1454,7 @@ fn macro_enum_rc() { let leaf = MacroLeaf::new(&alloc, 4).unwrap(); let leaf_off = leaf.handle().range().start(); - let rc = RcNode::new(&alloc, RcNodeInit::Child(leaf)).unwrap(); // BStackRc, strong = 1 + let rc = RcNode::new(&alloc, RcNodeData::Child(leaf)).unwrap(); // BStackRc, strong = 1 let rc2 = rc.try_clone().unwrap(); // strong = 2 match rc.handle().read(&alloc).unwrap() { @@ -1484,7 +1484,7 @@ fn macro_enum_rc_weak() { let stack = alloc.stack(); let leaf = MacroLeaf::new(&alloc, 8).unwrap(); - let rc = RcwNode::new(&alloc, RcwNodeInit::One(leaf)).unwrap(); // BStackRc, strong = 1 + let rc = RcwNode::new(&alloc, RcwNodeData::One(leaf)).unwrap(); // BStackRc, strong = 1 let weak = rc.downgrade().unwrap(); match rc.handle().read(&alloc).unwrap() { @@ -1530,7 +1530,7 @@ fn macro_enum_strong_weak_variants() { // Strong variant: consumes a BStackRc, the enum holds one strong reference. let child = MacroStrongChild::new(&alloc, 11).unwrap(); // strong = 1 let keep = child.try_clone().unwrap(); // strong = 2 (observe after the enum drops) - let cell = Cell::new(&alloc, CellInit::Shared(child)).unwrap(); // consumes child's ref + let cell = Cell::new(&alloc, CellData::Shared(child)).unwrap(); // consumes child's ref match cell.handle().read(&alloc).unwrap() { CellView::Shared(c) => assert_eq!(c.val(stack).unwrap(), 11), _ => panic!("expected Shared"), @@ -1541,7 +1541,7 @@ fn macro_enum_strong_weak_variants() { // Weak variant: consumes a BStackWeak; reading upgrades it. let owner = MacroStrongChild::new(&alloc, 22).unwrap(); // strong owner - let cell = Cell::new(&alloc, CellInit::Watch(owner.downgrade().unwrap())).unwrap(); + let cell = Cell::new(&alloc, CellData::Watch(owner.downgrade().unwrap())).unwrap(); match cell.handle().read(&alloc).unwrap() { CellView::Watch(Some(up)) => assert_eq!(up.handle().val(stack).unwrap(), 22), _ => panic!("expected a live Watch"), @@ -1556,7 +1556,122 @@ fn macro_enum_strong_weak_variants() { cell.bstack_drop(&alloc).unwrap(); // releases the enum's weak ref (frees control) // The Nil unit variant still works alongside the shared ones. - let cell = Cell::new(&alloc, CellInit::Nil).unwrap(); + let cell = Cell::new(&alloc, CellData::Nil).unwrap(); assert!(matches!(cell.handle().read(&alloc).unwrap(), CellView::Nil)); cell.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// bstack_move! and bstack_cast! on enums +// -------------------------------------------------------------------------- + +#[test] +fn macro_enum_move() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Owned variant: the child is moved out; the enum shell is freed. + let leaf = MacroLeaf::new(&alloc, 5).unwrap(); + let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); + match bstack_move!(node, &alloc).unwrap() { + NodeData::Child(owned_leaf) => { + assert_eq!(owned_leaf.handle().val(stack).unwrap(), 5); // survived the move + owned_leaf.bstack_drop(&alloc).unwrap(); + } + _ => panic!("expected Child"), + } + + // POD / unit variants move by value. + let node = Node::new(&alloc, NodeData::Num(9)).unwrap(); + assert!(matches!( + bstack_move!(node, &alloc).unwrap(), + NodeData::Num(9) + )); + let node = Node::new(&alloc, NodeData::Empty).unwrap(); + assert!(matches!( + bstack_move!(node, &alloc).unwrap(), + NodeData::Empty + )); + + // Ref variant: the raw ref is handed out; the target is not owned. + let keep = MacroLeaf::new(&alloc, 3).unwrap(); + let link = unsafe { BStackRef::from_range(keep.handle().range()) }; + let node = Node::new(&alloc, NodeData::Link(link)).unwrap(); + match bstack_move!(node, &alloc).unwrap() { + NodeData::Link(r) => { + assert_eq!(r.into_range().start(), keep.handle().range().start()); + } + _ => panic!("expected Link"), + } + assert_eq!(keep.handle().val(stack).unwrap(), 3); // untouched + keep.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_enum_move_shared() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Strong variant: the BStackRc is moved out (transferring the strong ref). + let child = MacroStrongChild::new(&alloc, 11).unwrap(); + let keep = child.try_clone().unwrap(); + let cell = Cell::new(&alloc, CellData::Shared(child)).unwrap(); + match bstack_move!(cell, &alloc).unwrap() { + CellData::Shared(rc) => { + assert_eq!(rc.handle().val(stack).unwrap(), 11); + drop(rc); // releases the moved-out strong ref + } + _ => panic!("expected Shared"), + } + assert_eq!(keep.handle().val(stack).unwrap(), 11); // still alive + drop(keep); + + // Weak variant: the BStackWeak is moved out (transferring the weak ref). + let owner = MacroStrongChild::new(&alloc, 22).unwrap(); + let cell = Cell::new(&alloc, CellData::Watch(owner.downgrade().unwrap())).unwrap(); + match bstack_move!(cell, &alloc).unwrap() { + CellData::Watch(w) => { + assert_eq!( + w.upgrade() + .unwrap() + .expect("alive") + .handle() + .val(stack) + .unwrap(), + 22 + ); + drop(w); + } + _ => panic!("expected Watch"), + } + drop(owner); +} + +#[test] +fn macro_enum_cast() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let node = Node::new(&alloc, NodeData::Num(7)).unwrap(); + + // Borrowed downcast: slice -> enum handle (tag-checked), like a struct. + let slice = node.handle().as_slice(stack); + let n = bstack_cast!(slice as Node).unwrap().expect("tag matches"); + assert!(matches!(n.read(&alloc).unwrap(), NodeView::Num(7))); + assert!(slice.cast_as::().unwrap().is_none()); // wrong tag + + // Owned upcast then downcast round-trips through BStackOwnedSlice. + let owned_slice = bstack_cast!(node.auto(&alloc) as BStackOwnedSlice); + let back = bstack_cast!(owned_slice as BStackOwned) + .unwrap() + .ok() + .unwrap(); + assert!(matches!( + back.handle().read(&alloc).unwrap(), + NodeView::Num(7) + )); + back.bstack_drop(&alloc).unwrap(); +} From a9be9da4dfb2a6427b82f4586a9f7d593c044388 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 21 Jul 2026 20:12:03 -0700 Subject: [PATCH 029/140] Better README --- bstack_raii/README.md | 611 +++++++++++++++++++++--------------------- 1 file changed, 304 insertions(+), 307 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 6f1d204..9584c88 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -6,9 +6,10 @@ survive a process restart or crash, backed by a single [`bstack`] file. `std::rc::Rc` and `Weak` live and die with the process. `bstack_raii` gives you the same model — shared strong handles, non-owning weak handles, and automatic cleanup when the last owner drops — but the object graph *and its reference -counts* are stored on disk, crash-safely. You define blocks as ordinary structs; -the `#[bstack_block]` macro generates the on-disk layout, typed accessors, -constructors, recursive teardown, and refcounting. +counts* are stored on disk, crash-safely. You define blocks as ordinary structs +and enums; the [`#[bstack_block]`](#structs) / [`#[bstack_enum]`](#enums-bstack_enum) +macros generate the on-disk layout, typed accessors, constructors, recursive +teardown, and refcounting. It is a thin, typed layer over the mainline `bstack` allocator (`BStackRange` / `BStackSlice` / `BStackOwnedSlice`), which already provides the @@ -23,15 +24,20 @@ object model on top. - [Quick start](#quick-start) - [Concepts](#concepts) -- [Defining blocks](#defining-blocks) -- [Field ownership](#field-ownership) -- [Enums: `#[bstack_enum]`](#enums-bstack_enum) +- [How it works on disk](#how-it-works-on-disk) - [Handles & lifetimes](#handles--lifetimes) -- [Shared ownership & weak references](#shared-ownership--weak-references) -- [Moving fields out: `bstack_move!`](#moving-fields-out-bstack_move) +- [Generated types](#generated-types) +- [Blocks](#blocks) + - [Field ownership](#field-ownership) + - [Structs](#structs) + - [Reference-counted blocks](#reference-counted-blocks) + - [Vectors and strings](#vectors-and-strings) + - [Nullable fields: `Option`](#nullable-fields-option) + - [Enums: `#[bstack_enum]`](#enums-bstack_enum) + - [Field types](#field-types) +- [Moving out: `bstack_move!`](#moving-out-bstack_move) - [Casting: `bstack_cast!`](#casting-bstack_cast) - [Type tags (`EightCC`)](#type-tags-eightcc) -- [How it works on disk](#how-it-works-on-disk) - [Limitations](#limitations) ## Quick start @@ -76,18 +82,21 @@ fn main() -> io::Result<()> { let cfg = session.handle().config(stack)?; // -> a Config handle println!("v{} flags {:#b}", cfg.version(stack)?, cfg.flags(stack)?); - drop(config); // strong = 1 — the session still owns it (Rc: auto-decrement) + drop(config); // strong = 1 — the session still owns it (Rc: auto-decrement) session.bstack_drop(&alloc)?; // strong = 0 — Config freed automatically by its refcount Ok(()) } ``` -> **Owned vs. shared teardown.** A `Session` is a *uniquely owned* block, so its -> handle (`BStackOwned`) frees **nothing on `Drop`** — you free it -> explicitly with `bstack_drop`, so a persistent root is never silently deleted -> when a handle goes out of scope. A shared `Config` handle (`BStackRc`) *does* -> auto-manage its refcount on `Drop` (like `std::rc::Rc`). See -> [Handles & lifetimes](#handles--lifetimes). +Two things are load-bearing here, both covered below: + +- **Owned vs. shared teardown.** A `Session` is a uniquely-owned block, so its + handle frees **nothing on `Drop`** — you free it explicitly with `bstack_drop`, + so a persistent root is never silently deleted when a handle goes out of scope. + A shared `Config` handle (`BStackRc`) *does* auto-manage its refcount on `Drop`. + See [Handles & lifetimes](#handles--lifetimes). +- Every block also gets a stable 8-byte on-disk type tag — see + [Type tags](#type-tags-eightcc). A fuller walk-through (shared ownership, weak observers, durability across a reopen) is in [`examples/sessions.rs`](examples/sessions.rs): @@ -95,63 +104,116 @@ reopen) is in [`examples/sessions.rs`](examples/sessions.rs): ## Concepts -A **block** is a fixed-size record on disk. You write it as an ordinary struct -and annotate it with `#[bstack_block]`; the macro generates a parallel +A **block** is a fixed-size record on disk. You write it as an ordinary `struct` +(or [`enum`](#enums-bstack_enum)) and annotate it; the macro generates a parallel `#[repr(C, packed)]` on-disk layout plus all the machinery to work with it. -Three block **modes**: +Both macros take the same three **modes** (chosen by their arguments — e.g. +`#[bstack_block(rc)]` or `#[bstack_enum(rc)]`): -| Mode | Meaning | -|-----------------------------|----------------------------------------------------------| -| `#[bstack_block]` | Plain, exclusively owned (like `Box`). | -| `#[bstack_block(rc)]` | Reference-counted, inline count (like `Rc`, no `Weak`). | -| `#[bstack_block(rc, weak)]` | Refcounted **and** weak-observable (like `Rc` + `Weak`). | +| Mode | Meaning | +|--------------|----------------------------------------------------------| +| *(none)* | Plain, exclusively owned (like `Box`). | +| `(rc)` | Reference-counted, inline count (like `Rc`, no `Weak`). | +| `(rc, weak)` | Refcounted **and** weak-observable (like `Rc` + `Weak`). | -Every non-POD field carries an ownership annotation that decides how it is torn -down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, etc.) are -stored inline and copied by value. +Every non-POD field carries an [ownership annotation](#field-ownership) deciding +how it is torn down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, +…) are stored inline and copied by value. > **Requires a real allocator.** This layer needs a `bstack` allocator that > actually frees (`dealloc`) and reserves offset 0 for its own metadata — e.g. > `FirstFitBStackAllocator`, `SlabBStackAllocator`, `GhostTreeBstackAllocator`. -> **Do not use `LinearBStackAllocator`**: it's a bump allocator whose `dealloc` -> is a no-op (so teardown frees nothing), and it can hand out offset 0 (which -> would break the `Option` niche). RAII over a non-freeing allocator doesn't -> make sense anyway. -> -> For growable fields (`Vec` / `String`), prefer a **realloc-safe** allocator: -> growth reallocates the backing block, and a torn realloc under a poorly-behaved -> allocator can corrupt it. `FirstFitBStackAllocator` is realloc-safe. +> **Not `LinearBStackAllocator`**: its `dealloc` is a no-op (teardown would free +> nothing) and it can hand out offset 0 (breaking the `Option` niche). For +> growable fields, use a **realloc-safe** allocator (growth reallocates the +> backing block); `FirstFitBStackAllocator` is realloc-safe. + +## How it works on disk -## Defining blocks +Every block begins with a 16-byte `BlockHeader { size: u64, tag: EightCC }` +(the [tag](#type-tags-eightcc) is the downcast discriminant). References between +blocks are stored as bare `u64` offsets; a target's length is recovered from its +compile-time `size_of::()` — which is why blocks are **fixed-size**. + +The three modes differ only in what is injected after the header: + +- **`#[bstack_block]`** — nothing; the payload follows the header directly. +- **`(rc)`** — an inline `refcount: u64`. The block is freed when it hits zero. +- **`(rc, weak)`** — a `ctrl` back-pointer to a separate **control block** + (`XOnDiskRef`) holding `strong` / `weak` counters and a forward pointer to the + data. The data block is reclaimed when `strong` hits zero; the small control + block persists until `weak` also hits zero — exactly like `Arc` / `Weak`, so a + `Weak` can outlive the data and observe that it's gone. + +**Growable fields** (`Vec` / `String`) don't fit a fixed-size block, so they live +out of line: the field holds a 16-byte descriptor `{ data_off, data_size }` +*inline*, pointing at a separate data block that reallocates as it grows. Since +the block owns the vector uniquely, that descriptor needs no block of its own — +details in [Vectors and strings](#vectors-and-strings). + +Refcount updates are single-lock read-modify-writes on `bstack` (crash-atomic, +no spin loop). Everything is durable and speaks [`std::io::Result`]. + +## Handles & lifetimes + +The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, +`Copy`, and the thing you read fields through. Ownership wrappers layer on top: + +| Handle | Allocator | Ownership | Teardown | +|----------------------|-----------|----------------------------------|----------------------------------------------------------------------| +| `X` (the block type) | no | none (borrowed view / bare ref) | `x.bstack_drop(alloc)?` — explicit only | +| `BStackOwned` | no | exclusive (ownership marker) | `owned.bstack_drop(alloc)?` — **nothing on `Drop`** | +| `AutoDrop` | yes | RAII guard over any `BStackDrop` | runs `bstack_drop` on Rust `Drop` | +| `BStackRc` | yes | shared strong | `try_clone` / `downgrade`; **auto-decrements on `Drop`**, frees at 0 | +| `BStackWeak` | yes | none (keeps control block alive) | `try_clone` / `upgrade`; auto-decrements weak on `Drop` | +| `BStackRef` | no | none (raw offset) | none | + +**Owned is manual; shared is automatic.** A uniquely-owned `BStackOwned` +carries no allocator and frees **nothing** when it drops — so a persistent root +is never silently deleted by going out of scope. Free it explicitly, or wrap it +in an `AutoDrop` guard for RAII: ```rust -#[bstack_block] -struct Node { - #[bstack_owned] // this block exclusively owns the child - payload: Payload, - #[bstack_strong] // a shared, refcounted reference - shared: SharedThing, - #[bstack_weak] // a non-owning back-pointer (may dangle) - parent: Node, - #[bstack_ref] // a raw reference, no ownership semantics - sibling: Node, - tag: u32, // POD, stored inline -} +let owned: BStackOwned = Node::new(&alloc, /* … */)?; +let value = owned.handle().tag(stack)?; // read a field (or `owned.tag(stack)?` via Deref) + +owned.bstack_drop(&alloc)?; // free it now, explicitly … +// … or: let _guard = owned.auto(&alloc); // RAII — freed when `_guard` drops ``` -For each block the macro generates: +Shared handles (`BStackRc` / `BStackWeak`) *do* manage their counts on `Drop`, +like `std::rc`. Because duplicating one bumps an on-disk counter (fallible I/O), +cloning is the [`TryClone`] trait, not `Clone`. -- a typed handle `struct Node(BStackRange)` and its `NodeOnDisk` payload; -- a `new(...)` **constructor** that allocates and wires the block; -- **accessors** — `node.field(stack)` for each field; -- **`set_`** setters for `#[bstack_weak]` fields; -- recursive **teardown** (`BStackDrop`), casting, and (for `rc` / `rc, weak`) - the control block and refcount machinery. +## Generated types + +Each macro generates a small, fixed set of types (for a block named `X` / `E`): + +| Source | Types generated | +|---------------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `#[bstack_block] struct X` | `X` — the [handle](#handles--lifetimes); `XOnDisk` — the `#[repr(C, packed)]` on-disk payload | +| `#[bstack_block(rc, weak)] struct X` | the above, plus `XOnDiskRef` — the [control block](#how-it-works-on-disk) (`strong`/`weak` counters) | +| `#[bstack_enum] enum E` | `E`, `EOnDisk` (plus `EOnDiskRef` for `(rc, weak)`), and two companion enums — see [Enums](#enums-bstack_enum): `EData` (owned form) and `EView` (read result) | + +Alongside the types come the trait impls (`BStackBlock`, `BStackDrop`, +`BStackCast`, `BStackMove`, and for rc modes `BStackShared` / `BStackWeakable`) +and inherent methods (`new`, field accessors, `set_`, enum `read`, …). In +your code you name only the handle (`X` / `E`) and, for enums, `EData` / `EView`; +`XOnDisk` and friends are internal. -## Field ownership +## Blocks -| Annotation | Child kind required | On drop | `bstack_move!` yields | +A block is a `struct` or `enum` annotated with `#[bstack_block]` / +`#[bstack_enum]`. The rest of this section covers how fields and variants are +declared and what you can put in them. + +### Field ownership + +Every non-POD field carries exactly one annotation, which decides its teardown +and what [`bstack_move!`](#moving-out-bstack_move) yields: + +| Annotation | Child kind required | On teardown | `bstack_move!` yields | |--------------------|------------------------|------------------------------------|-------------------------| | `#[bstack_owned]` | any block | recursively frees the child | `BStackOwned` | | `#[bstack_strong]` | `(rc)` or `(rc, weak)` | decrements refcount; frees at zero | `BStackRc` | @@ -159,41 +221,75 @@ For each block the macro generates: | `#[bstack_ref]` | any block | nothing | `BStackRef` | | *(none)* — POD | `Pod` type | nothing (inline) | the value | -Ownership rules are enforced at compile time: a `#[bstack_weak]` field whose -target isn't `(rc, weak)`, or a non-`Pod` field with no annotation, is a -compile error. - -### Nullable references: `Option` +Rules are enforced at compile time: a `#[bstack_weak]` field whose target isn't +`(rc, weak)`, or a non-`Pod` field with no annotation, is a compile error. -Wrap a reference field in `Option` to make it nullable — on disk it's still a -single `u64`, with `0 == None` (no allocation ever lives at offset 0, so it's a -free niche, no tag byte): +### Structs ```rust #[bstack_block] struct Node { - #[bstack_owned] left: Option, // may be absent - #[bstack_strong] shared: Option, + #[bstack_owned] payload: Payload, // exclusively owns the child + #[bstack_strong] shared: SharedThing, // a shared, refcounted reference + #[bstack_weak] parent: Node, // a non-owning back-pointer (may dangle) + #[bstack_ref] sibling: Node, // a raw reference, no ownership + tag: u32, // POD, stored inline } ``` -The accessor then returns `io::Result>`, the constructor takes -`Option>` / `Option>`, and `bstack_move!` -yields `Option<…>`. (`#[bstack_weak]` fields are already nullable by nature.) +Besides the [generated types](#generated-types), the macro emits inherent +methods: + +- a `new(...)` **constructor** that allocates and wires the block, consuming the + child handles it takes ownership of (`#[bstack_owned]` → `BStackOwned`, + `#[bstack_strong]` → `BStackRc`, `#[bstack_ref]` → `BStackRef`, POD by + value; `#[bstack_weak]` fields are **not** parameters); +- **accessors** — `node.field(stack)` for each field; +- **`set_`** setters for `#[bstack_weak]` fields (see below); +- recursive teardown, [casting](#casting-bstack_cast), and + [moving](#moving-out-bstack_move). -### Variable-length: `Vec` and `String` +### Reference-counted blocks -A `Vec` (POD `T`) or `String` field stores a growable sequence. On disk the -field holds a fixed-size **descriptor** — `{ data_off, data_size }` stored -*inline* — pointing at the (growable, reallocating) data block. The field stays -fixed-size while the data grows and moves; because the struct uniquely owns the -vector, the descriptor needs no separate block: +Declare `#[bstack_block(rc)]` / `#[bstack_block(rc, weak)]` (the on-disk layout +is in [How it works on disk](#how-it-works-on-disk)). `new` then returns a +`BStackRc`; clone it with `try_clone`, and for `(rc, weak)` get a non-owning +observer with `downgrade` / `upgrade` (see [Handles & lifetimes](#handles--lifetimes)): + +```rust +let a = Config::new(&alloc, 1, 0)?; // BStackRc, strong = 1 +let b = a.try_clone()?; // strong = 2 +let w = a.downgrade()?; // BStackWeak +drop(a); drop(b); // strong = 0 — the data is freed +assert!(w.upgrade()?.is_none()); // the object is gone +``` + +**Weak fields** are for back-pointers and cycles, where the target doesn't exist +at construction. They start null and are wired afterward; the accessor is an +*upgrade*. The field stores the target's *control-block* offset, so dropping the +strong owner first and the holder second is sound — no use-after-free: + +```rust +let a = WNode::new(&alloc, 1)?; +let b = WNode::new(&alloc, 2)?; +b.handle().set_back(&alloc, a.downgrade()?)?; // wire b.back -> a (weak) + +if let Some(a2) = b.handle().back(&alloc)? { // accessor upgrades + println!("a still alive: {}", a2.handle().val(stack)?); +} +``` + +### Vectors and strings + +A `Vec` (POD `T`) or `String` field stores a growable sequence, backed by the +inline descriptor described in [How it works on disk](#how-it-works-on-disk). +(These are [field-type spellings](#field-types), not `std::vec::Vec` / `String`.) ```rust #[bstack_block] struct Record { - name: String, // POD vectors are un-annotated - tags: Vec, + name: String, + tags: Vec, // POD vectors are un-annotated (any Pod element type) id: u64, } @@ -203,32 +299,17 @@ tags.push(4)?; // grows; rewrites the inline desc assert_eq!(rec.handle().tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); ``` -The accessor returns a [`BStackVec`] handle (`len` / `to_vec` / `push`); the -constructor takes `&str` / `&[T]`; freeing the block frees the data (the inline -descriptor goes with the struct). A field handle rewrites the inline descriptor -when a push reallocates. - -A vector **not** resident in a field — built by `BStackVec::from_slice` or handed -out by `bstack_move!` — is *detached*: it carries its descriptor in memory and -frees only its data block on `bstack_drop`. It becomes persistent when written -into a struct field (which stamps the inline descriptor) — the general -[moved-out-values-are-unrooted](#moving-fields-out-bstack_move) rule. - -Wrapping a vector field in `Option` makes it nullable — `Option>` / -`Option` (and `Option<#[bstack_owned] Vec>`, etc.). On disk it is -the same inline descriptor with the `data_off == 0` niche as `None` (distinct -from an *empty* present vector, whose data block is at a non-zero offset). The -constructor takes `Option<&[T]>` / `Option<&str>` / `Option>`, the -accessor returns `Option<_>`, and `bstack_move!` yields `Option<_>`. +The accessor returns a [`BStackVec`] (`len` / `to_vec` / `push`); freeing the +block frees the data. A vector built by `BStackVec::from_slice` or handed out by +`bstack_move!` is *detached* — it carries its descriptor in memory and is +persistent only once written into a field (the general +[moved-out-is-unrooted](#moving-out-bstack_move) rule). -#### Vectors of blocks: `#[bstack_owned/strong/weak/ref] Vec` +When the elements are `#[bstack_block]` values, the **annotation** states the +elements' ownership (the descriptor + offset array stay owned by the struct). An +**un-annotated** `Vec` is therefore always POD (`T: Pod`): -When the elements are `#[bstack_block]` values, the field annotation states the -**elements'** ownership (the descriptor + offset array are still owned by the -struct). The vector stores each element's offset; the annotation decides what -happens to the elements on teardown — mirroring single-field annotations: - -| Field | Element handle | Accessor type | On the struct's teardown | +| Field | Element handle | Accessor | On the struct's teardown | |--------------------------------------|----------------------|--------------------------|-------------------------------------------------------| | `Vec` / `String` *(un-annotated)* | POD value (`T: Pod`) | `BStackVec` | frees the data block | | `#[bstack_owned] Vec` | `BStackOwned` | `BStackBlockVec` | recursively frees every child, then the offset array | @@ -236,43 +317,48 @@ happens to the elements on teardown — mirroring single-field annotations: | `#[bstack_weak] Vec` | `BStackWeak` | `BStackWeakVec` | releases each weak ref, then the array | | `#[bstack_ref] Vec` | `BStackRef` | `BStackRefVec` | frees the offset array only | +The constructor takes a `Vec` of the matching element handle; the accessor +returns the vector handle (`len` / `to_vec` / `get`; `BStackWeakVec` has +`upgrade(i)`; each has a `push_*`). + +To **share** a vector between two structs, wrap it in its own `#[bstack_block]` +and share *that* block with `#[bstack_strong]` / `#[bstack_ref]` — a descriptor +has a single owner. + +### Nullable fields: `Option` + +Wrap a reference or vector field in `Option` (another +[field-type spelling](#field-types)) to make it nullable. On disk it's unchanged +— a `0` offset (or a `0` vector descriptor) is `None`, since no allocation ever +lives at offset 0 (an *empty* present vector still has a non-zero data block, so +it's distinct from `None`): + ```rust #[bstack_block] -struct Tree { - #[bstack_owned] kids: Vec, // Tree owns each Leaf - label: u32, +struct Node { + #[bstack_owned] left: Option, // may be absent + #[bstack_strong] shared: Option, + labels: Option>, // nullable POD vector } - -let kids = vec![Leaf::new(&alloc, 10)?, Leaf::new(&alloc, 20)?]; -let tree = Tree::new(&alloc, kids, 7)?; // ctor takes Vec> -let v = tree.handle().kids(&alloc)?; // a BStackBlockVec -assert_eq!(v.get(1)?.unwrap().val(stack)?, 20); -tree.bstack_drop(&alloc)?; // recursively frees every child ``` -The constructor takes a `Vec` of the corresponding element handle; the accessor -returns the vector handle (`len` / `to_vec` / `get`; `BStackWeakVec` has -`upgrade(i)`; each has a `push_*`). Because the annotation *is* what marks -block elements, an **un-annotated** `Vec` is always POD and requires `T: Pod`. - -**Sharing a vector** between two structs isn't done by pointing both at the same -descriptor (a descriptor has a single owner). Instead, wrap the vector in its own -`#[bstack_block]` and share *that* block with `#[bstack_strong]` / `#[bstack_ref]`. +The accessor returns `io::Result>`, the constructor takes an `Option` +(`Option>` / `Option<&[T]>` / …), and `bstack_move!` yields +`Option<_>`. (`#[bstack_weak]` fields are already nullable.) -### Ergonomic reference coercion +`Option` *is* a Rust `enum`, but this is a niche optimization baked into the +macro — **not** a [`#[bstack_enum]`](#enums-bstack_enum) (no discriminant byte, no +`EData` / `EView`, no extra block). `Option` is the only enum that gets it; any +other sum type is a `#[bstack_enum]`. -For convenience, a field written `&T` is coerced to owned `T` (and `&str` to -`String`) with a compile warning — so a stray reference doesn't fail to compile, -but you're nudged to write the owned type. - -## Enums: `#[bstack_enum]` +### Enums: `#[bstack_enum]` A `#[bstack_enum]` lowers a Rust `enum` to a **tagged-union block**: a 1-byte discriminant plus a payload area sized to the largest variant. Each variant is **unit** (no data), a **POD** newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype whose annotation states the *variant's* relationship, exactly like a struct field — `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / -`#[bstack_ref]` (each a `u64` offset to the child / control block). +`#[bstack_ref]` (each a `u64` offset to the child / control block): ```rust #[bstack_enum] @@ -285,208 +371,140 @@ enum Node { #[bstack_weak] Watch(Thing), // a weak ref (Thing is (rc, weak)) } -let leaf = Leaf::new(&alloc, 7)?; -let node = Node::new(&alloc, NodeInit::Child(leaf))?; // construct a variant -match node.handle().read(&alloc)? { // read / match the current one +let node = Node::new(&alloc, NodeData::Child(leaf))?; // construct a variant +match node.handle().read(&alloc)? { // read / match it NodeView::Child(c) => assert_eq!(c.val(stack)?, 7), _ => {} } node.bstack_drop(&alloc)?; // frees the owned child too ``` -The macro generates the handle `Node` plus two companion enums. **`NodeInit`** -is the in-memory *owned* form (POD by value, `#[bstack_owned]` → `BStackOwned`, -`#[bstack_strong]` → `BStackRc`, `#[bstack_weak]` → `BStackWeak`, -`#[bstack_ref]` → `BStackRef`) — the **same** type you pass to `new` and get -back from `bstack_move!` (construction and destructuring are duals). **`NodeView`** -is the read result (POD by value, owned/ref children as borrowed handles, a weak -variant *upgraded* to `Option>`); `read` takes the allocator (a weak -variant upgrades through it). Teardown matches the discriminant and releases the -variant's reference — recursively freeing an owned child, decrementing a -strong/weak count, and nothing for a ref. - -Like a struct, an enum has **modes**: `#[bstack_enum]` is owned, while -`#[bstack_enum(rc)]` / `#[bstack_enum(rc, weak)]` make the enum itself -reference-counted / weak-observable — `new` then returns a `BStackRc` (with -`try_clone` / `downgrade` / `upgrade`), and the enum can be a `#[bstack_strong]` / -`#[bstack_weak]` field of a struct. - -`bstack_move!` and `bstack_cast!` work on enums too. Moving destructures the -active variant, freeing the enum shell and handing the payload out through -`NodeInit` (each child moved out as an owned handle): - -```rust -match bstack_move!(node, &alloc)? { // owned enum: `bstack_move!(node, &alloc)` - NodeInit::Child(owned) => { /* owned: BStackOwned — you now own it */ } - _ => {} -} -let n: Option = bstack_cast!(slice as Node)?; // tag-checked, like a struct -``` - -An enum is a block, so it is **always referenced** — store it as a field of a -struct (inline embedding isn't supported). Struct and multi-field tuple variants -aren't supported. - -## Handles & lifetimes - -The typed handle `X` is a bare `(offset, len)` with no allocator — cheap, -`Copy`, and the thing you read fields through. Ownership wrappers layer on top: - -| Handle | Allocator | Ownership | Teardown | -|----------------------|-----------|----------------------------------|----------------------------------------------------------------------| -| `X` (the block type) | no | none (borrowed view / bare ref) | `x.bstack_drop(alloc)?` — explicit only | -| `BStackOwned` | no | exclusive (ownership marker) | `owned.bstack_drop(alloc)?` — **nothing on `Drop`** | -| `AutoDrop` | yes | RAII guard over any `BStackDrop` | runs `bstack_drop` on Rust `Drop` | -| `BStackRc` | yes | shared strong | `try_clone` / `downgrade`; **auto-decrements on `Drop`**, frees at 0 | -| `BStackWeak` | yes | none (keeps control block alive) | `try_clone` / `upgrade`; auto-decrements weak on `Drop` | -| `BStackRef` | no | none (raw offset) | none | - -**Owned is manual; shared is automatic.** A uniquely-owned `BStackOwned` -carries no allocator and frees **nothing** when its handle drops — so a -persistent root is never silently deleted by going out of scope. You free it -explicitly, or wrap it in an `AutoDrop` guard for RAII: - -```rust -let owned: BStackOwned = Node::new(&alloc, /* … */)?; -let value = owned.handle().tag(stack)?; // read a field (or `owned.tag(stack)?` via Deref) - -owned.bstack_drop(&alloc)?; // free it now, explicitly … -// … or: let _guard = owned.auto(&alloc); // RAII — freed when `_guard` drops -``` - -Shared handles (`BStackRc` / `BStackWeak`) *do* manage their reference counts -automatically on `Drop`, exactly like `std::rc::Rc` / `Weak`. - -Construction (`new`) consumes the children it takes ownership of: +The two [companion enums](#generated-types) are duals of each other's directions: -- `#[bstack_owned] p: P` → parameter `p: BStackOwned

` -- `#[bstack_strong] s: S` → parameter `s: BStackRc` -- `#[bstack_ref] r: R` → parameter `r: BStackRef` -- POD `t: u32` → parameter `t: u32` -- `#[bstack_weak]` → **not** a parameter; starts null, wired later with - `set_` (see below). +- **`NodeData`** — the in-memory *owned* form (POD by value; `#[bstack_owned]` → + `BStackOwned`, `#[bstack_strong]` → `BStackRc`, `#[bstack_weak]` → + `BStackWeak`, `#[bstack_ref]` → `BStackRef`). The **same** type is passed + to `new` and returned by [`bstack_move!`](#moving-out-bstack_move). +- **`NodeView`** — the read result: POD by value, owned/ref children as borrowed + handles, a weak variant *upgraded* to `Option>`. `read` takes the + allocator (a weak variant upgrades through it). -## Shared ownership & weak references - -`BStackRc` is the on-disk `Rc`. Because duplicating it must atomically bump an -on-disk counter (which can fail with I/O), cloning is the fallible -[`TryClone`] trait rather than `Clone`: +Like a struct, an enum has [modes](#concepts): `#[bstack_enum(rc)]` / +`(rc, weak)` make the enum itself refcounted / weak-observable (`new` returns +`BStackRc`), and such an enum can be a `#[bstack_strong]` / `#[bstack_weak]` +field of a struct. [`bstack_move!`](#moving-out-bstack_move) and +[`bstack_cast!`](#casting-bstack_cast) work as on structs — moving frees the enum +shell and hands the active variant out through `NodeData`: ```rust -let a = Config::new(&alloc, 1, 0)?; // BStackRc, strong = 1 -let b = a.try_clone()?; // strong = 2 -let w = a.downgrade()?; // BStackWeak, weak observer -drop(a); drop(b); // strong = 0 — Config's data is freed -assert!(w.upgrade()?.is_none()); // upgrade fails: the object is gone +match bstack_move!(node, &alloc)? { + NodeData::Child(owned) => { /* owned: BStackOwned — you now own it */ } + _ => {} +} ``` -`BStackWeak` never keeps the data alive, only the small control block, so -`upgrade()` is a sound liveness check (atomic CAS on the strong count). +An enum is a block, so it is **always referenced** — store it as a struct field +(inline embedding isn't supported). Struct and multi-field tuple variants aren't +supported. -**Weak fields** are for back-pointers and cycles, where you can't supply the -target at construction. They start null and are wired afterward; the accessor is -an *upgrade*: +### Field types -```rust -#[bstack_block(rc, weak)] -struct WNode { - #[bstack_weak] - back: WNode, - val: u32, -} +`Vec`, `String`, and `Option<…>` in a field are **recognized spellings**, not +the `std` types — the macro lowers each to a bstack_raii on-disk form (a growable +[vector descriptor](#vectors-and-strings), a nullable offset, …). Nothing on disk +is ever an actual `std::vec::Vec` / `String` / `Option`; they're borrowed as +familiar names for convenience. -let a = WNode::new(&alloc, 1)?; // BStackRc -let b = WNode::new(&alloc, 2)?; -b.handle().set_back(&alloc, a.downgrade()?)?; // wire b.back -> a (weak) +In the same spirit, a field written `&T` is coerced to owned `T` (and `&str` to +`String`) with a compile warning — a stray reference doesn't fail to compile, but +you're nudged to write the owned type. Silence it with +`#[bstack_block(allow(coerced_ref))]`. -if let Some(a2) = b.handle().back(&alloc)? { // upgrade the weak field - println!("a is still alive: {}", a2.handle().val(stack)?); -} -``` - -A weak field stores its target's *control-block* offset, so dropping the strong -owner first and then the holder is sound — no use-after-free of freed data. - -## Moving fields out: `bstack_move!` +## Moving out: `bstack_move!` -`bstack_move!` destructures a handle into its fields, transferring ownership of -each out as a tuple and freeing only the parent *shell* — the children stay live -on disk, now owned independently. +`bstack_move!` destructures a handle, transferring each field/variant out and +freeing only the parent *shell* — the children stay live on disk, now owned +independently. -On a **`BStackOwned`** it is infallible (a unique owner). Because a bare -owned handle carries no allocator, pass one — `bstack_move!(owned, &alloc)` -(symmetric with `owned.bstack_drop(&alloc)`): +On a **`BStackOwned`** it is infallible. Because a bare owned handle carries +no allocator, pass one — `bstack_move!(owned, &alloc)` (symmetric with +`owned.bstack_drop(&alloc)`): ```rust -#[bstack_block] -struct Pair { - #[bstack_owned] left: Leaf, - #[bstack_strong] shared: Thing, - right: u32, -} - let pair: BStackOwned = /* … */; let (left, shared, right) = bstack_move!(pair, &alloc)?; // ^BStackOwned ^BStackRc ^u32 ``` -On a **`BStackRc`** (an `(rc)` or `(rc, weak)` block) it is a `try_unwrap`: it -succeeds only when this handle is the **sole strong owner** (an atomic -`strong: 1 → 0`), otherwise it hands the handle back. A weak observer does *not* -block the move — afterward its `upgrade()` just returns `None`. - -An allocator-carrying handle — a `BStackRc`, or a `BStackOwned` wrapped as -`owned.auto(&alloc)` — takes the single-argument form (the allocator rides along): +On a **`BStackRc`** (an `(rc)` / `(rc, weak)` block) it is a `try_unwrap`: +success only when this is the **sole strong owner** (atomic `strong: 1 → 0`), +else it hands the handle back. A weak observer doesn't block it. An +allocator-carrying handle — a `BStackRc`, or a `BStackOwned` wrapped as +`owned.auto(&alloc)` — takes the single-argument form: ```rust -let rc: BStackRc = /* … */; match bstack_move!(rc)? { Ok((left, shared, right)) => { /* we were the only owner */ } Err(rc) => { /* someone else still holds it */ } } ``` -> **Moved-out values are unrooted.** A handle produced by `bstack_move!` — like -> one from `X::new` — is detached from any persistent structure. Its block still -> lives on disk, but it is reachable *only* through your in-memory handle: if the -> program ends without re-attaching it (storing it into another block's field) or -> freeing it (`bstack_drop`), it becomes unreachable garbage. Persistence comes -> from being reachable through a struct, not from having been moved out. +An [enum](#enums-bstack_enum) moves out through its `EData` companion instead of +a tuple. + +> **Moved-out values are unrooted.** A handle from `bstack_move!` — like one from +> `X::new` — is detached from any persistent structure. Its block still lives on +> disk, but it is reachable *only* through your in-memory handle: drop it without +> re-attaching it (into another block's field) or freeing it and it becomes +> unreachable garbage. Persistence comes from being reachable through a struct. ## Casting: `bstack_cast!` Convert between typed handles and the untyped `bstack` primitives. Upcasts are -infallible; downcasts check the block's tag. Because a function-like macro can't -read a `let x: T = …` annotation, the target is given explicitly with `as`: +infallible; downcasts check the block's [tag](#type-tags-eightcc). Because a +function-like macro can't read a `let x: T = …` annotation, the target is given +explicitly with `as`: ```rust use bstack_raii::{BStackCastAs, BStackCastInto}; // the cast methods let owned: BStackOwned = /* … */; +let slice = bstack_cast!(owned.auto(&alloc) as BStackOwnedSlice); // owned upcast -// Upcast needs an allocator, so wrap the bare owned handle first (`auto`): -let slice = bstack_cast!(owned.auto(&alloc) as BStackOwnedSlice); // infallible - -match bstack_cast!(slice as BStackOwned)? { // owned downcast (bare handle) - Ok(node) => { /* tag matched */ } +match bstack_cast!(slice as BStackOwned)? { // owned downcast + Ok(node) => { /* tag matched */ } Err(slice) => { /* tag mismatch — slice handed back */ } } -let view = node.handle().as_slice(stack); // borrowed upcast -let maybe: Option = bstack_cast!(view as Node)?; // borrowed downcast +let view = node.handle().as_slice(stack); // borrowed upcast +let maybe: Option = bstack_cast!(view as Node)?; // borrowed downcast ``` The equivalent methods (`into_slice`, `cast_into::`, `cast_as::`, -`as_slice`) can also be called directly. +`as_slice`) can also be called directly. Casting works the same for enums. ## Type tags (`EightCC`) -Each block gets an 8-byte tag used as the downcast discriminant. It is a -**readable prefix** over a **hash tail**: camel-case initials (or a de-voweled -single word), followed by the high-bit-set tail of a 64-bit hash of the crate + -type name — so distinct types stay distinct even when their prefixes collide, and -the tag is deterministic and stable across builds. Override it if you want a -documented, fixed on-disk tag: +Each block's header carries an 8-byte tag — the discriminant a +[downcast](#casting-bstack_cast) checks. It's computed at compile time, not +random, so it's worth knowing how the 8 bytes are laid out: a short **readable +prefix** followed by a **hash tail**. + +1. **Prefix** — derived from the type name. For a multi-word camel-case name, the + uppercased word initials (`OrderLine` → `OL`); for a single word, its + de-voweled uppercase (`Session` → `SSSN`, clamped). It's 2–5 bytes. +2. **Hash** — a 64-bit **FNV-1a** hash of `crate_name ++ "\0" ++ type_name`, + little-endian. Every byte then has its **high bit set** (`| 0x80`), pushing it + into the non-printable range so it can't be mistaken for prefix text. +3. **Overlay** — the prefix bytes overwrite the low bytes of the hash from the + front; the remaining high bytes are the hash tail. + +So a hex dump reads as a recognizable prefix followed by clearly-not-a-name +bytes (every hash byte ≥ `0x80`), e.g. `O L 8B C2 A9 F0 BD 91`. The hash keeps distinct types apart even +when their prefixes collide, and — being pure and deterministic — the tag is +stable across builds and versions, safe to treat as on-disk ABI. Override the +prefix for a documented, fixed tag (0–8 bytes; fewer than 8 leaves room for the +hash, exactly 8 is fully manual): ```rust #[bstack_block(rc, tag = "ORDLINE")] // explicit data tag @@ -495,38 +513,20 @@ struct OrderLine { /* … */ } `ctrl_tag = "…"` overrides the control-block tag (default: the data tag, lowercased). An override longer than 8 bytes is truncated with a compile warning; -`#[bstack_block(allow(overlong_tag))]` silences it (as does the reference-coercion -warning's `allow(coerced_ref)`, or a real `#[allow(deprecated)]` on the struct). - -## How it works on disk - -Every block begins with a 16-byte `BlockHeader { size: u64, tag: EightCC }`. -References between blocks are stored as `u64` offsets; a target's length is -recovered from its compile-time `size_of::()`. - -- **`(rc)`** injects an inline `refcount` after the header. -- **`(rc, weak)`** splits into a *data block* (with a back-pointer to its control - block) and a separate *control block* holding `strong` / `weak` counters and a - forward pointer. The data block is reclaimed when `strong` hits zero; the small - control block persists until `weak` also hits zero — exactly like `Arc`/`Weak`. - -Refcount updates are single-lock read-modify-writes on `bstack` (crash-atomic, -no spin loop). All operations are durable and speak `std::io::Result`. +`#[bstack_block(allow(overlong_tag))]` silences it (as does `allow(coerced_ref)` +for the coercion warning, or a real `#[allow(deprecated)]` on the item). ## Limitations -- **Fixed-size block payloads.** A block's `OnDisk` struct is fixed-size — no - *inline* variable-length arrays or slices. Growable data lives out-of-line via - an inline descriptor: `Vec` / `String` (POD), - `#[bstack_owned/strong/weak/ref] Vec` (block elements), and their - `Option<…>` (nullable) forms are all supported. +- **Fixed-size block payloads** — no *inline* variable-length arrays. Growable + data lives out-of-line via an inline descriptor: `Vec` / `String`, + `#[bstack_owned/strong/weak/ref] Vec`, and their `Option<…>` forms. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). -- **No generic block types**, and non-`Pod` fields must carry an annotation. -- **Enums** ([`#[bstack_enum]`](#enums-bstack_enum)) support unit / POD / - `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` - variants in all three modes (owned / `(rc)` / `(rc, weak)`), plus `bstack_move!` - and `bstack_cast!`. Struct / multi-field tuple variants aren't supported. +- **No generic block types**; non-`Pod` fields must carry an annotation. +- **Enums** support unit / POD / all four annotated variant kinds in all three + modes, plus `bstack_move!` / `bstack_cast!`; struct and multi-field tuple + variants aren't supported. - The on-disk **ABI is not yet stable**. ## License @@ -534,9 +534,6 @@ no spin loop). All operations are durable and speak `std::io::Result`. MIT (same as `bstack`). [`bstack`]: https://github.com/williamwutq/bstack +[`std::io::Result`]: https://doc.rust-lang.org/std/io/type.Result.html [`TryClone`]: src/clone.rs [`BStackVec`]: src/vec.rs -[`BStackBlockVec`]: src/vec.rs -[`BStackStrongVec`]: src/vec.rs -[`BStackWeakVec`]: src/vec.rs -[`BStackRefVec`]: src/vec.rs From 3b89c1a5837e24247a7aaf95bb9e94ebe963c3f0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 21 Jul 2026 20:39:38 -0700 Subject: [PATCH 030/140] Allow explicit repr and tagging for enums --- bstack_raii/README.md | 22 +++- bstack_raii/derive/src/block.rs | 172 +++++++++++++++++++++++++++++--- bstack_raii/derive/src/lib.rs | 18 +++- bstack_raii/src/tests.rs | 134 +++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 18 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 9584c88..4e5aeef 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -353,7 +353,7 @@ other sum type is a `#[bstack_enum]`. ### Enums: `#[bstack_enum]` -A `#[bstack_enum]` lowers a Rust `enum` to a **tagged-union block**: a 1-byte +A `#[bstack_enum]` lowers a Rust `enum` to a **tagged-union block**: a discriminant plus a payload area sized to the largest variant. Each variant is **unit** (no data), a **POD** newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype whose annotation states the *variant's* relationship, exactly @@ -407,6 +407,24 @@ An enum is a block, so it is **always referenced** — store it as a struct fiel (inline embedding isn't supported). Struct and multi-field tuple variants aren't supported. +#### Discriminant width + +The discriminant defaults to the **smallest integer** that fits every variant's +value — honoring explicit `= value` discriminants (Rust's rules: explicit, else +previous + 1), and choosing a **signed** type if any value is negative. So a +plain enum is a `u8`; `enum S { Ok = 200, NotFound = 404 }` widens to `u16`; +`enum T { Freezing = -40, .. }` becomes `i8`. + +Pin it with `repr(..)` — `#[bstack_enum(repr(u16))]` (any of +`u8|u16|u32|u64|i8|i16|i32|i64`; `usize`/`isize` are rejected, since bstack +offsets are 64-bit). `repr(aligned)` is `repr(u64)`: the 8-byte discriminant +leaves the payload **8-aligned**, so a variant's on-disk `u64` ref gets aligned +(single-I/O) writes. + +Enums take the same tag controls as structs: `tag = "…"`, `ctrl_tag = "…"` (for +`(rc, weak)`), and `allow(overlong_tag)` — e.g. +`#[bstack_enum(repr(u64), rc, weak, tag = "NODE")]`. + ### Field types `Vec`, `String`, and `Option<…>` in a field are **recognized spellings**, not @@ -516,6 +534,8 @@ lowercased). An override longer than 8 bytes is truncated with a compile warning `#[bstack_block(allow(overlong_tag))]` silences it (as does `allow(coerced_ref)` for the coercion warning, or a real `#[allow(deprecated)]` on the item). +This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG")] enum Mode { Unit, Val(u32) }`. + ## Limitations - **Fixed-size block payloads** — no *inline* variable-length arrays. Growable diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 7630d09..d46d0bb 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -45,6 +45,13 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let attr = parse_attr(attr)?; let mode = attr.mode; + if attr.repr.is_some() { + return Err(Error::new( + Span::call_site(), + "`repr(..)` selects an enum discriminant width; it is only for #[bstack_enum]", + )); + } + if !input.generics.params.is_empty() { return Err(Error::new_spanned( &input.generics, @@ -1205,7 +1212,7 @@ fn weak_drop_stmt(fname: &Ident, inner_ty: &Type) -> TokenStream { } } -/// Parsed `#[bstack_block(...)]` arguments. +/// Parsed `#[bstack_block(...)]` / `#[bstack_enum(...)]` arguments. struct Attr { mode: Mode, /// Explicit data-block tag prefix (`tag = "..."`). @@ -1216,16 +1223,21 @@ struct Attr { allow_overlong: bool, /// Suppress the reference-coercion warning (`allow(coerced_ref)`). allow_coerced_ref: bool, + /// `#[bstack_enum(repr(..))]`: the discriminant integer type name (e.g. + /// `"u16"`), with `aligned` normalized to `"u64"`. Enum-only. + repr: Option, } -/// Parse `rc`, `weak`, `tag = "..."`, `ctrl_tag = "..."`, and -/// `allow(overlong_tag | coerced_ref | deprecated)` in any order. +/// Parse `rc`, `weak`, `tag = "..."`, `ctrl_tag = "..."`, +/// `allow(overlong_tag | coerced_ref | deprecated)`, and (enums) +/// `repr(u8|u16|u32|u64|i8|i16|i32|i64|aligned)` in any order. fn parse_attr(attr: TokenStream) -> syn::Result { let (mut rc, mut weak) = (false, false); let mut tag = None; let mut ctrl_tag = None; let mut allow_overlong = false; let mut allow_coerced_ref = false; + let mut repr = None; if !attr.is_empty() { let metas = Punctuated::::parse_terminated.parse2(attr)?; @@ -1251,6 +1263,36 @@ fn parse_attr(attr: TokenStream) -> syn::Result { _ => return Err(Error::new_spanned(&meta, unknown_opt())), } } + // `repr(u16)` / `repr(aligned)` — the enum discriminant width. + Meta::List(list) if list.path.is_ident("repr") => { + let r: Ident = list.parse_args().map_err(|_| { + Error::new_spanned( + list, + "expected `repr(u8|u16|u32|u64|i8|i16|i32|i64|aligned)`", + ) + })?; + repr = Some(match r.to_string().as_str() { + // `aligned` == `u64`: an 8-byte discriminant leaves the + // payload 8-aligned, so its on-disk refs get aligned writes. + "aligned" => "u64".to_string(), + name @ ("u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64") => { + name.to_string() + } + "usize" | "isize" => { + return Err(Error::new_spanned( + &r, + "`repr(usize)` / `repr(isize)` are not allowed — bstack offsets \ + are 64-bit, so pick an explicit width (e.g. `repr(u64)`)", + )); + } + _ => { + return Err(Error::new_spanned( + &r, + "expected `u8|u16|u32|u64|i8|i16|i32|i64|aligned`", + )); + } + }); + } // `allow(overlong_tag, coerced_ref, deprecated)` — suppress warnings. Meta::List(list) if list.path.is_ident("allow") => { let lints = @@ -1296,6 +1338,7 @@ fn parse_attr(attr: TokenStream) -> syn::Result { ctrl_tag, allow_overlong, allow_coerced_ref, + repr, }) } @@ -1312,7 +1355,7 @@ fn is_allow_deprecated(attr: &syn::Attribute) -> bool { } fn unknown_opt() -> &'static str { - "expected `rc`, `weak`, `tag = \"...\"`, `ctrl_tag = \"...\"`, or `allow(...)`" + "expected `rc`, `weak`, `tag = \"...\"`, `ctrl_tag = \"...\"`, `allow(...)`, or (enums) `repr(...)`" } // --------------------------------------------------------------------------- @@ -1479,7 +1522,67 @@ fn classify(field: &syn::Field) -> syn::Result { // through a generated `EView` + `E::read`; teardown matches the discriminant and // frees the owned child, if any. -/// Implementation of the `#[bstack_enum]` attribute macro (plain/owned mode). +/// Parse a variant's explicit discriminant expression (`= ` / `= -`) +/// into an `i128`. +fn parse_disc_expr(expr: &Expr) -> syn::Result { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Int(li), .. + }) => li.base10_parse::(), + Expr::Unary(syn::ExprUnary { + op: syn::UnOp::Neg(_), + expr, + .. + }) => match &**expr { + Expr::Lit(ExprLit { + lit: Lit::Int(li), .. + }) => Ok(-li.base10_parse::()?), + other => Err(Error::new_spanned( + other, + "expected an integer literal discriminant", + )), + }, + other => Err(Error::new_spanned( + other, + "expected an integer literal discriminant", + )), + } +} + +/// The `[min, max]` bounds of an integer type name, as `i128`. +fn int_bounds(ty: &str) -> (i128, i128) { + match ty { + "u8" => (0, u8::MAX as i128), + "u16" => (0, u16::MAX as i128), + "u32" => (0, u32::MAX as i128), + "u64" => (0, u64::MAX as i128), + "i8" => (i8::MIN as i128, i8::MAX as i128), + "i16" => (i16::MIN as i128, i16::MAX as i128), + "i32" => (i32::MIN as i128, i32::MAX as i128), + "i64" => (i64::MIN as i128, i64::MAX as i128), + _ => unreachable!("discriminant repr is validated in parse_attr"), + } +} + +/// The smallest integer type name holding every value in `[min, max]` — signed +/// iff a value is negative. This is the inferred discriminant width when no +/// explicit `repr(..)` is given. +fn infer_disc_ty(min: i128, max: i128) -> &'static str { + let candidates: [&str; 4] = if min < 0 { + ["i8", "i16", "i32", "i64"] + } else { + ["u8", "u16", "u32", "u64"] + }; + for ty in candidates { + let (lo, hi) = int_bounds(ty); + if min >= lo && max <= hi { + return ty; + } + } + candidates[3] // i64 / u64 — the widest +} + +/// Implementation of the `#[bstack_enum]` attribute macro. pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let attr = parse_attr(attr)?; let mode = attr.mode; @@ -1489,12 +1592,53 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result 256 { - return Err(Error::new_spanned( - &input.variants, - "#[bstack_enum] supports at most 256 variants (1-byte discriminant)", - )); - } + + // The on-disk discriminant. Each variant's value follows Rust's rules + // (explicit `= N`, else previous + 1); the width is an explicit `repr(..)` + // or, absent that, the smallest integer type that fits every value. + let disc_values: Vec = { + let mut next: i128 = 0; + let mut out = Vec::with_capacity(input.variants.len()); + for v in &input.variants { + let d = match &v.discriminant { + Some((_, expr)) => parse_disc_expr(expr)?, + None => next, + }; + out.push(d); + next = d.checked_add(1).ok_or_else(|| { + Error::new_spanned(v, "#[bstack_enum] discriminant overflow") + })?; + } + out + }; + let dmin = disc_values.iter().copied().min().unwrap_or(0); + let dmax = disc_values.iter().copied().max().unwrap_or(0); + let disc_ty_name: String = match &attr.repr { + Some(r) => { + let (lo, hi) = int_bounds(r); + if dmin < lo || dmax > hi { + return Err(Error::new_spanned( + &input.variants, + format!( + "a discriminant value is out of range for `repr({r})` \ + (values span {dmin}..={dmax})" + ), + )); + } + r.clone() + } + None => infer_disc_ty(dmin, dmax).to_string(), + }; + let disc_ty: TokenStream = disc_ty_name.parse().expect("valid integer type name"); + // Typed literal patterns for the match arms / stored value (e.g. `300u16`). + let disc_pats: Vec = disc_values + .iter() + .map(|v| { + format!("{v}{disc_ty_name}") + .parse() + .expect("valid integer literal") + }) + .collect(); let name = &input.ident; let vis = &input.vis; @@ -1521,7 +1665,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result syn::Result ::std::io::Result<#new_ret> { - let (__disc, __payload): (u8, [u8; Self::__PAYLOAD]) = match data { + let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { #(#new_arms)* }; let __on_disk = #on_disk { diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index 69a99d3..d978b6c 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -67,15 +67,27 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { /// `#[bstack_enum]` — a tagged-union block. /// -/// Lowers an `enum` to a fixed-size block: a 1-byte discriminant plus a payload -/// area sized to the largest variant. Variants may be **unit** (no data), a -/// **POD** newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype +/// Lowers an `enum` to a fixed-size block: a discriminant plus a payload area +/// sized to the largest variant. Variants may be **unit** (no data), a **POD** +/// newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype /// `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` /// `V(T)` (a `u64` offset to the child / control block, released on teardown per /// the annotation). Only single-field tuple variants are allowed (no struct or /// multi-field variants). All three modes are supported (`#[bstack_enum]`, /// `(rc)`, `(rc, weak)`). /// +/// # Arguments +/// +/// Accepts the same `rc` / `weak` / `tag = "…"` / `ctrl_tag = "…"` / +/// `allow(overlong_tag)` as [`macro@bstack_block`], plus **`repr(..)`** to fix +/// the discriminant width: `repr(u8|u16|u32|u64|i8|i16|i32|i64)`, or +/// `repr(aligned)` (== `repr(u64)`, so the 8-byte discriminant leaves the payload +/// 8-aligned and its on-disk refs get aligned writes). `usize` / `isize` are +/// rejected (bstack offsets are 64-bit). Without `repr`, the width is **inferred** +/// as the smallest integer type holding every variant's discriminant — honoring +/// explicit `= value` discriminants (Rust's rules: explicit, else previous + 1) +/// and choosing a **signed** type if any value is negative. +/// /// Generates: /// * `struct E(BStackRange)` (the handle) and its `EOnDisk` payload. /// * `enum EData` — the in-memory owned form: passed to `new` **and** returned by diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 5a24714..13f8ce9 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1675,3 +1675,137 @@ fn macro_enum_cast() { )); back.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// #[bstack_enum] discriminant width — repr(..) + inference from values +// -------------------------------------------------------------------------- + +// repr(u64) (== `repr(aligned)`): an 8-byte discriminant leaves the payload +// 8-aligned. header(16) + disc(8) + payload(8) = 32. +#[bstack_enum(repr(u64))] +enum Aligned { + X(u32), + #[bstack_owned] + Y(MacroLeaf), +} + +#[test] +fn macro_enum_repr_aligned() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + assert_eq!(size_of::<::OnDisk>(), 32); + + let e = Aligned::new(&alloc, AlignedData::X(77)).unwrap(); + match e.handle().read(&alloc).unwrap() { + AlignedView::X(n) => assert_eq!(n, 77), + _ => panic!("expected X"), + } + e.bstack_drop(&alloc).unwrap(); + + let leaf = MacroLeaf::new(&alloc, 3).unwrap(); + let e = Aligned::new(&alloc, AlignedData::Y(leaf)).unwrap(); + match e.handle().read(&alloc).unwrap() { + AlignedView::Y(c) => assert_eq!(c.val(stack).unwrap(), 3), + _ => panic!("expected Y"), + } + e.bstack_drop(&alloc).unwrap(); +} + +// Explicit values wider than a byte force a `u16` discriminant (a `u8` literal +// `404` would be a compile error, so compiling here proves inference widened). +#[bstack_enum] +enum Status { + Ok = 200, + NotFound = 404, + Error = 500, +} + +// A negative value forces a *signed* discriminant. +#[bstack_enum] +enum Temp { + Freezing = -40, + Zero = 0, + Boiling = 100, +} + +#[test] +fn macro_enum_discriminant_inference() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + // header(16) + disc(u16 = 2) + payload(0) = 18. (The value 404 would not fit a + // `u8` discriminant, so compiling at all proves inference widened to u16.) + assert_eq!(size_of::<::OnDisk>(), 18); + + let e = Status::new(&alloc, StatusData::Ok).unwrap(); + assert!(matches!(e.handle().read(&alloc).unwrap(), StatusView::Ok)); + e.bstack_drop(&alloc).unwrap(); + let e = Status::new(&alloc, StatusData::NotFound).unwrap(); + assert!(matches!( + e.handle().read(&alloc).unwrap(), + StatusView::NotFound + )); + e.bstack_drop(&alloc).unwrap(); + let e = Status::new(&alloc, StatusData::Error).unwrap(); + assert!(matches!( + e.handle().read(&alloc).unwrap(), + StatusView::Error + )); + e.bstack_drop(&alloc).unwrap(); + + // Signed: header(16) + disc(i8 = 1) + payload(0) = 17. + assert_eq!(size_of::<::OnDisk>(), 17); + let e = Temp::new(&alloc, TempData::Freezing).unwrap(); + assert!(matches!( + e.handle().read(&alloc).unwrap(), + TempView::Freezing + )); + e.bstack_drop(&alloc).unwrap(); + let e = Temp::new(&alloc, TempData::Boiling).unwrap(); + assert!(matches!( + e.handle().read(&alloc).unwrap(), + TempView::Boiling + )); + e.bstack_drop(&alloc).unwrap(); +} + +// -------------------------------------------------------------------------- +// #[bstack_enum] custom tags — tag / ctrl_tag / allow(overlong_tag) +// -------------------------------------------------------------------------- + +#[bstack_enum(tag = "EN", ctrl_tag = "ec", rc, weak)] +enum TaggedEnum { + A, + B(u32), +} + +#[bstack_enum(tag = "WAYTOOLONGENUMTAG", allow(overlong_tag))] +enum LongTagEnum { + A, +} + +#[test] +fn macro_enum_tags() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Custom data-tag prefix; overlong override truncated to 8 (allow silences). + assert_eq!(&TaggedEnum::eightcc().0[0..2], b"EN"); + assert_eq!(&LongTagEnum::eightcc().0, b"WAYTOOLO"); + + // ctrl_tag applies to the (rc, weak) control block. + let rc = TaggedEnum::new(&alloc, TaggedEnumData::A).unwrap(); + let data_off = rc.handle().range().start(); + let mut buf = [0u8; 8]; + stack + .get_into(data_off + layout::CTRL_BACKPTR_OFFSET, &mut buf) + .unwrap(); + let ctrl_off = u64::from_le_bytes(buf); + let mut ctag = [0u8; 8]; + stack.get_into(ctrl_off + 8, &mut ctag).unwrap(); + assert_eq!(&ctag[0..2], b"ec"); + drop(rc); +} From a1fbd2d9ba3242e3bd3d24415300715e1db58131 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 22 Jul 2026 13:48:11 -0700 Subject: [PATCH 031/140] Add duplicate discriminant value check in expand_enum function --- bstack_raii/derive/src/block.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index d46d0bb..ba7b15d 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1598,16 +1598,25 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = { let mut next: i128 = 0; - let mut out = Vec::with_capacity(input.variants.len()); + let mut out: Vec = Vec::with_capacity(input.variants.len()); for v in &input.variants { let d = match &v.discriminant { Some((_, expr)) => parse_disc_expr(expr)?, None => next, }; + // The macro replaces the enum, so rustc's E0081 never fires; a + // duplicate would only surface as an `unreachable_patterns` warning on + // the generated match (and read the wrong variant). Reject it clearly. + if out.contains(&d) { + return Err(Error::new_spanned( + v, + format!("discriminant value `{d}` assigned more than once"), + )); + } out.push(d); - next = d.checked_add(1).ok_or_else(|| { - Error::new_spanned(v, "#[bstack_enum] discriminant overflow") - })?; + next = d + .checked_add(1) + .ok_or_else(|| Error::new_spanned(v, "#[bstack_enum] discriminant overflow"))?; } out }; From 13c7ab928b02601cb9240c471839dd55efb1b1dc Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 22 Jul 2026 14:48:33 -0700 Subject: [PATCH 032/140] Support Pod struct and tuple variants --- bstack_raii/README.md | 21 +++-- bstack_raii/derive/src/block.rs | 131 ++++++++++++++++++++++---------- bstack_raii/derive/src/lib.rs | 23 +++--- bstack_raii/src/tests.rs | 57 ++++++++++++++ 4 files changed, 175 insertions(+), 57 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 4e5aeef..3ee9a8c 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -354,17 +354,20 @@ other sum type is a `#[bstack_enum]`. ### Enums: `#[bstack_enum]` A `#[bstack_enum]` lowers a Rust `enum` to a **tagged-union block**: a -discriminant plus a payload area sized to the largest variant. Each variant is -**unit** (no data), a **POD** newtype `V(P)` (`P: Pod`, stored inline), or an -annotated newtype whose annotation states the *variant's* relationship, exactly -like a struct field — `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / -`#[bstack_ref]` (each a `u64` offset to the child / control block): +discriminant plus a payload area sized to the largest variant. A variant is +either a **POD aggregate** — unit, an all-`Pod` tuple `V(A, B, …)`, or an +all-`Pod` struct `V { x: A, … }` (fields packed inline, no annotation) — or an +**annotated single-field tuple** whose annotation states the *variant's* +relationship, exactly like a struct field — `#[bstack_owned]` / +`#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` (each a `u64` offset to +the child / control block): ```rust #[bstack_enum] enum Node { - Empty, // unit + Empty, // unit (POD aggregate, 0 fields) Num(u32), // POD, inline + Rect { w: u32, h: u32 }, // POD struct variant, packed inline #[bstack_ref] Link(Leaf), // borrowed reference (frees nothing) #[bstack_owned] Child(Leaf), // owned child (freed on teardown) #[bstack_strong] Shared(Thing), // a strong ref (Thing is (rc)/(rc, weak)) @@ -404,8 +407,10 @@ match bstack_move!(node, &alloc)? { ``` An enum is a block, so it is **always referenced** — store it as a struct field -(inline embedding isn't supported). Struct and multi-field tuple variants aren't -supported. +(inline embedding isn't supported). A POD aggregate variant's fields must all be +`Pod`, and it takes no ownership annotation (only a single-field tuple variant +does). Duplicate discriminant values are a clear compile error (rustc's `E0081` +can't fire, since the macro replaces the `enum`). #### Discriminant width diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index ba7b15d..9cd44a4 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1698,44 +1698,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - if kind != Kind::Pod { - return Err(Error::new_spanned( - variant, - "a unit variant carries no data, so it cannot have an ownership annotation", - )); - } - data_variants.push(quote!(#vname,)); - view_variants.push(quote!(#vname,)); - new_arms.push(quote!(#data::#vname => (#disc, [0u8; Self::__PAYLOAD]),)); - read_arms.push(quote!(#disc => #view::#vname,)); - move_arms.push(quote!(#disc => #data::#vname,)); - } - Fields::Unnamed(f) if f.unnamed.len() == 1 => { + // Annotated single-field tuple `#[..] V(T)`: an owned / strong / weak / + // ref child stored as a `u64` offset. (Unit, un-annotated single-POD, + // multi-field tuple, and struct variants are POD aggregates, below.) + Fields::Unnamed(f) if f.unnamed.len() == 1 && kind != Kind::Pod => { needs_payload = true; let ty = &f.unnamed.first().unwrap().ty; match kind { - Kind::Pod => { - pod_types.push(ty.clone()); - payload_sizes.push(quote!(::core::mem::size_of::<#ty>())); - data_variants.push(quote!(#vname(#ty),)); - view_variants.push(quote!(#vname(#ty),)); - new_arms.push(quote! { - #data::#vname(__v) => { - let mut __pl = [0u8; Self::__PAYLOAD]; - __pl[..::core::mem::size_of::<#ty>()] - .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__v)); - (#disc, __pl) - } - }); - let read_pod = quote! { - ::bstack_raii::bytemuck::pod_read_unaligned::<#ty>( - &__pl[..::core::mem::size_of::<#ty>()], - ) - }; - read_arms.push(quote!(#disc => #view::#vname(#read_pod),)); - move_arms.push(quote!(#disc => #data::#vname(#read_pod),)); - } + Kind::Pod => unreachable!("guarded out above"), Kind::Owned => { payload_sizes.push(quote!(8usize)); let child = child_from_off(ty); @@ -1890,12 +1860,93 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - return Err(Error::new_spanned( - &variant.fields, - "#[bstack_enum] variants must be unit or single-field tuple `V(T)` \ - (struct and multi-field tuple variants are not supported)", - )); + if kind != Kind::Pod { + return Err(Error::new_spanned( + variant, + "an ownership annotation is only allowed on a single-field tuple \ + variant, e.g. `#[bstack_owned] V(T)`", + )); + } + let named = matches!(&variant.fields, Fields::Named(_)); + let mut binds = Vec::new(); + let mut tys: Vec = Vec::new(); + let mut fnames = Vec::new(); + for (j, f) in variant.fields.iter().enumerate() { + pod_types.push(f.ty.clone()); + tys.push(f.ty.clone()); + binds.push(format_ident!("__f{}", j)); + if let Some(id) = &f.ident { + fnames.push(id.clone()); + } + } + + // Cumulative byte offsets of each field within the payload. + let mut offsets = Vec::new(); + let mut acc = quote!(0usize); + for ty in &tys { + offsets.push(acc.clone()); + acc = quote!(#acc + ::core::mem::size_of::<#ty>()); + } + let payload_size = if tys.is_empty() { quote!(0usize) } else { acc }; + + let writes = binds.iter().zip(&offsets).zip(&tys).map(|((b, off), ty)| { + quote! { + __pl[(#off)..(#off) + ::core::mem::size_of::<#ty>()] + .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&#b)); + } + }); + let reads: Vec = offsets + .iter() + .zip(&tys) + .map(|(off, ty)| { + quote! { + ::bstack_raii::bytemuck::pod_read_unaligned::<#ty>( + &__pl[(#off)..(#off) + ::core::mem::size_of::<#ty>()], + ) + } + }) + .collect(); + + // The variant's in-memory shape (`V`, `V(A, B)`, or `V { x: A, .. }`), + // its destructuring pattern, and its reconstruction from `reads`. + let (decl, pat, cons) = if tys.is_empty() { + (quote!(#vname,), quote!(#vname), quote!(#vname)) + } else if named { + ( + quote!(#vname { #(#fnames: #tys),* },), + quote!(#vname { #(#fnames: #binds),* }), + quote!(#vname { #(#fnames: #reads),* }), + ) + } else { + ( + quote!(#vname(#(#tys),*),), + quote!(#vname(#(#binds),*)), + quote!(#vname(#(#reads),*)), + ) + }; + if !tys.is_empty() { + needs_payload = true; + } + + data_variants.push(decl.clone()); + view_variants.push(decl); + payload_sizes.push(payload_size); + new_arms.push(quote! { + #data::#pat => { + let mut __pl = [0u8; Self::__PAYLOAD]; + #(#writes)* + (#disc, __pl) + } + }); + read_arms.push(quote!(#disc => #view::#cons,)); + move_arms.push(quote!(#disc => #data::#cons,)); + // POD: no teardown. } } } diff --git a/bstack_raii/derive/src/lib.rs b/bstack_raii/derive/src/lib.rs index d978b6c..9f8244e 100644 --- a/bstack_raii/derive/src/lib.rs +++ b/bstack_raii/derive/src/lib.rs @@ -68,13 +68,19 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { /// `#[bstack_enum]` — a tagged-union block. /// /// Lowers an `enum` to a fixed-size block: a discriminant plus a payload area -/// sized to the largest variant. Variants may be **unit** (no data), a **POD** -/// newtype `V(P)` (`P: Pod`, stored inline), or an annotated newtype -/// `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` -/// `V(T)` (a `u64` offset to the child / control block, released on teardown per -/// the annotation). Only single-field tuple variants are allowed (no struct or -/// multi-field variants). All three modes are supported (`#[bstack_enum]`, -/// `(rc)`, `(rc, weak)`). +/// sized to the largest variant. A variant is either: +/// +/// * a **POD aggregate** — unit (`V`), an all-`Pod` tuple (`V(A, B, ..)`), or an +/// all-`Pod` struct (`V { x: A, .. }`); the fields are packed into the payload +/// in declaration order (read/written unaligned, so alignment is irrelevant), +/// with no ownership annotation; or +/// * an **annotated single-field tuple** `#[bstack_owned]` / `#[bstack_strong]` / +/// `#[bstack_weak]` / `#[bstack_ref]` `V(T)` — a `u64` offset to the child / +/// control block, released on teardown per the annotation. +/// +/// All three modes are supported (`#[bstack_enum]`, `(rc)`, `(rc, weak)`). +/// Duplicate discriminant values are rejected (rustc's `E0081` cannot fire, since +/// the macro replaces the `enum`). /// /// # Arguments /// @@ -99,8 +105,7 @@ pub fn bstack_block(args: TokenStream, item: TokenStream) -> TokenStream { /// `bstack_move!` and `bstack_cast!` work on enums as on structs. /// /// An enum is always **referenced** (it is a block; store it as a field of a -/// struct — inline embedding is not supported). Struct / multi-field tuple -/// variants are not supported. +/// struct — inline embedding is not supported). #[proc_macro_attribute] pub fn bstack_enum(args: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as syn::ItemEnum); diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 13f8ce9..25e4a81 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1809,3 +1809,60 @@ fn macro_enum_tags() { assert_eq!(&ctag[0..2], b"ec"); drop(rc); } + +// -------------------------------------------------------------------------- +// #[bstack_enum] POD aggregate variants — multi-field tuple + struct variants +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum Shape { + Empty, + Point(i32, i32), // multi-field tuple (POD) + Rect { w: u32, h: u32 }, // struct variant (POD) + Tagged(u8, u16, u8), // heterogeneous, packed unaligned +} + +#[test] +fn macro_enum_pod_aggregate_variants() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + // header(16) + disc(u8 = 1) + payload(max 8: Point/Rect) = 25. + assert_eq!(size_of::<::OnDisk>(), 25); + + // Multi-field tuple round-trips. + let e = Shape::new(&alloc, ShapeData::Point(3, -4)).unwrap(); + match e.handle().read(&alloc).unwrap() { + ShapeView::Point(x, y) => assert_eq!((x, y), (3, -4)), + _ => panic!("expected Point"), + } + e.bstack_drop(&alloc).unwrap(); + + // Struct variant round-trips. + let e = Shape::new(&alloc, ShapeData::Rect { w: 100, h: 200 }).unwrap(); + match e.handle().read(&alloc).unwrap() { + ShapeView::Rect { w, h } => assert_eq!((w, h), (100, 200)), + _ => panic!("expected Rect"), + } + e.bstack_drop(&alloc).unwrap(); + + // Heterogeneous, packed (u8, u16, u8) — read unaligned. + let e = Shape::new(&alloc, ShapeData::Tagged(1, 258, 255)).unwrap(); + match e.handle().read(&alloc).unwrap() { + ShapeView::Tagged(a, b, c) => assert_eq!((a, b, c), (1, 258, 255)), + _ => panic!("expected Tagged"), + } + e.bstack_drop(&alloc).unwrap(); + + // Unit still round-trips through the same aggregate path. + let e = Shape::new(&alloc, ShapeData::Empty).unwrap(); + assert!(matches!(e.handle().read(&alloc).unwrap(), ShapeView::Empty)); + e.bstack_drop(&alloc).unwrap(); + + // bstack_move! yields the same aggregate variant. + let e = Shape::new(&alloc, ShapeData::Point(7, 8)).unwrap(); + match bstack_move!(e, &alloc).unwrap() { + ShapeData::Point(x, y) => assert_eq!((x, y), (7, 8)), + _ => panic!("expected Point"), + } +} From f1ccba6dce9cb3d9d89f8f7cd5b1d0ed60295933 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 22 Jul 2026 16:56:15 -0700 Subject: [PATCH 033/140] Add compile_fail tests for macro input validation in bstack_enum --- bstack_raii/src/lib.rs | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 97042e4..6b44469 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -88,3 +88,117 @@ pub use bytemuck; // Procedural macros, re-exported so downstream depends only on `bstack_raii`. pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move}; + +/// `compile_fail` checks for illegal macro inputs. Each block must **fail** to +/// compile; the accompanying comment says why. +/// +/// An ownership annotation is only allowed on a **single-field tuple** variant; a +/// unit, struct, or multi-field tuple variant is a POD aggregate that rejects +/// annotations. +/// +/// `#[bstack_ref]` on a **unit** variant: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { #[bstack_ref] Unit } +/// # fn main() {} +/// ``` +/// +/// `#[bstack_ref]` on a **struct** variant: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { #[bstack_ref] S { a: u32, b: u32 } } +/// # fn main() {} +/// ``` +/// +/// `#[bstack_strong]` on a **multi-field tuple** variant: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { #[bstack_strong] T(u32, i8) } +/// # fn main() {} +/// ``` +/// +/// **Two** ownership annotations on one variant: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { #[bstack_owned] #[bstack_ref] V(u32) } +/// # fn main() {} +/// ``` +/// +/// **Duplicate discriminant** values (rustc's `E0081` can't fire — the macro +/// replaces the enum — so the macro rejects it itself): +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { A = 1, B = 1 } +/// # fn main() {} +/// ``` +/// +/// A discriminant **out of range** for the chosen `repr`: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum(repr(u8))] +/// enum E { A = 300 } +/// # fn main() {} +/// ``` +/// +/// `repr(usize)` / `repr(isize)` (bstack offsets are 64-bit): +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum(repr(usize))] +/// enum E { A } +/// # fn main() {} +/// ``` +/// +/// An unsupported `repr` width: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum(repr(u128))] +/// enum E { A } +/// # fn main() {} +/// ``` +/// +/// A **generic** enum: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { A(T) } +/// # fn main() {} +/// ``` +/// +/// `weak` without `rc`: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum(weak)] +/// enum E { A } +/// # fn main() {} +/// ``` +/// +/// A **non-`Pod`** field in a POD aggregate variant: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { V(String) } +/// # fn main() {} +/// ``` +/// +/// An ownership annotation targeting a **non-block** type: +/// ```compile_fail +/// use bstack_raii::bstack_enum; +/// #[bstack_enum] +/// enum E { #[bstack_owned] V(u32) } +/// # fn main() {} +/// ``` +/// +/// `repr(..)` on a **struct** (it selects an enum discriminant width): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block(repr(u64))] +/// struct X { a: u32 } +/// # fn main() {} +/// ``` +#[doc(hidden)] +pub mod __macro_compile_fail_tests {} From e69665ec400f1db6438d5f4346b09ade41ef8650 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 24 Jul 2026 02:06:25 -0700 Subject: [PATCH 034/140] Support edge cases: unit structs, tuple structs, tuple in structs, Option, Generic --- bstack_raii/README.md | 19 ++++++ bstack_raii/derive/src/block.rs | 105 +++++++++++++++++++++++++------- bstack_raii/src/lib.rs | 58 ++++++++++++++++++ bstack_raii/src/tests.rs | 92 ++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 22 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 3ee9a8c..5914661 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -249,6 +249,13 @@ methods: - recursive teardown, [casting](#casting-bstack_cast), and [moving](#moving-out-bstack_move). +A **tuple struct** works too, as long as every field is `Pod`: its positional +fields get synthetic names, so `struct Rgb(u8, u8, u8)` is constructed +`Rgb::new(&alloc, 10, 20, 30)`, read via `rgb.field0(stack)?` / `field1` / …, and +`bstack_move!` hands the fields back in order. A **unit struct** +(`#[bstack_block] struct Marker;`) is a valid **header-only** block — just the +16-byte header, no payload. + ### Reference-counted blocks Declare `#[bstack_block(rc)]` / `#[bstack_block(rc, weak)]` (the on-disk layout @@ -346,6 +353,12 @@ The accessor returns `io::Result>`, the constructor takes an `Option` (`Option>` / `Option<&[T]>` / …), and `bstack_move!` yields `Option<_>`. (`#[bstack_weak]` fields are already nullable.) +An `Option` on an **un-annotated POD** field is different: `Option` is stored +*inline* whenever `A: bytemuck::PodInOption` (so `Option: Pod`) — e.g. +`Option` — riding bytemuck's niche, with the `Option` handed back +by value. (A plain `Option` is *not* `Pod`, so it doesn't compile as a POD +field — annotate it, or use a `NonZero`.) + `Option` *is* a Rust `enum`, but this is a niche optimization baked into the macro — **not** a [`#[bstack_enum]`](#enums-bstack_enum) (no discriminant byte, no `EData` / `EView`, no extra block). `Option` is the only enum that gets it; any @@ -438,6 +451,12 @@ the `std` types — the macro lowers each to a bstack_raii on-disk form (a growa is ever an actual `std::vec::Vec` / `String` / `Option`; they're borrowed as familiar names for convenience. +A **POD tuple** field — `a: (A, B, …)` where every element is `Pod` — also works, +even though a Rust tuple isn't itself `Pod`: it's stored through a generated +packed wrapper (alignment is irrelevant on disk) and handed back as a tuple by +the accessor. `bstack_move!` keeps each tuple as **one** element — a `(u8, u8)` +field comes back as `(u8, u8)`, not flattened into the surrounding tuple. + In the same spirit, a field written `&T` is coerced to owned `T` (and `&str` to `String`) with a compile warning — a stray reference doesn't fail to compile, but you're nudged to write the owned type. Silence it with diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 9cd44a4..12b8650 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -59,14 +59,23 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result )); } - let fields = match &input.fields { - Fields::Named(named) => &named.named, - _ => { - return Err(Error::new_spanned( - &input.fields, - "#[bstack_block] requires a struct with named fields", - )); - } + // Normalize fields to `(name, field)`: named fields keep their name, a tuple + // struct's positional fields get synthetic `field0` / `field1` / … names (so + // they access as `x.field0(stack)` and reuse the whole field machinery), and a + // unit struct has none — yielding a valid **header-only** block. + let field_list: Vec<(Ident, &syn::Field)> = match &input.fields { + Fields::Named(named) => named + .named + .iter() + .map(|f| (f.ident.clone().expect("named field"), f)) + .collect(), + Fields::Unnamed(unnamed) => unnamed + .unnamed + .iter() + .enumerate() + .map(|(i, f)| (format_ident!("field{i}"), f)) + .collect(), + Fields::Unit => Vec::new(), }; let name = &input.ident; @@ -94,11 +103,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let mut mv_caps = Vec::new(); let mut mv_types = Vec::new(); let mut mv_recon = Vec::new(); + // Generated `#[repr(C, packed)]` Pod wrappers for POD tuple fields. + let mut wrapper_defs = Vec::new(); // Whether any field was written `&T` (coerced to owned `T`, with a warning). let mut ref_coerced = false; - for field in fields { - let fname = field.ident.as_ref().expect("named field"); + for (fname, field) in &field_list { let kind = classify(field)?; // Ergonomic: `&T` is coerced to owned `T` (and `&str` to `String`), with @@ -111,10 +121,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ref_coerced = true; } - // `Option` makes a field nullable (`0` on disk == `None`, since no - // allocation ever lives at offset 0). Strip it first, so the inner type — - // which may itself be a `Vec` / `String` — is what we classify. - let (inner_ty, nullable) = match option_inner(eff_ty) { + // Peek through `Option` (which makes a *reference* field nullable, + // `0` on disk == `None`) so the inner type — which may itself be a `Vec` / + // `String` — is what we classify. A POD field keeps the whole type (below). + let (opt_inner, nullable) = match option_inner(eff_ty) { Some(inner) => (inner, true), None => (eff_ty, false), }; @@ -122,13 +132,13 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // `Vec` / `String` (and `&str` → `String`): an inline descriptor on // disk, a `BStackVec` at runtime. A nullable vec uses the `data_off == 0` // niche. Handled here. - let vinfo = if is_str(inner_ty) { + let vinfo = if is_str(opt_inner) { Some(VecInfo { elem: quote!(u8), is_string: true, }) } else { - vec_field(inner_ty) + vec_field(opt_inner) }; if let Some(vinfo) = vinfo { let elem = &vinfo.elem; @@ -216,12 +226,60 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } - if nullable && kind == Kind::Pod { - return Err(Error::new_spanned( - &field.ty, - "Option is only supported on #[bstack_owned] / #[bstack_strong] / \ - #[bstack_weak] / #[bstack_ref] fields", - )); + // Decide the stored type + nullability now that vectors are handled. + // + // * A **reference** kind (owned/strong/weak/ref) lowers to a `u64` offset; + // `Option` makes it nullable. + // * A **POD** field stores its *whole* type inline. That includes an + // `Option` — stored via the bytemuck `PodInOption` niche (so + // `Option: Pod` iff `A: PodInOption`) — so no annotation is needed and + // the accessor/`bstack_move!` hand back the `Option` by value. + let (inner_ty, nullable) = if kind == Kind::Pod { + (eff_ty, false) + } else { + (opt_inner, nullable) + }; + + // A POD **tuple** field `a: (A, B, ..)`: a Rust tuple is not `Pod`, but a + // packed struct of its (POD) elements is — alignment is irrelevant on disk + // — so store it through a generated wrapper and rebuild the tuple on read. + // `bstack_move!` hands back the tuple as one element (not flattened). + if kind == Kind::Pod && let Type::Tuple(tup) = inner_ty { + let elems: Vec<&Type> = tup.elems.iter().collect(); + let wrapper = format_ident!("__BstackTup_{}_{}", name, fname); + let idx: Vec = (0..elems.len()).map(syn::Index::from).collect(); + wrapper_defs.push(quote! { + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #[doc(hidden)] + #vis struct #wrapper( #(#elems),* ); + // SAFETY: `#[repr(C, packed)]` => no padding; every element is + // `Pod` (asserted below), so all bit patterns are valid. + unsafe impl ::bstack_raii::Zeroable for #wrapper {} + unsafe impl ::bstack_raii::Pod for #wrapper {} + }); + pod_types.extend(elems.iter().copied()); + on_disk_fields.push(quote!(#fname: #wrapper,)); + accessors.push(quote! { + #vis fn #fname( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<#inner_ty> { + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; + let __w = __od.#fname; + ::std::result::Result::Ok(( #(__w.#idx,)* )) + } + }); + ctor_params.push(quote!(#fname: #inner_ty,)); + ctor_preps.push(quote!(let #fname: #wrapper = #wrapper( #(#fname.#idx),* );)); + ctor_inits.push(quote!(#fname: #fname,)); + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + mv_types.push(quote!(#inner_ty)); + mv_recon.push(quote!(( #(#cap.#idx,)* ))); + continue; } // On-disk lowering. @@ -460,6 +518,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #[derive(::core::clone::Clone, ::core::marker::Copy)] #vis struct #name(::bstack_raii::BStackRange); + // Packed Pod wrappers for any POD tuple fields. + #(#wrapper_defs)* + #[repr(C, packed)] #[derive(::core::clone::Clone, ::core::marker::Copy)] #vis struct #on_disk { diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 6b44469..ce9cbe7 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -200,5 +200,63 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// struct X { a: u32 } /// # fn main() {} /// ``` +/// +/// --- +/// +/// The same shapes on `#[bstack_block]` **structs**. +/// +/// **Two** ownership annotations on one field: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[bstack_owned] #[bstack_ref] f: u32 } +/// # fn main() {} +/// ``` +/// +/// An ownership annotation targeting a **non-block** type — including a generic +/// wrapper like `Wrapper>` (an on-disk ref is not generic), even when +/// the type would be fine stored inline as POD *without* the annotation: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[bstack_owned] f: core::num::Wrapping } +/// # fn main() {} +/// ``` +/// +/// A `#[bstack_weak]` field whose target isn't weak-observable: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[bstack_weak] f: u32 } +/// # fn main() {} +/// ``` +/// +/// An un-annotated **non-`Pod`** field: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// struct NotPod(String); +/// #[bstack_block] +/// struct X { f: NotPod } +/// # fn main() {} +/// ``` +/// +/// A **generic** struct: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { f: T } +/// # fn main() {} +/// ``` +/// +/// `weak` without `rc`: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block(weak)] +/// struct X { f: u32 } +/// # fn main() {} +/// ``` +/// +/// (Tuple structs of POD fields and unit structs are **valid**, not errors — see +/// the tests.) #[doc(hidden)] pub mod __macro_compile_fail_tests {} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 25e4a81..7a8abde 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1866,3 +1866,95 @@ fn macro_enum_pod_aggregate_variants() { _ => panic!("expected Point"), } } + +// -------------------------------------------------------------------------- +// POD field conveniences: Option via bytemuck::PodInOption, tuple fields +// (`bstack_move!` keeps each tuple as one element), and generic POD wrappers. +// -------------------------------------------------------------------------- + +#[bstack_block] +struct PodFeat { + maybe: Option, // PodInOption niche, stored inline + wrap: core::num::Wrapping, // a generic wrapper that *is* POD + pair: (u8, u8), // POD tuple field + mixed: (u16, i32), + n: u64, +} + +#[test] +#[allow(clippy::type_complexity)] // the explicit move tuple type is the assertion +fn macro_pod_option_and_tuple_fields() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let f = PodFeat::new( + &alloc, + core::num::NonZeroU32::new(7), + core::num::Wrapping(42), + (1, 2), + (300, -5), + 99, + ) + .unwrap(); + assert_eq!( + f.handle().maybe(stack).unwrap(), + core::num::NonZeroU32::new(7) + ); + assert_eq!(f.handle().wrap(stack).unwrap(), core::num::Wrapping(42u32)); + assert_eq!(f.handle().pair(stack).unwrap(), (1u8, 2u8)); + assert_eq!(f.handle().mixed(stack).unwrap(), (300u16, -5i32)); + assert_eq!(f.handle().n(stack).unwrap(), 99); + + // `bstack_move!` returns each tuple as ONE element (not flattened into + // `(u8, u8, u16, i32, ..)`), so this exact type annotation must hold. + let (maybe, wrap, pair, mixed, n): ( + Option, + core::num::Wrapping, + (u8, u8), + (u16, i32), + u64, + ) = bstack_move!(f, &alloc).unwrap(); + assert_eq!(maybe, core::num::NonZeroU32::new(7)); + assert_eq!(wrap, core::num::Wrapping(42)); + assert_eq!(pair, (1, 2)); + assert_eq!(mixed, (300, -5)); + assert_eq!(n, 99); + + // `Option` None round-trips too (the niche). + let g = PodFeat::new(&alloc, None, core::num::Wrapping(0), (0, 0), (0, 0), 0).unwrap(); + assert!(g.handle().maybe(stack).unwrap().is_none()); + g.bstack_drop(&alloc).unwrap(); +} + +// -------------------------------------------------------------------------- +// Unit struct (header-only block) and tuple struct (POD positional fields) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct Marker; + +#[bstack_block] +struct Rgb(u8, u8, u8); + +#[test] +fn macro_unit_and_tuple_structs() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Unit struct: a valid header-only block (just the 16-byte BlockHeader). + assert_eq!(size_of::<::OnDisk>(), 16); + let m = Marker::new(&alloc).unwrap(); + let () = bstack_move!(m, &alloc).unwrap(); // moving a unit yields () + + // Tuple struct: positional constructor, `.field0` / `.field1` / … accessors. + let c = Rgb::new(&alloc, 10, 20, 30).unwrap(); + assert_eq!(c.handle().field0(stack).unwrap(), 10); + assert_eq!(c.handle().field1(stack).unwrap(), 20); + assert_eq!(c.handle().field2(stack).unwrap(), 30); + + // bstack_move! yields the fields in order. + let (r, g, b) = bstack_move!(c, &alloc).unwrap(); + assert_eq!((r, g, b), (10, 20, 30)); +} From cb76a7834068dc6caa0b5702a46da89977945006 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 24 Jul 2026 23:01:11 -0700 Subject: [PATCH 035/140] Add #[embed] primitive] --- bstack_raii/README.md | 34 +++++ bstack_raii/derive/src/block.rs | 218 ++++++++++++++++++++++++++++++-- bstack_raii/src/lib.rs | 36 ++++++ bstack_raii/src/tests.rs | 75 +++++++++++ 4 files changed, 354 insertions(+), 9 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 5914661..5886c28 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -216,6 +216,7 @@ and what [`bstack_move!`](#moving-out-bstack_move) yields: | Annotation | Child kind required | On teardown | `bstack_move!` yields | |--------------------|------------------------|------------------------------------|-------------------------| | `#[bstack_owned]` | any block | recursively frees the child | `BStackOwned` | +| `#[embed]` | any block | frees the child's children in place | `BStackOwned` (re-homed) | | `#[bstack_strong]` | `(rc)` or `(rc, weak)` | decrements refcount; frees at zero | `BStackRc` | | `#[bstack_weak]` | `(rc, weak)` | decrements weak count only | `Option>` | | `#[bstack_ref]` | any block | nothing | `BStackRef` | @@ -224,6 +225,39 @@ and what [`bstack_move!`](#moving-out-bstack_move) yields: Rules are enforced at compile time: a `#[bstack_weak]` field whose target isn't `(rc, weak)`, or a non-`Pod` field with no annotation, is a compile error. +#### `#[embed]` — inline a child block + +`#[bstack_owned]` stores a `u64` **offset** to a separately-allocated child. +`#[embed]` instead stores the child's *whole on-disk form* — its header and all — +**inline** in the parent, so the parent block is one contiguous region and the +child needs no separate allocation: + +```text +#[bstack_owned]: [ parent header ][ .. u64 offset .. ] ─▶ [ child header ][ child fields ] +#[embed]: [ parent header ][ child header ][ child fields ][ .. ] +``` + +```rust +#[bstack_block] +struct Holder { + #[embed] child: Child, // Child's OnDisk lives here, inline + tag: u32, +} +enum Wrapper { + #[embed] One(Child), // also works as an enum variant + None, +} +``` + +It's still **exclusive ownership** (like `#[bstack_owned]`): `new` takes a +`BStackOwned` — you build the child normally, and the parent folds its +bytes in and frees the child's now-redundant shell (the child's *own* children +stay live). The accessor (`holder.handle().child()`) hands back a borrowed +`Child` handle into the inline region; teardown frees the embedded child's +children in place; and `bstack_move!` re-homes the child to a fresh standalone +`BStackOwned`. You can embed any block (`#[bstack_block]` / +`#[bstack_enum]`), but not a tuple, a `Vec`, or an `Option`. + ### Structs ```rust diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 12b8650..1e2f090 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -37,6 +37,9 @@ enum Kind { Strong, Weak, Ref, + /// `#[embed]`: an exclusively-owned child block stored **inline** (its whole + /// on-disk form, header and all), not as a `u64` offset. + Embed, /// POD field stored inline. Pod, } @@ -159,6 +162,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // + array is always owned by this struct regardless). No annotation => // POD elements (byte storage, requiring `T: Pod`). let (drop_s, acc, ctor, mv) = match kind { + Kind::Embed => { + return Err(Error::new_spanned( + &field.ty, + "cannot #[embed] a `Vec` / `String`; embed a `#[bstack_block]` type", + )); + } Kind::Pod => ( vec_drop_stmt(fname, elem, nullable), vec_accessor(vis, fname, elem, &on_disk, nullable), @@ -282,6 +291,92 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } + // `#[embed] child: Block`: store the child's whole on-disk form INLINE + // (`::OnDisk`, header and all) instead of a `u64` + // offset — an exclusively-owned inline block. + if kind == Kind::Embed { + if let Type::Tuple(_) = inner_ty { + return Err(Error::new_spanned( + &field.ty, + "cannot #[embed] a tuple — embed a `#[bstack_block]` / `#[bstack_enum]` type", + )); + } + if nullable { + return Err(Error::new_spanned( + &field.ty, + "#[embed] does not support `Option`", + )); + } + let child = inner_ty; + let child_od = quote!(<#child as ::bstack_raii::BStackBlock>::OnDisk); + on_disk_fields.push(quote!(#fname: #child_od,)); + + // Teardown: free the embedded child's own children *in place* (its + // storage is part of this block, so no separate dealloc). `__range` is + // this block's range, bound by `__bstack_drop_children`. + drop_stmts.push(quote! { + { + let __embed = ::bstack_raii::BStackRange::new( + __range.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + ::core::mem::size_of::<#child_od>() as u64, + ); + <#child>::__bstack_drop_children(__embed, allocator)?; + } + }); + + // Accessor: a child handle at the embedded offset (pure offset math). + accessors.push(quote! { + #vis fn #fname(&self) -> #child { + <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + ::core::mem::size_of::<#child_od>() as u64, + ), + ) + } + }); + + // Constructor: fold a `BStackOwned` in — read its OnDisk, free + // its shell (its own children stay live, now owned by the embed). + ctor_params.push(quote!(#fname: ::bstack_raii::BStackOwned<#child>,)); + ctor_preps.push(quote! { + let #fname: #child_od = { + let __h = #fname.into_inner(); + let __cr = ::bstack_raii::BStackBlock::range(&__h); + let mut __b = [0u8; ::core::mem::size_of::<#child_od>()]; + let __od = *unsafe { ::bstack_raii::BStackRef::<#child>::from_range(__cr) } + .read_on_disk(allocator.stack(), &mut __b)?; + unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } + __od + }; + }); + ctor_inits.push(quote!(#fname: #fname,)); + + // Move: re-home the embedded child to a fresh standalone allocation. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + mv_types.push(quote!(::bstack_raii::BStackOwned<#child>)); + mv_recon.push(quote! { + { + let mut __slice = + __alloc.alloc(::core::mem::size_of::<#child_od>() as u64)?; + let __r = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&#cap)) + { + let _ = __alloc.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#child as ::bstack_raii::BStackBlock>::from_range(__r), + ) + } + } + }); + continue; + } + // On-disk lowering. match kind { Kind::Pod => { @@ -306,7 +401,8 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result quote!(<#inner_ty as ::bstack_raii::BStackShared>::drop_strong_ref(__child, allocator)?;), )), Kind::Weak => drop_stmts.push(weak_drop_stmt(fname, inner_ty)), - Kind::Ref | Kind::Pod => {} + // `#[embed]` is fully handled above (it `continue`s). + Kind::Ref | Kind::Pod | Kind::Embed => {} } // Accessor. @@ -555,17 +651,32 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } - impl ::bstack_raii::BStackDrop for #name { - fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( - self, + impl #name { + /// Free this block's owned children (recursively) given its range, + /// **without** freeing the block itself — used when the block is + /// `#[embed]`ded (its storage is part of its parent), and by + /// `bstack_drop` before the self-dealloc. + #[doc(hidden)] + #vis fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; let __stack = allocator.stack(); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; - let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; let __on_disk: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; #(#drop_stmts)* + ::std::result::Result::Ok(()) + } + } + + impl ::bstack_raii::BStackDrop for #name { + fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + self, + allocator: &__A, + ) -> ::std::io::Result<()> { + Self::__bstack_drop_children(self.0, allocator)?; unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } } } @@ -994,6 +1105,7 @@ fn ctor_field( quote!(__handle.into_range().start()), ), Kind::Weak => unreachable!("weak fields are wired via set_, not the constructor"), + Kind::Embed => unreachable!("#[embed] fields are handled before ctor_field"), }; if nullable { ( @@ -1027,6 +1139,7 @@ fn move_field( quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); match kind { Kind::Pod => (quote!(#inner_ty), quote!(#cap)), + Kind::Embed => unreachable!("#[embed] fields are handled before move_field"), // Weak is inherently nullable and stores the control offset. Kind::Weak => { let ty = quote! { @@ -1553,6 +1666,7 @@ fn classify_attrs(attrs: &[syn::Attribute]) -> syn::Result { "bstack_strong" => Kind::Strong, "bstack_weak" => Kind::Weak, "bstack_ref" => Kind::Ref, + "embed" => Kind::Embed, _ => continue, }; if found.is_some() { @@ -1919,6 +2033,75 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { + let co = quote!(<#ty as ::bstack_raii::BStackBlock>::OnDisk); + payload_sizes.push(quote!(::core::mem::size_of::<#co>())); + data_variants.push(quote!(#vname(::bstack_raii::BStackOwned<#ty>),)); + view_variants.push(quote!(#vname(#ty),)); + // new: fold a `BStackOwned` in (read OnDisk, free its + // shell), copying its bytes into the payload. + new_arms.push(quote! { + #data::#vname(__v) => { + let __h = __v.into_inner(); + let __cr = ::bstack_raii::BStackBlock::range(&__h); + let mut __b = [0u8; ::core::mem::size_of::<#co>()]; + let __cod = *unsafe { + ::bstack_raii::BStackRef::<#ty>::from_range(__cr) + } + .read_on_disk(allocator.stack(), &mut __b)?; + unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } + let mut __pl = [0u8; Self::__PAYLOAD]; + __pl[..::core::mem::size_of::<#co>()] + .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__cod)); + (#disc, __pl) + } + }); + // read (view): a child handle at the embedded payload offset. + read_arms.push(quote! { + #disc => #view::#vname( + <#ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + self.0.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) + as u64, + ::core::mem::size_of::<#co>() as u64, + ), + ) + ), + }); + // move: re-home the embedded child to a fresh allocation. + move_arms.push(quote! { + #disc => { + let mut __slice = + __alloc.alloc(::core::mem::size_of::<#co>() as u64)?; + let __r = __slice.as_range(); + if let ::std::result::Result::Err(__e) = __slice + .write_range(0, &__pl[..::core::mem::size_of::<#co>()]) + { + let _ = __alloc.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + #data::#vname(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#ty as ::bstack_raii::BStackBlock>::from_range(__r), + ) + }) + } + }); + // teardown: free the embedded child's children in place. + drop_arms.push(quote! { + #disc => { + let __embed = ::bstack_raii::BStackRange::new( + __range.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64, + ::core::mem::size_of::<#co>() as u64, + ); + <#ty>::__bstack_drop_children(__embed, allocator)?; + } + }); + } } } // A POD aggregate: unit, an all-POD tuple `V(A, B, ..)`, or an all-POD @@ -2186,13 +2369,16 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result()]; - let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; let __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; let __disc = __od.__bstack_disc; let __pl = __od.__bstack_payload; @@ -2310,13 +2496,27 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + __range: ::bstack_raii::BStackRange, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + #drop_children_body + ::std::result::Result::Ok(()) + } + } + impl ::bstack_raii::BStackDrop for #name { fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( self, allocator: &__A, ) -> ::std::io::Result<()> { - use ::bstack_raii::BStackDrop as _; - #drop_body + Self::__bstack_drop_children(self.0, allocator)?; unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } } } diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index ce9cbe7..1a47f07 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -256,6 +256,42 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// # fn main() {} /// ``` /// +/// --- +/// +/// Misuse of `#[embed]` (which inlines a whole child *block*). +/// +/// `#[embed]` on a **non-block** type: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[embed] f: u32 } +/// # fn main() {} +/// ``` +/// +/// `#[embed]` on a **tuple**: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[embed] f: (u8, u8) } +/// # fn main() {} +/// ``` +/// +/// `#[embed]` wrapped in `Option`: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[embed] f: Option } +/// # fn main() {} +/// ``` +/// +/// `#[embed]` combined with another ownership annotation: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct X { #[embed] #[bstack_owned] f: u32 } +/// # fn main() {} +/// ``` +/// /// (Tuple structs of POD fields and unit structs are **valid**, not errors — see /// the tests.) #[doc(hidden)] diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 7a8abde..4e7a6aa 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1958,3 +1958,78 @@ fn macro_unit_and_tuple_structs() { let (r, g, b) = bstack_move!(c, &alloc).unwrap(); assert_eq!((r, g, b), (10, 20, 30)); } + +// -------------------------------------------------------------------------- +// #[embed] — a child block stored inline (its whole on-disk form), in a struct +// and an enum variant. The embedded child keeps its OWN owned children. +// -------------------------------------------------------------------------- + +#[bstack_block] +struct EmbChild { + #[bstack_owned] + leaf: MacroLeaf, + n: u32, +} + +#[bstack_block] +struct EmbHolder { + #[embed] + child: EmbChild, + tag: u32, +} + +#[bstack_enum] +enum EmbEnum { + Empty, + #[embed] + Wrap(EmbChild), +} + +#[test] +fn macro_embed_struct_and_enum() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + // Struct embed: parent -> embedded child -> the child's own owned leaf. + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let leaf_off = leaf.handle().range().start(); + let child = EmbChild::new(&alloc, leaf, 7).unwrap(); + let holder = EmbHolder::new(&alloc, child, 99).unwrap(); + assert_eq!(holder.handle().tag(stack).unwrap(), 99); + let c = holder.handle().child(); // a handle into the inline region (no I/O) + assert_eq!(c.n(stack).unwrap(), 7); + assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 42); + + // Teardown frees the embedded child's owned leaf *in place*, then the holder; + // the leaf's slot (lowest) is reclaimed — proof the embed recursed. + holder.bstack_drop(&alloc).unwrap(); + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; + + // bstack_move! re-homes the embedded child to a fresh standalone allocation. + let leaf = MacroLeaf::new(&alloc, 5).unwrap(); + let child = EmbChild::new(&alloc, leaf, 8).unwrap(); + let holder = EmbHolder::new(&alloc, child, 1).unwrap(); + let (moved, tag) = bstack_move!(holder, &alloc).unwrap(); + assert_eq!(tag, 1); + assert_eq!(moved.handle().leaf(stack).unwrap().val(stack).unwrap(), 5); + moved.bstack_drop(&alloc).unwrap(); + + // Enum embed: construct, read (a borrowed child handle), move out. + let leaf = MacroLeaf::new(&alloc, 3).unwrap(); + let child = EmbChild::new(&alloc, leaf, 9).unwrap(); + let e = EmbEnum::new(&alloc, EmbEnumData::Wrap(child)).unwrap(); + match e.handle().read(&alloc).unwrap() { + EmbEnumView::Wrap(c) => assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3), + _ => panic!("expected Wrap"), + } + let moved = match bstack_move!(e, &alloc).unwrap() { + EmbEnumData::Wrap(c) => c, + _ => panic!("expected Wrap"), + }; + assert_eq!(moved.handle().n(stack).unwrap(), 9); + moved.bstack_drop(&alloc).unwrap(); +} From 7b7485ef476ffd447b2fb730b295fd03d33c9fae Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 00:10:54 -0700 Subject: [PATCH 036/140] Basic clone machinary --- bstack_raii/derive/src/block.rs | 180 ++++++++++++++++++++++++++++++ bstack_raii/src/clone.rs | 189 +++++++++++++++++++++++++++++++- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 73 +++++++++++- 4 files changed, 436 insertions(+), 8 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 1e2f090..9468261 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -96,6 +96,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } let mut drop_stmts = Vec::new(); + // `TryCloneIn` deep-clone statements for scalar fields, mirroring `drop_stmts` + // in reverse. If a field kind whose clone is not yet supported (vec / embed) + // is present, `clone_block_reason` is set and the whole clone short-circuits + // to a runtime error instead (so the block still compiles). + let mut clone_stmts = Vec::new(); + let mut clone_block_reason: Option<&'static str> = None; let mut pod_types: Vec<&Type> = Vec::new(); let mut accessors = Vec::new(); let mut setters = Vec::new(); @@ -224,6 +230,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), }; drop_stmts.push(drop_s); + clone_block_reason = Some("`Vec` / `String`"); accessors.push(acc); let (param, prep, init) = ctor; ctor_params.push(param); @@ -374,6 +381,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } }); + clone_block_reason = Some("`#[embed]`"); continue; } @@ -405,6 +413,11 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result Kind::Ref | Kind::Pod | Kind::Embed => {} } + // Deep clone (mirror of teardown; POD / ref are copied verbatim). + if let Some(cs) = clone_field_stmt(fname, inner_ty, kind) { + clone_stmts.push(cs); + } + // Accessor. accessors.push(accessor(vis, fname, inner_ty, &on_disk, kind, nullable)); @@ -610,6 +623,86 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }; + // Deep clone. `__bstack_clone_into` is generated for every block (so an + // owned child of any kind can be recursed into); it does the real work for a + // plain block whose fields are all clone-supported, and returns a runtime + // error otherwise. The public `TryCloneIn` entry point is generated for plain + // blocks only — `rc` / `rc, weak` blocks are shared, not deep-cloned to owned + // (re-initializing their injected refcount / control block is a later step). + let clone_reason: Option = if mode != Mode::Plain { + Some("a reference count (`rc` / `rc, weak`)".to_string()) + } else { + clone_block_reason.map(|s| s.to_string()) + }; + let clone_into_body = match &clone_reason { + Some(what) => clone_unsupported_body(what), + None => quote! { + let __stack = allocator.stack(); + let __src = ::bstack_raii::BStackBlock::range(self); + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; + #[allow(unused_mut)] + let mut __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let __dst = __plan.alloc_raw( + allocator, + ::core::mem::size_of::<#on_disk>() as u64, + )?; + #(#clone_stmts)* + __plan.write( + __dst.start(), + ::bstack_raii::bytemuck::bytes_of(&__od).to_vec(), + ); + ::std::result::Result::Ok(__dst) + }, + }; + let clone_into_method = quote! { + impl #name { + /// Deep-clone this block's subtree into `__plan`: allocate a fresh + /// destination block, recurse into owned children (bumping shared + /// children's refcounts), and stage the destination payload — + /// returning the new block's range. Writes are staged, not committed; + /// the caller commits `__plan` once. + #[doc(hidden)] + #[allow(unused_variables)] + #vis fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<::bstack_raii::BStackRange> { + #clone_into_body + } + } + }; + let clone_impl = if mode == Mode::Plain { + quote! { + #clone_into_method + + impl ::bstack_raii::TryCloneIn for #name { + fn try_clone_in<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + ) -> ::std::io::Result<::bstack_raii::BStackOwned> { + let mut __plan = ::bstack_raii::ClonePlan::new(); + let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { + ::std::result::Result::Ok(__d) => __d, + ::std::result::Result::Err(__e) => { + __plan.rollback(allocator); + return ::std::result::Result::Err(__e); + } + }; + __plan.commit(allocator)?; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__dst), + ) + }) + } + } + } + } else { + clone_into_method + }; + Ok(quote! { #[derive(::core::clone::Clone, ::core::marker::Copy)] #vis struct #name(::bstack_raii::BStackRange); @@ -704,6 +797,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #shared_impl #weakable_items #move_impl + #clone_impl #overlong_warning #ref_warning }) @@ -1386,6 +1480,76 @@ fn weak_drop_stmt(fname: &Ident, inner_ty: &Type) -> TokenStream { } } +/// A `TryCloneIn` statement for a scalar reference/POD field, the mirror of the +/// teardown dispatch run in reverse. Reads/patches the mutable `__od` OnDisk copy +/// and appends allocations / refcount bumps to `__plan`. `None` = nothing to do: +/// POD and `#[bstack_ref]` fields are byte-copied verbatim (a ref clone aliases +/// the same borrowed target — see the borrow-rules TODO). A `0` offset (a null +/// `Option` field, or an unset weak) is left copied as-is. +fn clone_field_stmt(fname: &Ident, inner_ty: &Type, kind: Kind) -> Option { + match kind { + // Deep-clone the owned child into a fresh block, repoint the field. + Kind::Owned => Some(quote! { + { + let __coff: u64 = __od.#fname; + if __coff != 0 { + let __child = <#inner_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __coff, + ::core::mem::size_of::< + <#inner_ty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ); + let __new = __child.__bstack_clone_into(allocator, __plan)?; + __od.#fname = __new.start(); + } + } + }), + // Shared: keep the copied data offset, bump the target's strong count. + Kind::Strong => Some(quote! { + { + let __coff: u64 = __od.#fname; + if __coff != 0 { + let __child = unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range( + ::bstack_raii::BStackRange::new( + __coff, + ::core::mem::size_of::< + <#inner_ty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ) + }; + __plan.bump_strong(__child, allocator)?; + } + } + }), + // Weak: keep the copied control offset, bump the target's weak count. + Kind::Weak => Some(quote! { + { + let __coff: u64 = __od.#fname; + if __coff != 0 { + __plan.bump_weak(__coff); + } + } + }), + Kind::Ref | Kind::Pod | Kind::Embed => None, + } +} + +/// The body of a not-yet-supported deep clone: a runtime error naming what makes +/// the block unclonable. The block still compiles; only `try_clone_in` fails. +fn clone_unsupported_body(what: &str) -> TokenStream { + let msg = format!("TryCloneIn: cloning a block with {what} is not yet implemented"); + quote! { + ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::Unsupported, + #msg, + )) + } +} + /// Parsed `#[bstack_block(...)]` / `#[bstack_enum(...)]` arguments. struct Attr { mode: Mode, @@ -2509,6 +2673,22 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<::bstack_raii::BStackRange> { + ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::Unsupported, + "TryCloneIn: cloning an enum block is not yet implemented", + )) + } } impl ::bstack_raii::BStackDrop for #name { diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index a7ee93f..83eb0c7 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -1,13 +1,190 @@ -//! [`TryClone`]: a fallible clone for handles whose duplication touches disk. +//! Fallible clone for handles whose duplication touches disk. //! -//! [`crate::BStackRc`] and [`crate::BStackWeak`] cannot implement `Clone`, -//! because duplicating them must atomically bump an on-disk refcount, which can -//! fail with an [`io::Error`]. `Clone::clone` has no way to report that, so this -//! layer exposes an explicit fallible clone instead. +//! Two flavours, split by what the duplication needs: +//! +//! * [`TryClone`] — a same-type clone that needs **no allocator**. Implemented by +//! [`crate::BStackRc`] / [`crate::BStackWeak`], whose clone only bumps an +//! on-disk refcount (they carry their own allocator). +//! * [`TryCloneIn`] — a **deep** clone of a whole block, producing a fresh +//! independent [`BStackOwned`] copy. A without-allocator block handle +//! (`X(BStackRange)`) has no allocator to allocate the copy with, so the +//! allocator is passed in. Generated by `#[bstack_block]` (mirror of the +//! block's `__bstack_drop_children` teardown, run in reverse): +//! - POD / `#[bstack_ref]` fields — byte-copied (a ref aliases the same +//! borrowed target; see the borrow-rules TODO); +//! - `#[bstack_owned]` — recursively deep-cloned into a fresh child; +//! - `#[bstack_strong]` / `#[bstack_weak]` — the shared target's refcount is +//! bumped (the clone stays shared, not copied). +//! +//! # Atomicity model +//! +//! A deep clone mixes two workloads with different atomicity domains: +//! **allocations** (which extend the stack and cannot be part of an atomic +//! in-place batch) and **writes** (in-place payload writes, which can). So +//! [`TryCloneIn`] is built as two hard-separated phases via [`ClonePlan`]: +//! +//! 1. **Plan + allocate.** Recurse the block graph, allocating every new block +//! up front (each fallible) and recording the payload bytes to write — but +//! committing *no* write yet. A mid-descent allocation failure rolls back with +//! zero payload written, so there is no partially-fixed-up state to repair. +//! 2. **Commit.** Once every allocation has succeeded, apply the refcount bumps +//! and flush every payload write as one crash-atomic [`BStack::set_batched`] +//! batch (the new blocks are distinct ranges, so the writes never overlap). +//! +//! A crash *between* the phases leaks the allocated-but-uncommitted blocks (a +//! recoverable leak, since the clone result is not yet reachable from any +//! persistent root) but never a torn write. use std::io; -/// Duplicate `self`, performing any fallible I/O the duplication requires. +use bstack::{BStackOwnedSliceAllocator, BStackRange}; + +use crate::block::BStackShared; +use crate::layout; +use crate::owned::BStackOwned; +use crate::reference::BStackRef; +use crate::refcount; +use crate::teardown::{BStackDrop, dealloc_range}; + +/// Duplicate `self`, performing any fallible I/O the duplication requires, +/// **without** needing an allocator. For handles that clone by bumping an on-disk +/// refcount ([`crate::BStackRc`], [`crate::BStackWeak`]). pub trait TryClone: Sized { fn try_clone(&self) -> io::Result; } + +/// Deep-clone a whole block into a fresh, independent [`BStackOwned`] copy, +/// allocating the copy with the supplied allocator. +/// +/// Generated by `#[bstack_block]` for plain blocks. Owned children are deep +/// copied; shared (`#[bstack_strong]` / `#[bstack_weak]`) children stay shared +/// (their refcount is bumped); POD and `#[bstack_ref]` fields are byte-copied. +pub trait TryCloneIn: BStackDrop + Sized { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result>; +} + +/// Accumulates a deep clone's allocations, payload writes, and refcount bumps so +/// the writes commit as one atomic batch after every allocation has succeeded. +/// +/// See the [module docs](self#atomicity-model) for the two-phase model. Built up +/// by the generated `__bstack_clone_into` methods during the recursive descent, +/// then either [`commit`](Self::commit)ted or [`rollback`](Self::rollback)ed. +pub struct ClonePlan { + /// New blocks allocated during planning; freed in reverse on rollback. + allocated: Vec, + /// Pending in-place payload writes `(offset, bytes)`, flushed as one batch. + writes: Vec<(u64, Vec)>, + /// Absolute offsets of `u64` counters to increment by 1 at commit (the + /// strong/weak counts a `#[bstack_strong]` / `#[bstack_weak]` clone acquires). + bumps: Vec, +} + +impl Default for ClonePlan { + fn default() -> Self { + Self::new() + } +} + +impl ClonePlan { + /// A fresh, empty plan. + pub fn new() -> Self { + ClonePlan { + allocated: Vec::new(), + writes: Vec::new(), + bumps: Vec::new(), + } + } + + /// Allocate a `size`-byte block **without writing anything**, recording it + /// for rollback. The caller supplies the block's bytes later via + /// [`write`](Self::write). The allocator's owned-slice handle is not RAII, so + /// letting it drop here does not free the block. + pub fn alloc_raw( + &mut self, + allocator: &A, + size: u64, + ) -> io::Result { + let slice = allocator.alloc(size)?; + let range = slice.as_range(); + self.allocated.push(range); + Ok(range) + } + + /// Record a pending in-place write of `bytes` at absolute `offset`. + pub fn write(&mut self, offset: u64, bytes: Vec) { + self.writes.push((offset, bytes)); + } + + /// Record that the strong count of a shared child at `data` must be bumped by + /// one — the strong reference this clone's `#[bstack_strong]` field acquires. + pub fn bump_strong( + &mut self, + data: BStackRef, + allocator: &A, + ) -> io::Result<()> { + let (data_ref, ctrl) = T::strong_parts(data, allocator)?; + let off = match ctrl { + None => data_ref.into_range().start() + layout::RC_REFCOUNT_OFFSET, + Some(c) => c.start() + layout::CTRL_STRONG_OFFSET, + }; + self.bumps.push(off); + Ok(()) + } + + /// Record that the weak count of the control block at `ctrl_off` must be + /// bumped by one — the weak reference this clone's `#[bstack_weak]` field + /// acquires. + pub fn bump_weak(&mut self, ctrl_off: u64) { + self.bumps.push(ctrl_off + layout::CTRL_WEAK_OFFSET); + } + + /// Free everything allocated so far, in reverse order. The error path, taken + /// when planning fails before [`commit`](Self::commit). + pub fn rollback(self, allocator: &A) { + Self::free_all(self.allocated, allocator); + } + + /// Apply the refcount bumps, then commit every pending write as one + /// crash-atomic batch. On any failure, undoes the applied bumps and frees the + /// allocations before propagating. + pub fn commit(self, allocator: &A) -> io::Result<()> { + let stack = allocator.stack(); + // Phase 2a: refcount bumps (each atomic on its own counter). + let mut applied = 0usize; + for &off in &self.bumps { + if let Err(e) = refcount::fetch_add(stack, off, 1) { + self.unwind(applied, allocator); + return Err(e); + } + applied += 1; + } + // Phase 2b: one crash-atomic batch of all block payloads. The new blocks + // are distinct ranges, so `set_batched`'s no-overlap rule always holds. + if let Err(e) = stack.set_batched(self.writes.iter().map(|(o, d)| (*o, d.as_slice()))) { + self.unwind(applied, allocator); + return Err(e); + } + Ok(()) + } + + /// Undo the first `applied` bumps and free every allocation. Shared error + /// path for [`commit`](Self::commit). + fn unwind(self, applied: usize, allocator: &A) { + let stack = allocator.stack(); + for &off in &self.bumps[..applied] { + let _ = refcount::fetch_sub(stack, off, 1); + } + Self::free_all(self.allocated, allocator); + } + + fn free_all(allocated: Vec, allocator: &A) { + for r in allocated.into_iter().rev() { + // SAFETY: each range was returned by our own `alloc_raw` and never + // handed to another owner, so freeing it here is sound. + let _ = unsafe { dealloc_range(allocator, r) }; + } + } +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 1a47f07..3402afc 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -68,7 +68,7 @@ pub use block::{ BStackBlock, BStackCast, BStackMove, BStackMoveExpr, BStackShared, BStackWeakable, }; pub use cast::{BStackCastAs, BStackCastInto}; -pub use clone::TryClone; +pub use clone::{ClonePlan, TryClone, TryCloneIn}; pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 4e7a6aa..76e549a 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -15,7 +15,7 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBlock, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, BStackOwned, - BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, alloc_block, + BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; @@ -537,6 +537,77 @@ fn macro_new_and_accessors() { parent.bstack_drop(&alloc).unwrap(); } +// -------------------------------------------------------------------------- +// TryCloneIn — deep clone: owned children copied, shared children re-referenced +// -------------------------------------------------------------------------- + +#[test] +fn macro_clone_deep_owned() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); + let orig_child = parent.handle().child(stack).unwrap(); + + // Deep clone -> a fresh, independent BStackOwned copy. + let clone = parent.try_clone_in(&alloc).unwrap(); + + // Same values read back through the clone. + assert_eq!(clone.handle().tag(stack).unwrap(), 7); + assert_eq!(clone.handle().child(stack).unwrap().val(stack).unwrap(), 42); + + // Independent storage: both the clone's block and its owned child are new + // allocations, distinct from the originals (proves the recursion + repoint). + assert_ne!( + clone.handle().range().start(), + parent.handle().range().start() + ); + assert_ne!( + clone.handle().child(stack).unwrap().range().start(), + orig_child.range().start() + ); + + // Freeing the clone frees only the clone's subtree; the original stays intact. + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(parent.handle().child(stack).unwrap().val(stack).unwrap(), 42); + parent.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_clone_bumps_shared_refcount() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A shared child, kept alive by an extra handle, wired into a parent's + // `#[bstack_strong]` field. After `new` + `try_clone`, strong = 2. + let rc = MacroStrongChild::new(&alloc, 5).unwrap(); + let rc_keep = rc.try_clone().unwrap(); + let parent = MacroStrongParent::new(&alloc, rc).unwrap(); + + // Resolve the child's strong-count offset: parent.s (first user field) -> + // data block -> ctrl back-pointer -> strong counter. + let s_data = crate::refcount::load(stack, parent.handle().range().start() + layout::HEADER_SIZE) + .unwrap(); + let ctrl = crate::refcount::load(stack, s_data + layout::CTRL_BACKPTR_OFFSET).unwrap(); + let strong_off = ctrl + layout::CTRL_STRONG_OFFSET; + assert_eq!(crate::refcount::load(stack, strong_off).unwrap(), 2); + + // Deep-cloning the parent must make the clone's `s` acquire its OWN strong + // reference (a shared child is re-referenced, not deep-copied): 2 -> 3. + let clone = parent.try_clone_in(&alloc).unwrap(); + assert_eq!(crate::refcount::load(stack, strong_off).unwrap(), 3); + + // Both parents release their strong ref: 3 -> 1. `rc_keep` still holds one. + clone.bstack_drop(&alloc).unwrap(); + parent.bstack_drop(&alloc).unwrap(); + assert_eq!(crate::refcount::load(stack, strong_off).unwrap(), 1); + assert_eq!(rc_keep.handle().val(stack).unwrap(), 5); + drop(rc_keep); +} + #[test] fn macro_new_rc_weak() { let tmp = TempStack::new(); From 8f36c4bde6dc18710ab8f40f17b15e813d71f38d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 12:24:58 -0700 Subject: [PATCH 037/140] Add clone vec --- bstack_raii/derive/src/block.rs | 168 +++++++++++++++++++++++-- bstack_raii/src/clone.rs | 8 ++ bstack_raii/src/tests.rs | 215 ++++++++++++++++++++++++++++++++ bstack_raii/src/vec.rs | 80 ++++++++++++ 4 files changed, 463 insertions(+), 8 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 9468261..a497a61 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -230,7 +230,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), }; drop_stmts.push(drop_s); - clone_block_reason = Some("`Vec` / `String`"); + clone_stmts.push(vec_clone_stmt(fname, kind, elem)); accessors.push(acc); let (param, prep, init) = ctor; ctor_params.push(param); @@ -943,6 +943,52 @@ fn vec_move(cap: &Ident, elem: &TokenStream, nullable: bool) -> (TokenStream, To wrap_vec_move(ty, build, cap, nullable) } +/// A `TryCloneIn` statement for any `Vec` / `String` field: reconstruct the +/// source vector from its (already-read) inline descriptor, deep-clone its data +/// block into `__plan` per the element relationship, and repoint `__od`'s inline +/// descriptor at the fresh block. A `data_off` of `0` (a null `Option` / +/// unset vec) is left copied as-is. POD elements are byte-copied; owned elements +/// are deep-cloned (recursing each child); strong/weak elements are +/// re-referenced (their refcount bumped); ref elements are aliased. +fn vec_clone_stmt(fname: &Ident, kind: Kind, elem: &TokenStream) -> TokenStream { + let clone_expr = match kind { + Kind::Pod => quote! { + ::bstack_raii::BStackVec::<#elem, __A>::from_desc(__srcdesc, allocator) + .clone_data_into(__plan)? + }, + Kind::Owned => quote! { + ::bstack_raii::BStackBlockVec::<#elem, __A>::from_desc(__srcdesc, allocator) + .clone_into(__plan, |__er, __p| { + <#elem as ::bstack_raii::BStackBlock>::from_range(__er) + .__bstack_clone_into(allocator, __p) + })? + }, + Kind::Strong => quote! { + ::bstack_raii::BStackStrongVec::<#elem, __A>::from_desc(__srcdesc, allocator) + .clone_into(__plan)? + }, + Kind::Weak => quote! { + ::bstack_raii::BStackWeakVec::<#elem, __A>::from_desc(__srcdesc, allocator) + .clone_into(__plan)? + }, + Kind::Ref => quote! { + ::bstack_raii::BStackRefVec::<#elem, __A>::from_desc(__srcdesc, allocator) + .clone_into(__plan)? + }, + // `#[embed]` never reaches the vec branch (rejected earlier). + Kind::Embed => quote!(unreachable!()), + }; + quote! { + { + let __srcdesc: ::bstack_raii::VecDesc = __od.#fname; + if __srcdesc.data_off != 0 { + let __newdesc: ::bstack_raii::VecDesc = #clone_expr; + __od.#fname = __newdesc; + } + } + } +} + // Block-element vectors (`#[bstack_owned/strong/weak/ref] Vec`) all share // the same inline-descriptor offset-array storage and a uniform codegen-facing // API (`from_field` / `from_field_opt` / `from_desc` / `from_handles` / @@ -2004,6 +2050,10 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = Vec::new(); let mut needs_payload = false; @@ -2080,6 +2130,21 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { + let __off = u64::from_le_bytes(__pl[..8].try_into().unwrap()); + let __child = <#ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __off, + ::core::mem::size_of::< + <#ty as ::bstack_raii::BStackBlock>::OnDisk + >() as u64, + ), + ); + let __new = __child.__bstack_clone_into(allocator, __plan)?; + __pl[..8].copy_from_slice(&__new.start().to_le_bytes()); + } + }); } Kind::Ref => { payload_sizes.push(quote!(8usize)); @@ -2137,6 +2202,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { + let __data = unsafe { #cref }; + __plan.bump_strong(__data, allocator)?; + } + }); } Kind::Weak => { has_shared = true; @@ -2196,6 +2267,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result(#ctrl_ref).bstack_drop(allocator)?; } }); + clone_arms.push(quote! { + #disc => { + let __ctrl_off = u64::from_le_bytes(__pl[..8].try_into().unwrap()); + __plan.bump_weak(__ctrl_off); + } + }); } // `#[embed] V(Child)`: the child's whole on-disk form is stored // INLINE in the payload (header and all). @@ -2265,6 +2342,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::__bstack_drop_children(__embed, allocator)?; } }); + clone_arms.push(quote! { + #disc => { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::Unsupported, + "TryCloneIn: cloning an #[embed] enum variant is not yet implemented", + )); + } + }); } } } @@ -2553,6 +2638,74 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result {} + } + __od.__bstack_payload = __pl; + } + }; + quote! { + let __stack = allocator.stack(); + let __src = self.0; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; + #[allow(unused_mut)] + let mut __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let __dst = __plan.alloc_raw(allocator, ::core::mem::size_of::<#on_disk>() as u64)?; + #dispatch + __plan.write(__dst.start(), ::bstack_raii::bytemuck::bytes_of(&__od).to_vec()); + ::std::result::Result::Ok(__dst) + } + }; + // The public `TryCloneIn` entry point, for plain enums only. + let enum_clone_trait = if mode == Mode::Plain { + quote! { + impl ::bstack_raii::TryCloneIn for #name { + fn try_clone_in<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + ) -> ::std::io::Result<::bstack_raii::BStackOwned> { + let mut __plan = ::bstack_raii::ClonePlan::new(); + let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { + ::std::result::Result::Ok(__d) => __d, + ::std::result::Result::Err(__e) => { + __plan.rollback(allocator); + return ::std::result::Result::Err(__e); + } + }; + __plan.commit(allocator)?; + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__dst), + ) + }) + } + } + } + } else { + quote!() + }; + // `EData` is generic over `<'e, A>` when a variant holds a strong/weak // reference; `EView` only when a weak variant makes `read` upgrade. let data_generics = if has_shared { @@ -2674,9 +2827,10 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( @@ -2684,10 +2838,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<::bstack_raii::BStackRange> { - ::std::result::Result::Err(::std::io::Error::new( - ::std::io::ErrorKind::Unsupported, - "TryCloneIn: cloning an enum block is not yet implemented", - )) + #clone_into_body } } @@ -2797,6 +2948,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 76e549a..0d080ec 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1227,6 +1227,135 @@ fn macro_owned_block_vec_move() { kids_vec.bstack_drop().unwrap(); } +// -------------------------------------------------------------------------- +// TryCloneIn on vector fields — POD data copied, owned children deep-cloned, +// shared elements re-referenced +// -------------------------------------------------------------------------- + +#[test] +fn macro_clone_pod_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42).unwrap(); + let orig_name_off = rec.handle().name(&alloc).unwrap().descriptor().data_off; + + let clone = rec.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().id(stack).unwrap(), 42); + assert_eq!( + clone.handle().name(&alloc).unwrap().to_vec().unwrap(), + b"hello" + ); + assert_eq!( + clone.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3] + ); + + // The clone's data blocks are fresh allocations, distinct from the original's. + let clone_name_off = clone.handle().name(&alloc).unwrap().descriptor().data_off; + assert_ne!(clone_name_off, orig_name_off); + + // Growing the clone's vector leaves the original untouched (independent data). + let mut ct = clone.handle().tags(&alloc).unwrap(); + ct.push(99).unwrap(); + assert_eq!( + clone.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3, 99] + ); + assert_eq!( + rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3] + ); + + clone.bstack_drop(&alloc).unwrap(); + rec.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_clone_owned_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let kids = vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + ]; + let tree = Tree::new(&alloc, kids, 7).unwrap(); + let orig_first = tree + .handle() + .kids(&alloc) + .unwrap() + .get(0) + .unwrap() + .unwrap() + .range() + .start(); + + let clone = tree.try_clone_in(&alloc).unwrap(); + let cv = clone.handle().kids(&alloc).unwrap(); + assert_eq!(cv.len().unwrap(), 2); + let vals: Vec = cv + .to_vec() + .unwrap() + .iter() + .map(|k| k.val(stack).unwrap()) + .collect(); + assert_eq!(vals, vec![10, 20]); + + // Each child is a fresh, independent block (deep clone, not aliased). + let clone_first = cv.get(0).unwrap().unwrap().range().start(); + assert_ne!(clone_first, orig_first); + + // Freeing the clone frees only the clone's children; the original survives. + clone.bstack_drop(&alloc).unwrap(); + assert_eq!( + tree.handle() + .kids(&alloc) + .unwrap() + .get(0) + .unwrap() + .unwrap() + .val(stack) + .unwrap(), + 10 + ); + tree.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_clone_strong_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 100).unwrap(); + let a_keep = a.try_clone().unwrap(); + let a_data = a_keep.handle().range().start(); + let b = MacroStrongChild::new(&alloc, 200).unwrap(); + let b_keep = b.try_clone().unwrap(); + let b_data = b_keep.handle().range().start(); + + let list = StrongList::new(&alloc, vec![a, b], 3).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); // list + a_keep + assert_eq!(strong_of(stack, b_data), 2); + + // Cloning the list re-references each shared element: strong 2 -> 3. + let clone = list.try_clone_in(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 3); + assert_eq!(strong_of(stack, b_data), 3); + + // Freeing both lists releases their references: 3 -> 1. The `keep`s survive. + clone.bstack_drop(&alloc).unwrap(); + list.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 1); + assert_eq!(strong_of(stack, b_data), 1); + assert_eq!(a_keep.handle().val(stack).unwrap(), 100); + drop(a_keep); + drop(b_keep); +} + // -------------------------------------------------------------------------- // #[bstack_strong] / #[bstack_weak] / #[bstack_ref] Vec — block-element // vectors whose annotation states the *elements'* ownership @@ -1632,6 +1761,92 @@ fn macro_enum_strong_weak_variants() { cell.bstack_drop(&alloc).unwrap(); } +// -------------------------------------------------------------------------- +// TryCloneIn on enums — POD copied, owned variant deep-cloned, ref aliased, +// shared variant re-referenced +// -------------------------------------------------------------------------- + +#[test] +fn macro_clone_enum() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // POD variant: value byte-copied into an independent block. + let e = Node::new(&alloc, NodeData::Num(42)).unwrap(); + let c = e.try_clone_in(&alloc).unwrap(); + assert_ne!(c.handle().range().start(), e.handle().range().start()); + match c.handle().read(&alloc).unwrap() { + NodeView::Num(n) => assert_eq!(n, 42), + _ => panic!("expected Num"), + } + c.bstack_drop(&alloc).unwrap(); + e.bstack_drop(&alloc).unwrap(); + + // Owned variant: the child is deep-cloned into a fresh block. + let leaf = MacroLeaf::new(&alloc, 7).unwrap(); + let e = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); + let orig_child_off = match e.handle().read(&alloc).unwrap() { + NodeView::Child(ch) => ch.range().start(), + _ => panic!("expected Child"), + }; + let c = e.try_clone_in(&alloc).unwrap(); + let clone_child_off = match c.handle().read(&alloc).unwrap() { + NodeView::Child(ch) => { + assert_eq!(ch.val(stack).unwrap(), 7); + ch.range().start() + } + _ => panic!("expected Child"), + }; + assert_ne!(clone_child_off, orig_child_off); // deep clone, not aliased + c.bstack_drop(&alloc).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Child(ch) => assert_eq!(ch.val(stack).unwrap(), 7), // original intact + _ => panic!("expected Child"), + } + e.bstack_drop(&alloc).unwrap(); + + // Ref variant: the clone aliases the same target (non-owning). + let keep = MacroLeaf::new(&alloc, 9).unwrap(); + let link = unsafe { BStackRef::from_range(keep.handle().range()) }; + let e = Node::new(&alloc, NodeData::Link(link)).unwrap(); + let c = e.try_clone_in(&alloc).unwrap(); + match c.handle().read(&alloc).unwrap() { + NodeView::Link(l) => { + assert_eq!(l.val(stack).unwrap(), 9); + assert_eq!(l.range().start(), keep.handle().range().start()); // aliased + } + _ => panic!("expected Link"), + } + c.bstack_drop(&alloc).unwrap(); + e.bstack_drop(&alloc).unwrap(); + assert_eq!(keep.handle().val(stack).unwrap(), 9); // target untouched + keep.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_clone_enum_shared() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let child = MacroStrongChild::new(&alloc, 11).unwrap(); + let keep = child.try_clone().unwrap(); + let data = keep.handle().range().start(); + let cell = Cell::new(&alloc, CellData::Shared(child)).unwrap(); + assert_eq!(strong_of(stack, data), 2); // cell + keep + + // Cloning the enum re-references the strong variant's target: 2 -> 3. + let clone = cell.try_clone_in(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 3); + + clone.bstack_drop(&alloc).unwrap(); + cell.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 1); + assert_eq!(keep.handle().val(stack).unwrap(), 11); + drop(keep); +} + // -------------------------------------------------------------------------- // bstack_move! and bstack_cast! on enums // -------------------------------------------------------------------------- diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 567e04b..53ab4b1 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -34,12 +34,27 @@ use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; +use crate::clone::ClonePlan; use crate::handle::WeakRef; use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; +/// Build a fresh data block holding `offs` (an offset array), register it in +/// `plan` for rollback, and return its descriptor. The shared back end of the +/// block-element vector clones, whose elements are all `u64` offsets. +fn build_offset_desc( + allocator: &A, + offs: &[u64], + plan: &mut ClonePlan, +) -> io::Result { + let v = BStackVec::::from_slice(allocator, offs)?; + let desc = v.descriptor(); + plan.track_alloc(BStackRange::new(desc.data_off, desc.data_size)); + Ok(desc) +} + /// The inline, fixed-size descriptor of a persistent vector: the current offset /// and byte size of its (reallocating) data block. /// @@ -218,6 +233,18 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { self.data = bytevec.into_raw_block().as_range(); self.persist() } + + /// Deep-clone this POD vector's data into a fresh block for a [`ClonePlan`]: + /// copy every element into a new data block, register it for rollback, and + /// return its descriptor (what the cloned owner stores inline). The new block + /// is written eagerly by the vector runtime, not staged in the plan's batch. + pub fn clone_data_into(&self, plan: &mut ClonePlan) -> io::Result { + let elems = self.to_vec()?; + let v = BStackVec::from_slice(self.allocator, &elems)?; + let desc = v.descriptor(); + plan.track_alloc(BStackRange::new(desc.data_off, desc.data_size)); + Ok(desc) + } } // --------------------------------------------------------------------------- @@ -338,6 +365,24 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> } self.offsets.bstack_drop() } + + /// Deep-clone this owned vector into a fresh block for a [`ClonePlan`]: + /// deep-clone every child via `clone_elem` (which recurses the child into the + /// plan and returns its new block range), then build a new offset array over + /// the fresh children. The per-element callback is supplied by codegen so it + /// can name the concrete child type's `__bstack_clone_into`. + pub fn clone_into(&self, plan: &mut ClonePlan, mut clone_elem: F) -> io::Result + where + F: FnMut(BStackRange, &mut ClonePlan) -> io::Result, + { + let allocator = self.offsets.allocator(); + let mut new_offs = Vec::new(); + for off in self.offsets.to_vec()? { + let new_block = clone_elem(Self::elem_range(off), plan)?; + new_offs.push(new_block.start()); + } + build_offset_desc(allocator, &new_offs, plan) + } } /// A persistent, growable vector of **strong references** to shared block @@ -455,6 +500,20 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A } self.offsets.bstack_drop() } + + /// Clone this strong vector into a fresh block for a [`ClonePlan`]: the shared + /// children are re-referenced, not copied — bump each element's strong count + /// and keep its data offset, then build a new offset array over the same + /// targets. + pub fn clone_into(&self, plan: &mut ClonePlan) -> io::Result { + let allocator = self.offsets.allocator(); + let offs = self.offsets.to_vec()?; + for &off in &offs { + let data = unsafe { BStackRef::::from_range(Self::elem_range(off)) }; + plan.bump_strong(data, allocator)?; + } + build_offset_desc(allocator, &offs, plan) + } } /// A persistent, growable vector of **weak references** to `(rc, weak)` block @@ -565,6 +624,18 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A } self.offsets.bstack_drop() } + + /// Clone this weak vector into a fresh block for a [`ClonePlan`]: bump each + /// element's weak count and keep its control offset, then build a new offset + /// array over the same control blocks. + pub fn clone_into(&self, plan: &mut ClonePlan) -> io::Result { + let allocator = self.offsets.allocator(); + let offs = self.offsets.to_vec()?; + for &off in &offs { + plan.bump_weak(off); + } + build_offset_desc(allocator, &offs, plan) + } } /// A persistent, growable vector of **raw references** to block children. @@ -665,4 +736,13 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { pub fn bstack_drop(self) -> io::Result<()> { self.offsets.bstack_drop() } + + /// Clone this ref vector into a fresh block for a [`ClonePlan`]: the elements + /// are non-owning, so copy the offset array verbatim (the clone aliases the + /// same targets). + pub fn clone_into(&self, plan: &mut ClonePlan) -> io::Result { + let allocator = self.offsets.allocator(); + let offs = self.offsets.to_vec()?; + build_offset_desc(allocator, &offs, plan) + } } From 720118db31583a150dec8a4d380f3d366df8221f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 12:50:05 -0700 Subject: [PATCH 038/140] Add clone for rc --- bstack_raii/derive/src/block.rs | 32 +++++++++++++++++++++----------- bstack_raii/src/clone.rs | 33 ++++++++++++++++++++++++++++----- bstack_raii/src/lib.rs | 15 +++++++++++++++ bstack_raii/src/shared.rs | 10 ++++++++++ 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index a497a61..e2f5d0a 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -627,16 +627,24 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // owned child of any kind can be recursed into); it does the real work for a // plain block whose fields are all clone-supported, and returns a runtime // error otherwise. The public `TryCloneIn` entry point is generated for plain - // blocks only — `rc` / `rc, weak` blocks are shared, not deep-cloned to owned - // (re-initializing their injected refcount / control block is a later step). - let clone_reason: Option = if mode != Mode::Plain { - Some("a reference count (`rc` / `rc, weak`)".to_string()) + // blocks only — a shared (`rc` / `rc, weak`) block is cloned by duplicating + // its handle (a refcount bump) via `BStackRc::try_clone`, never deep-copied to + // an owned block, so it never implements `TryCloneIn`. + let clone_into_body = if mode != Mode::Plain { + // Reachable only by `#[bstack_owned]`-owning a shared block (a misuse: + // shared blocks are referenced, not owned) and deep-cloning the owner. + quote! { + ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::Unsupported, + "TryCloneIn: a reference-counted (`rc` / `rc, weak`) block is shared, \ + not deep-cloned — duplicate its handle with `BStackRc::try_clone` \ + (see the `TryClone` trait)", + )) + } + } else if let Some(what) = clone_block_reason { + clone_unsupported_body(what) } else { - clone_block_reason.map(|s| s.to_string()) - }; - let clone_into_body = match &clone_reason { - Some(what) => clone_unsupported_body(what), - None => quote! { + quote! { let __stack = allocator.stack(); let __src = ::bstack_raii::BStackBlock::range(self); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; @@ -653,7 +661,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::bstack_raii::bytemuck::bytes_of(&__od).to_vec(), ); ::std::result::Result::Ok(__dst) - }, + } }; let clone_into_method = quote! { impl #name { @@ -2647,7 +2655,9 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result io::Result; } @@ -56,9 +71,17 @@ pub trait TryClone: Sized { /// Deep-clone a whole block into a fresh, independent [`BStackOwned`] copy, /// allocating the copy with the supplied allocator. /// -/// Generated by `#[bstack_block]` for plain blocks. Owned children are deep -/// copied; shared (`#[bstack_strong]` / `#[bstack_weak]`) children stay shared -/// (their refcount is bumped); POD and `#[bstack_ref]` fields are byte-copied. +/// Generated by `#[bstack_block]` for **plain** (uniquely-owned) blocks only. +/// Owned children are deep copied; shared (`#[bstack_strong]` / `#[bstack_weak]`) +/// children stay shared (their refcount is bumped); POD and `#[bstack_ref]` +/// fields are byte-copied. +/// +/// A **shared** (`rc` / `rc, weak`) block does *not* implement this — its clone +/// is a handle duplication (a refcount bump) via [`TryClone`], not a deep copy, +/// so `try_clone_in` on such a block is a compile error that points you at +/// `BStackRc::try_clone` / `BStackWeak::try_clone` instead. See [`TryClone`] for +/// why (in particular, why a weak reference can only ever be cloned as another +/// weak reference). pub trait TryCloneIn: BStackDrop + Sized { fn try_clone_in( &self, diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 3402afc..fb1aafc 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -294,5 +294,20 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// /// (Tuple structs of POD fields and unit structs are **valid**, not errors — see /// the tests.) +/// +/// --- +/// +/// A **shared** (`rc` / `rc, weak`) block has no `TryCloneIn`: it is cloned by +/// duplicating its handle (a refcount bump) via `BStackRc::try_clone`, not +/// deep-copied to an owned block, so `try_clone_in` on one is a compile error: +/// ```compile_fail +/// use bstack_raii::{bstack_block, BStackOwnedSliceAllocator, TryCloneIn}; +/// #[bstack_block(rc)] +/// struct S { v: u32 } +/// fn f(s: &S, a: &A) { +/// let _ = s.try_clone_in(a); // no such method on a shared block +/// } +/// # fn main() {} +/// ``` #[doc(hidden)] pub mod __macro_compile_fail_tests {} diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 4357721..4c9c8e5 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -116,6 +116,10 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { } } +/// Cloning a strong handle bumps the block's strong count and returns another +/// handle to the **same** block — sharing, not copying (like `Rc::clone`). This +/// is the clone semantics for a shared block; there is deliberately no +/// deep-copy-to-owned (`TryCloneIn`) for one. impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { refcount::fetch_add(self.allocator().stack(), self.strong_offset(), 1)?; @@ -251,6 +255,12 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { } } +/// Cloning a weak handle bumps the control block's weak count and returns +/// another weak handle to the **same** control block. This is the *only* sound +/// meaning of a weak clone: a weak reference observes a live object's control +/// block, and a copy that observed anything else would not be observing what the +/// original does. So a weak clone shares the observation (a count bump) rather +/// than deep-copying — there is no `TryCloneIn` for a weak reference. impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWeak<'a, T, A> { fn try_clone(&self) -> io::Result { let weak_off = self.ctrl().into_range().start() + layout::CTRL_WEAK_OFFSET; From 608c48fb0a37db83cc871cc60290b3bae8ac4a04 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 13:18:12 -0700 Subject: [PATCH 039/140] Try clone for all --- bstack_raii/derive/src/block.rs | 149 +++++++++++++++++++++----------- bstack_raii/src/tests.rs | 56 ++++++++++++ 2 files changed, 156 insertions(+), 49 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index e2f5d0a..74c0659 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -96,12 +96,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } let mut drop_stmts = Vec::new(); - // `TryCloneIn` deep-clone statements for scalar fields, mirroring `drop_stmts` - // in reverse. If a field kind whose clone is not yet supported (vec / embed) - // is present, `clone_block_reason` is set and the whole clone short-circuits - // to a runtime error instead (so the block still compiles). + // `TryCloneIn` deep-clone statements for user fields, mirroring `drop_stmts` + // in reverse (owned → recurse, strong/weak → refcount bump, embed → fold + // inline, vec → per-element; POD / ref are byte-copied so emit nothing). let mut clone_stmts = Vec::new(); - let mut clone_block_reason: Option<&'static str> = None; let mut pod_types: Vec<&Type> = Vec::new(); let mut accessors = Vec::new(); let mut setters = Vec::new(); @@ -381,7 +379,21 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } }); - clone_block_reason = Some("`#[embed]`"); + // Clone: fold the embedded child's clone inline — deep-clone its own + // children into the plan and store the fixed-up child OnDisk in place + // (no separate child allocation, mirroring the in-place teardown). + clone_stmts.push(quote! { + { + let __child = <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __src.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + ::core::mem::size_of::<#child_od>() as u64, + ), + ); + __od.#fname = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + } + }); continue; } @@ -623,48 +635,70 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }; - // Deep clone. `__bstack_clone_into` is generated for every block (so an - // owned child of any kind can be recursed into); it does the real work for a - // plain block whose fields are all clone-supported, and returns a runtime - // error otherwise. The public `TryCloneIn` entry point is generated for plain - // blocks only — a shared (`rc` / `rc, weak`) block is cloned by duplicating - // its handle (a refcount bump) via `BStackRc::try_clone`, never deep-copied to - // an owned block, so it never implements `TryCloneIn`. - let clone_into_body = if mode != Mode::Plain { - // Reachable only by `#[bstack_owned]`-owning a shared block (a misuse: - // shared blocks are referenced, not owned) and deep-cloning the owner. - quote! { + // Deep clone. `__bstack_clone_children_inplace` reads this block's OnDisk and + // returns a fixed-up copy — owned children deep-cloned into `__plan`, shared + // children's refcounts bumped, embedded children cloned in place — without + // allocating a block for `self` (so an `#[embed]` parent can fold it inline). + // `__bstack_clone_into` layers on the destination allocation + staged write. + // Both are generated for every block (so an owned/embedded child of any kind + // can be recursed into) but do real work only for a plain block; a shared + // (`rc` / `rc, weak`) block returns an error — its clone is a handle + // duplication via `BStackRc::try_clone`, never a deep copy. The public + // `TryCloneIn` entry point is generated for plain blocks only. + let (clone_children_body, clone_into_body) = if mode != Mode::Plain { + // Reachable only by owning / embedding a shared block (a misuse: shared + // blocks are referenced, not owned) and deep-cloning the owner. + let err = quote! { ::std::result::Result::Err(::std::io::Error::new( ::std::io::ErrorKind::Unsupported, "TryCloneIn: a reference-counted (`rc` / `rc, weak`) block is shared, \ not deep-cloned — duplicate its handle with `BStackRc::try_clone` \ (see the `TryClone` trait)", )) - } - } else if let Some(what) = clone_block_reason { - clone_unsupported_body(what) + }; + (err.clone(), err) } else { - quote! { + let children = quote! { let __stack = allocator.stack(); let __src = ::bstack_raii::BStackBlock::range(self); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; #[allow(unused_mut)] let mut __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + #(#clone_stmts)* + ::std::result::Result::Ok(__od) + }; + let into = quote! { + let __od = self.__bstack_clone_children_inplace(allocator, __plan)?; let __dst = __plan.alloc_raw( allocator, ::core::mem::size_of::<#on_disk>() as u64, )?; - #(#clone_stmts)* __plan.write( __dst.start(), ::bstack_raii::bytemuck::bytes_of(&__od).to_vec(), ); ::std::result::Result::Ok(__dst) - } + }; + (children, into) }; let clone_into_method = quote! { impl #name { + /// Read this block's OnDisk and return a deep-cloned copy: owned + /// children cloned into `__plan`, shared children's refcounts bumped, + /// embedded children folded in place. Does **not** allocate a block for + /// `self` — used to fold an `#[embed]`ded child inline into its parent's + /// clone, and by `__bstack_clone_into` before the self-allocation. + #[doc(hidden)] + #[allow(unused_variables)] + #vis fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<#on_disk> { + #clone_children_body + } + /// Deep-clone this block's subtree into `__plan`: allocate a fresh /// destination block, recurse into owned children (bumping shared /// children's refcounts), and stage the destination payload — @@ -1588,22 +1622,11 @@ fn clone_field_stmt(fname: &Ident, inner_ty: &Type, kind: Kind) -> Option None, } } -/// The body of a not-yet-supported deep clone: a runtime error naming what makes -/// the block unclonable. The block still compiles; only `try_clone_in` fails. -fn clone_unsupported_body(what: &str) -> TokenStream { - let msg = format!("TryCloneIn: cloning a block with {what} is not yet implemented"); - quote! { - ::std::result::Result::Err(::std::io::Error::new( - ::std::io::ErrorKind::Unsupported, - #msg, - )) - } -} - /// Parsed `#[bstack_block(...)]` / `#[bstack_enum(...)]` arguments. struct Attr { mode: Mode, @@ -2352,10 +2375,18 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - return ::std::result::Result::Err(::std::io::Error::new( - ::std::io::ErrorKind::Unsupported, - "TryCloneIn: cloning an #[embed] enum variant is not yet implemented", - )); + let __child = <#ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + self.0.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) + as u64, + ::core::mem::size_of::<#co>() as u64, + ), + ); + let __fixed = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + __pl[..::core::mem::size_of::<#co>()] + .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__fixed)); } }); } @@ -2651,15 +2682,16 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; #[allow(unused_mut)] let mut __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; - let __dst = __plan.alloc_raw(allocator, ::core::mem::size_of::<#on_disk>() as u64)?; #dispatch + ::std::result::Result::Ok(__od) + }; + let into = quote! { + let __od = self.__bstack_clone_children_inplace(allocator, __plan)?; + let __dst = __plan.alloc_raw(allocator, ::core::mem::size_of::<#on_disk>() as u64)?; __plan.write(__dst.start(), ::bstack_raii::bytemuck::bytes_of(&__od).to_vec()); ::std::result::Result::Ok(__dst) - } + }; + (children, into) }; // The public `TryCloneIn` entry point, for plain enums only. let enum_clone_trait = if mode == Mode::Plain { @@ -2837,10 +2874,24 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<#on_disk> { + #clone_children_body + } + + /// Deep-clone this enum into a `ClonePlan`: allocate a fresh block and + /// stage its fixed-up payload. Returns the new block's range. Also lets + /// an owned enum child of a struct be recursed into. #[doc(hidden)] #[allow(unused_variables)] #vis fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 0d080ec..ea0cb85 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2319,3 +2319,59 @@ fn macro_embed_struct_and_enum() { assert_eq!(moved.handle().n(stack).unwrap(), 9); moved.bstack_drop(&alloc).unwrap(); } + +#[test] +fn macro_clone_embed() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Struct embed: holder -> inline child -> the child's own owned leaf. + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let child = EmbChild::new(&alloc, leaf, 7).unwrap(); + let holder = EmbHolder::new(&alloc, child, 99).unwrap(); + let orig_leaf_off = holder.handle().child().leaf(stack).unwrap().range().start(); + + let clone = holder.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().tag(stack).unwrap(), 99); + let cc = clone.handle().child(); + assert_eq!(cc.n(stack).unwrap(), 7); + assert_eq!(cc.leaf(stack).unwrap().val(stack).unwrap(), 42); + + // The embedded child's OWN owned leaf was deep-cloned into a fresh block + // (the inline region was folded, not just byte-copied with an aliased offset). + let clone_leaf_off = cc.leaf(stack).unwrap().range().start(); + assert_ne!(clone_leaf_off, orig_leaf_off); + + // Freeing the clone frees only the clone's leaf; the original stays intact. + clone.bstack_drop(&alloc).unwrap(); + assert_eq!( + holder.handle().child().leaf(stack).unwrap().val(stack).unwrap(), + 42 + ); + holder.bstack_drop(&alloc).unwrap(); + + // Enum embed variant: same in-place fold through the payload. + let leaf = MacroLeaf::new(&alloc, 3).unwrap(); + let child = EmbChild::new(&alloc, leaf, 9).unwrap(); + let e = EmbEnum::new(&alloc, EmbEnumData::Wrap(child)).unwrap(); + let orig_off = match e.handle().read(&alloc).unwrap() { + EmbEnumView::Wrap(c) => c.leaf(stack).unwrap().range().start(), + _ => panic!("expected Wrap"), + }; + let ce = e.try_clone_in(&alloc).unwrap(); + let clone_off = match ce.handle().read(&alloc).unwrap() { + EmbEnumView::Wrap(c) => { + assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3); + c.leaf(stack).unwrap().range().start() + } + _ => panic!("expected Wrap"), + }; + assert_ne!(clone_off, orig_off); // deep-cloned, not aliased + ce.bstack_drop(&alloc).unwrap(); + match e.handle().read(&alloc).unwrap() { + EmbEnumView::Wrap(c) => assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3), + _ => panic!("expected Wrap"), + } + e.bstack_drop(&alloc).unwrap(); +} From 3deadae650cf60942fee5658b8b822566603914e Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 13:32:10 -0700 Subject: [PATCH 040/140] Add README section of cloning --- bstack_raii/README.md | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 5886c28..0ff8f42 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -36,6 +36,7 @@ object model on top. - [Enums: `#[bstack_enum]`](#enums-bstack_enum) - [Field types](#field-types) - [Moving out: `bstack_move!`](#moving-out-bstack_move) +- [Cloning: `TryCloneIn` / `TryClone`](#cloning-tryclonein--tryclone) - [Casting: `bstack_cast!`](#casting-bstack_cast) - [Type tags (`EightCC`)](#type-tags-eightcc) - [Limitations](#limitations) @@ -534,6 +535,64 @@ a tuple. > re-attaching it (into another block's field) or freeing it and it becomes > unreachable garbage. Persistence comes from being reachable through a struct. +## Cloning: `TryCloneIn` / `TryClone` + +Duplicating a handle means one of two things, depending on whether the block is +uniquely owned or shared. + +### Deep-clone an owned block: `TryCloneIn` + +A plain `#[bstack_block]` / `#[bstack_enum]` implements `TryCloneIn`, a **deep**, +fallible clone into a fresh, independent `BStackOwned`: + +```rust +use bstack_raii::TryCloneIn; + +let copy: BStackOwned = node.try_clone_in(&alloc)?; +``` + +Each field is duplicated according to its ownership — the mirror of teardown: + +| Field | On clone | +|-----------------------|-------------------------------------------------------------------| +| POD / `#[bstack_ref]` | byte-copied (a ref clone **aliases** the same target) | +| `#[bstack_owned]` | the child is recursively deep-cloned into a fresh block | +| `#[embed]` | the inline child is folded — its own children deep-cloned in place | +| `#[bstack_strong]` | the shared child stays shared; its strong count is bumped | +| `#[bstack_weak]` | stays weak to the same target; its weak count is bumped | +| `Vec` | per element, by the vector's annotation (POD data copied; owned elements deep-cloned; strong/weak bumped; ref aliased) | + +So an owned subtree is copied into independent storage while shared children are +*re-referenced* rather than duplicated: freeing the clone never disturbs the +original's owned data, and a shared target stays live as long as either handle +holds it. + +> **Atomicity.** A clone allocates the whole new subtree up front, then commits +> every payload write as one crash-atomic batch (`BStack::set_batched`): a +> mid-clone allocation failure rolls back with nothing written, and a crash can +> leak the fresh allocations but never leaves a torn copy. + +### Duplicate a shared handle: `TryClone` + +A shared block is **not** deep-cloned. `BStackRc` / `BStackWeak` implement +`TryClone`, whose `try_clone` bumps the on-disk refcount and hands back another +handle to the *same* block — exactly like `Rc::clone` / `shared_ptr`: + +```rust +use bstack_raii::TryClone; + +let rc2 = rc.try_clone()?; // another strong owner of the same block +let weak2 = weak.try_clone()?; // another weak observer of the same block +``` + +An `(rc)` / `(rc, weak)` block therefore has no `try_clone_in` — calling it is a +compile error. This is deliberate: sharing, not copying, is what a reference +count *means*. It is clearest for a **weak** reference, which has no coherent deep +copy at all: a weak reference observes a live object's control block, and a "copy" +would either point at the same object (just another weak handle — a count bump) or +at some other object (observing nothing the original did — not a copy). So a weak +clone can only ever be another weak reference to the same target. + ## Casting: `bstack_cast!` Convert between typed handles and the untyped `bstack` primitives. Upcasts are From 819eb6c4e2207dfaa966df837f3c91822ebaa370 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 21:30:14 -0700 Subject: [PATCH 041/140] Ensure atomicity for vec --- bstack_raii/src/clone.rs | 165 ++++++++++++++++++++++++++++------- bstack_raii/src/construct.rs | 18 ++-- bstack_raii/src/tests.rs | 29 ++++++ bstack_raii/src/vec.rs | 76 ++++++++++++---- 4 files changed, 237 insertions(+), 51 deletions(-) diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 6624062..87088fe 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -37,14 +37,14 @@ use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::block::BStackShared; use crate::layout; use crate::owned::BStackOwned; use crate::reference::BStackRef; -use crate::refcount; use crate::teardown::{BStackDrop, dealloc_range}; +use crate::vec::{BYTEVEC_HEADER, VecDesc}; /// Duplicate `self`, performing any fallible I/O the duplication requires, /// **without** needing an allocator. @@ -142,13 +142,38 @@ impl ClonePlan { } /// Register an already-allocated range for rollback — for allocations made - /// outside [`alloc_raw`](Self::alloc_raw) (e.g. a vector data block built and - /// written eagerly by the vector runtime, whose bytes are committed on - /// creation rather than staged in [`write`](Self::write)). + /// outside [`alloc_raw`](Self::alloc_raw). pub fn track_alloc(&mut self, range: BStackRange) { self.allocated.push(range); } + /// Stage a fresh `BStackByteVec` data block holding `data` into the plan: + /// allocate the block (`[len | cap | data]`, `16`-byte header) via + /// [`alloc_raw`](Self::alloc_raw), stage its full on-disk image into the + /// commit batch, and return its descriptor. This folds a cloned vector's data + /// block into the plan's single atomic commit — the block is allocated through + /// our machinery and its bytes ride the same `inplace_gen` as everything else, + /// instead of the vector runtime writing it eagerly. `cap == len` (a fresh + /// clone carries no spare capacity, matching `BStackByteVec::from_slice`). + pub fn stage_bytevec( + &mut self, + allocator: &A, + data: &[u8], + ) -> io::Result { + let len = data.len() as u64; + let size = BYTEVEC_HEADER + len; + let range = self.alloc_raw(allocator, size)?; + let mut image = Vec::with_capacity(size as usize); + image.extend_from_slice(&len.to_le_bytes()); // len @ 0 + image.extend_from_slice(&len.to_le_bytes()); // cap @ 8 (== len) + image.extend_from_slice(data); // elements @ 16 + self.write(range.start(), image); + Ok(VecDesc { + data_off: range.start(), + data_size: size, + }) + } + /// Record that the strong count of a shared child at `data` must be bumped by /// one — the strong reference this clone's `#[bstack_strong]` field acquires. pub fn bump_strong( @@ -178,37 +203,117 @@ impl ClonePlan { Self::free_all(self.allocated, allocator); } - /// Apply the refcount bumps, then commit every pending write as one - /// crash-atomic batch. On any failure, undoes the applied bumps and frees the - /// allocations before propagating. + /// Commit the whole plan as **one** crash-atomic unit: every refcount bump + /// (as an atomic read-modify-write) and every staged payload / vec-data write, + /// via a single [`BStack::inplace_gen`]. Either all of it lands or none of it + /// does. On failure (I/O, or a refcount overflow) nothing is committed and the + /// plan's allocations are freed. + /// + /// Bumps are de-duplicated to distinct counters (a shared child referenced N + /// times in the clone is one counter `+N`), then applied as **all reads, then + /// all writes**: because the counters are distinct, no read depends on another + /// bump's pending write, so an overflow can be detected after the reads — + /// before any write is emitted — and abort with nothing committed. pub fn commit(self, allocator: &A) -> io::Result<()> { + let ClonePlan { + allocated, + writes, + mut bumps, + } = self; let stack = allocator.stack(); - // Phase 2a: refcount bumps (each atomic on its own counter). - let mut applied = 0usize; - for &off in &self.bumps { - if let Err(e) = refcount::fetch_add(stack, off, 1) { - self.unwind(applied, allocator); - return Err(e); + + // De-duplicate bump offsets into distinct `(counter, delta)`. + bumps.sort_unstable(); + let mut counters: Vec<(u64, u64)> = Vec::new(); + for off in bumps { + match counters.last_mut() { + Some(last) if last.0 == off => last.1 += 1, + _ => counters.push((off, 1)), } - applied += 1; } - // Phase 2b: one crash-atomic batch of all block payloads. The new blocks - // are distinct ranges, so `set_batched`'s no-overlap rule always holds. - if let Err(e) = stack.set_batched(self.writes.iter().map(|(o, d)| (*o, d.as_slice()))) { - self.unwind(applied, allocator); - return Err(e); - } - Ok(()) - } - /// Undo the first `applied` bumps and free every allocation. Shared error - /// path for [`commit`](Self::commit). - fn unwind(self, applied: usize, allocator: &A) { - let stack = allocator.stack(); - for &off in &self.bumps[..applied] { - let _ = refcount::fetch_sub(stack, off, 1); + // Buffers that must outlive the whole `inplace_gen` call (bstack's + // documented generator pattern): the read-back counter values and the + // computed new values. `writes` is borrowed for its `Write` data. + let n = counters.len(); + let mut readbufs: Vec<[u8; 8]> = vec![[0u8; 8]; n]; + let mut newbufs: Vec<[u8; 8]> = vec![[0u8; 8]; n]; + let mut overflow = false; + + let mut read_i = 0usize; + let mut computed = false; + let mut cwrite_i = 0usize; + let mut write_i = 0usize; + + let result = stack.inplace_gen(|_feedback| { + // Phase 1 — read every distinct counter. + if read_i < n { + let i = read_i; + read_i += 1; + // SAFETY: `readbufs` outlives this `inplace_gen` call and each + // read buffer is used by exactly one in-flight `Read` op. + let buf: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut readbufs[i][..]) }; + return Some(BStackGenOp::Read { + offset: counters[i].0, + buf, + }); + } + // Transition — compute new values + check overflow before any write. + if !computed { + computed = true; + for i in 0..n { + let cur = u64::from_le_bytes(readbufs[i]); + match cur.checked_add(counters[i].1) { + Some(v) => newbufs[i] = v.to_le_bytes(), + None => overflow = true, + } + } + if overflow { + // No `Write` emitted yet → ending now commits nothing. + return None; + } + } + // Phase 2a — write the incremented counters. + if cwrite_i < n { + let i = cwrite_i; + cwrite_i += 1; + // SAFETY: `newbufs` outlives this call; each slot is written once + // above and only read here. + let data: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(&newbufs[i][..]) }; + return Some(BStackGenOp::Write { + offset: counters[i].0, + data, + }); + } + // Phase 2b — write every staged payload / vec-data block. + if write_i < writes.len() { + let i = write_i; + write_i += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives this call; each entry is read once. + let data: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data }); + } + None + }); + + match result { + Ok(()) if overflow => { + Self::free_all(allocated, allocator); + Err(io::Error::new( + io::ErrorKind::InvalidData, + "refcount overflow while committing clone", + )) + } + Ok(()) => Ok(()), + // `inplace_gen` is atomic: on error nothing committed, so just free + // the plan's allocations (no bumps to undo — none were applied). + Err(e) => { + Self::free_all(allocated, allocator); + Err(e) + } } - Self::free_all(self.allocated, allocator); } fn free_all(allocated: Vec, allocator: &A) { diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 7ec411e..98753ce 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -113,10 +113,21 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( ) -> io::Result<()> { let stack = allocator.stack(); - // Release the control reference the field previously held, if any. + // Read the old target before overwriting it. let mut buf = [0u8; 8]; stack.get_into(field_off, &mut buf)?; let old = u64::from_le_bytes(buf); + + // Commit the new pointer FIRST, as a single atomic write: the live field + // transitions directly from the old target to the new one and is never + // observed pointing at a released control block. `new_weak` is consumed + // without decrementing — its weak count becomes the field's. + let ctrl = new_weak.into_raw(); + stack.set(field_off, ctrl.into_range().start().to_le_bytes())?; + + // Only now release the old target — pure reclamation, since the field no + // longer refers to it. A crash before this leaks at most the old control + // block (its weak count stays one too high), never a dangling field. if old != 0 { let old_ctrl = unsafe { BStackRef::::from_range(BStackRange::new( @@ -126,10 +137,7 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( }; WeakRef::(old_ctrl).bstack_drop(allocator)?; } - - // Store the new control offset; the consumed weak's count is now the field's. - let ctrl = new_weak.into_raw(); - stack.set(field_off, ctrl.into_range().start().to_le_bytes()) + Ok(()) } /// Attempt to upgrade a `#[bstack_weak]` field (holding a control-block offset at diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index ea0cb85..c7d37f0 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1135,6 +1135,35 @@ fn macro_vec_string_fields() { rec2.bstack_drop(&alloc).unwrap(); } +#[test] +fn macro_vec_field_push_growth_reclaims_old() { + // A field-resident growth push uses allocate → commit → free: the descriptor + // moves to a fresh block and the OLD block is reclaimed (not leaked). + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let rec = Record::new(&alloc, "hi", &[1u32, 2, 3], 0).unwrap(); + let old = rec.handle().tags(&alloc).unwrap().descriptor(); // cap == len == 12 B + + // len 12 + elem 4 > cap 12 → field-resident growth → the reorder path. + let mut tags = rec.handle().tags(&alloc).unwrap(); + tags.push(4).unwrap(); + + let new = rec.handle().tags(&alloc).unwrap().descriptor(); + assert_ne!(new.data_off, old.data_off); // moved to a fresh block + assert_eq!( + rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3, 4] + ); + + // The old block's slot is reclaimed: a probe of its size reuses its offset. + let probe = alloc_block(&alloc, MacroLeaf::eightcc(), old.data_size).unwrap(); + assert_eq!(probe.start(), old.data_off); + unsafe { dealloc_range(&alloc, probe).unwrap() }; + + rec.bstack_drop(&alloc).unwrap(); +} + #[test] fn macro_vec_bstack_move() { let tmp = TempStack::new(); diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 53ab4b1..3663927 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -41,6 +41,12 @@ use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; +/// The on-disk header length of a `BStackByteVec` block: `len: u64` @ 0, +/// `cap: u64` @ 8, elements from offset 16. Fixed by bstack's ABI (stable across +/// `0.4.x`). Used where we build a byte-vec block image by hand to keep a +/// mutation crash-atomic. +pub(crate) const BYTEVEC_HEADER: u64 = 16; + /// Build a fresh data block holding `offs` (an offset array), register it in /// `plan` for rollback, and return its descriptor. The shared back end of the /// block-element vector clones, whose elements are all `u64` offsets. @@ -49,10 +55,9 @@ fn build_offset_desc( offs: &[u64], plan: &mut ClonePlan, ) -> io::Result { - let v = BStackVec::::from_slice(allocator, offs)?; - let desc = v.descriptor(); - plan.track_alloc(BStackRange::new(desc.data_off, desc.data_size)); - Ok(desc) + // Fold the offset-array block into the plan: allocated through our machinery, + // its bytes committed in the plan's single atomic batch. + plan.stage_bytevec(allocator, bytemuck::cast_slice(offs)) } /// The inline, fixed-size descriptor of a persistent vector: the current offset @@ -223,15 +228,55 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { .collect()) } - /// Append an element, growing the data block if needed (which may move it — - /// the inline descriptor is rewritten to follow, if field-resident). + /// Append an element, growing the data block if needed. + /// + /// When the element fits the current capacity, or this is a **detached** vec + /// (no live on-disk descriptor), the ordinary path is used — no block move is + /// observable. When a **field-resident** vec must grow (a `realloc` could + /// *move* the block, freeing the old one before the inline descriptor is + /// rewritten, momentarily leaving the live descriptor pointing at freed + /// space), it instead allocates a new larger block, **commits** the descriptor + /// to it in one atomic write, then frees the old block — allocate → commit → + /// free, so the live descriptor is never observed dangling. pub fn push(&mut self, value: T) -> io::Result<()> { - let mut bytevec = self.bytes()?; - for &b in bytemuck::bytes_of(&value) { - bytevec.push(b)?; + let bytevec = self.bytes()?; + let elem = size_of::() as u64; + let len = bytevec.len()?; + let cap = bytevec.capacity()?; + + if len + elem <= cap || self.writeback.is_none() { + let mut bytevec = bytevec; + for &b in bytemuck::bytes_of(&value) { + bytevec.push(b)?; + } + self.data = bytevec.into_raw_block().as_range(); + return self.persist(); + } + + // Field-resident growth: build the new block, commit, then free the old. + let mut bytes = bytevec.read_bytes()?; + bytes.extend_from_slice(bytemuck::bytes_of(&value)); + let new_len = bytes.len() as u64; + let new_cap = core::cmp::max(cap.saturating_mul(2), new_len); + let old = self.data; + + let mut slice = self.allocator.alloc(BYTEVEC_HEADER + new_cap)?; + let new_range = slice.as_range(); + let mut image = Vec::with_capacity((BYTEVEC_HEADER + new_len) as usize); + image.extend_from_slice(&new_len.to_le_bytes()); // len @ 0 + image.extend_from_slice(&new_cap.to_le_bytes()); // cap @ 8 + image.extend_from_slice(&bytes); // elements @ 16 + if let Err(e) = slice.write_range(0, &image) { + let _ = self.allocator.dealloc(slice); + return Err(e); } - self.data = bytevec.into_raw_block().as_range(); - self.persist() + + // Commit: repoint the (in-memory + inline) descriptor at the new block. + self.data = new_range; + self.persist()?; + // Reclaim the old block (a crash before here leaks it; never dangles). + unsafe { dealloc_range(self.allocator, old)? }; + Ok(()) } /// Deep-clone this POD vector's data into a fresh block for a [`ClonePlan`]: @@ -239,11 +284,10 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { /// return its descriptor (what the cloned owner stores inline). The new block /// is written eagerly by the vector runtime, not staged in the plan's batch. pub fn clone_data_into(&self, plan: &mut ClonePlan) -> io::Result { - let elems = self.to_vec()?; - let v = BStackVec::from_slice(self.allocator, &elems)?; - let desc = v.descriptor(); - plan.track_alloc(BStackRange::new(desc.data_off, desc.data_size)); - Ok(desc) + // Fold the data block into the plan: read the source elements, then let + // the plan allocate + stage a fresh block so it rides the atomic commit. + let bytes = self.bytes()?.read_bytes()?; + plan.stage_bytevec(self.allocator, &bytes) } } From 0318bb85cbcc1a508cbfce1de47a2e7cd1c92056 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 25 Jul 2026 22:24:00 -0700 Subject: [PATCH 042/140] batch rc --- bstack_raii/derive/src/block.rs | 363 ++++++++++++++++++++------------ bstack_raii/src/bulk.rs | 64 ++++++ bstack_raii/src/construct.rs | 43 ++-- bstack_raii/src/lib.rs | 6 +- 4 files changed, 322 insertions(+), 154 deletions(-) create mode 100644 bstack_raii/src/bulk.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 74c0659..060a4af 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1443,80 +1443,123 @@ fn constructor( preps: &[TokenStream], inits: &[TokenStream], ) -> TokenStream { - let injected = match mode { - Mode::Plain => quote!(), - Mode::Rc => quote!(__bstack_refcount: 1u64,), - Mode::RcWeak => quote!(__bstack_ctrl: 0u64,), - }; - let ret = match mode { - Mode::Plain => quote!(::bstack_raii::BStackOwned), - _ => quote!(::bstack_raii::BStackRc<'__ctor, Self, __A>), - }; - let finish = match mode { - Mode::Plain => quote! { - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackOwned::from_raw( - ::from_range(__data), - ) - }) - }, - Mode::Rc => quote! { - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackRc::from_raw( - ::bstack_raii::BStackRef::from_range(__data), - ::core::option::Option::None, - allocator, - ) - }) + let header = quote! { + __bstack_header: ::bstack_raii::BlockHeader { + size: ::core::mem::size_of::<#on_disk>() as u64, + tag: ::eightcc(), }, - Mode::RcWeak => { + }; + let size = quote!(::core::mem::size_of::<#on_disk>() as u64); + + match mode { + // Plain and `rc` are already a single atomic write: the injected refcount + // is baked into the OnDisk image, so one `alloc` + one `write_range` fully + // constructs the block (a crash before the write just orphans the block). + Mode::Plain | Mode::Rc => { + let injected = if let Mode::Rc = mode { + quote!(__bstack_refcount: 1u64,) + } else { + quote!() + }; + let ret = if let Mode::Rc = mode { + quote!(::bstack_raii::BStackRc<'__ctor, Self, __A>) + } else { + quote!(::bstack_raii::BStackOwned) + }; + let finish = if let Mode::Rc = mode { + quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::None, + allocator, + ) + }) + } + } else { + quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__data), + ) + }) + } + }; quote! { - let __ctrl = match ::bstack_raii::alloc_control( - allocator, - #ctrl_eightcc, - __data, - ::core::mem::size_of::<::Control>() as u64, - ) { - ::std::result::Result::Ok(__c) => __c, - ::std::result::Result::Err(__e) => { - let _ = unsafe { ::bstack_raii::dealloc_range(allocator, __data) }; + #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__ctor __A, + #(#params)* + ) -> ::std::io::Result<#ret> { + #(#preps)* + let __on_disk = #on_disk { + #header + #injected + #(#inits)* + }; + let mut __slice = allocator.alloc(#size)?; + let __data = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) + { + let _ = allocator.dealloc(__slice); return ::std::result::Result::Err(__e); } - }; - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackRc::from_raw( - ::bstack_raii::BStackRef::from_range(__data), - ::core::option::Option::Some(__ctrl), - allocator, - ) - }) + #finish + } } } - }; - - quote! { - #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( - allocator: &'__ctor __A, - #(#params)* - ) -> ::std::io::Result<#ret> { - #(#preps)* - let __on_disk = #on_disk { - __bstack_header: ::bstack_raii::BlockHeader { - size: ::core::mem::size_of::<#on_disk>() as u64, - tag: ::eightcc(), - }, - #injected - #(#inits)* + // `(rc, weak)` needs two blocks (data + control). Allocate both up front, + // bake the real control back-pointer into the data image, and commit both + // images in ONE `set_batched` — so the block is created atomically, with + // no separate back-pointer write and no transient half-wired state (a + // crash before the commit just orphans the two fresh blocks). + Mode::RcWeak => { + let ctrl_size = quote! { + ::core::mem::size_of::<::Control>() as u64 }; - let mut __slice = allocator.alloc(::core::mem::size_of::<#on_disk>() as u64)?; - let __data = __slice.as_range(); - if let ::std::result::Result::Err(__e) = - __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) - { - let _ = allocator.dealloc(__slice); - return ::std::result::Result::Err(__e); + quote! { + #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__ctor __A, + #(#params)* + ) -> ::std::io::Result<::bstack_raii::BStackRc<'__ctor, Self, __A>> { + #(#preps)* + // Allocate data + control up front (atomically when the + // allocator supports bulk); both are orphans until the commit. + let __blocks = ::bstack_raii::alloc_many(allocator, &[#size, #ctrl_size])?; + let __data = __blocks[0]; + let __ctrl = __blocks[1]; + let __on_disk = #on_disk { + #header + __bstack_ctrl: __ctrl.start(), + #(#inits)* + }; + let __ctrl_payload = ::bstack_raii::build_control_payload( + #ctrl_eightcc, + __data.start(), + #ctrl_size, + ); + let __writes: [(u64, ::std::vec::Vec); 2] = [ + ( + __data.start(), + ::bstack_raii::bytemuck::bytes_of(&__on_disk).to_vec(), + ), + (__ctrl.start(), __ctrl_payload), + ]; + if let ::std::result::Result::Err(__e) = + allocator.stack().set_batched(__writes) + { + let _ = ::bstack_raii::free_many(allocator, [__data, __ctrl]); + return ::std::result::Result::Err(__e); + } + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::Some(__ctrl), + allocator, + ) + }) + } } - #finish } } } @@ -2511,53 +2554,129 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote!(__bstack_refcount: u64,), Mode::RcWeak => quote!(__bstack_ctrl: u64,), }; - let injected_init = match mode { - Mode::Plain => quote!(), - Mode::Rc => quote!(__bstack_refcount: 1u64,), - Mode::RcWeak => quote!(__bstack_ctrl: 0u64,), - }; let new_ret = match mode { Mode::Plain => quote!(::bstack_raii::BStackOwned), _ => quote!(::bstack_raii::BStackRc<'__e, Self, __A>), }; - let new_finish = match mode { - Mode::Plain => quote! { - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackOwned::from_raw( - ::from_range(__data), - ) - }) - }, - Mode::Rc => quote! { - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackRc::from_raw( - ::bstack_raii::BStackRef::from_range(__data), - ::core::option::Option::None, - allocator, - ) - }) + // `EData` type name — `new`'s `data` parameter and `bstack_move!` output. + let data_ty = if has_shared { + quote!(#data<'__e, __A>) + } else { + quote!(#data) + }; + // The `new` constructor. Plain / `rc` are one atomic write (the injected + // refcount is baked into the image); `(rc, weak)` allocates data + control and + // commits both images in one `set_batched`, with the control back-pointer + // baked into the data image — no separate back-pointer write, no half-wired + // transient state. + let enum_header = quote! { + __bstack_header: ::bstack_raii::BlockHeader { + size: ::core::mem::size_of::<#on_disk>() as u64, + tag: ::eightcc(), }, - Mode::RcWeak => quote! { - let __ctrl = match ::bstack_raii::alloc_control( - allocator, - #ctrl_eightcc, - __data, - ::core::mem::size_of::<::Control>() as u64, - ) { - ::std::result::Result::Ok(__c) => __c, - ::std::result::Result::Err(__e) => { - let _ = unsafe { ::bstack_raii::dealloc_range(allocator, __data) }; - return ::std::result::Result::Err(__e); + }; + let enum_size = quote!(::core::mem::size_of::<#on_disk>() as u64); + let enum_new = match mode { + Mode::Plain | Mode::Rc => { + let injected_init = if let Mode::Rc = mode { + quote!(__bstack_refcount: 1u64,) + } else { + quote!() + }; + let finish = if let Mode::Rc = mode { + quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::None, + allocator, + ) + }) + } + } else { + quote! { + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackOwned::from_raw( + ::from_range(__data), + ) + }) } }; - ::std::result::Result::Ok(unsafe { - ::bstack_raii::BStackRc::from_raw( - ::bstack_raii::BStackRef::from_range(__data), - ::core::option::Option::Some(__ctrl), - allocator, - ) - }) - }, + quote! { + #vis fn new<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__e __A, + data: #data_ty, + ) -> ::std::io::Result<#new_ret> { + let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { + #(#new_arms)* + }; + let __on_disk = #on_disk { + #enum_header + #injected_init + __bstack_disc: __disc, + __bstack_payload: __payload, + }; + let mut __slice = allocator.alloc(#enum_size)?; + let __data = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) + { + let _ = allocator.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + #finish + } + } + } + Mode::RcWeak => { + let ctrl_size = quote! { + ::core::mem::size_of::<::Control>() as u64 + }; + quote! { + #vis fn new<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + allocator: &'__e __A, + data: #data_ty, + ) -> ::std::io::Result<::bstack_raii::BStackRc<'__e, Self, __A>> { + let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { + #(#new_arms)* + }; + let __blocks = ::bstack_raii::alloc_many(allocator, &[#enum_size, #ctrl_size])?; + let __data = __blocks[0]; + let __ctrl = __blocks[1]; + let __on_disk = #on_disk { + #enum_header + __bstack_ctrl: __ctrl.start(), + __bstack_disc: __disc, + __bstack_payload: __payload, + }; + let __ctrl_payload = ::bstack_raii::build_control_payload( + #ctrl_eightcc, + __data.start(), + #ctrl_size, + ); + let __writes: [(u64, ::std::vec::Vec); 2] = [ + ( + __data.start(), + ::bstack_raii::bytemuck::bytes_of(&__on_disk).to_vec(), + ), + (__ctrl.start(), __ctrl_payload), + ]; + if let ::std::result::Result::Err(__e) = + allocator.stack().set_batched(__writes) + { + let _ = ::bstack_raii::free_many(allocator, [__data, __ctrl]); + return ::std::result::Result::Err(__e); + } + ::std::result::Result::Ok(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::from_range(__data), + ::core::option::Option::Some(__ctrl), + allocator, + ) + }) + } + } + } }; let shared_impl = match mode { Mode::Plain => quote!(), @@ -2760,11 +2879,6 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result) - } else { - quote!(#data) - }; let view_generics = if has_weak { quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) } else { @@ -2915,32 +3029,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( - allocator: &'__e __A, - data: #data_ty, - ) -> ::std::io::Result<#new_ret> { - let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { - #(#new_arms)* - }; - let __on_disk = #on_disk { - __bstack_header: ::bstack_raii::BlockHeader { - size: ::core::mem::size_of::<#on_disk>() as u64, - tag: ::eightcc(), - }, - #injected_init - __bstack_disc: __disc, - __bstack_payload: __payload, - }; - let mut __slice = allocator.alloc(::core::mem::size_of::<#on_disk>() as u64)?; - let __data = __slice.as_range(); - if let ::std::result::Result::Err(__e) = - __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__on_disk)) - { - let _ = allocator.dealloc(__slice); - return ::std::result::Result::Err(__e); - } - #new_finish - } + #enum_new /// Read the current variant. Takes the allocator (a weak variant's /// read upgrades through it; other variants just read the block). diff --git a/bstack_raii/src/bulk.rs b/bstack_raii/src/bulk.rs new file mode 100644 index 0000000..ee0986e --- /dev/null +++ b/bstack_raii/src/bulk.rs @@ -0,0 +1,64 @@ +//! [`alloc_many`] / [`free_many`]: multi-block allocation and free with +//! all-or-nothing rollback on the allocation side. +//! +//! ## Why these are sequential, not atomic bulk +//! +//! bstack provides atomic [`BStackBulkAllocator::alloc_bulk`] / +//! [`dealloc_bulk`](bstack::BStackBulkAllocator::dealloc_bulk), but only some +//! allocators implement it, and **all** of `bstack_raii` is generic over +//! `A: BStackOwnedSliceAllocator`. On stable Rust a generic function cannot +//! dispatch on whether its concrete `A` *also* implements `BStackBulkAllocator`: +//! trait-method selection happens once, at the generic definition site, where the +//! extra bound is unprovable — so any "prefer bulk when available" shim (autoref +//! specialization included) collapses to the sequential path in generic code, and +//! `min_specialization` is nightly-only. Requiring the bulk bound instead would +//! exclude `FirstFit` (and every current test), so these helpers stay sequential: +//! +//! * each individual `alloc` / `dealloc` **is** crash-atomic (allocator contract); +//! * the *set* is not — a crash mid-sequence orphans the blocks done so far (a +//! leak, never a torn structure), which the WAL layer reclaims. +//! +//! A caller that statically knows its allocator is bulk-capable can still call +//! `alloc_bulk` / `dealloc_bulk` directly for atomicity. + +use std::io; + +use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; + +use crate::teardown::dealloc_range; + +/// Allocate one block per entry in `sizes`, in order. On any failure the blocks +/// already allocated are freed (reverse order) before the error is returned, so a +/// partial allocation never leaks within the call. +pub fn alloc_many( + allocator: &A, + sizes: &[u64], +) -> io::Result> { + let mut out = Vec::with_capacity(sizes.len()); + for &size in sizes { + match allocator.alloc(size) { + Ok(slice) => out.push(slice.as_range()), + Err(e) => { + for r in out.into_iter().rev() { + // SAFETY: our own fresh, unshared allocations. + let _ = unsafe { dealloc_range(allocator, r) }; + } + return Err(e); + } + } + } + Ok(out) +} + +/// Free every range in turn. Stops and propagates on the first error (the +/// remaining ranges are left allocated for the caller to handle). +pub fn free_many( + allocator: &A, + ranges: impl IntoIterator, +) -> io::Result<()> { + for r in ranges { + // SAFETY: the caller's contract, as for `dealloc_range`. + unsafe { dealloc_range(allocator, r)? }; + } + Ok(()) +} diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 98753ce..89c9afd 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -64,22 +64,7 @@ pub fn alloc_control( data: BStackRange, control_size: u64, ) -> io::Result { - // Build the entire control-block payload in memory and commit it in a single - // write: header, strong = 1, weak = 1 (phantom), x -> data. - let mut payload = vec![0u8; control_size as usize]; - let header = BlockHeader { - size: control_size, - tag: ctrl_tag, - }; - payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); - let put = |payload: &mut [u8], off: u64, val: u64| { - let o = off as usize; - payload[o..o + 8].copy_from_slice(&val.to_le_bytes()); - }; - put(&mut payload, layout::CTRL_STRONG_OFFSET, 1); - put(&mut payload, layout::CTRL_WEAK_OFFSET, 1); - put(&mut payload, layout::CTRL_DATA_OFFSET, data.start()); - + let payload = build_control_payload(ctrl_tag, data.start(), control_size); let mut slice = allocator.alloc(control_size)?; let ctrl = slice.as_range(); if let Err(e) = slice.write_range(0, &payload) { @@ -97,6 +82,32 @@ pub fn alloc_control( Ok(ctrl) } +/// Build a `(rc, weak)` control-block payload image in memory (no allocation, no +/// write): header, `strong = 1`, `weak = 1` (the phantom weak the strong owners +/// hold), and the `x` forward pointer to the data block at `data_start`. +/// +/// The building block for a **batched** constructor: the caller allocates the +/// data and control blocks up front, bakes the control offset into the data +/// block's `ctrl` back-pointer, and commits both block images in one +/// [`bstack::BStack::set_batched`] — so a `(rc, weak)` block is created atomically, +/// with no separate back-pointer write and no transient half-wired state. +pub fn build_control_payload(ctrl_tag: EightCC, data_start: u64, control_size: u64) -> Vec { + let mut payload = vec![0u8; control_size as usize]; + let header = BlockHeader { + size: control_size, + tag: ctrl_tag, + }; + payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); + let put = |payload: &mut [u8], off: u64, val: u64| { + let o = off as usize; + payload[o..o + 8].copy_from_slice(&val.to_le_bytes()); + }; + put(&mut payload, layout::CTRL_STRONG_OFFSET, 1); + put(&mut payload, layout::CTRL_WEAK_OFFSET, 1); + put(&mut payload, layout::CTRL_DATA_OFFSET, data_start); + payload +} + /// Set a `#[bstack_weak]` field, located at absolute on-disk offset `field_off`, /// to point at `new_weak` — releasing any weak reference the field previously /// held. diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index fb1aafc..e64f3e5 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -49,6 +49,7 @@ extern crate self as bstack_raii; mod block; +mod bulk; mod cast; mod clone; mod construct; @@ -68,8 +69,11 @@ pub use block::{ BStackBlock, BStackCast, BStackMove, BStackMoveExpr, BStackShared, BStackWeakable, }; pub use cast::{BStackCastAs, BStackCastInto}; +pub use bulk::{alloc_many, free_many}; pub use clone::{ClonePlan, TryClone, TryCloneIn}; -pub use construct::{alloc_block, alloc_control, init_rc, set_weak_field, upgrade_weak_field}; +pub use construct::{ + alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, +}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; From 213edba29a5b8069b7617c30acce2b518fbabe7a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 26 Jul 2026 14:46:46 -0700 Subject: [PATCH 043/140] Add WAL --- bstack_raii/src/lib.rs | 2 + bstack_raii/src/wal.rs | 364 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 bstack_raii/src/wal.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index e64f3e5..b2ba573 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -61,6 +61,7 @@ mod reference; mod shared; mod teardown; mod vec; +mod wal; #[cfg(test)] mod tests; @@ -81,6 +82,7 @@ pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; +pub use wal::{AllocReq, Reduced, WalEntry, WalLog, WalOp, WalStatus, reduce}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs new file mode 100644 index 0000000..9e4a7ce --- /dev/null +++ b/bstack_raii/src/wal.rs @@ -0,0 +1,364 @@ +//! Write-ahead log data model for atomic multi-slice transactions. +//! +//! ## The categories +//! +//! An on-disk **slice** `S = (ptr, len)` is allocated/freed by the adjoint +//! functors `Alloc`/`Dealloc`. Because `Alloc ∘ Dealloc` and `Dealloc ∘ Alloc` +//! are identities, slices form a groupoid — which is what licenses the +//! [reduction](reduce) optimisation (reuse a freed slice for an equal-length +//! allocation instead of freeing then re-allocating). +//! +//! But `Alloc` is *non-deterministic in address*: two requests for the same +//! length yield different slices. So we split the "slice" notion into a +//! requirement category `R = (len)` and a slice category `S`, bridged by an +//! address-preserving `R' = (id, len)`: +//! +//! ```text +//! R --Choice--> R' --Alloc--> S S --Dealloc--> R' --ForgetAddress--> R +//! ``` +//! +//! `Alloc: R' → S` and `Dealloc: S → R'` are adjoint, and equal-length slices are +//! interchangeable (`ForgetAddress ∘ Dealloc ∘ Alloc ∘ Choice = id`). +//! +//! ## The log +//! +//! A traditional durable-monoid WAL can't work here: `Alloc`/`Dealloc` aren't +//! idempotent, so operations can't be replayed. Instead each operation carries a +//! [`WalStatus`] from the ordered set `{None < Pending < Complete}` (plus the +//! recovery sink `Abandon`), with two monotonic maps: +//! +//! * normal progress [`advance`](WalStatus::advance): `None → Pending → Complete`; +//! * [`recover`](WalStatus::recover): `Pending → Abandon`, else identity. +//! +//! An operation is thus `(R', Alloc|Dealloc) × Status`, and the WAL is the functor +//! `wal_append` mapping operations into disk state ([`WalLog`]). On disk an `Alloc` +//! stores its `R' = (id, len)` and a `Dealloc` stores its `S = (ptr, len)`. +//! +//! Recovery semantics (per operation): a `Pending` op was in flight at the crash, +//! so it is **abandoned and its slice leaked** — never re-run (which would +//! double-free) — while a `Complete` op stands. Each `Dealloc` self-brackets +//! (`write Pending → run → write Complete`), so its own status is the progress +//! marker; no separate cursor is needed. Leaks are accepted and minimised by the +//! reduction; recovery's job is consistency (no double-free, no dangling), not +//! reclamation. + +use core::mem::size_of; + +use bstack::BStackRange; +use bytemuck::{Pod, Zeroable}; + +/// `R'`: an allocation requirement carrying identity — a length whose address has +/// been "forgotten", plus an `id` that keeps equal-length requirements distinct. +/// The `id` is a wrapping autoincrement (see [`WalLog::fresh_id`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AllocReq { + pub id: u64, + pub len: u64, +} + +/// The status lifecycle of a WAL operation: the ordered set +/// `{None < Pending < Complete}` plus the recovery sink `Abandon`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum WalStatus { + None = 0, + Pending = 1, + Complete = 2, + Abandon = 3, +} + +impl WalStatus { + /// Normal monotonic progress: `None → Pending → Complete` (idempotent at + /// `Complete`; `Abandon` is terminal). + pub fn advance(self) -> Self { + match self { + WalStatus::None => WalStatus::Pending, + WalStatus::Pending => WalStatus::Complete, + other => other, + } + } + + /// Recovery monotonic map: `Pending → Abandon` (an in-flight op is abandoned, + /// its slice leaked rather than re-run); `None` and `Complete` are unchanged. + pub fn recover(self) -> Self { + match self { + WalStatus::Pending => WalStatus::Abandon, + other => other, + } + } + + fn from_u8(v: u8) -> Self { + match v { + 0 => WalStatus::None, + 1 => WalStatus::Pending, + 2 => WalStatus::Complete, + // Any other byte (incl. 3 and corruption) is treated as `Abandon` — + // the safe sink: never run, leak. + _ => WalStatus::Abandon, + } + } +} + +/// The two morphisms of the slice groupoid, as recorded in the log. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum WalOp { + Alloc = 0, + Dealloc = 1, +} + +impl WalOp { + fn from_u8(v: u8) -> Self { + match v { + 1 => WalOp::Dealloc, + _ => WalOp::Alloc, + } + } +} + +/// One on-disk WAL entry: `(status, op, payload)`. +/// +/// `status` and `op` are **separate** fields — never packed into one byte. The two +/// payload words are `R' = (id, len)` for an [`Alloc`](WalOp::Alloc) and +/// `S = (ptr, len)` for a [`Dealloc`](WalOp::Dealloc). 24 bytes, 8-aligned, `Pod`. +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +#[repr(C)] +pub struct WalEntry { + status: u8, + op: u8, + _pad: [u8; 6], + /// `Alloc`: requirement `id`. `Dealloc`: slice `ptr` (start offset). + word_a: u64, + /// `Alloc` / `Dealloc`: `len`. + word_b: u64, +} + +impl WalEntry { + /// An `Alloc` entry recording a requirement `R' = (id, len)`. + pub fn alloc(status: WalStatus, req: AllocReq) -> Self { + WalEntry { + status: status as u8, + op: WalOp::Alloc as u8, + _pad: [0; 6], + word_a: req.id, + word_b: req.len, + } + } + + /// A `Dealloc` entry recording a concrete slice `S = (ptr, len)`. + pub fn dealloc(status: WalStatus, slice: BStackRange) -> Self { + WalEntry { + status: status as u8, + op: WalOp::Dealloc as u8, + _pad: [0; 6], + word_a: slice.start(), + word_b: slice.len(), + } + } + + pub fn status(&self) -> WalStatus { + WalStatus::from_u8(self.status) + } + + pub fn op(&self) -> WalOp { + WalOp::from_u8(self.op) + } + + pub fn set_status(&mut self, status: WalStatus) { + self.status = status as u8; + } + + /// The recorded `R'`, if this is an `Alloc` entry. + pub fn as_alloc(&self) -> Option { + match self.op() { + WalOp::Alloc => Some(AllocReq { + id: self.word_a, + len: self.word_b, + }), + WalOp::Dealloc => None, + } + } + + /// The recorded slice `S`, if this is a `Dealloc` entry. + pub fn as_dealloc(&self) -> Option { + match self.op() { + WalOp::Dealloc => Some(BStackRange::new(self.word_a, self.word_b)), + WalOp::Alloc => None, + } + } +} + +/// In-memory write-ahead log: a vector of [`WalEntry`] plus the `R'` id counter. +/// +/// [`with_capacity`](Self::with_capacity) pre-reserves to the known operation +/// count (a transaction knows how many allocs/deallocs it will log up front), so +/// [`append`](Self::append) never reallocates mid-transaction. +pub struct WalLog { + entries: Vec, + next_id: u64, +} + +impl WalLog { + /// A log pre-reserved for `ops` entries. + pub fn with_capacity(ops: usize) -> Self { + WalLog { + entries: Vec::with_capacity(ops), + next_id: 0, + } + } + + /// The next `R'` identity — a wrapping autoincrement. + pub fn fresh_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + id + } + + /// Append an operation to the log (`wal_append`). + pub fn append(&mut self, entry: WalEntry) { + self.entries.push(entry); + } + + pub fn entries(&self) -> &[WalEntry] { + &self.entries + } + + pub fn entries_mut(&mut self) -> &mut [WalEntry] { + &mut self.entries + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The log's on-disk image (a packed array of [`WalEntry`]). + pub fn as_bytes(&self) -> &[u8] { + bytemuck::cast_slice(&self.entries) + } + + /// Parse a log image back into entries (unaligned-safe, so a raw disk buffer + /// works). Trailing bytes shorter than one entry are ignored. + pub fn entries_from_bytes(bytes: &[u8]) -> Vec { + let sz = size_of::(); + let count = bytes.len() / sz; + let mut out = Vec::with_capacity(count); + for i in 0..count { + out.push(bytemuck::pod_read_unaligned(&bytes[i * sz..(i + 1) * sz])); + } + out + } +} + +/// The result of [`reduce`]: allocation requirements that were satisfied by +/// repurposing a freed slice (`reused`), and the physical operations that remain. +#[derive(Debug, Default)] +pub struct Reduced { + /// `(requirement, repurposed slice)` — no physical alloc *or* dealloc needed. + pub reused: Vec<(AllocReq, BStackRange)>, + /// Requirements still needing a physical `Alloc`. + pub allocs: Vec, + /// Slices still needing a physical `Dealloc`. + pub deallocs: Vec, +} + +/// The groupoid reduction: cancel each allocation requirement against a to-be-freed +/// slice of **equal length**, handing that slice's storage straight to the new +/// allocation (`ForgetAddress ∘ Dealloc ∘ Alloc ∘ Choice = id`). Only the unpaired +/// remainder becomes physical work. +pub fn reduce(allocs: Vec, mut deallocs: Vec) -> Reduced { + let mut reused = Vec::new(); + let mut rem_allocs = Vec::new(); + for req in allocs { + if let Some(pos) = deallocs.iter().position(|d| d.len() == req.len) { + reused.push((req, deallocs.remove(pos))); + } else { + rem_allocs.push(req); + } + } + Reduced { + reused, + allocs: rem_allocs, + deallocs, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wal_status_maps() { + // Normal progress: N → P → C → C; Abandon terminal. + assert_eq!(WalStatus::None.advance(), WalStatus::Pending); + assert_eq!(WalStatus::Pending.advance(), WalStatus::Complete); + assert_eq!(WalStatus::Complete.advance(), WalStatus::Complete); + assert_eq!(WalStatus::Abandon.advance(), WalStatus::Abandon); + // Recovery: only Pending → Abandon. + assert_eq!(WalStatus::None.recover(), WalStatus::None); + assert_eq!(WalStatus::Pending.recover(), WalStatus::Abandon); + assert_eq!(WalStatus::Complete.recover(), WalStatus::Complete); + } + + #[test] + fn wal_entry_roundtrip() { + let a = WalEntry::alloc(WalStatus::Pending, AllocReq { id: 7, len: 256 }); + assert_eq!(a.op(), WalOp::Alloc); + assert_eq!(a.status(), WalStatus::Pending); + assert_eq!(a.as_alloc(), Some(AllocReq { id: 7, len: 256 })); + assert_eq!(a.as_dealloc(), None); + + let d = WalEntry::dealloc(WalStatus::Complete, BStackRange::new(0x6CD4, 256)); + assert_eq!(d.op(), WalOp::Dealloc); + assert_eq!(d.as_dealloc(), Some(BStackRange::new(0x6CD4, 256))); + assert_eq!(d.as_alloc(), None); + } + + #[test] + fn wal_entry_is_24_bytes_and_pod_roundtrips() { + assert_eq!(size_of::(), 24); + let mut log = WalLog::with_capacity(2); + log.append(WalEntry::alloc(WalStatus::Pending, AllocReq { id: 0, len: 64 })); + log.append(WalEntry::dealloc(WalStatus::Pending, BStackRange::new(4096, 64))); + let bytes = log.as_bytes().to_vec(); + let back = WalLog::entries_from_bytes(&bytes); + assert_eq!(back.len(), 2); + assert_eq!(back[0].as_alloc(), Some(AllocReq { id: 0, len: 64 })); + assert_eq!(back[1].as_dealloc(), Some(BStackRange::new(4096, 64))); + } + + #[test] + fn wal_fresh_id_wraps() { + let mut log = WalLog::with_capacity(0); + assert_eq!(log.fresh_id(), 0); + assert_eq!(log.fresh_id(), 1); + log.next_id = u64::MAX; + assert_eq!(log.fresh_id(), u64::MAX); + assert_eq!(log.fresh_id(), 0); // wrapped + } + + #[test] + fn reduce_cancels_equal_length_pairs() { + // (Alloc 256, Alloc 600, Dealloc 256) → reuse the 256 slice, Alloc 600 left. + let allocs = vec![ + AllocReq { id: 0, len: 256 }, + AllocReq { id: 1, len: 600 }, + ]; + let deallocs = vec![BStackRange::new(0x1FF0, 256)]; + let r = reduce(allocs, deallocs); + assert_eq!(r.reused.len(), 1); + assert_eq!(r.reused[0].0, AllocReq { id: 0, len: 256 }); + assert_eq!(r.reused[0].1, BStackRange::new(0x1FF0, 256)); + assert_eq!(r.allocs, vec![AllocReq { id: 1, len: 600 }]); + assert!(r.deallocs.is_empty()); + } + + #[test] + fn reduce_leaves_unpaired_on_both_sides() { + let allocs = vec![AllocReq { id: 0, len: 100 }]; + let deallocs = vec![BStackRange::new(8, 200)]; + let r = reduce(allocs, deallocs); + assert!(r.reused.is_empty()); + assert_eq!(r.allocs.len(), 1); + assert_eq!(r.deallocs.len(), 1); + } +} From 6a7eac0266532c2b426c3c8af277384207007d79 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 26 Jul 2026 23:01:55 -0700 Subject: [PATCH 044/140] WAL! --- bstack_raii/src/clone.rs | 7 +- bstack_raii/src/construct.rs | 12 +-- bstack_raii/src/layout.rs | 12 +++ bstack_raii/src/lib.rs | 5 +- bstack_raii/src/tests.rs | 55 ++++++++++++ bstack_raii/src/vec.rs | 18 +++- bstack_raii/src/wal.rs | 157 ++++++++++++++++++++++++++++++++++- 7 files changed, 247 insertions(+), 19 deletions(-) diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 87088fe..3582f62 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -163,11 +163,8 @@ impl ClonePlan { let len = data.len() as u64; let size = BYTEVEC_HEADER + len; let range = self.alloc_raw(allocator, size)?; - let mut image = Vec::with_capacity(size as usize); - image.extend_from_slice(&len.to_le_bytes()); // len @ 0 - image.extend_from_slice(&len.to_le_bytes()); // cap @ 8 (== len) - image.extend_from_slice(data); // elements @ 16 - self.write(range.start(), image); + // cap == len: a fresh clone carries no spare capacity. + self.write(range.start(), crate::vec::bytevec_image(len, len, data)); Ok(VecDesc { data_off: range.start(), data_size: size, diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 89c9afd..85ba8d5 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -15,7 +15,7 @@ use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::BStackWeakable; use crate::handle::WeakRef; -use crate::layout::{self, BlockHeader, EightCC}; +use crate::layout::{self, BlockHeader, EightCC, put_u64}; use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; @@ -98,13 +98,9 @@ pub fn build_control_payload(ctrl_tag: EightCC, data_start: u64, control_size: u tag: ctrl_tag, }; payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); - let put = |payload: &mut [u8], off: u64, val: u64| { - let o = off as usize; - payload[o..o + 8].copy_from_slice(&val.to_le_bytes()); - }; - put(&mut payload, layout::CTRL_STRONG_OFFSET, 1); - put(&mut payload, layout::CTRL_WEAK_OFFSET, 1); - put(&mut payload, layout::CTRL_DATA_OFFSET, data_start); + put_u64!(payload, layout::CTRL_STRONG_OFFSET, 1); + put_u64!(payload, layout::CTRL_WEAK_OFFSET, 1); + put_u64!(payload, layout::CTRL_DATA_OFFSET, data_start); payload } diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index f74be33..be7d81c 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -5,6 +5,18 @@ use bytemuck::{Pod, Zeroable}; +/// Write `$val` as a little-endian `u64` at byte offset `$off` in the slice +/// `$buf`. The one place the crate builds on-disk integer fields by hand, instead +/// of the ad-hoc `copy_from_slice(&x.to_le_bytes())` pattern repeated across the +/// image builders (control payloads, byte-vec headers, WAL records). +macro_rules! put_u64 { + ($buf:expr, $off:expr, $val:expr) => {{ + let __o = ($off) as usize; + $buf[__o..__o + 8].copy_from_slice(&($val as u64).to_le_bytes()); + }}; +} +pub(crate) use put_u64; + /// An 8-byte type tag stored in every [`BlockHeader`]. /// /// Used instead of a 4-byte `FourCC` because `bstack` offsets are 64-bit, so diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index b2ba573..c29c0e0 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,7 +82,10 @@ pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; -pub use wal::{AllocReq, Reduced, WalEntry, WalLog, WalOp, WalStatus, reduce}; +pub use wal::{ + AllocReq, BStackWalAnchor, Reduced, WalEntry, WalHeader, WalLog, WalOp, WalStatus, finish, + finish_at, persist_at, reduce, +}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index c7d37f0..c81725e 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2404,3 +2404,58 @@ fn macro_clone_embed() { } e.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// WAL: completing / abandoning a crash-left transaction +// -------------------------------------------------------------------------- + +#[test] +fn wal_finish_rolls_forward_committed() { + use crate::wal::{finish_at, persist_at}; + use crate::{WalEntry, WalLog, WalStatus}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + // A stable anchor slot, plus two "old" slices the transaction was freeing. + let anchor = alloc.alloc(8).unwrap().as_range().start(); + let v1 = alloc.alloc(64).unwrap().as_range(); + let v2 = alloc.alloc(64).unwrap().as_range(); + + // A COMMITTED transaction that had not finished its deallocs. + let mut log = WalLog::with_capacity(2); + log.append(WalEntry::dealloc(WalStatus::Pending, v1)); + log.append(WalEntry::dealloc(WalStatus::Pending, v2)); + persist_at(&alloc, anchor, &log, WalStatus::Complete).unwrap(); + + // Completing it rolls both deallocs forward. + assert_eq!(finish_at(&alloc, anchor).unwrap(), 2); + + // Anchor cleared, and v1/v2 reclaimed (a fresh 64-byte alloc reuses a slot). + let mut buf = [0u8; 8]; + alloc.stack().get_into(anchor, &mut buf).unwrap(); + assert_eq!(u64::from_le_bytes(buf), 0); + let reused = alloc.alloc(64).unwrap().as_range(); + assert!(reused.start() == v1.start() || reused.start() == v2.start()); +} + +#[test] +fn wal_finish_abandons_uncommitted() { + use crate::wal::{finish_at, persist_at}; + use crate::{WalEntry, WalLog, WalStatus}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let anchor = alloc.alloc(8).unwrap().as_range().start(); + let v1 = alloc.alloc(64).unwrap().as_range(); + + // An UNCOMMITTED transaction: its dealloc must NOT be performed. + let mut log = WalLog::with_capacity(1); + log.append(WalEntry::dealloc(WalStatus::Pending, v1)); + persist_at(&alloc, anchor, &log, WalStatus::Pending).unwrap(); + + // Abandoned: nothing freed, anchor cleared. + assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); + let mut buf = [0u8; 8]; + alloc.stack().get_into(anchor, &mut buf).unwrap(); + assert_eq!(u64::from_le_bytes(buf), 0); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 3663927..be712b3 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -36,6 +36,7 @@ use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; use crate::clone::ClonePlan; use crate::handle::WeakRef; +use crate::layout::put_u64; use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; @@ -47,6 +48,18 @@ use crate::teardown::{BStackDrop, dealloc_range}; /// mutation crash-atomic. pub(crate) const BYTEVEC_HEADER: u64 = 16; +/// Build a `BStackByteVec` block image `[len@0 | cap@8 | data@16]` by hand — the +/// single place that on-disk shape is assembled, shared by a cloned vec +/// ([`crate::ClonePlan::stage_bytevec`]) and a field-resident growth +/// [`push`](BStackVec::push). +pub(crate) fn bytevec_image(len: u64, cap: u64, data: &[u8]) -> Vec { + let mut img = vec![0u8; BYTEVEC_HEADER as usize + data.len()]; + put_u64!(img, 0, len); + put_u64!(img, 8, cap); + img[BYTEVEC_HEADER as usize..].copy_from_slice(data); + img +} + /// Build a fresh data block holding `offs` (an offset array), register it in /// `plan` for rollback, and return its descriptor. The shared back end of the /// block-element vector clones, whose elements are all `u64` offsets. @@ -262,10 +275,7 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { let mut slice = self.allocator.alloc(BYTEVEC_HEADER + new_cap)?; let new_range = slice.as_range(); - let mut image = Vec::with_capacity((BYTEVEC_HEADER + new_len) as usize); - image.extend_from_slice(&new_len.to_le_bytes()); // len @ 0 - image.extend_from_slice(&new_cap.to_le_bytes()); // cap @ 8 - image.extend_from_slice(&bytes); // elements @ 16 + let image = bytevec_image(new_len, new_cap, &bytes); if let Err(e) = slice.write_range(0, &image) { let _ = self.allocator.dealloc(slice); return Err(e); diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 9e4a7ce..6ba1a0c 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -43,10 +43,13 @@ //! reclamation. use core::mem::size_of; +use std::io; -use bstack::BStackRange; +use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; +use crate::teardown::dealloc_range; + /// `R'`: an allocation requirement carrying identity — a length whose address has /// been "forgotten", plus an `id` that keeps equal-length requirements distinct. /// The `id` is a wrapping autoincrement (see [`WalLog::fresh_id`]). @@ -282,6 +285,158 @@ pub fn reduce(allocs: Vec, mut deallocs: Vec) -> Reduced } } +// --------------------------------------------------------------------------- +// On-disk WAL block + completion runtime. +// +// A WAL block is `[WalHeader | WalEntry × count]`, allocated from the allocator +// and reached through a stable anchor slot (see [`BStackWalAnchor`]) that holds +// the block's offset (`0` = none). The header's `txn_status` is the +// transaction-level commit marker: `Complete` = committed (roll forward on the +// next open), `Pending` = uncommitted (abandon). +// --------------------------------------------------------------------------- + +const WAL_MAGIC: u64 = 0x6273_7461_636b_5741; // "bstackWA" + +/// On-disk header of a WAL block. `txn_status` is the transaction-level commit +/// marker (`Pending` = uncommitted, `Complete` = committed). +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +#[repr(C)] +pub struct WalHeader { + magic: u64, + txn_status: u8, + _pad: [u8; 7], + count: u64, +} + +impl WalHeader { + fn txn_status(&self) -> WalStatus { + WalStatus::from_u8(self.txn_status) + } +} + +impl WalLog { + /// The full WAL-block image `[WalHeader | entries]` at the given + /// transaction-level status, ready to write to an allocated block. + pub fn block_image(&self, txn_status: WalStatus) -> Vec { + let header = WalHeader { + magic: WAL_MAGIC, + txn_status: txn_status as u8, + _pad: [0; 7], + count: self.entries.len() as u64, + }; + let mut img = Vec::with_capacity( + size_of::() + self.entries.len() * size_of::(), + ); + img.extend_from_slice(bytemuck::bytes_of(&header)); + img.extend_from_slice(bytemuck::cast_slice(&self.entries)); + img + } +} + +/// A [`BStackOwnedSliceAllocator`] that can point `bstack_raii` at a stable +/// on-disk slot for its WAL block pointer. +/// +/// # Safety +/// +/// The implementor asserts that `[wal_anchor(), wal_anchor() + 8)` is a stable, +/// persistent 8-byte region that the allocator **never** hands out via `alloc` +/// and **never** uses for its own metadata, and that survives across open/close. +/// `bstack_raii` stores the current WAL block's offset there (`0` = none). +pub unsafe trait BStackWalAnchor: BStackOwnedSliceAllocator { + fn wal_anchor(&self) -> u64; +} + +/// Write `log` as a WAL block with transaction status `txn_status`, allocate the +/// block, and point the anchor slot at it. Returns the block's range. +pub fn persist_at( + allocator: &A, + anchor: u64, + log: &WalLog, + txn_status: WalStatus, +) -> io::Result { + let image = log.block_image(txn_status); + let mut slice = allocator.alloc(image.len() as u64)?; + let range = slice.as_range(); + if let Err(e) = slice.write_range(0, &image) { + let _ = allocator.dealloc(slice); + return Err(e); + } + allocator.stack().set(anchor, range.start().to_le_bytes())?; + Ok(range) +} + +/// Read the WAL block referenced by the anchor slot, if any (and valid). +fn load_at( + allocator: &A, + anchor: u64, +) -> io::Result)>> { + let stack = allocator.stack(); + let mut buf = [0u8; 8]; + stack.get_into(anchor, &mut buf)?; + let wal_off = u64::from_le_bytes(buf); + if wal_off == 0 { + return Ok(None); + } + let mut hbuf = [0u8; size_of::()]; + stack.get_into(wal_off, &mut hbuf)?; + let header: WalHeader = bytemuck::pod_read_unaligned(&hbuf); + if header.magic != WAL_MAGIC { + return Ok(None); + } + let ebytes = header.count as usize * size_of::(); + let mut ebuf = vec![0u8; ebytes]; + stack.get_into(wal_off + size_of::() as u64, &mut ebuf)?; + let entries = WalLog::entries_from_bytes(&ebuf); + let block_size = size_of::() as u64 + ebytes as u64; + Ok(Some((BStackRange::new(wal_off, block_size), header, entries))) +} + +/// **Complete** a crash-left transaction referenced by the anchor slot at +/// `anchor`, or abandon it. +/// +/// * **Committed** (`txn_status == Complete`): roll forward — for each still- +/// `Pending` `Dealloc`, persist its entry `Complete` **then** free the slice +/// (so a re-`finish` after another crash skips it — no double-free). +/// * **Uncommitted** (`txn_status == Pending`): abandon — free nothing (the old +/// slices stay; any new allocations leak, since an `Alloc` records only `R'`). +/// +/// Either way the WAL block is then cleared (anchor `:= 0`) and freed. Returns +/// the number of deallocations completed. This is what a caller runs after +/// `open` — a *completion*, not a leaky recovery. +pub fn finish_at(allocator: &A, anchor: u64) -> io::Result { + let (wal_range, header, entries) = match load_at(allocator, anchor)? { + Some(x) => x, + None => return Ok(0), + }; + let stack = allocator.stack(); + let mut completed = 0usize; + + if header.txn_status() == WalStatus::Complete { + let base = wal_range.start() + size_of::() as u64; + for (i, e) in entries.iter().enumerate() { + // `as_dealloc` is `Some` only for a `Dealloc` entry. + if e.status() == WalStatus::Pending && let Some(slice) = e.as_dealloc() { + // Persist Complete for this entry (its status is byte 0), THEN + // free — so a second crash can't double-free it. + let entry_off = base + (i * size_of::()) as u64; + stack.set(entry_off, [WalStatus::Complete as u8])?; + unsafe { dealloc_range(allocator, slice)? }; + completed += 1; + } + } + } + + // Clear the anchor, then free the WAL block. + stack.set(anchor, 0u64.to_le_bytes())?; + unsafe { dealloc_range(allocator, wal_range)? }; + Ok(completed) +} + +/// Like [`finish_at`], using the allocator's own [`BStackWalAnchor`] slot. +pub fn finish(allocator: &A) -> io::Result { + finish_at(allocator, allocator.wal_anchor()) +} + #[cfg(test)] mod tests { use super::*; From c51c95443273e074d5537ac3f861bada89c61ca8 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 30 Jul 2026 20:19:13 -0700 Subject: [PATCH 045/140] Add support for [T; len] --- bstack_raii/derive/src/block.rs | 184 ++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 171 +++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 060a4af..4dd70aa 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -240,6 +240,190 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } + // Inline fixed-size array `[T; N]` of block references. (A POD array falls + // through to the POD path below — an array of `Pod` is `Pod`.) A reference + // array is stored inline as `[u64; N]`, no data block, with per-element + // ownership; the accessor / ctor traffic in arrays of handles `[Handle; N]`. + if kind != Kind::Pod && let Type::Array(arr) = opt_inner { + let elem = &arr.elem; + let len = &arr.len; + if kind == Kind::Embed { + return Err(Error::new_spanned( + &field.ty, + "#[embed] arrays are not yet supported", + )); + } + if nullable { + return Err(Error::new_spanned( + &field.ty, + "nullable arrays (`Option<[T; N]>` / `[Option<_>; N]`) are not yet supported", + )); + } + if kind == Kind::Weak { + return Err(Error::new_spanned( + &field.ty, + "`#[bstack_weak]` arrays are not yet supported", + )); + } + let size_elem = quote! { + ::core::mem::size_of::<<#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64 + }; + on_disk_fields.push(quote!(#fname: [u64; #len],)); + + // Accessor: read the inline offsets, resolve each to a handle. + accessors.push(quote! { + #vis fn #fname( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<[#elem; #len]> { + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; + let __offs: [u64; #len] = __od.#fname; + ::std::result::Result::Ok(__offs.map(|__off| { + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem), + ) + })) + } + }); + + // Constructor: `[Handle; N]` → `[u64; N]` (per-kind offset extraction). + let (handle_ty, to_off): (TokenStream, TokenStream) = match kind { + Kind::Owned => ( + quote!(::bstack_raii::BStackOwned<#elem>), + quote!({ + let __h = __handle.into_inner(); + ::bstack_raii::BStackBlock::range(&__h).start() + }), + ), + Kind::Strong => ( + quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + quote!({ + let (__d, _) = __handle.into_raw(); + __d.into_range().start() + }), + ), + Kind::Ref => ( + quote!(::bstack_raii::BStackRef<#elem>), + quote!(__handle.into_range().start()), + ), + _ => unreachable!(), + }; + ctor_params.push(quote!(#fname: [#handle_ty; #len],)); + ctor_preps + .push(quote!(let #fname: [u64; #len] = #fname.map(|__handle| #to_off);)); + ctor_inits.push(quote!(#fname: #fname,)); + + // Teardown: free / release each non-null element (a ref owns nothing). + let per_teardown = match kind { + Kind::Owned => quote! { + ::bstack_raii::OwnedRef(unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }).bstack_drop(allocator)?; + }, + Kind::Strong => quote! { + <#elem as ::bstack_raii::BStackShared>::drop_strong_ref(unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }, allocator)?; + }, + _ => quote!(), + }; + if kind != Kind::Ref { + drop_stmts.push(quote! { + { + let __offs: [u64; #len] = __on_disk.#fname; + for __off in __offs { + if __off != 0 { #per_teardown } + } + } + }); + } + + // Clone: owned deep-clones each; strong bumps each; ref aliases. + match kind { + Kind::Owned => clone_stmts.push(quote! { + { + let __offs: [u64; #len] = __od.#fname; + let mut __new = __offs; + for __i in 0..#len { + let __off = __offs[__i]; + if __off != 0 { + let __child = <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)); + __new[__i] = + __child.__bstack_clone_into(allocator, __plan)?.start(); + } + } + __od.#fname = __new; + } + }), + Kind::Strong => clone_stmts.push(quote! { + { + let __offs: [u64; #len] = __od.#fname; + for __off in __offs { + if __off != 0 { + let __child = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }; + __plan.bump_strong(__child, allocator)?; + } + } + } + }), + // Ref: aliased — the copied `[u64; N]` is kept verbatim. + _ => {} + } + + // Move: `[u64; N]` → `[Handle; N]`. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + match kind { + Kind::Owned => { + mv_types.push(quote!([::bstack_raii::BStackOwned<#elem>; #len])); + mv_recon.push(quote!(#cap.map(|__off| unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem))) + }))); + } + Kind::Ref => { + mv_types.push(quote!([::bstack_raii::BStackRef<#elem>; #len])); + mv_recon.push(quote!(#cap.map(|__off| unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }))); + } + Kind::Strong => { + // `strong_parts` is fallible → build via a `Vec`, then convert. + mv_types.push(quote!([::bstack_raii::BStackRc<'__mv, #elem, __A>; #len])); + mv_recon.push(quote! { + { + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __off in #cap { + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }; + let (__d, __c) = <#elem as ::bstack_raii::BStackShared>::strong_parts( + __data, __alloc)?; + __v.push(unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }); + } + match <[::bstack_raii::BStackRc<'__mv, #elem, __A>; #len]>::try_from(__v) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + } + } + }); + } + _ => unreachable!(), + } + continue; + } + // Decide the stored type + nullability now that vectors are handled. // // * A **reference** kind (owned/strong/weak/ref) lowers to a `u64` offset; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index c81725e..917ac04 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2459,3 +2459,174 @@ fn wal_finish_abandons_uncommitted() { alloc.stack().get_into(anchor, &mut buf).unwrap(); assert_eq!(u64::from_le_bytes(buf), 0); } + +// -------------------------------------------------------------------------- +// Inline fixed-size arrays [T; N] +// -------------------------------------------------------------------------- + +#[bstack_block] +struct PodArr { + xs: [u16; 4], + tag: u32, +} + +#[test] +fn macro_pod_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let p = PodArr::new(&alloc, [1u16, 2, 3, 4], 9).unwrap(); + assert_eq!(p.handle().xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().tag(stack).unwrap(), 9); + p.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct ArrHolder { + #[bstack_owned] + leaves: [MacroLeaf; 3], + tag: u32, +} + +#[test] +fn macro_owned_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + let l0 = MacroLeaf::new(&alloc, 10).unwrap(); + let l1 = MacroLeaf::new(&alloc, 20).unwrap(); + let l2 = MacroLeaf::new(&alloc, 30).unwrap(); + let off0 = l0.handle().range().start(); + + let h = ArrHolder::new(&alloc, [l0, l1, l2], 7).unwrap(); + assert_eq!(h.handle().tag(stack).unwrap(), 7); + let arr = h.handle().leaves(stack).unwrap(); // [MacroLeaf; 3] + assert_eq!(arr[0].val(stack).unwrap(), 10); + assert_eq!(arr[1].val(stack).unwrap(), 20); + assert_eq!(arr[2].val(stack).unwrap(), 30); + + // Teardown frees all three inline children; the lowest slot (l0) is reclaimed. + h.bstack_drop(&alloc).unwrap(); + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), off0); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[test] +fn macro_owned_array_clone() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let h = ArrHolder::new( + &alloc, + [ + MacroLeaf::new(&alloc, 1).unwrap(), + MacroLeaf::new(&alloc, 2).unwrap(), + MacroLeaf::new(&alloc, 3).unwrap(), + ], + 0, + ) + .unwrap(); + + let clone = h.try_clone_in(&alloc).unwrap(); + let carr = clone.handle().leaves(stack).unwrap(); + let oarr = h.handle().leaves(stack).unwrap(); + assert_eq!(carr[1].val(stack).unwrap(), 2); + // Deep-cloned: each clone element is a fresh block, distinct from the original. + assert_ne!(carr[0].range().start(), oarr[0].range().start()); + + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(h.handle().leaves(stack).unwrap()[2].val(stack).unwrap(), 3); + h.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct RefArrHolder { + #[bstack_ref] + refs: [MacroLeaf; 2], +} + +#[test] +fn macro_ref_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let l0 = MacroLeaf::new(&alloc, 1).unwrap(); + let l1 = MacroLeaf::new(&alloc, 2).unwrap(); + let r0 = unsafe { BStackRef::from_range(l0.handle().range()) }; + let r1 = unsafe { BStackRef::from_range(l1.handle().range()) }; + + let h = RefArrHolder::new(&alloc, [r0, r1]).unwrap(); + let arr = h.handle().refs(stack).unwrap(); + assert_eq!(arr[0].val(stack).unwrap(), 1); + assert_eq!(arr[1].val(stack).unwrap(), 2); + + // A ref array owns nothing: dropping the holder leaves the targets alive. + h.bstack_drop(&alloc).unwrap(); + assert_eq!(l0.handle().val(stack).unwrap(), 1); + l0.bstack_drop(&alloc).unwrap(); + l1.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct StrongArrHolder { + #[bstack_strong] + shared: [MacroStrongChild; 2], +} + +#[test] +fn macro_strong_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c0 = MacroStrongChild::new(&alloc, 5).unwrap(); + let c1 = MacroStrongChild::new(&alloc, 6).unwrap(); + // A keep-alive so element 0's control block survives the holders' teardown. + let keep0 = c0.try_clone().unwrap(); + + let h = StrongArrHolder::new(&alloc, [c0, c1]).unwrap(); + let arr = h.handle().shared(stack).unwrap(); + assert_eq!(arr[0].val(stack).unwrap(), 5); + + // Cloning the holder re-references each shared child: strong count +1. + let d0 = arr[0].range().start(); + let ctrl0 = crate::refcount::load(stack, d0 + layout::CTRL_BACKPTR_OFFSET).unwrap(); + let strong0 = ctrl0 + layout::CTRL_STRONG_OFFSET; + let before = crate::refcount::load(stack, strong0).unwrap(); // keep0 + h = 2 + let clone = h.try_clone_in(&alloc).unwrap(); + assert_eq!(crate::refcount::load(stack, strong0).unwrap(), before + 1); + + // Tear both holders down: element 0's count returns to keep0's alone. + clone.bstack_drop(&alloc).unwrap(); + h.bstack_drop(&alloc).unwrap(); + assert_eq!(crate::refcount::load(stack, strong0).unwrap(), before - 1); + assert_eq!(keep0.handle().val(stack).unwrap(), 5); + drop(keep0); +} + +#[test] +fn macro_owned_array_move() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let h = ArrHolder::new( + &alloc, + [ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + MacroLeaf::new(&alloc, 30).unwrap(), + ], + 7, + ) + .unwrap(); + let (leaves, tag) = bstack_move!(h, &alloc).unwrap(); + assert_eq!(tag, 7); + assert_eq!(leaves[0].handle().val(stack).unwrap(), 10); + assert_eq!(leaves[2].handle().val(stack).unwrap(), 30); + for l in leaves { + l.bstack_drop(&alloc).unwrap(); + } +} From dfc1c65cc5f95d5d931de3bd1eaacbabdd0cb993 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 30 Jul 2026 20:50:39 -0700 Subject: [PATCH 046/140] Support weak for [Weak; size] --- bstack_raii/derive/src/block.rs | 102 ++++++++++++++++++++++++++++++-- bstack_raii/src/tests.rs | 44 ++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 4dd70aa..91856ff 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -259,16 +259,110 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result "nullable arrays (`Option<[T; N]>` / `[Option<_>; N]`) are not yet supported", )); } + on_disk_fields.push(quote!(#fname: [u64; #len],)); + + // A weak array stores control offsets (`0` = unset), is not a ctor + // parameter (starts null, wired per-index via a setter), and its + // accessor upgrades each element. if kind == Kind::Weak { - return Err(Error::new_spanned( - &field.ty, - "`#[bstack_weak]` arrays are not yet supported", + let ctrl_ty = quote!(<#elem as ::bstack_raii::BStackWeakable>::Control); + let ctrl_size = quote!(::core::mem::size_of::<#ctrl_ty>() as u64); + ctor_inits.push(quote!(#fname: [0u64; #len],)); + + let setter = format_ident!("set_{}", fname); + setters.push(quote! { + #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__s __A, + index: usize, + weak: ::bstack_raii::BStackWeak<'__s, #elem, __A>, + ) -> ::std::io::Result<()> { + let __field = self.0.start() + + ::core::mem::offset_of!(#on_disk, #fname) as u64 + + (index as u64) * 8; + ::bstack_raii::set_weak_field(allocator, __field, weak) + } + }); + + accessors.push(quote! { + #vis fn #fname<'__u, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &'__u __A, + ) -> ::std::io::Result< + [::core::option::Option<::bstack_raii::BStackRc<'__u, #elem, __A>>; #len] + > { + let __base = + self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __i in 0..#len { + __v.push(::bstack_raii::upgrade_weak_field( + allocator, __base + (__i as u64) * 8)?); + } + match <[::core::option::Option< + ::bstack_raii::BStackRc<'__u, #elem, __A>>; #len]>::try_from(__v) + { + ::std::result::Result::Ok(__a) => ::std::result::Result::Ok(__a), + ::std::result::Result::Err(_) => unreachable!(), + } + } + }); + + // Teardown: release each non-null weak reference. + drop_stmts.push(quote! { + { + let __offs: [u64; #len] = __on_disk.#fname; + for __off in __offs { + if __off != 0 { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) + }; + ::bstack_raii::WeakRef::<#elem>(__ctrl).bstack_drop(allocator)?; + } + } + } + }); + + // Clone: bump each non-null weak count (offsets are kept — a weak + // clone aliases the same control block). + clone_stmts.push(quote! { + { + let __offs: [u64; #len] = __od.#fname; + for __off in __offs { + if __off != 0 { + __plan.bump_weak(__off); + } + } + } + }); + + // Move: `[u64; N]` → `[Option; N]`. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + mv_types.push(quote!( + [::core::option::Option<::bstack_raii::BStackWeak<'__mv, #elem, __A>>; #len] )); + mv_recon.push(quote! { + #cap.map(|__off| { + if __off == 0 { + ::core::option::Option::None + } else { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) + }; + ::core::option::Option::Some(unsafe { + ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) + }) + } + }) + }); + continue; } + let size_elem = quote! { ::core::mem::size_of::<<#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64 }; - on_disk_fields.push(quote!(#fname: [u64; #len],)); // Accessor: read the inline offsets, resolve each to a handle. accessors.push(quote! { diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 917ac04..cb2376c 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2630,3 +2630,47 @@ fn macro_owned_array_move() { l.bstack_drop(&alloc).unwrap(); } } + +#[bstack_block] +struct WeakArrHolder { + #[bstack_weak] + weaks: [MacroStrongChild; 2], +} + +#[test] +fn macro_weak_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c0 = MacroStrongChild::new(&alloc, 5).unwrap(); + let c1 = MacroStrongChild::new(&alloc, 6).unwrap(); + + // Weak arrays start null (not a ctor parameter). + let h = WeakArrHolder::new(&alloc).unwrap(); + let arr = h.handle().weaks(&alloc).unwrap(); + assert!(arr[0].is_none() && arr[1].is_none()); + + // Wire each element via the per-index setter. + h.handle().set_weaks(&alloc, 0, c0.downgrade().unwrap()).unwrap(); + h.handle().set_weaks(&alloc, 1, c1.downgrade().unwrap()).unwrap(); + + // The accessor upgrades each live element. + let arr = h.handle().weaks(&alloc).unwrap(); + assert_eq!(arr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); + assert_eq!(arr[1].as_ref().unwrap().handle().val(stack).unwrap(), 6); + drop(arr); + + // Cloning aliases the same control blocks (weak counts bumped). + let clone = h.try_clone_in(&alloc).unwrap(); + let carr = clone.handle().weaks(&alloc).unwrap(); + assert_eq!(carr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); + drop(carr); + + // Both holders' teardown releases the weak refs (no underflow); c0/c1 live. + clone.bstack_drop(&alloc).unwrap(); + h.bstack_drop(&alloc).unwrap(); + assert_eq!(c0.handle().val(stack).unwrap(), 5); + assert_eq!(c1.handle().val(stack).unwrap(), 6); + drop(c0); + drop(c1); +} From eebc37887e4462220dfb168272b75f650bd24aea Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 30 Jul 2026 21:04:51 -0700 Subject: [PATCH 047/140] Support for [Option; size] --- bstack_raii/derive/src/block.rs | 138 +++++++++++++++++++++++--------- bstack_raii/src/tests.rs | 99 +++++++++++++++++++++++ 2 files changed, 200 insertions(+), 37 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 91856ff..8cab07b 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -245,8 +245,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // array is stored inline as `[u64; N]`, no data block, with per-element // ownership; the accessor / ctor traffic in arrays of handles `[Handle; N]`. if kind != Kind::Pod && let Type::Array(arr) = opt_inner { - let elem = &arr.elem; let len = &arr.len; + // A per-element `Option` makes each slot nullable (offset 0 == None). + let (elem, elem_nullable): (&Type, bool) = match option_inner(&arr.elem) { + Some(__inner) => (__inner, true), + None => (&arr.elem, false), + }; if kind == Kind::Embed { return Err(Error::new_spanned( &field.ty, @@ -256,7 +260,8 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result if nullable { return Err(Error::new_spanned( &field.ty, - "nullable arrays (`Option<[T; N]>` / `[Option<_>; N]`) are not yet supported", + "a whole-array `Option<[T; N]>` is not supported; use `[Option; N]` \ + for per-element nullability", )); } on_disk_fields.push(quote!(#fname: [u64; #len],)); @@ -364,21 +369,32 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::core::mem::size_of::<<#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64 }; - // Accessor: read the inline offsets, resolve each to a handle. + // Accessor: read the inline offsets, resolve each to a handle (or + // `None` for a `0` slot in an `Option`-element array). + let resolve_expr = quote!(<#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem))); + let (acc_ret, acc_map) = if elem_nullable { + ( + quote!([::core::option::Option<#elem>; #len]), + quote!(if __off == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some(#resolve_expr) + }), + ) + } else { + (quote!([#elem; #len]), resolve_expr.clone()) + }; accessors.push(quote! { #vis fn #fname( &self, stack: &::bstack_raii::BStack, - ) -> ::std::io::Result<[#elem; #len]> { + ) -> ::std::io::Result<#acc_ret> { let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; let __offs: [u64; #len] = __od.#fname; - ::std::result::Result::Ok(__offs.map(|__off| { - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem), - ) - })) + ::std::result::Result::Ok(__offs.map(|__off| #acc_map)) } }); @@ -404,9 +420,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), _ => unreachable!(), }; - ctor_params.push(quote!(#fname: [#handle_ty; #len],)); - ctor_preps - .push(quote!(let #fname: [u64; #len] = #fname.map(|__handle| #to_off);)); + if elem_nullable { + ctor_params.push(quote!(#fname: [::core::option::Option<#handle_ty>; #len],)); + ctor_preps.push(quote!(let #fname: [u64; #len] = #fname.map(|__opt| match __opt { + ::core::option::Option::Some(__handle) => #to_off, + ::core::option::Option::None => 0u64, + });)); + } else { + ctor_params.push(quote!(#fname: [#handle_ty; #len],)); + ctor_preps + .push(quote!(let #fname: [u64; #len] = #fname.map(|__handle| #to_off);)); + } ctor_inits.push(quote!(#fname: #fname,)); // Teardown: free / release each non-null element (a ref owns nothing). @@ -472,41 +496,81 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result _ => {} } - // Move: `[u64; N]` → `[Handle; N]`. + // Move: `[u64; N]` → `[Handle; N]` (or `[Option; N]`). let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); match kind { - Kind::Owned => { - mv_types.push(quote!([::bstack_raii::BStackOwned<#elem>; #len])); - mv_recon.push(quote!(#cap.map(|__off| unsafe { - ::bstack_raii::BStackOwned::from_raw( - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem))) - }))); - } - Kind::Ref => { - mv_types.push(quote!([::bstack_raii::BStackRef<#elem>; #len])); - mv_recon.push(quote!(#cap.map(|__off| unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem)) - }))); + // Owned / ref reconstruct infallibly, via `array::map`. + Kind::Owned | Kind::Ref => { + let (inner_ty, recon_one) = if kind == Kind::Owned { + ( + quote!(::bstack_raii::BStackOwned<#elem>), + quote!(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem))) + }), + ) + } else { + ( + quote!(::bstack_raii::BStackRef<#elem>), + quote!(unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }), + ) + }; + if elem_nullable { + mv_types.push(quote!([::core::option::Option<#inner_ty>; #len])); + mv_recon.push(quote!(#cap.map(|__off| if __off == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some(#recon_one) + }))); + } else { + mv_types.push(quote!([#inner_ty; #len])); + mv_recon.push(quote!(#cap.map(|__off| #recon_one))); + } } + // Strong's `strong_parts` is fallible → build via a `Vec`. Kind::Strong => { - // `strong_parts` is fallible → build via a `Vec`, then convert. - mv_types.push(quote!([::bstack_raii::BStackRc<'__mv, #elem, __A>; #len])); + let (elem_ty, push_expr) = if elem_nullable { + ( + quote!(::core::option::Option< + ::bstack_raii::BStackRc<'__mv, #elem, __A>>), + quote!(if __off == 0 { + ::core::option::Option::None + } else { + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) }; + let (__d, __c) = + <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + ::core::option::Option::Some(unsafe { + ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }) + }), + ) + } else { + ( + quote!(::bstack_raii::BStackRc<'__mv, #elem, __A>), + quote!({ + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) }; + let (__d, __c) = + <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } + }), + ) + }; + mv_types.push(quote!([#elem_ty; #len])); mv_recon.push(quote! { { let mut __v = ::std::vec::Vec::with_capacity(#len); for __off in #cap { - let __data = unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem)) - }; - let (__d, __c) = <#elem as ::bstack_raii::BStackShared>::strong_parts( - __data, __alloc)?; - __v.push(unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }); + __v.push(#push_expr); } - match <[::bstack_raii::BStackRc<'__mv, #elem, __A>; #len]>::try_from(__v) { + match <[#elem_ty; #len]>::try_from(__v) { ::std::result::Result::Ok(__a) => __a, ::std::result::Result::Err(_) => unreachable!(), } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index cb2376c..70283fe 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2674,3 +2674,102 @@ fn macro_weak_array() { drop(c0); drop(c1); } + +#[bstack_block] +struct OptArrHolder { + #[bstack_owned] + leaves: [Option; 3], +} + +#[test] +fn macro_owned_option_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf_size = size_of::<::OnDisk>() as u64; + + // Middle element is None. + let l0 = MacroLeaf::new(&alloc, 10).unwrap(); + let l2 = MacroLeaf::new(&alloc, 30).unwrap(); + let off0 = l0.handle().range().start(); + let h = OptArrHolder::new(&alloc, [Some(l0), None, Some(l2)]).unwrap(); + + let arr = h.handle().leaves(stack).unwrap(); // [Option; 3] + assert_eq!(arr[0].as_ref().unwrap().val(stack).unwrap(), 10); + assert!(arr[1].is_none()); + assert_eq!(arr[2].as_ref().unwrap().val(stack).unwrap(), 30); + + // Clone deep-copies the present elements, keeps the hole. + let clone = h.try_clone_in(&alloc).unwrap(); + let carr = clone.handle().leaves(stack).unwrap(); + assert_eq!(carr[0].as_ref().unwrap().val(stack).unwrap(), 10); + assert!(carr[1].is_none()); + assert_ne!( + carr[0].as_ref().unwrap().range().start(), + arr[0].as_ref().unwrap().range().start() + ); + + // Move yields `[Option>; 3]`. + clone.bstack_drop(&alloc).unwrap(); + let (moved,) = bstack_move!(h, &alloc).unwrap(); + assert_eq!(moved[2].as_ref().unwrap().handle().val(stack).unwrap(), 30); + assert!(moved[1].is_none()); + for o in moved.into_iter().flatten() { + o.bstack_drop(&alloc).unwrap(); + } + // The present children were freed by the move re-home + drop; a slot comes back. + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + let _ = off0; + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[bstack_block] +struct OptRefArrHolder { + #[bstack_ref] + refs: [Option; 2], +} + +#[test] +fn macro_ref_option_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let l0 = MacroLeaf::new(&alloc, 1).unwrap(); + let r0 = unsafe { BStackRef::from_range(l0.handle().range()) }; + + // Element 1 is a null reference. + let h = OptRefArrHolder::new(&alloc, [Some(r0), None]).unwrap(); + let arr = h.handle().refs(stack).unwrap(); // [Option; 2] + assert_eq!(arr[0].as_ref().unwrap().val(stack).unwrap(), 1); + assert!(arr[1].is_none()); + + h.bstack_drop(&alloc).unwrap(); // owns nothing + assert_eq!(l0.handle().val(stack).unwrap(), 1); + l0.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct PodOptArr { + xs: [Option; 3], +} + +#[test] +fn macro_pod_option_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let p = PodOptArr::new( + &alloc, + [ + core::num::NonZeroU32::new(5), + None, + core::num::NonZeroU32::new(9), + ], + ) + .unwrap(); + let arr = p.handle().xs(stack).unwrap(); // [Option; 3] + assert_eq!(arr[0].map(|n| n.get()), Some(5)); + assert!(arr[1].is_none()); + assert_eq!(arr[2].map(|n| n.get()), Some(9)); + p.bstack_drop(&alloc).unwrap(); +} From 4dc6b6c6d20c46da7e22d43907e1e85770bbebde Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 30 Jul 2026 22:52:45 -0700 Subject: [PATCH 048/140] Support embed for [T; len] --- bstack_raii/derive/src/block.rs | 117 ++++++++++++++++++++++++++++++-- bstack_raii/src/tests.rs | 45 ++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 8cab07b..286787f 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -251,11 +251,120 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result Some(__inner) => (__inner, true), None => (&arr.elem, false), }; + // `#[embed] [Child; N]`: N verbatim child on-disk forms inline + // (`[::OnDisk; N]`). Construction folds each + // `BStackOwned` in (a lot of movement — read OnDisk, free shell). if kind == Kind::Embed { - return Err(Error::new_spanned( - &field.ty, - "#[embed] arrays are not yet supported", - )); + if nullable || elem_nullable { + return Err(Error::new_spanned( + &field.ty, + "#[embed] does not support `Option`", + )); + } + let child = elem; + let child_od = quote!(<#child as ::bstack_raii::BStackBlock>::OnDisk); + on_disk_fields.push(quote!(#fname: [#child_od; #len],)); + + // Teardown: free each embedded child's children in place. + drop_stmts.push(quote! { + { + let __base = + __range.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let __step = ::core::mem::size_of::<#child_od>() as u64; + for __i in 0..#len { + let __embed = ::bstack_raii::BStackRange::new( + __base + (__i as u64) * __step, __step); + <#child>::__bstack_drop_children(__embed, allocator)?; + } + } + }); + + // Accessor: `[Child; N]`, each a handle into its inline slot. + accessors.push(quote! { + #vis fn #fname(&self) -> [#child; #len] { + let __base = + self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let __step = ::core::mem::size_of::<#child_od>() as u64; + ::core::array::from_fn(|__i| { + <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __base + (__i as u64) * __step, __step)) + }) + } + }); + + // Constructor: fold each `BStackOwned` in — read its OnDisk, + // free its shell (its own children stay live, now owned by the embed). + ctor_params.push(quote!(#fname: [::bstack_raii::BStackOwned<#child>; #len],)); + ctor_preps.push(quote! { + let #fname: [#child_od; #len] = { + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __owned in #fname { + let __h = __owned.into_inner(); + let __cr = ::bstack_raii::BStackBlock::range(&__h); + let mut __b = [0u8; ::core::mem::size_of::<#child_od>()]; + let __cod = *unsafe { + ::bstack_raii::BStackRef::<#child>::from_range(__cr) + }.read_on_disk(allocator.stack(), &mut __b)?; + unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } + __v.push(__cod); + } + match <[#child_od; #len]>::try_from(__v) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + } + }; + }); + ctor_inits.push(quote!(#fname: #fname,)); + + // Move: re-home each embedded child to a fresh standalone allocation. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + mv_types.push(quote!([::bstack_raii::BStackOwned<#child>; #len])); + mv_recon.push(quote! { + { + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __cod in #cap { + let mut __slice = + __alloc.alloc(::core::mem::size_of::<#child_od>() as u64)?; + let __r = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__cod)) + { + let _ = __alloc.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + __v.push(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#child as ::bstack_raii::BStackBlock>::from_range(__r)) + }); + } + match <[::bstack_raii::BStackOwned<#child>; #len]>::try_from(__v) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + } + } + }); + + // Clone: fold each embedded child's clone inline (copy the array out, + // mutate, write back — a packed field's elements can't be `&mut`'d). + clone_stmts.push(quote! { + { + let __base = + __src.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let __step = ::core::mem::size_of::<#child_od>() as u64; + let mut __arr: [#child_od; #len] = __od.#fname; + for __i in 0..#len { + let __child = <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __base + (__i as u64) * __step, __step)); + __arr[__i] = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + } + __od.#fname = __arr; + } + }); + continue; } if nullable { return Err(Error::new_spanned( diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 70283fe..95960b7 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2773,3 +2773,48 @@ fn macro_pod_option_array() { assert_eq!(arr[2].map(|n| n.get()), Some(9)); p.bstack_drop(&alloc).unwrap(); } + +#[bstack_block] +struct EmbArrHolder { + #[embed] + kids: [EmbChild; 2], + tag: u32, +} + +#[test] +fn macro_embed_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Two embedded children, each owning its own leaf. + let k0 = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 10).unwrap(), 1).unwrap(); + let k1 = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 20).unwrap(), 2).unwrap(); + let h = EmbArrHolder::new(&alloc, [k0, k1], 99).unwrap(); + assert_eq!(h.handle().tag(stack).unwrap(), 99); + + // Accessor: `[EmbChild; 2]` handles into the inline slots (pure offset math). + let kids = h.handle().kids(); + assert_eq!(kids[0].n(stack).unwrap(), 1); + assert_eq!(kids[0].leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!(kids[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + + // Clone folds each embedded child inline, deep-cloning its owned leaf. + let clone = h.try_clone_in(&alloc).unwrap(); + let ckids = clone.handle().kids(); + assert_eq!(ckids[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + assert_ne!( + ckids[0].leaf(stack).unwrap().range().start(), + kids[0].leaf(stack).unwrap().range().start() + ); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(h.handle().kids()[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + + // Move re-homes each embedded child to a fresh standalone allocation. + let (moved, tag) = bstack_move!(h, &alloc).unwrap(); + assert_eq!(tag, 99); + assert_eq!(moved[0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + for m in moved { + m.bstack_drop(&alloc).unwrap(); + } +} From 4ecb4f6c3b607d7dd9e7bcdae76221911c1f3e03 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 30 Jul 2026 23:33:50 -0700 Subject: [PATCH 049/140] optimizations with copy --- bstack_raii/derive/src/block.rs | 130 ++++++++++++++++++++++---------- bstack_raii/src/vec.rs | 30 ++++++-- 2 files changed, 112 insertions(+), 48 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 286787f..83a3384 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -106,6 +106,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let mut ctor_params = Vec::new(); let mut ctor_preps = Vec::new(); let mut ctor_inits = Vec::new(); + // Post-write construction steps (`#[embed]` `BStack::copy`s the child into its + // inline slot after the block's OnDisk is written). + let mut ctor_post: Vec = Vec::new(); // `bstack_move!` support (owned/ref/pod fields only, plain blocks only). let mut mv_caps = Vec::new(); let mut mv_types = Vec::new(); @@ -293,29 +296,33 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }); - // Constructor: fold each `BStackOwned` in — read its OnDisk, - // free its shell (its own children stay live, now owned by the embed). + // Constructor: capture each child's block range; the OnDisk slots + // are zeroed placeholders, and a post-write step `BStack::copy`s each + // child into its slot (then frees the shell) — no materialising. + let src_id = format_ident!("__embed_src_{}", fname); ctor_params.push(quote!(#fname: [::bstack_raii::BStackOwned<#child>; #len],)); ctor_preps.push(quote! { - let #fname: [#child_od; #len] = { - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __owned in #fname { - let __h = __owned.into_inner(); - let __cr = ::bstack_raii::BStackBlock::range(&__h); - let mut __b = [0u8; ::core::mem::size_of::<#child_od>()]; - let __cod = *unsafe { - ::bstack_raii::BStackRef::<#child>::from_range(__cr) - }.read_on_disk(allocator.stack(), &mut __b)?; - unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } - __v.push(__cod); - } - match <[#child_od; #len]>::try_from(__v) { - ::std::result::Result::Ok(__a) => __a, - ::std::result::Result::Err(_) => unreachable!(), + let #src_id: [::bstack_raii::BStackRange; #len] = #fname.map(|__owned| { + let __h = __owned.into_inner(); + ::bstack_raii::BStackBlock::range(&__h) + }); + }); + ctor_inits.push( + quote!(#fname: [<#child_od as ::bstack_raii::Zeroable>::zeroed(); #len],), + ); + ctor_post.push(quote! { + { + let __base = + __data.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + let __step = ::core::mem::size_of::<#child_od>() as u64; + for __i in 0..#len { + let __src = #src_id[__i]; + allocator.stack().copy( + __src.start(), __base + (__i as u64) * __step, __step)?; + unsafe { ::bstack_raii::dealloc_range(allocator, __src)?; } } - }; + } }); - ctor_inits.push(quote!(#fname: #fname,)); // Move: re-home each embedded child to a fresh standalone allocation. let cap = format_ident!("__cap_{}", fname); @@ -792,21 +799,28 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }); - // Constructor: fold a `BStackOwned` in — read its OnDisk, free - // its shell (its own children stay live, now owned by the embed). + // Constructor: capture the child's block range; the OnDisk slot is a + // zeroed placeholder, and a post-write step `BStack::copy`s the child + // into it (then frees the child shell) — no materialising the OnDisk. + let src_id = format_ident!("__embed_src_{}", fname); ctor_params.push(quote!(#fname: ::bstack_raii::BStackOwned<#child>,)); ctor_preps.push(quote! { - let #fname: #child_od = { + let #src_id = { let __h = #fname.into_inner(); - let __cr = ::bstack_raii::BStackBlock::range(&__h); - let mut __b = [0u8; ::core::mem::size_of::<#child_od>()]; - let __od = *unsafe { ::bstack_raii::BStackRef::<#child>::from_range(__cr) } - .read_on_disk(allocator.stack(), &mut __b)?; - unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } - __od + ::bstack_raii::BStackBlock::range(&__h) }; }); - ctor_inits.push(quote!(#fname: #fname,)); + ctor_inits.push(quote!(#fname: <#child_od as ::bstack_raii::Zeroable>::zeroed(),)); + ctor_post.push(quote! { + { + allocator.stack().copy( + #src_id.start(), + __data.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + ::core::mem::size_of::<#child_od>() as u64, + )?; + unsafe { ::bstack_raii::dealloc_range(allocator, #src_id)?; } + } + }); // Move: re-home the embedded child to a fresh standalone allocation. let cap = format_ident!("__cap_{}", fname); @@ -1054,6 +1068,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &ctor_params, &ctor_preps, &ctor_inits, + &ctor_post, ); // The field destructure is generated for every mode: plain blocks use it via @@ -1885,6 +1900,7 @@ fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Ident } /// Assemble the `new` constructor. +#[allow(clippy::too_many_arguments)] fn constructor( vis: &syn::Visibility, on_disk: &Ident, @@ -1893,6 +1909,10 @@ fn constructor( params: &[TokenStream], preps: &[TokenStream], inits: &[TokenStream], + // Steps run *after* the block's OnDisk is written (with `__data` = the block + // range in scope): `#[embed]` fields copy each child block into its now-written + // inline slot via `BStack::copy` and free the child shell. + post: &[TokenStream], ) -> TokenStream { let header = quote! { __bstack_header: ::bstack_raii::BlockHeader { @@ -1955,6 +1975,7 @@ fn constructor( let _ = allocator.dealloc(__slice); return ::std::result::Result::Err(__e); } + #(#post)* #finish } } @@ -2002,6 +2023,7 @@ fn constructor( let _ = ::bstack_raii::free_many(allocator, [__data, __ctrl]); return ::std::result::Result::Err(__e); } + #(#post)* ::std::result::Result::Ok(unsafe { ::bstack_raii::BStackRc::from_raw( ::bstack_raii::BStackRef::from_range(__data), @@ -2572,6 +2594,8 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result())); data_variants.push(quote!(#vname(::bstack_raii::BStackOwned<#ty>),)); view_variants.push(quote!(#vname(#ty),)); - // new: fold a `BStackOwned` in (read OnDisk, free its - // shell), copying its bytes into the payload. + // new: capture the child's block range; the payload is a + // zeroed placeholder, and a post-write step `BStack::copy`s + // the child into it (then frees the shell) — no materialising. + enum_has_embed = true; new_arms.push(quote! { #data::#vname(__v) => { let __h = __v.into_inner(); let __cr = ::bstack_raii::BStackBlock::range(&__h); - let mut __b = [0u8; ::core::mem::size_of::<#co>()]; - let __cod = *unsafe { - ::bstack_raii::BStackRef::<#ty>::from_range(__cr) - } - .read_on_disk(allocator.stack(), &mut __b)?; - unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } - let mut __pl = [0u8; Self::__PAYLOAD]; - __pl[..::core::mem::size_of::<#co>()] - .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__cod)); - (#disc, __pl) + __embed_copy = ::core::option::Option::Some(( + __cr, + ::core::mem::size_of::<#co>() as u64, + )); + (#disc, [0u8; Self::__PAYLOAD]) } }); // read (view): a child handle at the embedded payload offset. @@ -3027,6 +3048,29 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result() as u64); + // For an `#[embed]` variant, `new` declares a captured child range (set by the + // active variant's arm) and, after writing the OnDisk with a zeroed payload, + // `BStack::copy`s the child into the payload and frees its shell. + let (embed_decl, embed_post) = if enum_has_embed { + ( + quote!(let mut __embed_copy: + ::core::option::Option<(::bstack_raii::BStackRange, u64)> = + ::core::option::Option::None;), + quote! { + if let ::core::option::Option::Some((__cr, __sz)) = __embed_copy { + allocator.stack().copy( + __cr.start(), + __data.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64, + __sz, + )?; + unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } + } + }, + ) + } else { + (quote!(), quote!()) + }; let enum_new = match mode { Mode::Plain | Mode::Rc => { let injected_init = if let Mode::Rc = mode { @@ -3058,6 +3102,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<#new_ret> { + #embed_decl let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { #(#new_arms)* }; @@ -3075,6 +3120,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result ::std::io::Result<::bstack_raii::BStackRc<'__e, Self, __A>> { + #embed_decl let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { #(#new_arms)* }; @@ -3118,6 +3165,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result BStackVec<'a, T, A> { return self.persist(); } - // Field-resident growth: build the new block, commit, then free the old. - let mut bytes = bytevec.read_bytes()?; - bytes.extend_from_slice(bytemuck::bytes_of(&value)); - let new_len = bytes.len() as u64; + // Field-resident growth: allocate a new block, copy the old elements over + // with the crash-atomic `BStack::copy` (no materialising), append the new + // element, commit the descriptor, then free the old block. + let new_len = len + elem; let new_cap = core::cmp::max(cap.saturating_mul(2), new_len); let old = self.data; - let mut slice = self.allocator.alloc(BYTEVEC_HEADER + new_cap)?; + let slice = self.allocator.alloc(BYTEVEC_HEADER + new_cap)?; let new_range = slice.as_range(); - let image = bytevec_image(new_len, new_cap, &bytes); - if let Err(e) = slice.write_range(0, &image) { + let stack = self.allocator.stack(); + let build = (|| -> io::Result<()> { + stack.set(new_range.start(), bytevec_image(new_len, new_cap, &[]))?; + if len > 0 { + stack.copy( + old.start() + BYTEVEC_HEADER, + new_range.start() + BYTEVEC_HEADER, + len, + )?; + } + stack.set( + new_range.start() + BYTEVEC_HEADER + len, + bytemuck::bytes_of(&value), + ) + })(); + if let Err(e) = build { let _ = self.allocator.dealloc(slice); return Err(e); } @@ -296,6 +310,8 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { pub fn clone_data_into(&self, plan: &mut ClonePlan) -> io::Result { // Fold the data block into the plan: read the source elements, then let // the plan allocate + stage a fresh block so it rides the atomic commit. + // (Staging is intentional — the clone commits as one unit — so this does + // NOT use `BStack::copy`, which would land outside the batch.) let bytes = self.bytes()?.read_bytes()?; plan.stage_bytevec(self.allocator, &bytes) } From d9bf7b10134a673d2a9e019c5db244c5592b218f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 00:04:23 -0700 Subject: [PATCH 050/140] enum variants for [T; N] --- bstack_raii/derive/src/block.rs | 263 ++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 162 ++++++++++++++++++++ 2 files changed, 425 insertions(+) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 83a3384..e7ab680 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -2642,6 +2642,269 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { needs_payload = true; let ty = &f.unnamed.first().unwrap().ty; + + // Annotated **array** variant `#[..] V([T; N])`: N block references + // stored inline in the payload as `[u64; N]` (N*8 bytes), the + // per-element mirror of a `#[bstack_owned/strong/weak/ref] V(T)`. + if let Type::Array(__arr) = ty { + let elem = &__arr.elem; + let len = &__arr.len; + let elem_size = quote!(::core::mem::size_of::< + <#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64); + payload_sizes.push(quote!((#len) * 8)); + match kind { + Kind::Owned => { + data_variants + .push(quote!(#vname([::bstack_raii::BStackOwned<#elem>; #len]),)); + view_variants.push(quote!(#vname([#elem; #len]),)); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + for (__i, __owned) in __arr.into_iter().enumerate() { + let __off = ::bstack_raii::BStackBlock::range( + &__owned.into_inner()).start(); + __pl[__i * 8..__i * 8 + 8] + .copy_from_slice(&__off.to_le_bytes()); + } + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => #view::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) + })), + }); + move_arms.push(quote! { + #disc => #data::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + unsafe { ::bstack_raii::BStackOwned::from_raw( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size))) } + })), + }); + drop_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __child = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) }; + ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; + } + } + }); + clone_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __child = + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)); + let __new = __child.__bstack_clone_into(allocator, __plan)?; + __pl[__i * 8..__i * 8 + 8] + .copy_from_slice(&__new.start().to_le_bytes()); + } + } + }); + } + Kind::Ref => { + data_variants + .push(quote!(#vname([::bstack_raii::BStackRef<#elem>; #len]),)); + view_variants.push(quote!(#vname([#elem; #len]),)); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + for (__i, __r) in __arr.into_iter().enumerate() { + __pl[__i * 8..__i * 8 + 8] + .copy_from_slice(&__r.into_range().start().to_le_bytes()); + } + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => #view::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) + })), + }); + move_arms.push(quote! { + #disc => #data::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) } + })), + }); + // A ref owns nothing: no teardown, and clone aliases + // (the payload offsets are copied verbatim). + } + Kind::Strong => { + has_shared = true; + data_variants.push( + quote!(#vname([::bstack_raii::BStackRc<'__e, #elem, __A>; #len]),), + ); + view_variants.push(quote!(#vname([#elem; #len]),)); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + for (__i, __rc) in __arr.into_iter().enumerate() { + let (__d, _c) = __rc.into_raw(); + __pl[__i * 8..__i * 8 + 8] + .copy_from_slice(&__d.into_range().start().to_le_bytes()); + } + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => #view::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) + })), + }); + move_arms.push(quote! { + #disc => { + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) }; + let (__d, __c) = + <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + __v.push(unsafe { + ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }); + } + #data::#vname( + match <[::bstack_raii::BStackRc<'__mv, #elem, __A>; #len]>::try_from(__v) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + }) + } + }); + drop_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) }; + <#elem as ::bstack_raii::BStackShared>::drop_strong_ref( + __data, allocator)?; + } + } + }); + clone_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) }; + __plan.bump_strong(__data, allocator)?; + } + } + }); + } + Kind::Weak => { + has_shared = true; + has_weak = true; + let ctrl_size = quote!(::core::mem::size_of::< + <#elem as ::bstack_raii::BStackWeakable>::Control>() as u64); + let ctrl_ty = quote!(<#elem as ::bstack_raii::BStackWeakable>::Control); + data_variants.push( + quote!(#vname([::bstack_raii::BStackWeak<'__e, #elem, __A>; #len]),), + ); + view_variants.push(quote!(#vname([::core::option::Option< + ::bstack_raii::BStackRc<'__e, #elem, __A>>; #len]),)); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + for (__i, __w) in __arr.into_iter().enumerate() { + __pl[__i * 8..__i * 8 + 8].copy_from_slice( + &__w.into_raw().into_range().start().to_le_bytes()); + } + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => { + let mut __v = ::std::vec::Vec::with_capacity(#len); + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; + let __wk = unsafe { + ::bstack_raii::BStackWeak::<#elem, __A>::from_raw(__ctrl, allocator) }; + let __up = __wk.upgrade()?; + let _ = __wk.into_raw(); + __v.push(__up); + } + #view::#vname( + match <[::core::option::Option< + ::bstack_raii::BStackRc<'__e, #elem, __A>>; #len]>::try_from(__v) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + }) + } + }); + move_arms.push(quote! { + #disc => #data::#vname(::core::array::from_fn(|__i| { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; + unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } + })), + }); + drop_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; + ::bstack_raii::WeakRef::<#elem>(__ctrl).bstack_drop(allocator)?; + } + } + }); + clone_arms.push(quote! { + #disc => { + for __i in 0..#len { + let __off = u64::from_le_bytes( + __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + __plan.bump_weak(__off); + } + } + }); + } + Kind::Embed => { + return Err(Error::new_spanned( + ty, + "#[embed] array enum variants are not yet supported", + )); + } + Kind::Pod => unreachable!("guarded out above"), + } + continue; + } + match kind { Kind::Pod => unreachable!("guarded out above"), Kind::Owned => { diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 95960b7..78702de 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2818,3 +2818,165 @@ fn macro_embed_array() { m.bstack_drop(&alloc).unwrap(); } } + +#[bstack_enum] +enum ArrEnum { + Empty, + #[bstack_owned] + Leaves([MacroLeaf; 2]), +} + +#[test] +fn macro_enum_owned_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let e = ArrEnum::new( + &alloc, + ArrEnumData::Leaves([ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + ]), + ) + .unwrap(); + match e.handle().read(&alloc).unwrap() { + ArrEnumView::Leaves(arr) => { + assert_eq!(arr[0].val(stack).unwrap(), 10); + assert_eq!(arr[1].val(stack).unwrap(), 20); + } + _ => panic!("expected Leaves"), + } + + // Clone deep-copies each element. + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + ArrEnumView::Leaves(arr) => assert_eq!(arr[0].val(stack).unwrap(), 10), + _ => panic!("expected Leaves"), + } + clone.bstack_drop(&alloc).unwrap(); + + // Move yields `[BStackOwned; 2]`. + match bstack_move!(e, &alloc).unwrap() { + ArrEnumData::Leaves(arr) => { + assert_eq!(arr[1].handle().val(stack).unwrap(), 20); + for l in arr { + l.bstack_drop(&alloc).unwrap(); + } + } + _ => panic!("expected Leaves"), + } +} + +#[bstack_enum] +enum RefArrEnum { + Empty, + #[bstack_ref] + Refs([MacroLeaf; 2]), +} + +#[test] +fn macro_enum_ref_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let l0 = MacroLeaf::new(&alloc, 1).unwrap(); + let l1 = MacroLeaf::new(&alloc, 2).unwrap(); + let e = RefArrEnum::new( + &alloc, + RefArrEnumData::Refs([ + unsafe { BStackRef::from_range(l0.handle().range()) }, + unsafe { BStackRef::from_range(l1.handle().range()) }, + ]), + ) + .unwrap(); + match e.handle().read(&alloc).unwrap() { + RefArrEnumView::Refs(arr) => { + assert_eq!(arr[0].val(stack).unwrap(), 1); + assert_eq!(arr[1].val(stack).unwrap(), 2); + } + _ => panic!("expected Refs"), + } + e.bstack_drop(&alloc).unwrap(); // owns nothing + assert_eq!(l0.handle().val(stack).unwrap(), 1); + l0.bstack_drop(&alloc).unwrap(); + l1.bstack_drop(&alloc).unwrap(); +} + +#[bstack_enum] +enum StrongArrEnum { + Empty, + #[bstack_strong] + Shared([MacroStrongChild; 2]), +} + +#[test] +fn macro_enum_strong_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c0 = MacroStrongChild::new(&alloc, 5).unwrap(); + let c1 = MacroStrongChild::new(&alloc, 6).unwrap(); + let keep0 = c0.try_clone().unwrap(); + let e = StrongArrEnum::new(&alloc, StrongArrEnumData::Shared([c0, c1])).unwrap(); + match e.handle().read(&alloc).unwrap() { + StrongArrEnumView::Shared(arr) => assert_eq!(arr[0].val(stack).unwrap(), 5), + _ => panic!("expected Shared"), + } + // Clone re-references each; teardown of both holders returns to keep0's ref. + let clone = e.try_clone_in(&alloc).unwrap(); + clone.bstack_drop(&alloc).unwrap(); + e.bstack_drop(&alloc).unwrap(); + assert_eq!(keep0.handle().val(stack).unwrap(), 5); + drop(keep0); +} + +#[bstack_enum] +enum WeakArrEnum { + Empty, + #[bstack_weak] + Weaks([MacroStrongChild; 2]), +} + +#[test] +fn macro_enum_weak_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c0 = MacroStrongChild::new(&alloc, 5).unwrap(); + let c1 = MacroStrongChild::new(&alloc, 6).unwrap(); + let e = WeakArrEnum::new( + &alloc, + WeakArrEnumData::Weaks([c0.downgrade().unwrap(), c1.downgrade().unwrap()]), + ) + .unwrap(); + match e.handle().read(&alloc).unwrap() { + WeakArrEnumView::Weaks(arr) => { + assert_eq!(arr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); + assert_eq!(arr[1].as_ref().unwrap().handle().val(stack).unwrap(), 6); + } + _ => panic!("expected Weaks"), + } + e.bstack_drop(&alloc).unwrap(); // releases the weak refs + assert_eq!(c0.handle().val(stack).unwrap(), 5); + drop(c0); + drop(c1); +} + +#[bstack_enum] +enum PodArrEnum { + Empty, + Bytes([u16; 3]), +} + +#[test] +fn macro_enum_pod_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let e = PodArrEnum::new(&alloc, PodArrEnumData::Bytes([7, 8, 9])).unwrap(); + match e.handle().read(&alloc).unwrap() { + PodArrEnumView::Bytes(a) => assert_eq!(a, [7u16, 8, 9]), + _ => panic!("expected Bytes"), + } + e.bstack_drop(&alloc).unwrap(); +} From b58048ac1a6c5ab53f05c9688255d5db97d03b74 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 01:59:24 -0700 Subject: [PATCH 051/140] Support nesting for [[T;N]M] --- bstack_raii/derive/src/block.rs | 1248 +++++++++++++++++++------------ bstack_raii/src/tests.rs | 263 +++++++ 2 files changed, 1040 insertions(+), 471 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index e7ab680..0594bca 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -247,18 +247,27 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // through to the POD path below — an array of `Pod` is `Pod`.) A reference // array is stored inline as `[u64; N]`, no data block, with per-element // ownership; the accessor / ctor traffic in arrays of handles `[Handle; N]`. - if kind != Kind::Pod && let Type::Array(arr) = opt_inner { - let len = &arr.len; - // A per-element `Option` makes each slot nullable (offset 0 == None). - let (elem, elem_nullable): (&Type, bool) = match option_inner(&arr.elem) { - Some(__inner) => (__inner, true), - None => (&arr.elem, false), - }; - // `#[embed] [Child; N]`: N verbatim child on-disk forms inline - // (`[::OnDisk; N]`). Construction folds each - // `BStackOwned` in (a lot of movement — read OnDisk, free shell). + // Inline fixed-size array `[T; N]` — possibly *nested* `[[..]; ..]` — of + // block references. (A POD array falls through to the POD path below: an + // array of `Pod` is `Pod`.) Stored **flat** as `[u64; N0*..*Nk]` inline + // (no data block), one offset per leaf, with per-element ownership; the + // accessor / ctor / move traffic in the nested `[[Handle; ..]; ..]` shape. + if kind != Kind::Pod && let Type::Array(_) = opt_inner { + if nullable { + return Err(Error::new_spanned( + &field.ty, + "a whole-array `Option<[T; N]>` is not supported; use `[Option; N]` \ + for per-element nullability", + )); + } + let (dims, elem, elem_nullable) = array_shape(opt_inner)?; + let total = dims_prod(&dims); + + // `#[embed] [Child; N]` (or nested): N verbatim child on-disk forms + // inline (`[::OnDisk; TOTAL]`, flat). Construction + // folds each `BStackOwned` in (read OnDisk, copy, free shell). if kind == Kind::Embed { - if nullable || elem_nullable { + if elem_nullable { return Err(Error::new_spanned( &field.ty, "#[embed] does not support `Option`", @@ -266,7 +275,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } let child = elem; let child_od = quote!(<#child as ::bstack_raii::BStackBlock>::OnDisk); - on_disk_fields.push(quote!(#fname: [#child_od; #len],)); + on_disk_fields.push(quote!(#fname: [#child_od; #total],)); // Teardown: free each embedded child's children in place. drop_stmts.push(quote! { @@ -274,51 +283,64 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __base = __range.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; - for __i in 0..#len { + for __k in 0usize..(#total) { let __embed = ::bstack_raii::BStackRange::new( - __base + (__i as u64) * __step, __step); + __base + (__k as u64) * __step, __step); <#child>::__bstack_drop_children(__embed, allocator)?; } } }); - // Accessor: `[Child; N]`, each a handle into its inline slot. + // Accessor: nested `[[Child; ..]; ..]`, each a handle into its slot. + let acc_ret = nested_ty(&dims, "e!(#child)); + let acc_read = |k: &Ident| { + quote!(<#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__base + (#k as u64) * __step, __step))) + }; + let acc_body = nested_build(&dims, "e!(#child), &acc_read); accessors.push(quote! { - #vis fn #fname(&self) -> [#child; #len] { + #vis fn #fname(&self) -> #acc_ret { let __base = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; - ::core::array::from_fn(|__i| { - <#child as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new( - __base + (__i as u64) * __step, __step)) - }) + #acc_body } }); - // Constructor: capture each child's block range; the OnDisk slots - // are zeroed placeholders, and a post-write step `BStack::copy`s each - // child into its slot (then frees the shell) — no materialising. + // Constructor: flatten the nested owned array to `[BStackRange; TOTAL]` + // (each child's source block), zero the slots, and `copy` each child + // into place post-write (then free the shell) — no materialising. let src_id = format_ident!("__embed_src_{}", fname); - ctor_params.push(quote!(#fname: [::bstack_raii::BStackOwned<#child>; #len],)); + let param_ty = nested_ty(&dims, "e!(::bstack_raii::BStackOwned<#child>)); + ctor_params.push(quote!(#fname: #param_ty,)); + let cap_write = |k: &Ident, leaf: &Ident| { + quote! { + #src_id[#k] = { + let __h = #leaf.into_inner(); + ::bstack_raii::BStackBlock::range(&__h) + }; + } + }; + let flatten = nested_consume(&dims, "e!(#fname), &cap_write); ctor_preps.push(quote! { - let #src_id: [::bstack_raii::BStackRange; #len] = #fname.map(|__owned| { - let __h = __owned.into_inner(); - ::bstack_raii::BStackBlock::range(&__h) - }); + let #src_id: [::bstack_raii::BStackRange; #total] = { + let mut #src_id = [::bstack_raii::BStackRange::new(0, 0); #total]; + #flatten + #src_id + }; }); ctor_inits.push( - quote!(#fname: [<#child_od as ::bstack_raii::Zeroable>::zeroed(); #len],), + quote!(#fname: [<#child_od as ::bstack_raii::Zeroable>::zeroed(); #total],), ); ctor_post.push(quote! { { let __base = __data.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; - for __i in 0..#len { - let __src = #src_id[__i]; + for __k in 0usize..(#total) { + let __src = #src_id[__k]; allocator.stack().copy( - __src.start(), __base + (__i as u64) * __step, __step)?; + __src.start(), __base + (__k as u64) * __step, __step)?; unsafe { ::bstack_raii::dealloc_range(allocator, __src)?; } } } @@ -327,45 +349,44 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // Move: re-home each embedded child to a fresh standalone allocation. let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); - mv_types.push(quote!([::bstack_raii::BStackOwned<#child>; #len])); - mv_recon.push(quote! { - { - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __cod in #cap { - let mut __slice = - __alloc.alloc(::core::mem::size_of::<#child_od>() as u64)?; - let __r = __slice.as_range(); - if let ::std::result::Result::Err(__e) = - __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__cod)) - { - let _ = __alloc.dealloc(__slice); - return ::std::result::Result::Err(__e); - } - __v.push(unsafe { - ::bstack_raii::BStackOwned::from_raw( - <#child as ::bstack_raii::BStackBlock>::from_range(__r)) - }); + mv_types.push(nested_ty(&dims, "e!(::bstack_raii::BStackOwned<#child>))); + let mv_read = |k: &Ident| { + quote! {{ + let __cod = #cap[#k]; + let mut __slice = + __alloc.alloc(::core::mem::size_of::<#child_od>() as u64)?; + let __r = __slice.as_range(); + if let ::std::result::Result::Err(__e) = + __slice.write_range(0, ::bstack_raii::bytemuck::bytes_of(&__cod)) + { + let _ = __alloc.dealloc(__slice); + return ::std::result::Result::Err(__e); } - match <[::bstack_raii::BStackOwned<#child>; #len]>::try_from(__v) { - ::std::result::Result::Ok(__a) => __a, - ::std::result::Result::Err(_) => unreachable!(), + unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#child as ::bstack_raii::BStackBlock>::from_range(__r)) } - } - }); + }} + }; + mv_recon.push(nested_build( + &dims, + "e!(::bstack_raii::BStackOwned<#child>), + &mv_read, + )); - // Clone: fold each embedded child's clone inline (copy the array out, - // mutate, write back — a packed field's elements can't be `&mut`'d). + // Clone: fold each embedded child's clone inline (flat; copy the + // array out, mutate, write back — packed fields can't be `&mut`'d). clone_stmts.push(quote! { { let __base = __src.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; - let mut __arr: [#child_od; #len] = __od.#fname; - for __i in 0..#len { + let mut __arr: [#child_od; #total] = __od.#fname; + for __k in 0usize..(#total) { let __child = <#child as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new( - __base + (__i as u64) * __step, __step)); - __arr[__i] = + __base + (__k as u64) * __step, __step)); + __arr[__k] = __child.__bstack_clone_children_inplace(allocator, __plan)?; } __od.#fname = __arr; @@ -373,22 +394,19 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }); continue; } - if nullable { - return Err(Error::new_spanned( - &field.ty, - "a whole-array `Option<[T; N]>` is not supported; use `[Option; N]` \ - for per-element nullability", - )); - } - on_disk_fields.push(quote!(#fname: [u64; #len],)); + + on_disk_fields.push(quote!(#fname: [u64; #total],)); + let size_elem = quote! { + ::core::mem::size_of::<<#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64 + }; // A weak array stores control offsets (`0` = unset), is not a ctor - // parameter (starts null, wired per-index via a setter), and its - // accessor upgrades each element. + // parameter (starts null, wired per flat index via a setter), and its + // accessor upgrades each element (address-based). if kind == Kind::Weak { let ctrl_ty = quote!(<#elem as ::bstack_raii::BStackWeakable>::Control); let ctrl_size = quote!(::core::mem::size_of::<#ctrl_ty>() as u64); - ctor_inits.push(quote!(#fname: [0u64; #len],)); + ctor_inits.push(quote!(#fname: [0u64; #total],)); let setter = format_ident!("set_{}", fname); setters.push(quote! { @@ -405,33 +423,29 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }); + let leaf_ty = + quote!(::core::option::Option<::bstack_raii::BStackRc<'__u, #elem, __A>>); + let acc_ret = nested_ty(&dims, &leaf_ty); + let acc_read = |k: &Ident| { + quote!(::bstack_raii::upgrade_weak_field( + allocator, __base + (#k as u64) * 8)?) + }; + let acc_body = nested_build(&dims, &leaf_ty, &acc_read); accessors.push(quote! { #vis fn #fname<'__u, __A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, allocator: &'__u __A, - ) -> ::std::io::Result< - [::core::option::Option<::bstack_raii::BStackRc<'__u, #elem, __A>>; #len] - > { + ) -> ::std::io::Result<#acc_ret> { let __base = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __i in 0..#len { - __v.push(::bstack_raii::upgrade_weak_field( - allocator, __base + (__i as u64) * 8)?); - } - match <[::core::option::Option< - ::bstack_raii::BStackRc<'__u, #elem, __A>>; #len]>::try_from(__v) - { - ::std::result::Result::Ok(__a) => ::std::result::Result::Ok(__a), - ::std::result::Result::Err(_) => unreachable!(), - } + ::std::result::Result::Ok(#acc_body) } }); // Teardown: release each non-null weak reference. drop_stmts.push(quote! { { - let __offs: [u64; #len] = __on_disk.#fname; + let __offs: [u64; #total] = __on_disk.#fname; for __off in __offs { if __off != 0 { let __ctrl = unsafe { @@ -444,11 +458,11 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }); - // Clone: bump each non-null weak count (offsets are kept — a weak - // clone aliases the same control block). + // Clone: bump each non-null weak count (offsets kept — a weak clone + // aliases the same control block). clone_stmts.push(quote! { { - let __offs: [u64; #len] = __od.#fname; + let __offs: [u64; #total] = __od.#fname; for __off in __offs { if __off != 0 { __plan.bump_weak(__off); @@ -457,14 +471,15 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }); - // Move: `[u64; N]` → `[Option; N]`. + // Move: nested `[[Option; ..]; ..]` from flat offsets. let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); - mv_types.push(quote!( - [::core::option::Option<::bstack_raii::BStackWeak<'__mv, #elem, __A>>; #len] - )); - mv_recon.push(quote! { - #cap.map(|__off| { + let mv_leaf_ty = + quote!(::core::option::Option<::bstack_raii::BStackWeak<'__mv, #elem, __A>>); + mv_types.push(nested_ty(&dims, &mv_leaf_ty)); + let mv_read = |k: &Ident| { + quote! {{ + let __off = #cap[#k]; if __off == 0 { ::core::option::Option::None } else { @@ -476,31 +491,38 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) }) } - }) - }); + }} + }; + mv_recon.push(nested_build(&dims, &mv_leaf_ty, &mv_read)); continue; } - let size_elem = quote! { - ::core::mem::size_of::<<#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64 - }; - - // Accessor: read the inline offsets, resolve each to a handle (or - // `None` for a `0` slot in an `Option`-element array). - let resolve_expr = quote!(<#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem))); - let (acc_ret, acc_map) = if elem_nullable { - ( - quote!([::core::option::Option<#elem>; #len]), - quote!(if __off == 0 { - ::core::option::Option::None - } else { - ::core::option::Option::Some(#resolve_expr) - }), - ) + // Owned / strong / ref: nested `[[Handle; ..]; ..]`, value-based from + // the flat offsets. A `0` slot is `None` for an `Option`-element array. + let leaf_view = if elem_nullable { + quote!(::core::option::Option<#elem>) } else { - (quote!([#elem; #len]), resolve_expr.clone()) + quote!(#elem) }; + let acc_ret = nested_ty(&dims, &leaf_view); + let acc_read = |k: &Ident| { + if elem_nullable { + quote!({ + let __o = __offs[#k]; + if __o == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__o, #size_elem))) + } + }) + } else { + quote!(<#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__offs[#k], #size_elem))) + } + }; + let acc_body = nested_build(&dims, &leaf_view, &acc_read); accessors.push(quote! { #vis fn #fname( &self, @@ -509,44 +531,60 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; - let __offs: [u64; #len] = __od.#fname; - ::std::result::Result::Ok(__offs.map(|__off| #acc_map)) + let __offs: [u64; #total] = __od.#fname; + ::std::result::Result::Ok(#acc_body) } }); - // Constructor: `[Handle; N]` → `[u64; N]` (per-kind offset extraction). - let (handle_ty, to_off): (TokenStream, TokenStream) = match kind { - Kind::Owned => ( - quote!(::bstack_raii::BStackOwned<#elem>), - quote!({ - let __h = __handle.into_inner(); - ::bstack_raii::BStackBlock::range(&__h).start() - }), - ), - Kind::Strong => ( - quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), - quote!({ - let (__d, _) = __handle.into_raw(); - __d.into_range().start() - }), - ), - Kind::Ref => ( - quote!(::bstack_raii::BStackRef<#elem>), - quote!(__handle.into_range().start()), - ), + // Constructor: nested `[[Handle; ..]; ..]` → flat `[u64; TOTAL]`. + let handle_ty = match kind { + Kind::Owned => quote!(::bstack_raii::BStackOwned<#elem>), + Kind::Strong => quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + Kind::Ref => quote!(::bstack_raii::BStackRef<#elem>), _ => unreachable!(), }; - if elem_nullable { - ctor_params.push(quote!(#fname: [::core::option::Option<#handle_ty>; #len],)); - ctor_preps.push(quote!(let #fname: [u64; #len] = #fname.map(|__opt| match __opt { - ::core::option::Option::Some(__handle) => #to_off, - ::core::option::Option::None => 0u64, - });)); + let off_of = |h: &Ident| match kind { + Kind::Owned => quote!({ + let __h = #h.into_inner(); + ::bstack_raii::BStackBlock::range(&__h).start() + }), + Kind::Strong => quote!({ + let (__d, _) = #h.into_raw(); + __d.into_range().start() + }), + Kind::Ref => quote!(#h.into_range().start()), + _ => unreachable!(), + }; + let ctor_leaf_ty = if elem_nullable { + quote!(::core::option::Option<#handle_ty>) } else { - ctor_params.push(quote!(#fname: [#handle_ty; #len],)); - ctor_preps - .push(quote!(let #fname: [u64; #len] = #fname.map(|__handle| #to_off);)); - } + quote!(#handle_ty) + }; + let ctor_param_ty = nested_ty(&dims, &ctor_leaf_ty); + ctor_params.push(quote!(#fname: #ctor_param_ty,)); + let ctor_write = |k: &Ident, leaf: &Ident| { + if elem_nullable { + let h = format_ident!("__handle"); + let off = off_of(&h); + quote! { + __a[#k] = match #leaf { + ::core::option::Option::Some(#h) => #off, + ::core::option::Option::None => 0u64, + }; + } + } else { + let off = off_of(leaf); + quote!(__a[#k] = #off;) + } + }; + let flatten = nested_consume(&dims, "e!(#fname), &ctor_write); + ctor_preps.push(quote! { + let #fname: [u64; #total] = { + let mut __a = [0u64; #total]; + #flatten + __a + }; + }); ctor_inits.push(quote!(#fname: #fname,)); // Teardown: free / release each non-null element (a ref owns nothing). @@ -568,7 +606,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result if kind != Kind::Ref { drop_stmts.push(quote! { { - let __offs: [u64; #len] = __on_disk.#fname; + let __offs: [u64; #total] = __on_disk.#fname; for __off in __offs { if __off != 0 { #per_teardown } } @@ -580,23 +618,22 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result match kind { Kind::Owned => clone_stmts.push(quote! { { - let __offs: [u64; #len] = __od.#fname; - let mut __new = __offs; - for __i in 0..#len { - let __off = __offs[__i]; + let mut __arr: [u64; #total] = __od.#fname; + for __k in 0usize..(#total) { + let __off = __arr[__k]; if __off != 0 { let __child = <#elem as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new(__off, #size_elem)); - __new[__i] = + __arr[__k] = __child.__bstack_clone_into(allocator, __plan)?.start(); } } - __od.#fname = __new; + __od.#fname = __arr; } }), Kind::Strong => clone_stmts.push(quote! { { - let __offs: [u64; #len] = __od.#fname; + let __offs: [u64; #total] = __od.#fname; for __off in __offs { if __off != 0 { let __child = unsafe { @@ -608,93 +645,67 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } }), - // Ref: aliased — the copied `[u64; N]` is kept verbatim. + // Ref: aliased — the copied `[u64; TOTAL]` is kept verbatim. _ => {} } - // Move: `[u64; N]` → `[Handle; N]` (or `[Option; N]`). + // Move: nested `[[Handle; ..]; ..]` from flat offsets. let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); - match kind { - // Owned / ref reconstruct infallibly, via `array::map`. - Kind::Owned | Kind::Ref => { - let (inner_ty, recon_one) = if kind == Kind::Owned { - ( - quote!(::bstack_raii::BStackOwned<#elem>), - quote!(unsafe { - ::bstack_raii::BStackOwned::from_raw( - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem))) - }), - ) - } else { - ( - quote!(::bstack_raii::BStackRef<#elem>), - quote!(unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem)) - }), - ) - }; - if elem_nullable { - mv_types.push(quote!([::core::option::Option<#inner_ty>; #len])); - mv_recon.push(quote!(#cap.map(|__off| if __off == 0 { + let (mv_leaf, build_one): (TokenStream, TokenStream) = match kind { + Kind::Owned => ( + quote!(::bstack_raii::BStackOwned<#elem>), + quote!(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem))) + }), + ), + Kind::Ref => ( + quote!(::bstack_raii::BStackRef<#elem>), + quote!(unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }), + ), + Kind::Strong => ( + quote!(::bstack_raii::BStackRc<'__mv, #elem, __A>), + quote!({ + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }; + let (__d, __c) = + <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } + }), + ), + _ => unreachable!(), + }; + let mv_leaf_ty = if elem_nullable { + quote!(::core::option::Option<#mv_leaf>) + } else { + mv_leaf.clone() + }; + mv_types.push(nested_ty(&dims, &mv_leaf_ty)); + let mv_read = |k: &Ident| { + if elem_nullable { + quote! {{ + let __off = #cap[#k]; + if __off == 0 { ::core::option::Option::None } else { - ::core::option::Option::Some(#recon_one) - }))); - } else { - mv_types.push(quote!([#inner_ty; #len])); - mv_recon.push(quote!(#cap.map(|__off| #recon_one))); - } - } - // Strong's `strong_parts` is fallible → build via a `Vec`. - Kind::Strong => { - let (elem_ty, push_expr) = if elem_nullable { - ( - quote!(::core::option::Option< - ::bstack_raii::BStackRc<'__mv, #elem, __A>>), - quote!(if __off == 0 { - ::core::option::Option::None - } else { - let __data = unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem)) }; - let (__d, __c) = - <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; - ::core::option::Option::Some(unsafe { - ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }) - }), - ) - } else { - ( - quote!(::bstack_raii::BStackRc<'__mv, #elem, __A>), - quote!({ - let __data = unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #size_elem)) }; - let (__d, __c) = - <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; - unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } - }), - ) - }; - mv_types.push(quote!([#elem_ty; #len])); - mv_recon.push(quote! { - { - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __off in #cap { - __v.push(#push_expr); - } - match <[#elem_ty; #len]>::try_from(__v) { - ::std::result::Result::Ok(__a) => __a, - ::std::result::Result::Err(_) => unreachable!(), - } + ::core::option::Option::Some(#build_one) } - }); + }} + } else { + quote! {{ + let __off = #cap[#k]; + #build_one + }} } - _ => unreachable!(), - } + }; + mv_recon.push(nested_build(&dims, &mv_leaf_ty, &mv_read)); continue; } @@ -1649,6 +1660,140 @@ fn option_inner(ty: &Type) -> Option<&Type> { } } +// --------------------------------------------------------------------------- +// Nested fixed-size arrays `[[.. [T; N0]; N1]..; Nk]` of block references. +// +// A block-reference array — at any nesting depth — is stored **flat** on disk / +// in an enum payload as `[u64; N0*N1*..*Nk]` (row-major), one `u64` offset per +// leaf. The runtime rebuilds / consumes the nested `[[..]; ..]` shape of handles +// through the recursive helpers below, driven by a single flat counter `__k`. +// Every leaf may be an `Option` (per-element nullability, `0` == `None`); an +// `Option` wrapping a *whole* sub-array is rejected (only whole-field +// `Option<[T;N]>` was ever a niche, and that stays unsupported). +// --------------------------------------------------------------------------- + +/// Peel a (possibly nested) `[[..]; ..]` array type into its dimensions +/// (outer→inner) plus the innermost element and whether that element is a +/// per-element `Option`. Errors if an `Option` wraps a non-leaf sub-array. +fn array_shape(ty: &Type) -> syn::Result<(Vec<&Expr>, &Type, bool)> { + let mut dims: Vec<&Expr> = Vec::new(); + let mut cur = ty; + while let Type::Array(a) = cur { + dims.push(&a.len); + cur = &a.elem; + } + let (leaf, nullable) = match option_inner(cur) { + Some(inner) => (inner, true), + None => (cur, false), + }; + if let Type::Array(_) = leaf { + return Err(Error::new_spanned( + cur, + "`Option` wrapping a whole sub-array is not supported; move the \ + `Option` onto the leaf element, e.g. `[[Option; N]; M]`", + )); + } + Ok((dims, leaf, nullable)) +} + +/// The product `N0 * N1 * .. * Nk` of the dimensions as a `usize` const +/// expression (`1usize` when empty) — the flat element count. +fn dims_prod(dims: &[&Expr]) -> TokenStream { + if dims.is_empty() { + return quote!(1usize); + } + let first = dims[0]; + let mut t = quote!((#first)); + for d in &dims[1..] { + t = quote!(#t * (#d)); + } + t +} + +/// The nested handle-array type `[[.. [leaf; N0]..]; Nk]` for the given +/// dimensions (outer→inner) around a leaf token type. +fn nested_ty(dims: &[&Expr], leaf: &TokenStream) -> TokenStream { + if dims.is_empty() { + return leaf.clone(); + } + let d = dims[0]; + let inner = nested_ty(&dims[1..], leaf); + quote!([#inner; #d]) +} + +/// Build the nested array value by reading each leaf in flat, row-major order. +/// `leaf_read(k)` yields the leaf value expression for flat index ident `k` +/// (which it must NOT advance — the engine advances it). May be fallible (the +/// leaf expression may use `?`); the enclosing context must return `Result`. +fn nested_build( + dims: &[&Expr], + leaf_ty: &TokenStream, + leaf_read: &dyn Fn(&Ident) -> TokenStream, +) -> TokenStream { + let k = format_ident!("__k"); + let body = nested_build_inner(dims, leaf_ty, 0, &k, leaf_read); + quote!({ let mut #k = 0usize; #body }) +} + +fn nested_build_inner( + dims: &[&Expr], + leaf_ty: &TokenStream, + depth: usize, + k: &Ident, + leaf_read: &dyn Fn(&Ident) -> TokenStream, +) -> TokenStream { + if dims.is_empty() { + let val = leaf_read(k); + return quote!({ let __e = #val; #k += 1; __e }); + } + let d = dims[0]; + let rest = &dims[1..]; + let vv = format_ident!("__bv{depth}"); + let inner_ty = nested_ty(rest, leaf_ty); + let body = nested_build_inner(rest, leaf_ty, depth + 1, k, leaf_read); + quote!({ + let mut #vv = ::std::vec::Vec::with_capacity(#d); + for _ in 0usize..(#d) { + #vv.push(#body); + } + match <[#inner_ty; #d]>::try_from(#vv) { + ::std::result::Result::Ok(__a) => __a, + ::std::result::Result::Err(_) => unreachable!(), + } + }) +} + +/// Consume the nested array `val`, invoking `leaf_write(k, leaf)` for each leaf +/// in flat, row-major order (`k` is the flat-index ident, `leaf` the moved +/// element binding). The engine advances `k`. +fn nested_consume( + dims: &[&Expr], + val: &TokenStream, + leaf_write: &dyn Fn(&Ident, &Ident) -> TokenStream, +) -> TokenStream { + let k = format_ident!("__k"); + let body = nested_consume_inner(dims, val, 0, &k, leaf_write); + quote!({ let mut #k = 0usize; #body }) +} + +fn nested_consume_inner( + dims: &[&Expr], + val: &TokenStream, + depth: usize, + k: &Ident, + leaf_write: &dyn Fn(&Ident, &Ident) -> TokenStream, +) -> TokenStream { + if dims.is_empty() { + let leaf = format_ident!("__leaf"); + let w = leaf_write(k, &leaf); + return quote!({ let #leaf = #val; #w #k += 1; }); + } + let rest = &dims[1..]; + let cv = format_ident!("__cn{depth}"); + let inner = nested_consume_inner(rest, "e!(#cv), depth + 1, k, leaf_write); + quote!(for #cv in #val { #inner }) +} + /// Generate the reader method for one field. `nullable` (an `Option<_>` field) /// makes ref accessors return `Option`, treating a `0` offset as `None`. fn accessor( @@ -2646,261 +2791,417 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result` + // leaves. Block refs are stored **flat** in the payload as + // `[u64; TOTAL]` (TOTAL*8 bytes), the per-element mirror of a + // `#[bstack_owned/strong/weak/ref] V(T)`; `#[embed]` stores each + // child's whole on-disk form verbatim. + if let Type::Array(_) = ty { + let (dims, elem, elem_nullable) = array_shape(ty)?; + let total = dims_prod(&dims); + // Flat byte read/write of leaf `#k`'s `u64` in the payload. + let pl_off = |k: &Ident| { + quote!(u64::from_le_bytes( + __pl[(#k) * 8..(#k) * 8 + 8].try_into().unwrap())) + }; + let pl_put = |k: &Ident, off: TokenStream| { + quote!(__pl[(#k) * 8..(#k) * 8 + 8] + .copy_from_slice(&(#off).to_le_bytes());) + }; + + // ---- #[embed] array: verbatim child on-disk forms ---- + if kind == Kind::Embed { + if elem_nullable { + return Err(Error::new_spanned( + ty, + "#[embed] does not support `Option`", + )); + } + enum_has_embed = true; + let child = elem; + let co = quote!(<#child as ::bstack_raii::BStackBlock>::OnDisk); + payload_sizes.push(quote!((#total) * ::core::mem::size_of::<#co>())); + + let data_leaf = quote!(::bstack_raii::BStackOwned<#child>); + let data_ty_nested = nested_ty(&dims, &data_leaf); + data_variants.push(quote!(#vname(#data_ty_nested),)); + let view_ty_nested = nested_ty(&dims, "e!(#child)); + view_variants.push(quote!(#vname(#view_ty_nested),)); + + // new: push one copy entry per flat slot; payload stays zeroed. + let cap_write = |k: &Ident, leaf: &Ident| { + quote! { + let __h = #leaf.into_inner(); + let __cr = ::bstack_raii::BStackBlock::range(&__h); + __embed_copy.push(( + __cr, + ::core::mem::size_of::<#co>() as u64, + (#k as u64) * ::core::mem::size_of::<#co>() as u64, + )); + } + }; + let consume = nested_consume(&dims, "e!(__arr), &cap_write); + new_arms.push(quote! { + #data::#vname(__arr) => { + #consume + (#disc, [0u8; Self::__PAYLOAD]) + } + }); + + // read (view): nested child handles into the payload slots. + let read_leaf = |k: &Ident| { + quote!(<#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __base + (#k as u64) * __step, __step))) + }; + let read_body = nested_build(&dims, "e!(#child), &read_leaf); + read_arms.push(quote! { + #disc => { + let __base = self.0.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64; + let __step = ::core::mem::size_of::<#co>() as u64; + #view::#vname(#read_body) + } + }); + + // move: re-home each embedded child to a fresh allocation. + let mv_read = |k: &Ident| { + quote! {{ + let __start = (#k) * ::core::mem::size_of::<#co>(); + let mut __slice = + __alloc.alloc(::core::mem::size_of::<#co>() as u64)?; + let __r = __slice.as_range(); + if let ::std::result::Result::Err(__e) = __slice.write_range( + 0, &__pl[__start..__start + ::core::mem::size_of::<#co>()]) + { + let _ = __alloc.dealloc(__slice); + return ::std::result::Result::Err(__e); + } + unsafe { ::bstack_raii::BStackOwned::from_raw( + <#child as ::bstack_raii::BStackBlock>::from_range(__r)) } + }} + }; + let mv_body = nested_build(&dims, &data_leaf, &mv_read); + move_arms.push(quote!(#disc => #data::#vname(#mv_body),)); + + // teardown: free each embedded child's children in place. + drop_arms.push(quote! { + #disc => { + let __base = __range.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64; + let __step = ::core::mem::size_of::<#co>() as u64; + for __k in 0usize..(#total) { + let __embed = ::bstack_raii::BStackRange::new( + __base + (__k as u64) * __step, __step); + <#child>::__bstack_drop_children(__embed, allocator)?; + } + } + }); + + // clone: fold each embedded child inline; patch payload bytes. + clone_arms.push(quote! { + #disc => { + let __base = self.0.start() + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64; + let __step = ::core::mem::size_of::<#co>() as u64; + for __k in 0usize..(#total) { + let __child = <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __base + (__k as u64) * __step, __step)); + let __fixed = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + let __start = (__k) * ::core::mem::size_of::<#co>(); + __pl[__start..__start + ::core::mem::size_of::<#co>()] + .copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__fixed)); + } + } + }); + continue; + } + + // ---- owned / strong / weak / ref: flat `[u64; TOTAL]` ---- let elem_size = quote!(::core::mem::size_of::< <#elem as ::bstack_raii::BStackBlock>::OnDisk>() as u64); - payload_sizes.push(quote!((#len) * 8)); - match kind { - Kind::Owned => { - data_variants - .push(quote!(#vname([::bstack_raii::BStackOwned<#elem>; #len]),)); - view_variants.push(quote!(#vname([#elem; #len]),)); - new_arms.push(quote! { - #data::#vname(__arr) => { - let mut __pl = [0u8; Self::__PAYLOAD]; - for (__i, __owned) in __arr.into_iter().enumerate() { - let __off = ::bstack_raii::BStackBlock::range( - &__owned.into_inner()).start(); - __pl[__i * 8..__i * 8 + 8] - .copy_from_slice(&__off.to_le_bytes()); - } - (#disc, __pl) + payload_sizes.push(quote!((#total) * 8)); + + if kind == Kind::Weak { + has_shared = true; + has_weak = true; + let ctrl_ty = quote!(<#elem as ::bstack_raii::BStackWeakable>::Control); + let ctrl_size = quote!(::core::mem::size_of::<#ctrl_ty>() as u64); + + let data_leaf = quote!(::bstack_raii::BStackWeak<'__e, #elem, __A>); + let data_ty_nested = nested_ty(&dims, &data_leaf); + data_variants.push(quote!(#vname(#data_ty_nested),)); + let view_leaf = + quote!(::core::option::Option<::bstack_raii::BStackRc<'__e, #elem, __A>>); + let view_ty_nested = nested_ty(&dims, &view_leaf); + view_variants.push(quote!(#vname(#view_ty_nested),)); + + // new: consume nested weaks → control offsets. + let cap_write = |k: &Ident, leaf: &Ident| { + pl_put(k, quote!(#leaf.into_raw().into_range().start())) + }; + let consume = nested_consume(&dims, "e!(__arr), &cap_write); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + #consume + (#disc, __pl) + } + }); + + // read: upgrade each control offset (fallible). + let view_leaf_e = view_leaf.clone(); + let read_leaf = |k: &Ident| { + let off = pl_off(k); + quote! {{ + let __off = #off; + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; + let __wk = unsafe { + ::bstack_raii::BStackWeak::<#elem, __A>::from_raw(__ctrl, allocator) }; + let __up = __wk.upgrade()?; + let _ = __wk.into_raw(); + __up + }} + }; + let read_body = nested_build(&dims, &view_leaf_e, &read_leaf); + read_arms.push(quote!(#disc => #view::#vname(#read_body),)); + + // move: nested `BStackWeak<'__mv>` from control offsets. + let mv_leaf = quote!(::bstack_raii::BStackWeak<'__mv, #elem, __A>); + let mv_read = |k: &Ident| { + let off = pl_off(k); + quote! {{ + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(#off, #ctrl_size)) }; + unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } + }} + }; + let mv_body = nested_build(&dims, &mv_leaf, &mv_read); + move_arms.push(quote!(#disc => #data::#vname(#mv_body),)); + + // teardown: release each weak. + drop_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { + let __off = u64::from_le_bytes( + __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; + ::bstack_raii::WeakRef::<#elem>(__ctrl).bstack_drop(allocator)?; } - }); - read_arms.push(quote! { - #disc => #view::#vname(::core::array::from_fn(|__i| { + } + }); + // clone: bump each weak. + clone_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) - })), - }); - move_arms.push(quote! { - #disc => #data::#vname(::core::array::from_fn(|__i| { + __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + __plan.bump_weak(__off); + } + } + }); + continue; + } + + // owned / strong / ref + if kind == Kind::Strong { + has_shared = true; + } + let view_leaf = if elem_nullable { + quote!(::core::option::Option<#elem>) + } else { + quote!(#elem) + }; + let view_ty_nested = nested_ty(&dims, &view_leaf); + view_variants.push(quote!(#vname(#view_ty_nested),)); + + let data_leaf = match kind { + Kind::Owned => quote!(::bstack_raii::BStackOwned<#elem>), + Kind::Strong => quote!(::bstack_raii::BStackRc<'__e, #elem, __A>), + Kind::Ref => quote!(::bstack_raii::BStackRef<#elem>), + _ => unreachable!(), + }; + let data_leaf_full = if elem_nullable { + quote!(::core::option::Option<#data_leaf>) + } else { + data_leaf.clone() + }; + let data_ty_nested = nested_ty(&dims, &data_leaf_full); + data_variants.push(quote!(#vname(#data_ty_nested),)); + + let off_of = |h: &Ident| match kind { + Kind::Owned => quote!({ + let __h = #h.into_inner(); + ::bstack_raii::BStackBlock::range(&__h).start() + }), + Kind::Strong => quote!({ + let (__d, _c) = #h.into_raw(); + __d.into_range().start() + }), + Kind::Ref => quote!(#h.into_range().start()), + _ => unreachable!(), + }; + // new: consume nested handles → offsets. + let cap_write = |k: &Ident, leaf: &Ident| { + if elem_nullable { + let hh = format_ident!("__handle"); + let off = off_of(&hh); + let put = pl_put(k, quote!(__off)); + quote! {{ + let __off: u64 = match #leaf { + ::core::option::Option::Some(#hh) => #off, + ::core::option::Option::None => 0u64, + }; + #put + }} + } else { + pl_put(k, off_of(leaf)) + } + }; + let consume = nested_consume(&dims, "e!(__arr), &cap_write); + new_arms.push(quote! { + #data::#vname(__arr) => { + let mut __pl = [0u8; Self::__PAYLOAD]; + #consume + (#disc, __pl) + } + }); + + // read (view): nested block views. + let read_leaf = |k: &Ident| { + let off = pl_off(k); + let build = quote!(<#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size))); + if elem_nullable { + quote! {{ + let __off = #off; + if __off == 0 { ::core::option::Option::None } + else { ::core::option::Option::Some(#build) } + }} + } else { + quote!({ let __off = #off; #build }) + } + }; + let read_body = nested_build(&dims, &view_leaf, &read_leaf); + read_arms.push(quote!(#disc => #view::#vname(#read_body),)); + + // move. + let mv_leaf_base = match kind { + Kind::Owned => quote!(::bstack_raii::BStackOwned<#elem>), + Kind::Ref => quote!(::bstack_raii::BStackRef<#elem>), + Kind::Strong => quote!(::bstack_raii::BStackRc<'__mv, #elem, __A>), + _ => unreachable!(), + }; + let mv_leaf_ty = if elem_nullable { + quote!(::core::option::Option<#mv_leaf_base>) + } else { + mv_leaf_base.clone() + }; + let build_one = match kind { + Kind::Owned => quote!(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#elem as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size))) + }), + Kind::Ref => quote!(unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) + }), + Kind::Strong => quote!({ + let __data = unsafe { + ::bstack_raii::BStackRef::<#elem>::from_range( + ::bstack_raii::BStackRange::new(__off, #elem_size)) }; + let (__d, __c) = + <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) } + }), + _ => unreachable!(), + }; + let mv_read = |k: &Ident| { + let off = pl_off(k); + if elem_nullable { + quote! {{ + let __off = #off; + if __off == 0 { ::core::option::Option::None } + else { ::core::option::Option::Some(#build_one) } + }} + } else { + quote!({ let __off = #off; #build_one }) + } + }; + let mv_body = nested_build(&dims, &mv_leaf_ty, &mv_read); + move_arms.push(quote!(#disc => #data::#vname(#mv_body),)); + + // teardown (owned/strong; ref owns nothing). + if kind != Kind::Ref { + let per = match kind { + Kind::Owned => quote! { + ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; + }, + Kind::Strong => quote! { + <#elem as ::bstack_raii::BStackShared>::drop_strong_ref( + __child, allocator)?; + }, + _ => unreachable!(), + }; + drop_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - unsafe { ::bstack_raii::BStackOwned::from_raw( - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size))) } - })), - }); - drop_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + if __off != 0 { let __child = unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( ::bstack_raii::BStackRange::new(__off, #elem_size)) }; - ::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?; + #per } } - }); - clone_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + } + }); + } + + // clone: owned deep-clones each; strong bumps each; ref aliases. + match kind { + Kind::Owned => clone_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { + let __off = u64::from_le_bytes( + __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + if __off != 0 { let __child = <#elem as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new(__off, #elem_size)); let __new = __child.__bstack_clone_into(allocator, __plan)?; - __pl[__i * 8..__i * 8 + 8] + __pl[__k * 8..__k * 8 + 8] .copy_from_slice(&__new.start().to_le_bytes()); } } - }); - } - Kind::Ref => { - data_variants - .push(quote!(#vname([::bstack_raii::BStackRef<#elem>; #len]),)); - view_variants.push(quote!(#vname([#elem; #len]),)); - new_arms.push(quote! { - #data::#vname(__arr) => { - let mut __pl = [0u8; Self::__PAYLOAD]; - for (__i, __r) in __arr.into_iter().enumerate() { - __pl[__i * 8..__i * 8 + 8] - .copy_from_slice(&__r.into_range().start().to_le_bytes()); - } - (#disc, __pl) - } - }); - read_arms.push(quote! { - #disc => #view::#vname(::core::array::from_fn(|__i| { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) - })), - }); - move_arms.push(quote! { - #disc => #data::#vname(::core::array::from_fn(|__i| { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) } - })), - }); - // A ref owns nothing: no teardown, and clone aliases - // (the payload offsets are copied verbatim). - } - Kind::Strong => { - has_shared = true; - data_variants.push( - quote!(#vname([::bstack_raii::BStackRc<'__e, #elem, __A>; #len]),), - ); - view_variants.push(quote!(#vname([#elem; #len]),)); - new_arms.push(quote! { - #data::#vname(__arr) => { - let mut __pl = [0u8; Self::__PAYLOAD]; - for (__i, __rc) in __arr.into_iter().enumerate() { - let (__d, _c) = __rc.into_raw(); - __pl[__i * 8..__i * 8 + 8] - .copy_from_slice(&__d.into_range().start().to_le_bytes()); - } - (#disc, __pl) - } - }); - read_arms.push(quote! { - #disc => #view::#vname(::core::array::from_fn(|__i| { + } + }), + Kind::Strong => clone_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - <#elem as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) - })), - }); - move_arms.push(quote! { - #disc => { - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - let __data = unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) }; - let (__d, __c) = - <#elem as ::bstack_raii::BStackShared>::strong_parts(__data, __alloc)?; - __v.push(unsafe { - ::bstack_raii::BStackRc::from_raw(__d, __c, __alloc) }); - } - #data::#vname( - match <[::bstack_raii::BStackRc<'__mv, #elem, __A>; #len]>::try_from(__v) { - ::std::result::Result::Ok(__a) => __a, - ::std::result::Result::Err(_) => unreachable!(), - }) - } - }); - drop_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - let __data = unsafe { - ::bstack_raii::BStackRef::<#elem>::from_range( - ::bstack_raii::BStackRange::new(__off, #elem_size)) }; - <#elem as ::bstack_raii::BStackShared>::drop_strong_ref( - __data, allocator)?; - } - } - }); - clone_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); + __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + if __off != 0 { let __data = unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( ::bstack_raii::BStackRange::new(__off, #elem_size)) }; __plan.bump_strong(__data, allocator)?; } } - }); - } - Kind::Weak => { - has_shared = true; - has_weak = true; - let ctrl_size = quote!(::core::mem::size_of::< - <#elem as ::bstack_raii::BStackWeakable>::Control>() as u64); - let ctrl_ty = quote!(<#elem as ::bstack_raii::BStackWeakable>::Control); - data_variants.push( - quote!(#vname([::bstack_raii::BStackWeak<'__e, #elem, __A>; #len]),), - ); - view_variants.push(quote!(#vname([::core::option::Option< - ::bstack_raii::BStackRc<'__e, #elem, __A>>; #len]),)); - new_arms.push(quote! { - #data::#vname(__arr) => { - let mut __pl = [0u8; Self::__PAYLOAD]; - for (__i, __w) in __arr.into_iter().enumerate() { - __pl[__i * 8..__i * 8 + 8].copy_from_slice( - &__w.into_raw().into_range().start().to_le_bytes()); - } - (#disc, __pl) - } - }); - read_arms.push(quote! { - #disc => { - let mut __v = ::std::vec::Vec::with_capacity(#len); - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - let __ctrl = unsafe { - ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( - ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; - let __wk = unsafe { - ::bstack_raii::BStackWeak::<#elem, __A>::from_raw(__ctrl, allocator) }; - let __up = __wk.upgrade()?; - let _ = __wk.into_raw(); - __v.push(__up); - } - #view::#vname( - match <[::core::option::Option< - ::bstack_raii::BStackRc<'__e, #elem, __A>>; #len]>::try_from(__v) { - ::std::result::Result::Ok(__a) => __a, - ::std::result::Result::Err(_) => unreachable!(), - }) - } - }); - move_arms.push(quote! { - #disc => #data::#vname(::core::array::from_fn(|__i| { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - let __ctrl = unsafe { - ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( - ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; - unsafe { ::bstack_raii::BStackWeak::from_raw(__ctrl, __alloc) } - })), - }); - drop_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - let __ctrl = unsafe { - ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( - ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; - ::bstack_raii::WeakRef::<#elem>(__ctrl).bstack_drop(allocator)?; - } - } - }); - clone_arms.push(quote! { - #disc => { - for __i in 0..#len { - let __off = u64::from_le_bytes( - __pl[__i * 8..__i * 8 + 8].try_into().unwrap()); - __plan.bump_weak(__off); - } - } - }); - } - Kind::Embed => { - return Err(Error::new_spanned( - ty, - "#[embed] array enum variants are not yet supported", - )); - } - Kind::Pod => unreachable!("guarded out above"), + } + }), + // Ref: aliased — payload offsets copied verbatim. + _ => {} } continue; } @@ -3101,9 +3402,10 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let __h = __v.into_inner(); let __cr = ::bstack_raii::BStackBlock::range(&__h); - __embed_copy = ::core::option::Option::Some(( + __embed_copy.push(( __cr, ::core::mem::size_of::<#co>() as u64, + 0u64, )); (#disc, [0u8; Self::__PAYLOAD]) } @@ -3316,15 +3618,19 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = - ::core::option::Option::None;), + ::std::vec::Vec<(::bstack_raii::BStackRange, u64, u64)> = + ::std::vec::Vec::new();), quote! { - if let ::core::option::Option::Some((__cr, __sz)) = __embed_copy { + for (__cr, __sz, __doff) in __embed_copy { allocator.stack().copy( __cr.start(), __data.start() - + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64, + + ::core::mem::offset_of!(#on_disk, __bstack_payload) as u64 + + __doff, __sz, )?; unsafe { ::bstack_raii::dealloc_range(allocator, __cr)?; } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 78702de..e66a025 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2980,3 +2980,266 @@ fn macro_enum_pod_array() { } e.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Nested arrays `[[..]; ..]` of handles (structs) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct OwnedGrid { + #[bstack_owned] + grid: [[MacroLeaf; 2]; 2], + tag: u32, +} + +#[test] +fn macro_owned_nested_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); + let h = OwnedGrid::new( + &alloc, + [[mk(1), mk(2)], [mk(3), mk(4)]], + 7, + ) + .unwrap(); + + assert_eq!(h.handle().tag(stack).unwrap(), 7); + let g = h.handle().grid(stack).unwrap(); // [[MacroLeaf; 2]; 2] + assert_eq!(g[0][0].val(stack).unwrap(), 1); + assert_eq!(g[0][1].val(stack).unwrap(), 2); + assert_eq!(g[1][0].val(stack).unwrap(), 3); + assert_eq!(g[1][1].val(stack).unwrap(), 4); + + // Deep clone: fresh blocks, same values. + let clone = h.try_clone_in(&alloc).unwrap(); + let cg = clone.handle().grid(stack).unwrap(); + assert_eq!(cg[1][1].val(stack).unwrap(), 4); + assert_ne!(cg[0][0].range().start(), g[0][0].range().start()); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(h.handle().grid(stack).unwrap()[1][0].val(stack).unwrap(), 3); + + // Move: nested owning handles. + let (moved, tag) = bstack_move!(h, &alloc).unwrap(); + assert_eq!(tag, 7); + assert_eq!(moved[1][1].handle().val(stack).unwrap(), 4); + for row in moved { + for m in row { + m.bstack_drop(&alloc).unwrap(); + } + } +} + +#[bstack_block] +struct RefCube { + #[bstack_ref] + cube: [[[MacroLeaf; 2]; 1]; 2], +} + +#[test] +fn macro_ref_nested3_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaves: Vec<_> = (0..4).map(|v| MacroLeaf::new(&alloc, v).unwrap()).collect(); + let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; + let h = RefCube::new(&alloc, [[[r(0), r(1)]], [[r(2), r(3)]]]).unwrap(); + + let c = h.handle().cube(stack).unwrap(); // [[[MacroLeaf; 2]; 1]; 2] + assert_eq!(c[0][0][0].val(stack).unwrap(), 0); + assert_eq!(c[0][0][1].val(stack).unwrap(), 1); + assert_eq!(c[1][0][0].val(stack).unwrap(), 2); + assert_eq!(c[1][0][1].val(stack).unwrap(), 3); + + // A ref cube owns nothing: dropping leaves targets alive. + h.bstack_drop(&alloc).unwrap(); + for l in leaves { + assert!(l.handle().val(stack).unwrap() < 4); + l.bstack_drop(&alloc).unwrap(); + } +} + +#[bstack_block] +struct EmbGrid { + #[embed] + kids: [[EmbChild; 2]; 1], + tag: u32, +} + +#[test] +fn macro_embed_nested_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let k = |v| EmbChild::new(&alloc, MacroLeaf::new(&alloc, v).unwrap(), v).unwrap(); + let h = EmbGrid::new(&alloc, [[k(10), k(20)]], 5).unwrap(); + assert_eq!(h.handle().tag(stack).unwrap(), 5); + + let g = h.handle().kids(); // [[EmbChild; 2]; 1] + assert_eq!(g[0][0].leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!(g[0][1].leaf(stack).unwrap().val(stack).unwrap(), 20); + + let clone = h.try_clone_in(&alloc).unwrap(); + let cg = clone.handle().kids(); + assert_eq!(cg[0][1].leaf(stack).unwrap().val(stack).unwrap(), 20); + assert_ne!( + cg[0][0].leaf(stack).unwrap().range().start(), + g[0][0].leaf(stack).unwrap().range().start() + ); + clone.bstack_drop(&alloc).unwrap(); + + let (moved, tag) = bstack_move!(h, &alloc).unwrap(); + assert_eq!(tag, 5); + assert_eq!(moved[0][0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + for row in moved { + for m in row { + m.bstack_drop(&alloc).unwrap(); + } + } +} + +// -------------------------------------------------------------------------- +// Enum array variants: Option leaves, #[embed], and nesting +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum OptArrEnum { + Empty, + #[bstack_owned] + Slots([Option; 3]), +} + +#[test] +fn macro_enum_owned_option_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let e = OptArrEnum::new( + &alloc, + OptArrEnumData::Slots([ + Some(MacroLeaf::new(&alloc, 10).unwrap()), + None, + Some(MacroLeaf::new(&alloc, 30).unwrap()), + ]), + ) + .unwrap(); + match e.handle().read(&alloc).unwrap() { + OptArrEnumView::Slots(arr) => { + assert_eq!(arr[0].map(|h| h.val(stack).unwrap()), Some(10)); + assert!(arr[1].is_none()); + assert_eq!(arr[2].map(|h| h.val(stack).unwrap()), Some(30)); + } + _ => panic!("expected Slots"), + } + + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + OptArrEnumView::Slots(arr) => { + assert_eq!(arr[2].map(|h| h.val(stack).unwrap()), Some(30)); + assert!(arr[1].is_none()); + } + _ => panic!("expected Slots"), + } + clone.bstack_drop(&alloc).unwrap(); + + match bstack_move!(e, &alloc).unwrap() { + OptArrEnumData::Slots(arr) => { + assert_eq!(arr[0].as_ref().map(|h| h.handle().val(stack).unwrap()), Some(10)); + assert!(arr[1].is_none()); + for slot in arr.into_iter().flatten() { + slot.bstack_drop(&alloc).unwrap(); + } + } + _ => panic!("expected Slots"), + } +} + +#[bstack_enum] +enum EmbArrEnum { + Empty, + #[embed] + Kids([EmbChild; 2]), +} + +#[test] +fn macro_enum_embed_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let k = |v| EmbChild::new(&alloc, MacroLeaf::new(&alloc, v).unwrap(), v).unwrap(); + let e = EmbArrEnum::new(&alloc, EmbArrEnumData::Kids([k(10), k(20)])).unwrap(); + + match e.handle().read(&alloc).unwrap() { + EmbArrEnumView::Kids(arr) => { + assert_eq!(arr[0].leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + } + _ => panic!("expected Kids"), + } + + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + EmbArrEnumView::Kids(arr) => assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20), + _ => panic!("expected Kids"), + } + clone.bstack_drop(&alloc).unwrap(); + + match bstack_move!(e, &alloc).unwrap() { + EmbArrEnumData::Kids(arr) => { + assert_eq!(arr[0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + for m in arr { + m.bstack_drop(&alloc).unwrap(); + } + } + _ => panic!("expected Kids"), + } +} + +#[bstack_enum] +enum NestArrEnum { + Empty, + #[bstack_owned] + Grid([[MacroLeaf; 2]; 2]), +} + +#[test] +fn macro_enum_owned_nested_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); + let e = NestArrEnum::new( + &alloc, + NestArrEnumData::Grid([[mk(1), mk(2)], [mk(3), mk(4)]]), + ) + .unwrap(); + match e.handle().read(&alloc).unwrap() { + NestArrEnumView::Grid(g) => { + assert_eq!(g[0][0].val(stack).unwrap(), 1); + assert_eq!(g[1][1].val(stack).unwrap(), 4); + } + _ => panic!("expected Grid"), + } + + let clone = e.try_clone_in(&alloc).unwrap(); + clone.bstack_drop(&alloc).unwrap(); + + match bstack_move!(e, &alloc).unwrap() { + NestArrEnumData::Grid(g) => { + assert_eq!(g[1][0].handle().val(stack).unwrap(), 3); + for row in g { + for m in row { + m.bstack_drop(&alloc).unwrap(); + } + } + } + _ => panic!("expected Grid"), + } +} From 10a184ba5485ca7f986af7d402407b8e01b2b48c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 02:14:51 -0700 Subject: [PATCH 052/140] Better error for very weird nesting --- bstack_raii/derive/src/block.rs | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 0594bca..639a9e9 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -139,6 +139,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result None => (eff_ty, false), }; + // Reject unsupported `Vec` / `Option` nesting (`Vec>`, + // `Option>`, and every mix like `Vec>>`) with a + // directed error, scanning outermost-first so the message names the first + // offending construct. Valid mixes (`Option>>`, …) pass. + check_container_nesting(eff_ty)?; + // `Vec` / `String` (and `&str` → `String`): an inline descriptor on // disk, a `BStackVec` at runtime. A nullable vec uses the `data_off == 0` // niche. Handled here. @@ -1336,6 +1342,95 @@ fn is_str(ty: &Type) -> bool { matches!(ty, Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == "str")) } +/// The element type `T` of a `Vec`, if `ty` is a `Vec`. Used to reject +/// nested `Vec>` / `Vec` with a directed error. +fn vec_inner(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { + return None; + }; + let seg = tp.path.segments.last()?; + if seg.ident != "Vec" { + return None; + } + let PathArguments::AngleBracketed(ab) = &seg.arguments else { + return None; + }; + match ab.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// Directed error for a double `Option` (`Option>`) anywhere in the +/// container nesting. +fn err_double_option(ty: &Type) -> Error { + Error::new_spanned( + ty, + "nested `Option>` is not supported: a field / `Vec` slot lowers a \ + single `Option` to the absent/`0` niche, and a second layer has nowhere to \ + live on disk. Model the states explicitly with a `#[bstack_enum]`, e.g. \ + `enum Slot { Missing, Empty, Present(T) }`.", + ) +} + +/// Directed error for a `Vec` / `String` nested inside another `Vec` +/// (`Vec>`, `Vec`, `Vec>>`, …). +fn err_vec_in_vec(ty: &Type) -> Error { + Error::new_spanned( + ty, + "nested `Vec>` / `Vec` is not supported: a `Vec` field stores one \ + inline descriptor whose elements are a single leaf (POD or a block reference), \ + not another dynamically-sized region. Wrap the inner vector in an explicit \ + `#[bstack_block]` struct and store `Vec` (annotating the element \ + per its ownership).", + ) +} + +/// Validate the `Vec` / `Option` nesting of a field type, outermost-first. A +/// field allows at most one leading `Option` (the absent niche) around a `Vec` +/// or a leaf; a `Vec` element allows at most one `Option` around a leaf. Any +/// deeper `Vec`-in-`Vec` or `Option`-in-`Option` is rejected with a directed +/// error naming the first offending construct. Leaves (POD, blocks, arrays, +/// tuples) end the walk. +fn check_container_nesting(ty: &Type) -> syn::Result<()> { + // Field top: peel at most one `Option`, then validate the bare type. + if let Some(inner) = option_inner(ty) { + if option_inner(inner).is_some() { + return Err(err_double_option(ty)); + } + return check_bare(inner); + } + check_bare(ty) +} + +/// A "bare" (no leading `Option` to peel) type: a `Vec` whose element must be a +/// leaf-or-`Option`, `String`, or a leaf. +fn check_bare(ty: &Type) -> syn::Result<()> { + if let Some(elem) = vec_inner(ty) { + return check_vec_elem(elem); + } + Ok(()) +} + +/// A `Vec` element: a leaf, optionally wrapped in exactly one `Option`. A `Vec` +/// / `String` here is `Vec`; an `Option` with `RefBox`). An owned/strong/weak/embed/POD use + of the parameter — which would change the layout or need recursion into the + type — is rejected; plain mode only (not `rc` / `rc, weak`). Non-`Pod` fields + must still carry an annotation. - **`Vec` / `Option` nesting** is capped at a single leaf / one `Option` layer (see [Field types](#field-types)); deeper nesting or a tuple element must be named as a `#[bstack_block]` / `#[bstack_enum]`. diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index dd1ff4c..1a55e86 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -55,11 +55,29 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result )); } + // A generic block is supported only in the **layout-preserving** case: every + // type parameter must be used ONLY in `#[bstack_ref]` fields (a bare `u64` + // offset on disk), so `XOnDisk` stays independent of the parameters and + // teardown/clone need no recursion into them. The per-field check is below, + // once the fields are parsed; here we gate the coarse constraints. + let type_params: Vec<&Ident> = input.generics.type_params().map(|tp| &tp.ident).collect(); if !input.generics.params.is_empty() { - return Err(Error::new_spanned( - &input.generics, - "#[bstack_block] does not support generic block types", - )); + for p in &input.generics.params { + if !matches!(p, syn::GenericParam::Type(_)) { + return Err(Error::new_spanned( + p, + "a generic #[bstack_block] currently supports only type parameters (no \ + lifetime or const generics)", + )); + } + } + if mode != Mode::Plain { + return Err(Error::new_spanned( + &input.generics, + "a generic #[bstack_block] currently supports plain mode only (not `rc` / \ + `rc, weak`)", + )); + } } // Normalize fields to `(name, field)`: named fields keep their name, a tuple @@ -86,6 +104,39 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let on_disk = format_ident!("{}OnDisk", name); let control = format_ident!("{}OnDiskRef", name); + // Layout-preserving check: a type parameter may appear only in a + // `#[bstack_ref]` field (which lowers to a bare `u64` offset). + for (_, field) in &field_list { + if classify(field)? != Kind::Ref && type_mentions_any(&field.ty, &type_params) { + return Err(Error::new_spanned( + &field.ty, + "a generic type parameter may currently be used only in a `#[bstack_ref]` field \ + — it lowers to a bare `u64` offset, keeping the block's on-disk layout \ + independent of the type. An owned/strong/weak/embed/POD use would change the \ + layout or require recursing into the type, which is not yet supported.", + )); + } + } + // Generics threaded into the generated impls (with a `BStackBlock` bound + // added to every parameter), plus the handle's phantom marker over them. + // `impl_g`/`ty_g`/`where_g` carry the bound (for the bstack trait impls); + // `decl_g`/`decl_ty_g`/`decl_where` are the user's own (for the handle type + // and its `Clone`/`Copy`, which hold regardless of `T`). + let mut aug_generics = input.generics.clone(); + for tp in aug_generics.type_params_mut() { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); + } + let (impl_g, ty_g, where_g) = aug_generics.split_for_impl(); + let (decl_g, decl_ty_g, decl_where) = input.generics.split_for_impl(); + let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = if type_params.is_empty() { + (quote!(), quote!()) + } else { + ( + quote!(, ::core::marker::PhantomData (#(#type_params,)*)>), + quote!(, ::core::marker::PhantomData), + ) + }; + // On-disk fields: header, then the injected refcount/ctrl (if any), then user // fields lowered per annotation. let mut on_disk_fields = Vec::new(); @@ -1461,6 +1512,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let ctrl_tag = build_tag(hash, &ctrl_prefix); let data_eightcc = eightcc_expr(&data_tag.bytes); let ctrl_eightcc = eightcc_expr(&ctrl_tag.bytes); + // For a generic block, fold each type argument's tag into the discriminant + // so distinct instantiations get distinct tags (the `eightcc()` body — always + // called at runtime — mixes them; the readable prefix stays the outer name's). + let data_eightcc = if type_params.is_empty() { + data_eightcc + } else { + let mixes = type_params + .iter() + .map(|p| quote!(.mix(<#p as ::bstack_raii::BStackCast>::eightcc()))); + quote!(#data_eightcc #(#mixes)*) + }; // The warnings use the `deprecated` mechanism, so a real `#[allow(deprecated)]` // on the struct also silences them (in addition to the `allow(...)` args). @@ -1597,7 +1659,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result quote! { // Implemented on the block type (local downstream) so the orphan rule // is satisfied; `bstack_move!` selects it from the argument's type. - impl ::bstack_raii::BStackMove for #name { + impl #impl_g ::bstack_raii::BStackMove for #name #ty_g #where_g { type Fields<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> = ( #(#mv_types,)* ); fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( @@ -1610,7 +1672,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __stack = __alloc.stack(); let __range = ::bstack_raii::BStackBlock::range(&__inner); let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; - let __r = unsafe { ::bstack_raii::BStackRef::<#name>::from_range(__range) }; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; let __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; #(#mv_caps)* // Free the parent shell only; children stay live on disk. @@ -1669,7 +1731,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result (children, into) }; let clone_into_method = quote! { - impl #name { + impl #impl_g #name #ty_g #where_g { /// Read this block's OnDisk and return a deep-cloned copy: owned /// children cloned into `__plan`, shared children's refcounts bumped, /// embedded children folded in place. Does **not** allocate a block for @@ -1705,7 +1767,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result quote! { #clone_into_method - impl ::bstack_raii::TryCloneIn for #name { + impl #impl_g ::bstack_raii::TryCloneIn for #name #ty_g #where_g { fn try_clone_in<__A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, allocator: &__A, @@ -1731,9 +1793,26 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result clone_into_method }; + // The handle: a `BStackRange` newtype (plus a phantom over the type + // parameters when generic). `Clone`/`Copy` hold regardless of `T` — for the + // generic case they're hand-written (no `T: Copy` bound) rather than derived. + let handle_def = if type_params.is_empty() { + quote! { + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #vis struct #name(::bstack_raii::BStackRange); + } + } else { + quote! { + #vis struct #name #decl_g(::bstack_raii::BStackRange #phantom_field) #decl_where; + impl #decl_g ::core::clone::Clone for #name #decl_ty_g #decl_where { + fn clone(&self) -> Self { *self } + } + impl #decl_g ::core::marker::Copy for #name #decl_ty_g #decl_where {} + } + }; + Ok(quote! { - #[derive(::core::clone::Clone, ::core::marker::Copy)] - #vis struct #name(::bstack_raii::BStackRange); + #handle_def // Packed Pod wrappers for any POD tuple fields. #(#wrapper_defs)* @@ -1756,23 +1835,23 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #( __assert_pod::<#pod_types>(); )* }; - impl ::bstack_raii::BStackCast for #name { + impl #impl_g ::bstack_raii::BStackCast for #name #ty_g #where_g { fn eightcc() -> ::bstack_raii::EightCC { #data_eightcc } } - impl ::bstack_raii::BStackBlock for #name { + impl #impl_g ::bstack_raii::BStackBlock for #name #ty_g #where_g { type OnDisk = #on_disk; fn from_range(range: ::bstack_raii::BStackRange) -> Self { - #name(range) + #name(range #phantom_ctor) } fn range(&self) -> ::bstack_raii::BStackRange { self.0 } } - impl #name { + impl #impl_g #name #ty_g #where_g { /// Free this block's owned children (recursively) given its range, /// **without** freeing the block itself — used when the block is /// `#[embed]`ded (its storage is part of its parent), and by @@ -1792,7 +1871,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } - impl ::bstack_raii::BStackDrop for #name { + impl #impl_g ::bstack_raii::BStackDrop for #name #ty_g #where_g { fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( self, allocator: &__A, @@ -1802,7 +1881,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } - impl #name { + impl #impl_g #name #ty_g #where_g { #(#accessors)* #(#setters)* @@ -1845,6 +1924,20 @@ fn is_str(ty: &Type) -> bool { matches!(ty, Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == "str")) } +/// Whether `ty` mentions any of the given (generic type-parameter) identifiers +/// anywhere in its token tree. Used to enforce that a generic parameter is only +/// ever used in a `#[bstack_ref]` field. +fn type_mentions_any(ty: &Type, params: &[&Ident]) -> bool { + fn walk(ts: TokenStream, params: &[&Ident]) -> bool { + ts.into_iter().any(|t| match t { + proc_macro2::TokenTree::Ident(id) => params.iter().any(|p| **p == id), + proc_macro2::TokenTree::Group(g) => walk(g.stream(), params), + _ => false, + }) + } + walk(quote!(#ty), params) +} + /// The element type `T` of a `Vec`, if `ty` is a `Vec`. Used to reject /// nested `Vec>` / `Vec` with a directed error. fn vec_inner(ty: &Type) -> Option<&Type> { diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index be7d81c..7828214 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -49,6 +49,38 @@ impl EightCC { } Self(out) } + + /// Fold another tag into this one's non-readable (high-bit-set) bytes, + /// leaving the leading ASCII prefix untouched. A generic `#[bstack_block]` + /// uses this to give each instantiation a distinct-but-related tag — the + /// readable prefix stays the outer type's, while the type arguments perturb + /// the hash bytes so `Foo` and `Foo` never share a discriminant (which + /// would let `bstack_cast!` confuse them). Deterministic and associative + /// enough to compose for nested generics. + /// + /// Note: a fully-specified 8-byte explicit `tag = "…"` leaves no hash bytes, + /// so every instantiation shares it — don't pin an 8-byte tag on a generic. + pub const fn mix(self, other: EightCC) -> EightCC { + // A small FNV-1a digest of `other`'s bytes. + let mut d: u64 = 0xcbf2_9ce4_8422_2325; + let ob = other.0; + let mut i = 0; + while i < 8 { + d ^= ob[i] as u64; + d = d.wrapping_mul(0x0000_0100_0000_01b3); + i += 1; + } + let db = d.to_le_bytes(); + let mut out = self.0; + let mut i = 0; + while i < 8 { + if out[i] & 0x80 != 0 { + out[i] = (out[i] ^ db[i]) | 0x80; + } + i += 1; + } + Self(out) + } } /// The header prefixing every on-disk block. 16 bytes. diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 020fc20..39bc098 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -3742,3 +3742,77 @@ fn macro_enum_pod_vec() { } e2.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Generic blocks (layout-preserving: type params only in #[bstack_ref] fields) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct RefBox { + #[bstack_ref] + item: T, + tag: u64, +} + +#[test] +fn macro_generic_ref_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let r = unsafe { BStackRef::from_range(leaf.handle().range()) }; + let b = RefBox::::new(&alloc, r, 7).unwrap(); + assert_eq!(b.handle().tag(stack).unwrap(), 7); + assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + + // Clone aliases the ref (same target block); the box itself is fresh. + let clone = b.try_clone_in(&alloc).unwrap(); + assert_eq!( + clone.handle().item(stack).unwrap().range().start(), + b.handle().item(stack).unwrap().range().start() + ); + assert_ne!(clone.handle().range().start(), b.handle().range().start()); + clone.bstack_drop(&alloc).unwrap(); + + // The box references but does not own the leaf: dropping it leaves it alive. + b.bstack_drop(&alloc).unwrap(); + assert_eq!(leaf.handle().val(stack).unwrap(), 42); + leaf.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_generic_distinct_tags() { + // Each instantiation gets a distinct discriminant, so `bstack_cast!` can't + // confuse `RefBox` with `RefBox` (they have the same layout). + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + // …and distinct from an unrelated block, and from the type argument itself. + assert_ne!( + as BStackCast>::eightcc(), + ::eightcc(), + ); +} + +#[test] +fn macro_generic_move_cast() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let leaf = MacroLeaf::new(&alloc, 9).unwrap(); + let r = unsafe { BStackRef::from_range(leaf.handle().range()) }; + let b = RefBox::::new(&alloc, r, 3).unwrap(); + + // bstack_cast!: an untyped slice back to the typed generic block (tag checked). + let sl = b.handle().as_slice(stack); + let back = bstack_cast!(sl as RefBox).unwrap().expect("same tag"); + assert_eq!(back.item(stack).unwrap().val(stack).unwrap(), 9); + + // bstack_move!: hand out the ref + pod fields, freeing the box shell. + let (item, tag) = bstack_move!(b, &alloc).unwrap(); + assert_eq!(tag, 3); + assert_eq!(item.into_range().start(), leaf.handle().range().start()); + leaf.bstack_drop(&alloc).unwrap(); +} From 62db34d803f7201588b21556e0bdcbc3b2ed35ec Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 22:33:56 -0700 Subject: [PATCH 061/140] Enhance support for generic block types in `#[bstack_block]` macro for strong and weak --- bstack_raii/README.md | 21 +++++++----- bstack_raii/derive/src/block.rs | 58 +++++++++++++++++++++++++-------- bstack_raii/src/tests.rs | 55 +++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 22 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index a0b82d1..f2b53fc 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -745,15 +745,18 @@ This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG") `Option<…>` forms. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). -- **Generic block types** are supported only in the *layout-preserving* case: a - type parameter may appear **only** in `#[bstack_ref]` fields (scalar, `Vec`, - `[T; N]`, …), which lower to a bare `u64` offset so `XOnDisk` stays independent - of the parameter — e.g. `#[bstack_block] struct RefBox { #[bstack_ref] item: - T, tag: u64 }`. Each instantiation gets its own type tag (so `bstack_cast!` - can't confuse `RefBox` with `RefBox`). An owned/strong/weak/embed/POD use - of the parameter — which would change the layout or need recursion into the - type — is rejected; plain mode only (not `rc` / `rc, weak`). Non-`Pod` fields - must still carry an annotation. +- **Generic block types** are supported in the *layout-preserving* case: a type + parameter may appear in `#[bstack_ref]` / `#[bstack_strong]` / `#[bstack_weak]` + fields (scalar, `Vec`, `[T; N]`, …), each a bare `u64` offset so `XOnDisk` + stays independent of the parameter and its teardown/clone recurse through + traits — e.g. `#[bstack_block] struct RefBox { #[bstack_ref] item: T, tag: + u64 }`. The parameter is bounded `BStackBlock` (plus `BStackShared` / + `BStackWeakable` for strong / weak uses); each instantiation gets its own type + tag (so `bstack_cast!` can't confuse `RefBox` with `RefBox`). An + **owned / embed / POD** use of the parameter — which changes the on-disk layout + or needs recursion into the type — is rejected for now, as are lifetime / const + parameters and `rc` / `rc, weak` mode. Non-`Pod` fields must still carry an + annotation. - **`Vec` / `Option` nesting** is capped at a single leaf / one `Option` layer (see [Field types](#field-types)); deeper nesting or a tuple element must be named as a `#[bstack_block]` / `#[bstack_enum]`. diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 1a55e86..9571558 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -104,27 +104,59 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let on_disk = format_ident!("{}OnDisk", name); let control = format_ident!("{}OnDiskRef", name); - // Layout-preserving check: a type parameter may appear only in a - // `#[bstack_ref]` field (which lowers to a bare `u64` offset). + // Layout-preserving check + per-parameter bound: a type parameter may appear + // only in a `#[bstack_ref]` / `#[bstack_strong]` / `#[bstack_weak]` field — + // each a bare `u64` offset on disk whose teardown/clone recurse through + // *traits* (`BStackShared`) or library helpers, never the generated inherent + // methods. Every such parameter is bounded `BStackBlock`, plus `BStackShared` + // if it is ever a `#[bstack_strong]` element and `BStackWeakable` if ever a + // `#[bstack_weak]` element. (Owned needs a not-yet-done clone-recursion + // change; embed/POD change the on-disk layout — both rejected.) + let mut param_extra: Vec<(Ident, bool, bool)> = + type_params.iter().map(|p| ((*p).clone(), false, false)).collect(); for (_, field) in &field_list { - if classify(field)? != Kind::Ref && type_mentions_any(&field.ty, &type_params) { - return Err(Error::new_spanned( - &field.ty, - "a generic type parameter may currently be used only in a `#[bstack_ref]` field \ - — it lowers to a bare `u64` offset, keeping the block's on-disk layout \ - independent of the type. An owned/strong/weak/embed/POD use would change the \ - layout or require recursing into the type, which is not yet supported.", - )); + let kind = classify(field)?; + if !type_mentions_any(&field.ty, &type_params) { + continue; + } + match kind { + Kind::Ref => {} + Kind::Strong | Kind::Weak => { + for (p, is_strong, is_weak) in param_extra.iter_mut() { + if type_mentions_any(&field.ty, &[p]) { + *is_strong |= kind == Kind::Strong; + *is_weak |= kind == Kind::Weak; + } + } + } + _ => { + return Err(Error::new_spanned( + &field.ty, + "a generic type parameter may currently be used only in a `#[bstack_ref]` / \ + `#[bstack_strong]` / `#[bstack_weak]` field — each a bare `u64` offset that \ + keeps the block's on-disk layout independent of the type. An owned use needs \ + a clone-recursion change, and embed/POD change the layout — not yet \ + supported.", + )); + } } } - // Generics threaded into the generated impls (with a `BStackBlock` bound - // added to every parameter), plus the handle's phantom marker over them. - // `impl_g`/`ty_g`/`where_g` carry the bound (for the bstack trait impls); + // Generics threaded into the generated impls (with the computed bounds added + // to every parameter), plus the handle's phantom marker over them. + // `impl_g`/`ty_g`/`where_g` carry the bounds (for the bstack trait impls); // `decl_g`/`decl_ty_g`/`decl_where` are the user's own (for the handle type // and its `Clone`/`Copy`, which hold regardless of `T`). let mut aug_generics = input.generics.clone(); for tp in aug_generics.type_params_mut() { tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); + if let Some((_, is_strong, is_weak)) = param_extra.iter().find(|(p, ..)| *p == tp.ident) { + if *is_strong { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackShared)); + } + if *is_weak { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackWeakable)); + } + } } let (impl_g, ty_g, where_g) = aug_generics.split_for_impl(); let (decl_g, decl_ty_g, decl_where) = input.generics.split_for_impl(); diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 39bc098..6cb3e6c 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -3816,3 +3816,58 @@ fn macro_generic_move_cast() { assert_eq!(item.into_range().start(), leaf.handle().range().start()); leaf.bstack_drop(&alloc).unwrap(); } + +#[bstack_block] +struct StrongBox { + #[bstack_strong] + item: T, + tag: u64, +} + +#[test] +fn macro_generic_strong_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c = MacroStrongChild::new(&alloc, 10).unwrap(); + let keep = c.try_clone().unwrap(); // strong = 2 + let data = keep.handle().range().start(); + + let b = StrongBox::::new(&alloc, c, 5).unwrap(); + assert_eq!(strong_of(stack, data), 2); // b + keep + assert_eq!(b.handle().tag(stack).unwrap(), 5); + + // Deep-cloning the box bumps the shared child's strong count. + let clone = b.try_clone_in(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 3); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 2); + + b.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 1); // keep only + drop(keep); +} + +#[bstack_block] +struct WeakBox { + #[bstack_weak] + item: T, +} + +#[test] +fn macro_generic_weak_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let c = MacroStrongChild::new(&alloc, 7).unwrap(); + let b = WeakBox::::new(&alloc).unwrap(); + b.handle().set_item(&alloc, c.downgrade().unwrap()).unwrap(); + + let up = b.handle().item(&alloc).unwrap().expect("alive"); + assert_eq!(up.handle().val(stack).unwrap(), 7); + drop(up); + + drop(c); // sole strong owner gone → can't upgrade + assert!(b.handle().item(&alloc).unwrap().is_none()); + b.bstack_drop(&alloc).unwrap(); +} From 61809667f0016550dfaa9f899fce4366c700a9e2 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 23:07:59 -0700 Subject: [PATCH 062/140] Support Generic Owned refs --- bstack_raii/README.md | 22 +++--- bstack_raii/derive/src/block.rs | 115 ++++++++++++++++---------------- bstack_raii/src/block.rs | 38 +++++++++++ bstack_raii/src/tests.rs | 83 +++++++++++++++++++++++ 4 files changed, 189 insertions(+), 69 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index f2b53fc..98c6675 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -746,17 +746,17 @@ This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG") - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). - **Generic block types** are supported in the *layout-preserving* case: a type - parameter may appear in `#[bstack_ref]` / `#[bstack_strong]` / `#[bstack_weak]` - fields (scalar, `Vec`, `[T; N]`, …), each a bare `u64` offset so `XOnDisk` - stays independent of the parameter and its teardown/clone recurse through - traits — e.g. `#[bstack_block] struct RefBox { #[bstack_ref] item: T, tag: - u64 }`. The parameter is bounded `BStackBlock` (plus `BStackShared` / - `BStackWeakable` for strong / weak uses); each instantiation gets its own type - tag (so `bstack_cast!` can't confuse `RefBox` with `RefBox`). An - **owned / embed / POD** use of the parameter — which changes the on-disk layout - or needs recursion into the type — is rejected for now, as are lifetime / const - parameters and `rc` / `rc, weak` mode. Non-`Pod` fields must still carry an - annotation. + parameter may appear in `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` + / `#[bstack_ref]` fields (scalar, `Vec`, `[T; N]`, …), each a bare `u64` + offset so `XOnDisk` stays independent of the parameter and its teardown/clone + recurse through traits — e.g. `#[bstack_block] struct OwnedBox { + #[bstack_owned] item: T, tag: u64 }`. The parameter is bounded `BStackBlock` + (plus `BStackShared` / `BStackWeakable` for strong / weak uses); each + instantiation gets its own type tag (so `bstack_cast!` can't confuse + `OwnedBox` with `OwnedBox`). An **embed / POD** use — which stores the + type *inline*, changing the layout — is rejected for now, as are lifetime / + const parameters and `rc` / `rc, weak` mode. Non-`Pod` fields must still carry + an annotation. - **`Vec` / `Option` nesting** is capped at a single leaf / one `Option` layer (see [Field types](#field-types)); deeper nesting or a tuple element must be named as a `#[bstack_block]` / `#[bstack_enum]`. diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 9571558..93cf9ac 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -120,7 +120,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } match kind { - Kind::Ref => {} + // ref / owned lower to a `u64` offset and recurse through traits + // (`BStackDrop`, the clone hooks on `BStackBlock`) — base bound only. + Kind::Ref | Kind::Owned => {} Kind::Strong | Kind::Weak => { for (p, is_strong, is_weak) in param_extra.iter_mut() { if type_mentions_any(&field.ty, &[p]) { @@ -133,9 +135,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result return Err(Error::new_spanned( &field.ty, "a generic type parameter may currently be used only in a `#[bstack_ref]` / \ - `#[bstack_strong]` / `#[bstack_weak]` field — each a bare `u64` offset that \ - keeps the block's on-disk layout independent of the type. An owned use needs \ - a clone-recursion change, and embed/POD change the layout — not yet \ + `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` field — each a bare \ + `u64` offset that keeps the block's on-disk layout independent of the type. \ + An embed / POD use stores the type inline, changing the layout — not yet \ supported.", )); } @@ -1762,48 +1764,40 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; (children, into) }; - let clone_into_method = quote! { - impl #impl_g #name #ty_g #where_g { - /// Read this block's OnDisk and return a deep-cloned copy: owned - /// children cloned into `__plan`, shared children's refcounts bumped, - /// embedded children folded in place. Does **not** allocate a block for - /// `self` — used to fold an `#[embed]`ded child inline into its parent's - /// clone, and by `__bstack_clone_into` before the self-allocation. - #[doc(hidden)] - #[allow(unused_variables)] - #vis fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( - &self, - allocator: &__A, - __plan: &mut ::bstack_raii::ClonePlan, - ) -> ::std::io::Result<#on_disk> { - #clone_children_body - } - - /// Deep-clone this block's subtree into `__plan`: allocate a fresh - /// destination block, recurse into owned children (bumping shared - /// children's refcounts), and stage the destination payload — - /// returning the new block's range. Writes are staged, not committed; - /// the caller commits `__plan` once. - #[doc(hidden)] - #[allow(unused_variables)] - #vis fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( - &self, - allocator: &__A, - __plan: &mut ::bstack_raii::ClonePlan, - ) -> ::std::io::Result<::bstack_raii::BStackRange> { - #clone_into_body - } + // The two clone hooks are `BStackBlock` **trait** methods (overriding the + // childless defaults) so a generic parent can recurse into a `#[bstack_owned]` + // type parameter. Emitted into the `impl BStackBlock for X` block below. + let clone_trait_methods = quote! { + #[doc(hidden)] + #[allow(unused_variables, unused_imports)] + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<#on_disk> { + // Bring the trait into scope so a child's (possibly generic) clone hook + // resolves via method syntax. + use ::bstack_raii::BStackBlock as _; + #clone_children_body + } + #[doc(hidden)] + #[allow(unused_variables)] + fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::std::io::Result<::bstack_raii::BStackRange> { + #clone_into_body } }; let clone_impl = if mode == Mode::Plain { quote! { - #clone_into_method - impl #impl_g ::bstack_raii::TryCloneIn for #name #ty_g #where_g { fn try_clone_in<__A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { + use ::bstack_raii::BStackBlock as _; let mut __plan = ::bstack_raii::ClonePlan::new(); let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { ::std::result::Result::Ok(__d) => __d, @@ -1822,7 +1816,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } } else { - clone_into_method + quote!() }; // The handle: a `BStackRange` newtype (plus a phantom over the type @@ -1881,6 +1875,8 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result fn range(&self) -> ::bstack_raii::BStackRange { self.0 } + + #clone_trait_methods } impl #impl_g #name #ty_g #where_g { @@ -5039,6 +5035,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<::bstack_raii::BStackOwned> { + use ::bstack_raii::BStackBlock as _; let mut __plan = ::bstack_raii::ClonePlan::new(); let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { ::std::result::Result::Ok(__d) => __d, @@ -5160,34 +5157,21 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::bstack_raii::BStackRange { self.0 } - } - - impl #name { - /// Free the active variant's owned child (recursively) given this - /// block's range, **without** freeing the block itself — used when the - /// enum is `#[embed]`ded, and by `bstack_drop` before the self-dealloc. - #[doc(hidden)] - #vis fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( - __range: ::bstack_raii::BStackRange, - allocator: &__A, - ) -> ::std::io::Result<()> { - use ::bstack_raii::BStackDrop as _; - #drop_children_body - ::std::result::Result::Ok(()) - } /// Read this enum's OnDisk and return a deep-cloned copy: the active /// variant's payload fixed up (owned child cloned into `__plan`, /// strong/weak bumped, ref aliased, embedded child folded in place), - /// without allocating a block for `self`. Used to fold an - /// `#[embed]`ded enum inline, and by `__bstack_clone_into`. + /// without allocating a block for `self`. Overrides the childless + /// `BStackBlock` default. Used to fold an `#[embed]`ded enum inline, + /// and by `__bstack_clone_into`. #[doc(hidden)] - #[allow(unused_variables)] - #vis fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + #[allow(unused_variables, unused_imports)] + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, ) -> ::std::io::Result<#on_disk> { + use ::bstack_raii::BStackBlock as _; #clone_children_body } @@ -5196,7 +5180,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -5205,6 +5189,21 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + __range: ::bstack_raii::BStackRange, + allocator: &__A, + ) -> ::std::io::Result<()> { + use ::bstack_raii::BStackDrop as _; + #drop_children_body + ::std::result::Result::Ok(()) + } + } + impl ::bstack_raii::BStackDrop for #name { fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( self, diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index 949da4d..ae27ad6 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -9,6 +9,7 @@ use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; +use crate::clone::ClonePlan; use crate::layout::EightCC; use crate::owned::BStackOwned; use crate::reference::BStackRef; @@ -34,6 +35,43 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// The underlying range this handle points at. fn range(&self) -> BStackRange; + + /// Read this block's `OnDisk` and return a deep-cloned copy for a + /// [`ClonePlan`]: owned children cloned into the plan, shared children's + /// refcounts bumped, embedded children folded in place — *without* allocating + /// a block for `self`. The generated `#[bstack_block]` impl overrides this + /// with the field-aware body; the default (used by hand-written impls of a + /// **childless** block) copies the `OnDisk` verbatim. Exposed on the trait — + /// rather than as a generated inherent method — so a generic parent can + /// recurse into a type parameter's clone. `#[doc(hidden)]`: an impl detail. + #[doc(hidden)] + fn __bstack_clone_children_inplace( + &self, + allocator: &A, + _plan: &mut ClonePlan, + ) -> io::Result { + let mut buf = std::vec![0u8; core::mem::size_of::()]; + let r = unsafe { BStackRef::::from_range(self.range()) }; + Ok(*r.read_on_disk(allocator.stack(), &mut buf)?) + } + + /// Deep-clone this block's subtree into a [`ClonePlan`]: allocate a fresh + /// destination block, recurse into children via + /// [`__bstack_clone_children_inplace`](Self::__bstack_clone_children_inplace), + /// and stage the destination payload (writes are staged, committed by the + /// caller). Overridden by the generated impl; the default suffices for a + /// childless block. `#[doc(hidden)]`: an impl detail. + #[doc(hidden)] + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let od = self.__bstack_clone_children_inplace(allocator, plan)?; + let dst = plan.alloc_raw(allocator, core::mem::size_of::() as u64)?; + plan.write(dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(dst) + } } /// The per-block field destructure behind `bstack_move!`: read every field, then diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 6cb3e6c..f57bf13 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -3871,3 +3871,86 @@ fn macro_generic_weak_box() { assert!(b.handle().item(&alloc).unwrap().is_none()); b.bstack_drop(&alloc).unwrap(); } + +#[bstack_block] +struct OwnedBox { + #[bstack_owned] + item: T, + tag: u64, +} + +#[test] +fn macro_generic_owned_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let leaf_off = leaf.handle().range().start(); + let b = OwnedBox::::new(&alloc, leaf, 7).unwrap(); + assert_eq!(b.handle().tag(stack).unwrap(), 7); + assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + + // Deep clone: the owned child is a FRESH block (distinct offset), same value. + let clone = b.try_clone_in(&alloc).unwrap(); + let citem = clone.handle().item(stack).unwrap(); + assert_eq!(citem.val(stack).unwrap(), 42); + assert_ne!(citem.range().start(), b.handle().item(stack).unwrap().range().start()); + clone.bstack_drop(&alloc).unwrap(); + // Original child survives the clone's teardown. + assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + + // Dropping the box frees its owned child; the slot is reclaimable. + b.bstack_drop(&alloc).unwrap(); + let leaf_size = size_of::<::OnDisk>() as u64; + let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + assert_eq!(reused.start(), leaf_off); + unsafe { dealloc_range(&alloc, reused).unwrap() }; +} + +#[bstack_block] +struct OwnsEnumG { + #[bstack_owned] + e: T, + n: u32, +} + +// Generic owned Vec / array compile (deep-clone/teardown reuse the concrete paths). +#[bstack_block] +struct OwnedVecG { + #[bstack_owned] + items: Vec, +} +#[bstack_block] +struct OwnedArrG { + #[bstack_owned] + items: [T; 2], +} + +#[test] +fn macro_generic_owns_enum() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + // ArrEnum::Leaves owns two MacroLeaf children. + let e = ArrEnum::new( + &alloc, + ArrEnumData::Leaves([ + MacroLeaf::new(&alloc, 1).unwrap(), + MacroLeaf::new(&alloc, 2).unwrap(), + ]), + ) + .unwrap(); + let b = OwnsEnumG::::new(&alloc, e, 9).unwrap(); + + // Deep clone must recurse into the owned enum's OWN owned children — which + // works only because the enum's clone hook is a `BStackBlock` trait method + // (reachable through the generic `T` bound), not a generated inherent method. + let clone = b.try_clone_in(&alloc).unwrap(); + match clone.handle().e(stack).unwrap().read(&alloc).unwrap() { + ArrEnumView::Leaves(a) => assert_eq!(a[1].val(stack).unwrap(), 2), + _ => panic!("expected Leaves"), + } + clone.bstack_drop(&alloc).unwrap(); + b.bstack_drop(&alloc).unwrap(); +} From 99834cbc34521fb877d7591dc6fbfd88914b53af Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 31 Jul 2026 23:55:21 -0700 Subject: [PATCH 063/140] Refactor for generics --- bstack_raii/derive/src/block.rs | 105 +++++++++++++++++--------------- bstack_raii/src/block.rs | 15 +++++ 2 files changed, 70 insertions(+), 50 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 93cf9ac..6be5ab3 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -102,6 +102,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let name = &input.ident; let vis = &input.vis; let on_disk = format_ident!("{}OnDisk", name); + let on_disk_ty = quote!(#on_disk); let control = format_ident!("{}OnDiskRef", name); // Layout-preserving check + per-parameter bound: a type parameter may appear @@ -274,7 +275,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result <#elem_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); let store = quote!(::bstack_raii::BStackVec::); let field_loc = - quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); + quote!(self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64); let is_weak = kind == Kind::Weak; let (ctrl_ty, ctrl_size) = ( quote!(<#elem_ty as ::bstack_raii::BStackWeakable>::Control), @@ -551,13 +552,13 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } Kind::Pod => ( vec_drop_stmt(fname, elem, nullable), - vec_accessor(vis, fname, elem, &on_disk, nullable), + vec_accessor(vis, fname, elem, &on_disk_ty, nullable), vec_ctor(fname, &vinfo, nullable), vec_move(&cap, elem, nullable), ), Kind::Owned => ( block_vec_drop_stmt(fname, quote!(BStackBlockVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackBlockVec), nullable), + block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackBlockVec), nullable), block_vec_ctor( fname, elem, @@ -569,7 +570,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Strong => ( block_vec_drop_stmt(fname, quote!(BStackStrongVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackStrongVec), nullable), + block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackStrongVec), nullable), block_vec_ctor( fname, elem, @@ -581,7 +582,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Weak => ( block_vec_drop_stmt(fname, quote!(BStackWeakVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackWeakVec), nullable), + block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackWeakVec), nullable), block_vec_ctor( fname, elem, @@ -593,7 +594,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Ref => ( block_vec_drop_stmt(fname, quote!(BStackRefVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk, quote!(BStackRefVec), nullable), + block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackRefVec), nullable), block_vec_ctor( fname, elem, @@ -697,7 +698,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { let __base = - self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; ::std::result::Result::Ok(#acc_body) } }); @@ -875,7 +876,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result drop_stmts.push(quote! { { let __base = - __range.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + __range.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; for __k in 0usize..(#total) { let __embed = ::bstack_raii::BStackRange::new( @@ -895,7 +896,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result accessors.push(quote! { #vis fn #fname(&self) -> #acc_ret { let __base = - self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; #acc_body } @@ -929,7 +930,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ctor_post.push(quote! { { let __base = - __data.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + __data.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; for __k in 0usize..(#total) { let __src = #src_id[__k]; @@ -973,7 +974,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result clone_stmts.push(quote! { { let __base = - __src.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + __src.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; let mut __arr: [#child_od; #total] = __od.#fname; for __k in 0usize..(#total) { @@ -1011,7 +1012,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result weak: ::bstack_raii::BStackWeak<'__s, #elem, __A>, ) -> ::std::io::Result<()> { let __field = self.0.start() - + ::core::mem::offset_of!(#on_disk, #fname) as u64 + + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64 + (index as u64) * 8; ::bstack_raii::set_weak_field(allocator, __field, weak) } @@ -1031,7 +1032,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result allocator: &'__u __A, ) -> ::std::io::Result<#acc_ret> { let __base = - self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; ::std::result::Result::Ok(#acc_body) } }); @@ -1122,9 +1123,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#acc_ret> { - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; - let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; let __offs: [u64; #total] = __od.#fname; ::std::result::Result::Ok(#acc_body) } @@ -1342,9 +1343,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#inner_ty> { - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; - let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; let __w = __od.#fname; ::std::result::Result::Ok(( #(__w.#idx,)* )) } @@ -1385,7 +1386,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result drop_stmts.push(quote! { { let __embed = ::bstack_raii::BStackRange::new( - __range.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + __range.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64, ::core::mem::size_of::<#child_od>() as u64, ); <#child>::__bstack_drop_children(__embed, allocator)?; @@ -1397,7 +1398,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #vis fn #fname(&self) -> #child { <#child as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new( - self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64, ::core::mem::size_of::<#child_od>() as u64, ), ) @@ -1420,7 +1421,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result { allocator.stack().copy( #src_id.start(), - __data.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + __data.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64, ::core::mem::size_of::<#child_od>() as u64, )?; unsafe { ::bstack_raii::dealloc_range(allocator, #src_id)?; } @@ -1456,7 +1457,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result { let __child = <#child as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new( - __src.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64, + __src.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64, ::core::mem::size_of::<#child_od>() as u64, ), ); @@ -1501,13 +1502,13 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } // Accessor. - accessors.push(accessor(vis, fname, inner_ty, &on_disk, kind, nullable)); + accessors.push(accessor(vis, fname, inner_ty, &on_disk_ty, kind, nullable)); // Constructor. Weak fields are not parameters — they start null and are // wired afterwards via the generated `set_`. if kind == Kind::Weak { ctor_inits.push(quote!(#fname: 0u64,)); - setters.push(weak_setter(vis, fname, inner_ty, &on_disk)); + setters.push(weak_setter(vis, fname, inner_ty, &on_disk_ty)); } else { let (param, prep, init) = ctor_field(fname, inner_ty, kind, nullable); ctor_params.push(param); @@ -1678,7 +1679,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let constructor = constructor( vis, - &on_disk, + &on_disk_ty, mode, &ctrl_eightcc, &ctor_params, @@ -1705,9 +1706,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __inner = owned.into_inner(); let __stack = __alloc.stack(); let __range = ::bstack_raii::BStackBlock::range(&__inner); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; - let __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let __od: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; #(#mv_caps)* // Free the parent shell only; children stay live on disk. unsafe { ::bstack_raii::dealloc_range(__alloc, __range)?; } @@ -1743,10 +1744,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let children = quote! { let __stack = allocator.stack(); let __src = ::bstack_raii::BStackBlock::range(self); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; #[allow(unused_mut)] - let mut __od: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let mut __od: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; #(#clone_stmts)* ::std::result::Result::Ok(__od) }; @@ -1754,7 +1755,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __od = self.__bstack_clone_children_inplace(allocator, __plan)?; let __dst = __plan.alloc_raw( allocator, - ::core::mem::size_of::<#on_disk>() as u64, + ::core::mem::size_of::<#on_disk_ty>() as u64, )?; __plan.write( __dst.start(), @@ -1774,7 +1775,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, - ) -> ::std::io::Result<#on_disk> { + ) -> ::std::io::Result<#on_disk_ty> { // Bring the trait into scope so a child's (possibly generic) clone hook // resolves via method syntax. use ::bstack_raii::BStackBlock as _; @@ -1853,8 +1854,8 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // SAFETY: `#[repr(C, packed)]` guarantees no padding, and every field is // `Pod` (u64 for refs/injected counters, header is Pod, each inline field // is asserted `Pod` below), so all bit patterns are valid. - unsafe impl ::bstack_raii::Zeroable for #on_disk {} - unsafe impl ::bstack_raii::Pod for #on_disk {} + unsafe impl ::bstack_raii::Zeroable for #on_disk_ty {} + unsafe impl ::bstack_raii::Pod for #on_disk_ty {} const _: fn() = || { fn __assert_pod<__T: ::bstack_raii::Pod>() {} @@ -1868,7 +1869,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } impl #impl_g ::bstack_raii::BStackBlock for #name #ty_g #where_g { - type OnDisk = #on_disk; + type OnDisk = #on_disk_ty; fn from_range(range: ::bstack_raii::BStackRange) -> Self { #name(range #phantom_ctor) } @@ -1877,23 +1878,26 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } #clone_trait_methods - } - impl #impl_g #name #ty_g #where_g { /// Free this block's owned children (recursively) given its range, /// **without** freeing the block itself — used when the block is /// `#[embed]`ded (its storage is part of its parent), and by - /// `bstack_drop` before the self-dealloc. + /// `bstack_drop` before the self-dealloc. Overrides the childless + /// `BStackBlock` default. #[doc(hidden)] - #vis fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + #[allow(unused_imports)] + fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; + // Bring the trait into scope so a child's (possibly generic) teardown + // hook resolves. + use ::bstack_raii::BStackBlock as _; let __stack = allocator.stack(); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; - let __on_disk: #on_disk = *__r.read_on_disk(__stack, &mut __buf)?; + let __on_disk: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; #(#drop_stmts)* ::std::result::Result::Ok(()) } @@ -1904,7 +1908,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result self, allocator: &__A, ) -> ::std::io::Result<()> { - Self::__bstack_drop_children(self.0, allocator)?; + ::__bstack_drop_children(self.0, allocator)?; unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } } } @@ -2127,7 +2131,7 @@ fn vec_accessor( vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, - on_disk: &Ident, + on_disk: &TokenStream, nullable: bool, ) -> TokenStream { let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); @@ -2310,7 +2314,7 @@ fn block_vec_accessor( vis: &syn::Visibility, fname: &Ident, elem: &TokenStream, - on_disk: &Ident, + on_disk: &TokenStream, vec_ty: TokenStream, nullable: bool, ) -> TokenStream { @@ -2545,7 +2549,7 @@ fn accessor( vis: &syn::Visibility, fname: &Ident, inner_ty: &Type, - on_disk: &Ident, + on_disk: &TokenStream, kind: Kind, nullable: bool, ) -> TokenStream { @@ -2775,7 +2779,7 @@ fn wrap_move( /// Generate the `set_` method for a `#[bstack_weak]` field: point it at a /// weak target (consumed), releasing whatever it held before. -fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Ident) -> TokenStream { +fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &TokenStream) -> TokenStream { let setter = format_ident!("set_{}", fname); quote! { #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( @@ -2793,7 +2797,7 @@ fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Ident #[allow(clippy::too_many_arguments)] fn constructor( vis: &syn::Visibility, - on_disk: &Ident, + on_disk: &TokenStream, mode: Mode, ctrl_eightcc: &TokenStream, params: &[TokenStream], @@ -5187,18 +5191,19 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<::bstack_raii::BStackRange> { #clone_into_body } - } - impl #name { /// Free the active variant's owned child (recursively) given this /// block's range, **without** freeing the block itself — used when the - /// enum is `#[embed]`ded, and by `bstack_drop` before the self-dealloc. + /// enum is `#[embed]`ded, and by `bstack_drop`. Overrides the childless + /// `BStackBlock` default. #[doc(hidden)] - #vis fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + #[allow(unused_imports)] + fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; + use ::bstack_raii::BStackBlock as _; #drop_children_body ::std::result::Result::Ok(()) } @@ -5209,7 +5214,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<()> { - Self::__bstack_drop_children(self.0, allocator)?; + ::__bstack_drop_children(self.0, allocator)?; unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } } } diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index ae27ad6..63a05b3 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -36,6 +36,21 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// The underlying range this handle points at. fn range(&self) -> BStackRange; + /// Recursively free this block's owned children given its `range`, **without** + /// freeing the block itself — used when the block is `#[embed]`ded (its storage + /// is part of the parent) and by `bstack_drop` before the self-dealloc. The + /// generated impl overrides this; the default (a **childless** block) frees + /// nothing. Exposed on the trait — rather than as a generated inherent method — + /// so a generic parent can recurse into a type parameter. `#[doc(hidden)]`. + #[doc(hidden)] + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let _ = (range, allocator); + Ok(()) + } + /// Read this block's `OnDisk` and return a deep-cloned copy for a /// [`ClonePlan`]: owned children cloned into the plan, shared children's /// refcounts bumped, embedded children folded in place — *without* allocating From b8109b0e86192abcbd5c6601fa97fc1ffc1a4a40 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 00:24:18 -0700 Subject: [PATCH 064/140] Enhance generics for pod and embed --- bstack_raii/derive/src/block.rs | 233 ++++++++++++++++++++++++-------- bstack_raii/src/tests.rs | 74 ++++++++++ 2 files changed, 247 insertions(+), 60 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 6be5ab3..5865413 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -102,67 +102,133 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let name = &input.ident; let vis = &input.vis; let on_disk = format_ident!("{}OnDisk", name); - let on_disk_ty = quote!(#on_disk); let control = format_ident!("{}OnDiskRef", name); - // Layout-preserving check + per-parameter bound: a type parameter may appear - // only in a `#[bstack_ref]` / `#[bstack_strong]` / `#[bstack_weak]` field — - // each a bare `u64` offset on disk whose teardown/clone recurse through - // *traits* (`BStackShared`) or library helpers, never the generated inherent - // methods. Every such parameter is bounded `BStackBlock`, plus `BStackShared` - // if it is ever a `#[bstack_strong]` element and `BStackWeakable` if ever a - // `#[bstack_weak]` element. (Owned needs a not-yet-done clone-recursion - // change; embed/POD change the on-disk layout — both rejected.) - let mut param_extra: Vec<(Ident, bool, bool)> = - type_params.iter().map(|p| ((*p).clone(), false, false)).collect(); + // Per-parameter usage across fields — driving both the trait bound and whether + // the parameter is stored INLINE (making `XOnDisk`, and its `size_of` / + // `offset_of`, depend on it). A parameter is either a **POD** value (`T: Pod`, + // stored by value) or a **block reference / embed** (`T: BStackBlock`, plus + // `BStackShared` / `BStackWeakable` for strong / weak elements). `ref` / `owned` + // / `strong` / `weak` lower to a bare `u64` offset (not in `XOnDisk`); `#[embed]` + // and POD store the type inline (in `XOnDisk`). + #[derive(Default)] + struct Usage { + pod: bool, + blockish: bool, + strong: bool, + weak: bool, + in_ondisk: bool, + } + let mut usage: Vec<(Ident, Usage)> = type_params + .iter() + .map(|p| ((*p).clone(), Usage::default())) + .collect(); for (_, field) in &field_list { let kind = classify(field)?; if !type_mentions_any(&field.ty, &type_params) { continue; } - match kind { - // ref / owned lower to a `u64` offset and recurse through traits - // (`BStackDrop`, the clone hooks on `BStackBlock`) — base bound only. - Kind::Ref | Kind::Owned => {} - Kind::Strong | Kind::Weak => { - for (p, is_strong, is_weak) in param_extra.iter_mut() { - if type_mentions_any(&field.ty, &[p]) { - *is_strong |= kind == Kind::Strong; - *is_weak |= kind == Kind::Weak; - } - } + for (p, u) in usage.iter_mut() { + if !type_mentions_any(&field.ty, &[&*p]) { + continue; } - _ => { - return Err(Error::new_spanned( - &field.ty, - "a generic type parameter may currently be used only in a `#[bstack_ref]` / \ - `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` field — each a bare \ - `u64` offset that keeps the block's on-disk layout independent of the type. \ - An embed / POD use stores the type inline, changing the layout — not yet \ - supported.", - )); + match kind { + Kind::Pod => { + u.pod = true; + u.in_ondisk = true; + } + Kind::Embed => { + u.blockish = true; + u.in_ondisk = true; + } + Kind::Ref | Kind::Owned => u.blockish = true, + Kind::Strong => { + u.blockish = true; + u.strong = true; + } + Kind::Weak => { + u.blockish = true; + u.weak = true; + } } } } - // Generics threaded into the generated impls (with the computed bounds added - // to every parameter), plus the handle's phantom marker over them. - // `impl_g`/`ty_g`/`where_g` carry the bounds (for the bstack trait impls); - // `decl_g`/`decl_ty_g`/`decl_where` are the user's own (for the handle type - // and its `Clone`/`Copy`, which hold regardless of `T`). + for (p, u) in &usage { + if u.pod && u.blockish { + return Err(Error::new_spanned( + p, + "a generic type parameter cannot be used both as a POD field and as a \ + reference / embed field — a `Pod` value and a `#[bstack_block]` reference are \ + different kinds of thing, with incompatible bounds", + )); + } + } + // Generics threaded into the generated impls (with the computed bounds), plus + // the handle's phantom marker over them. `impl_g`/`ty_g`/`where_g` carry the + // bounds (for the bstack trait impls); `decl_g`/`decl_ty_g`/`decl_where` are + // the user's own (for the handle type + its `Clone`/`Copy`, which hold + // regardless of `T`). let mut aug_generics = input.generics.clone(); for tp in aug_generics.type_params_mut() { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); - if let Some((_, is_strong, is_weak)) = param_extra.iter().find(|(p, ..)| *p == tp.ident) { - if *is_strong { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackShared)); - } - if *is_weak { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackWeakable)); + let u = usage.iter().find(|(p, _)| *p == tp.ident).map(|(_, u)| u); + if u.is_some_and(|u| u.pod) { + tp.bounds.push(syn::parse_quote!(::bstack_raii::Pod)); + } else { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); + if let Some(u) = u { + if u.strong { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackShared)); + } + if u.weak { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackWeakable)); + } } } + // A parameter stored inline makes `XOnDisk: Pod` depend on it, and + // `bytemuck::Pod` requires `'static`. A stored parameter is a `Pod` value or + // a block handle (a `BStackRange` newtype) — both `'static` — so the block's + // own impls need the bound too, to use `Self::OnDisk: Pod`. + if u.is_some_and(|u| u.in_ondisk) { + tp.bounds.push(syn::parse_quote!('static)); + } } let (impl_g, ty_g, where_g) = aug_generics.split_for_impl(); let (decl_g, decl_ty_g, decl_where) = input.generics.split_for_impl(); + + // `XOnDisk` is generic over exactly the parameters stored inline (embed / POD). + // For a block with none (ref/owned/strong/weak-only, or non-generic), it stays + // a plain non-generic struct and `on_disk_ty` is just its name. + let ondisk_idents: Vec = usage + .iter() + .filter(|(_, u)| u.in_ondisk) + .map(|(p, _)| p.clone()) + .collect(); + let ondisk_generics: syn::Generics = { + let mut g = syn::Generics::default(); + for tp in aug_generics.type_params() { + if ondisk_idents.contains(&tp.ident) { + // Inherits the `Pod`/`BStackBlock` + `'static` bounds from + // `aug_generics` above. + g.params.push(syn::GenericParam::Type(tp.clone())); + } + } + g + }; + let (od_impl_g, od_ty_g, od_where) = ondisk_generics.split_for_impl(); + let on_disk_ty = if ondisk_idents.is_empty() { + quote!(#on_disk) + } else { + quote!(#on_disk #od_ty_g) + }; + // For a struct *literal* `XOnDisk { .. }`: bare when non-generic (or when the + // fields determine the parameters, as for a POD field), but an `#[embed]` + // field is `::OnDisk`, which does NOT determine `T` — so use a turbofish + // `XOnDisk:: { .. }` whenever generic. + let on_disk_ctor = if ondisk_idents.is_empty() { + quote!(#on_disk) + } else { + quote!(#on_disk::#od_ty_g) + }; let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = if type_params.is_empty() { (quote!(), quote!()) } else { @@ -1123,7 +1189,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#acc_ret> { - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; let __offs: [u64; #total] = __od.#fname; @@ -1343,7 +1409,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#inner_ty> { - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; let __w = __od.#fname; @@ -1553,9 +1619,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let data_eightcc = if type_params.is_empty() { data_eightcc } else { - let mixes = type_params - .iter() - .map(|p| quote!(.mix(<#p as ::bstack_raii::BStackCast>::eightcc()))); + // A block parameter has its own `eightcc`; a POD one does not, so fold in + // its byte size instead (distinct-size instantiations get distinct tags; + // same-size POD types are bit-compatible on disk, so sharing one is sound). + let mixes = usage.iter().map(|(p, u)| { + if u.pod { + quote!(.mix(::bstack_raii::EightCC::new( + (::core::mem::size_of::<#p>() as u64).to_le_bytes()))) + } else { + quote!(.mix(<#p as ::bstack_raii::BStackCast>::eightcc())) + } + }); quote!(#data_eightcc #(#mixes)*) }; @@ -1680,6 +1754,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let constructor = constructor( vis, &on_disk_ty, + &on_disk_ctor, mode, &ctrl_eightcc, &ctor_params, @@ -1706,7 +1781,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __inner = owned.into_inner(); let __stack = __alloc.stack(); let __range = ::bstack_raii::BStackBlock::range(&__inner); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; let __od: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; #(#mv_caps)* @@ -1744,7 +1819,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let children = quote! { let __stack = allocator.stack(); let __src = ::bstack_raii::BStackBlock::range(self); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__src) }; #[allow(unused_mut)] let mut __od: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; @@ -1838,6 +1913,37 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } }; + // A generic `OnDisk` (embed / POD parameters) can't `#[derive(Copy)]` — the + // derived `T: Copy` bound isn't implied by `T: BStackBlock`, though the fields + // (`::OnDisk` / `T: Pod`) always are — so hand-write `Clone`/`Copy` with the + // `OnDisk`'s own bounds. A non-generic `OnDisk` keeps the derive. + let (on_disk_derive, on_disk_clonecopy): (TokenStream, TokenStream) = if ondisk_idents + .is_empty() + { + ( + quote!(#[derive(::core::clone::Clone, ::core::marker::Copy)]), + quote!(), + ) + } else { + ( + quote!(), + quote! { + impl #od_impl_g ::core::clone::Clone for #on_disk_ty #od_where { + fn clone(&self) -> Self { *self } + } + impl #od_impl_g ::core::marker::Copy for #on_disk_ty #od_where {} + }, + ) + }; + // The `Pod` assertion can only name concrete field types — a generic parameter + // in a POD field carries a `T: Pod` bound instead (and the `OnDisk`'s own `Pod` + // impl checks the composite). + let concrete_pod_types: Vec<&Type> = pod_types + .iter() + .filter(|t| !type_mentions_any(t, &type_params)) + .copied() + .collect(); + Ok(quote! { #handle_def @@ -1845,21 +1951,23 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result #(#wrapper_defs)* #[repr(C, packed)] - #[derive(::core::clone::Clone, ::core::marker::Copy)] - #vis struct #on_disk { + #on_disk_derive + #vis struct #on_disk #od_impl_g #od_where { __bstack_header: ::bstack_raii::BlockHeader, #(#on_disk_fields)* } + #on_disk_clonecopy // SAFETY: `#[repr(C, packed)]` guarantees no padding, and every field is // `Pod` (u64 for refs/injected counters, header is Pod, each inline field - // is asserted `Pod` below), so all bit patterns are valid. - unsafe impl ::bstack_raii::Zeroable for #on_disk_ty {} - unsafe impl ::bstack_raii::Pod for #on_disk_ty {} + // is asserted `Pod` below, and a generic inline field is `Pod` by its + // parameter's bound), so all bit patterns are valid. + unsafe impl #od_impl_g ::bstack_raii::Zeroable for #on_disk_ty #od_where {} + unsafe impl #od_impl_g ::bstack_raii::Pod for #on_disk_ty #od_where {} const _: fn() = || { fn __assert_pod<__T: ::bstack_raii::Pod>() {} - #( __assert_pod::<#pod_types>(); )* + #( __assert_pod::<#concrete_pod_types>(); )* }; impl #impl_g ::bstack_raii::BStackCast for #name #ty_g #where_g { @@ -1895,7 +2003,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // hook resolves. use ::bstack_raii::BStackBlock as _; let __stack = allocator.stack(); - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(__range) }; let __on_disk: #on_disk_ty = *__r.read_on_disk(__stack, &mut __buf)?; #(#drop_stmts)* @@ -2568,7 +2676,7 @@ fn accessor( }; } let read = quote! { - let mut __buf = [0u8; ::core::mem::size_of::<#on_disk>()]; + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk>()]; let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; let __od: #on_disk = *__r.read_on_disk(stack, &mut __buf)?; }; @@ -2798,6 +2906,11 @@ fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &Token fn constructor( vis: &syn::Visibility, on_disk: &TokenStream, + // The `XOnDisk` name for a struct *literal* — bare, or a turbofish + // `XOnDisk::` when generic (`XOnDisk { .. }` in expression position would + // parse as a comparison, and `::OnDisk` fields don't infer `T`). `on_disk` + // above is the plain *type* (`XOnDisk`), for `size_of`. + on_disk_ctor: &TokenStream, mode: Mode, ctrl_eightcc: &TokenStream, params: &[TokenStream], @@ -2856,7 +2969,7 @@ fn constructor( #(#params)* ) -> ::std::io::Result<#ret> { #(#preps)* - let __on_disk = #on_disk { + let __on_disk = #on_disk_ctor { #header #injected #(#inits)* @@ -2894,7 +3007,7 @@ fn constructor( let __blocks = ::bstack_raii::alloc_many(allocator, &[#size, #ctrl_size])?; let __data = __blocks[0]; let __ctrl = __blocks[1]; - let __on_disk = #on_disk { + let __on_disk = #on_disk_ctor { #header __bstack_ctrl: __ctrl.start(), #(#inits)* diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index f57bf13..3edea7d 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -3954,3 +3954,77 @@ fn macro_generic_owns_enum() { clone.bstack_drop(&alloc).unwrap(); b.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Generic blocks storing T INLINE: POD (`item: T`, T: Pod) and #[embed] +// (`item: T`, T: BStackBlock) — XOnDisk is generic over the stored param. +// -------------------------------------------------------------------------- + +#[bstack_block] +struct PodBoxG { + item: T, + tag: u64, +} + +#[test] +fn macro_generic_pod_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let b = PodBoxG::::new(&alloc, 42u32, 7).unwrap(); + assert_eq!(b.handle().item(stack).unwrap(), 42); + assert_eq!(b.handle().tag(stack).unwrap(), 7); + + // Clone byte-copies the POD value. + let clone = b.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().item(stack).unwrap(), 42); + clone.bstack_drop(&alloc).unwrap(); + + // Distinct type args → distinct on-disk layout → distinct tags. + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + + // Move hands the POD value back. + let (item, tag) = bstack_move!(b, &alloc).unwrap(); + assert_eq!((item, tag), (42u32, 7u64)); +} + +#[bstack_block] +struct EmbBoxG { + #[embed] + item: T, + tag: u32, +} + +#[test] +fn macro_generic_emb_box() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // EmbChild owns a MacroLeaf; embedding inlines the whole child on disk. + let child = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 10).unwrap(), 1).unwrap(); + let b = EmbBoxG::::new(&alloc, child, 99).unwrap(); + assert_eq!(b.handle().tag(stack).unwrap(), 99); + // Accessor: an EmbChild handle into the inline slot (pure offset math). + assert_eq!(b.handle().item().leaf(stack).unwrap().val(stack).unwrap(), 10); + + // Clone folds the embedded child inline, deep-cloning its owned leaf — via the + // generic `T`'s `BStackBlock` clone hook (a trait method, not inherent). + let clone = b.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().item().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_ne!( + clone.handle().item().leaf(stack).unwrap().range().start(), + b.handle().item().leaf(stack).unwrap().range().start() + ); + clone.bstack_drop(&alloc).unwrap(); + + // Move re-homes the embedded child to a fresh standalone block. + let (moved, tag) = bstack_move!(b, &alloc).unwrap(); + assert_eq!(tag, 99); + assert_eq!(moved.handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + moved.bstack_drop(&alloc).unwrap(); +} From 5a9f975b3805e69aad6dc9f03cb15793b14f8733 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 15:07:53 -0700 Subject: [PATCH 065/140] Add support for generic enums --- bstack_raii/derive/src/block.rs | 250 +++++++++++++++++++++++--------- bstack_raii/src/tests.rs | 79 ++++++++++ 2 files changed, 263 insertions(+), 66 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 5865413..b00f760 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -3525,12 +3525,87 @@ fn infer_disc_ty(min: i128, max: i128) -> &'static str { pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let attr = parse_attr(attr)?; let mode = attr.mode; + + // Generic enums (layout-preserving): a type parameter may appear only in a + // *reference* variant (`#[bstack_owned/strong/weak/ref] V(T)`, and array / vec + // forms) — each a bare `u64` offset (or `VecDesc`) in the payload, keeping the + // fixed payload size independent of the parameter. A POD or `#[embed]` variant + // stores the parameter inline, so its payload width would depend on it — + // rejected. Const / lifetime parameters and non-plain modes are not supported. + let type_params: Vec<&Ident> = input.generics.type_params().map(|tp| &tp.ident).collect(); if !input.generics.params.is_empty() { - return Err(Error::new_spanned( - &input.generics, - "#[bstack_enum] does not support generic enums", - )); + for p in &input.generics.params { + if !matches!(p, syn::GenericParam::Type(_)) { + return Err(Error::new_spanned( + p, + "a generic #[bstack_enum] currently supports only type parameters (no \ + lifetime or const generics)", + )); + } + } + if mode != Mode::Plain { + return Err(Error::new_spanned( + &input.generics, + "a generic #[bstack_enum] currently supports plain mode only (not `rc` / \ + `rc, weak`)", + )); + } + } + #[derive(Default)] + struct EUsage { + strong: bool, + weak: bool, } + let mut eusage: Vec<(Ident, EUsage)> = type_params + .iter() + .map(|p| ((*p).clone(), EUsage::default())) + .collect(); + for variant in &input.variants { + let kind = classify_attrs(&variant.attrs)?; + for f in &variant.fields { + if !type_mentions_any(&f.ty, &type_params) { + continue; + } + if kind == Kind::Pod || kind == Kind::Embed { + return Err(Error::new_spanned( + &f.ty, + "a generic type parameter in a `#[bstack_enum]` variant must be a reference \ + (`#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]`), \ + not stored inline — a POD or `#[embed]` variant's payload width would depend \ + on the parameter", + )); + } + for (p, u) in eusage.iter_mut() { + if type_mentions_any(&f.ty, &[&*p]) { + u.strong |= kind == Kind::Strong; + u.weak |= kind == Kind::Weak; + } + } + } + } + let mut aug_generics = input.generics.clone(); + for tp in aug_generics.type_params_mut() { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); + if let Some((_, u)) = eusage.iter().find(|(p, _)| *p == tp.ident) { + if u.strong { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackShared)); + } + if u.weak { + tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackWeakable)); + } + } + } + let (enum_impl_g, enum_ty_g, enum_where) = aug_generics.split_for_impl(); + let (enum_decl_g, enum_decl_ty_g, enum_decl_where) = input.generics.split_for_impl(); + let (enum_phantom_field, enum_phantom_ctor): (TokenStream, TokenStream) = + if type_params.is_empty() { + (quote!(), quote!()) + } else { + ( + quote!(, ::core::marker::PhantomData (#(#type_params,)*)>), + quote!(, ::core::marker::PhantomData), + ) + }; // The on-disk discriminant. Each variant's value follows Rust's rules // (explicit `= N`, else previous + 1); the width is an explicit `repr(..)` @@ -3592,6 +3667,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result { let __desc = __v.descriptor(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..16].copy_from_slice( ::bstack_raii::bytemuck::bytes_of(&__desc)); (#disc, __pl) @@ -3812,7 +3888,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let __desc = __v.descriptor(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..16].copy_from_slice( ::bstack_raii::bytemuck::bytes_of(&__desc)); (#disc, __pl) @@ -3932,7 +4008,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::from_slice( allocator, &__flat)?.descriptor(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..16].copy_from_slice(::bstack_raii::bytemuck::bytes_of(&__desc)); (#disc, __pl) } @@ -4078,7 +4154,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { #consume - (#disc, [0u8; Self::__PAYLOAD]) + (#disc, [0u8; #payload_const]) } }); @@ -4179,7 +4255,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; #consume (#disc, __pl) } @@ -4302,7 +4378,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; #consume (#disc, __pl) } @@ -4450,7 +4526,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let __h = __v.into_inner(); let __off = ::bstack_raii::BStackBlock::range(&__h).start(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..8].copy_from_slice(&__off.to_le_bytes()); (#disc, __pl) } @@ -4500,7 +4576,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..8].copy_from_slice(&__v.into_range().start().to_le_bytes()); (#disc, __pl) } @@ -4522,7 +4598,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let (__data, _ctrl) = __v.into_raw(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..8].copy_from_slice(&__data.into_range().start().to_le_bytes()); (#disc, __pl) } @@ -4583,7 +4659,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { let __ctrl = __v.into_raw(); - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; __pl[..8].copy_from_slice(&__ctrl.into_range().start().to_le_bytes()); (#disc, __pl) } @@ -4640,7 +4716,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result() as u64, 0u64, )); - (#disc, [0u8; Self::__PAYLOAD]) + (#disc, [0u8; #payload_const]) } }); // read (view): a child handle at the embedded payload offset. @@ -4784,7 +4860,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let mut __pl = [0u8; Self::__PAYLOAD]; + let mut __pl = [0u8; #payload_const]; #(#writes)* (#disc, __pl) } @@ -4806,6 +4882,16 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result>()); let tag = build_tag(hash, &prefix); let eightcc = eightcc_expr(&tag.bytes); + // For a generic enum, fold each type argument's tag into the discriminant so + // distinct instantiations get distinct tags (mixed at runtime in `eightcc()`). + let eightcc = if type_params.is_empty() { + eightcc + } else { + let mixes = type_params + .iter() + .map(|p| quote!(.mix(<#p as ::bstack_raii::BStackCast>::eightcc()))); + quote!(#eightcc #(#mixes)*) + }; // Control-block tag (rc, weak): the data tag with its prefix lowercased, or a // `ctrl_tag` override. @@ -4829,11 +4915,39 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote!(::bstack_raii::BStackRc<'__e, Self, __A>), }; // `EData` type name — `new`'s `data` parameter and `bstack_move!` output. - let data_ty = if has_shared { - quote!(#data<'__e, __A>) - } else { - quote!(#data) + // The companion `EData` / `EView` enums are generic over `<'e, A>` (when a + // strong/weak variant needs it) AND over the enum's own type parameters. These + // helpers assemble a use (`EData<'e, A, T>`) or a decl (`<'e, A, T: Bound>`) + // with the right subset present. + let etp_args: Vec = type_params.iter().map(|p| quote!(#p)).collect(); + let etp_decl: Vec = aug_generics.type_params().map(|tp| quote!(#tp)).collect(); + let comp_ty = |id: &Ident, lt: &TokenStream, la: bool| -> TokenStream { + let mut args: Vec = Vec::new(); + if la { + args.push(lt.clone()); + args.push(quote!(__A)); + } + args.extend(etp_args.iter().cloned()); + if args.is_empty() { + quote!(#id) + } else { + quote!(#id < #(#args),* >) + } }; + let comp_decl = |lt: &TokenStream, la: bool| -> TokenStream { + let mut parts: Vec = Vec::new(); + if la { + parts.push(lt.clone()); + parts.push(quote!(__A: ::bstack_raii::BStackOwnedSliceAllocator)); + } + parts.extend(etp_decl.iter().cloned()); + if parts.is_empty() { + quote!() + } else { + quote!(< #(#parts),* >) + } + }; + let data_ty = comp_ty(&data, "e!('__e), has_shared); // The `new` constructor. Plain / `rc` are one atomic write (the injected // refcount is baked into the image); `(rc, weak)` allocates data + control and // commits both images in one `set_batched`, with the control back-pointer @@ -4905,7 +5019,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<#new_ret> { #embed_decl - let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { + let (__disc, __payload): (#disc_ty, [u8; #payload_const]) = match data { #(#new_arms)* }; let __on_disk = #on_disk { @@ -4937,7 +5051,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<::bstack_raii::BStackRc<'__e, Self, __A>> { #embed_decl - let (__disc, __payload): (#disc_ty, [u8; Self::__PAYLOAD]) = match data { + let (__disc, __payload): (#disc_ty, [u8; #payload_const]) = match data { #(#new_arms)* }; let __blocks = ::bstack_raii::alloc_many(allocator, &[#enum_size, #ctrl_size])?; @@ -5147,7 +5261,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( &self, allocator: &__A, @@ -5175,29 +5289,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result` when a variant holds a strong/weak - // reference; `EView` only when a weak variant makes `read` upgrade. - let data_generics = if has_shared { - quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) - } else { - quote!() - }; - let view_generics = if has_weak { - quote!(<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>) - } else { - quote!() - }; - let view_ty = if has_weak { - quote!(#view<'__e, __A>) - } else { - quote!(#view) - }; + // reference; `EView` only when a weak variant makes `read` upgrade; both are + // also generic over the enum's own type parameters. + let data_generics = comp_decl("e!('__e), has_shared); + let view_generics = comp_decl("e!('__e), has_weak); + let view_ty = comp_ty(&view, "e!('__e), has_weak); // `bstack_move!` yields the same `EData` (owned handles); `Fields` just names // it with the move lifetime. - let move_fields_ty = if has_shared { - quote!(#data<'__mv, __A>) - } else { - quote!(#data) - }; + let move_fields_ty = comp_ty(&data, "e!('__mv), has_shared); // `bstack_move!` frees the enum shell, then rebuilds the active variant's // payload as an owned handle. let move_payload = if needs_payload { @@ -5206,25 +5305,44 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result Self { *self } + } + impl #enum_decl_g ::core::marker::Copy for #name #enum_decl_ty_g #enum_decl_where {} + } + }; Ok(quote! { - #[derive(::core::clone::Clone, ::core::marker::Copy)] - #vis struct #name(::bstack_raii::BStackRange); + #enum_handle_def + + #[doc(hidden)] + #[allow(non_upper_case_globals)] + #vis const #payload_const: usize = { + let __s = [0usize #(, #payload_sizes)*]; + let mut __m = 0usize; + let mut __i = 0usize; + while __i < __s.len() { + if __s[__i] > __m { + __m = __s[__i]; + } + __i += 1; + } + __m + }; - impl #name { + impl #enum_impl_g #name #enum_ty_g #enum_where { /// The payload area size (bytes) — the max over all variants. #[doc(hidden)] - pub const __PAYLOAD: usize = { - let __s = [0usize #(, #payload_sizes)*]; - let mut __m = 0usize; - let mut __i = 0usize; - while __i < __s.len() { - if __s[__i] > __m { - __m = __s[__i]; - } - __i += 1; - } - __m - }; + pub const __PAYLOAD: usize = #payload_const; } #[repr(C, packed)] @@ -5233,7 +5351,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result ::bstack_raii::EightCC { #eightcc } } - impl ::bstack_raii::BStackBlock for #name { + impl #enum_impl_g ::bstack_raii::BStackBlock for #name #enum_ty_g #enum_where { type OnDisk = #on_disk; fn from_range(range: ::bstack_raii::BStackRange) -> Self { - #name(range) + #name(range #enum_phantom_ctor) } fn range(&self) -> ::bstack_raii::BStackRange { self.0 @@ -5322,7 +5440,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( self, allocator: &__A, @@ -5332,7 +5450,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result = #move_fields_ty; fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( owned: ::bstack_raii::BStackOwned, diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 3edea7d..e82a0d0 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -4028,3 +4028,82 @@ fn macro_generic_emb_box() { assert_eq!(moved.handle().leaf(stack).unwrap().val(stack).unwrap(), 10); moved.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Generic enums (layout-preserving: type params only in reference variants) +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum BoxEnumG { + Empty, + Tag(u32), + #[bstack_owned] + Item(T), +} + +#[test] +fn macro_generic_enum_owned() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let e = BoxEnumG::::new(&alloc, BoxEnumGData::Item(leaf)).unwrap(); + match e.handle().read(&alloc).unwrap() { + BoxEnumGView::Item(l) => assert_eq!(l.val(stack).unwrap(), 42), + _ => panic!("expected Item"), + } + + // Deep clone recurses into the owned child through T's BStackBlock hooks. + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + BoxEnumGView::Item(l) => assert_eq!(l.val(stack).unwrap(), 42), + _ => panic!("expected Item"), + } + clone.bstack_drop(&alloc).unwrap(); + + // Distinct instantiations → distinct tags. + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + + // Move yields the owned child. + match bstack_move!(e, &alloc).unwrap() { + BoxEnumGData::Item(owned) => { + assert_eq!(owned.handle().val(stack).unwrap(), 42); + owned.bstack_drop(&alloc).unwrap(); + } + _ => panic!("expected Item"), + } +} + +#[bstack_enum] +enum StrongEnumG { + Empty, + #[bstack_strong] + S(T), +} + +#[test] +fn macro_generic_enum_strong() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let c = MacroStrongChild::new(&alloc, 5).unwrap(); + let keep = c.try_clone().unwrap(); // strong = 2 + let data = keep.handle().range().start(); + + let e = StrongEnumG::::new(&alloc, StrongEnumGData::S(c)).unwrap(); + assert_eq!(strong_of(stack, data), 2); // e + keep + + // Clone bumps the strong count; teardown restores it. + let clone = e.try_clone_in(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 3); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 2); + e.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, data), 1); + drop(keep); +} From 167173c684fddf2ee00e461fdf436810fe6ca4ce Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 15:37:45 -0700 Subject: [PATCH 066/140] Const generics --- bstack_raii/derive/src/block.rs | 83 ++++++++++++++++++++++++--------- bstack_raii/src/tests.rs | 77 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 21 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index b00f760..08f4c18 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -61,13 +61,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // teardown/clone need no recursion into them. The per-field check is below, // once the fields are parsed; here we gate the coarse constraints. let type_params: Vec<&Ident> = input.generics.type_params().map(|tp| &tp.ident).collect(); + // Const parameters `const N: usize` are supported as array lengths (`[T; N]`); + // a direct const-param length is legal on stable, unlike an arbitrary const + // expression. Lifetimes are still rejected. + let const_params: Vec<&Ident> = input.generics.const_params().map(|cp| &cp.ident).collect(); if !input.generics.params.is_empty() { for p in &input.generics.params { - if !matches!(p, syn::GenericParam::Type(_)) { + if matches!(p, syn::GenericParam::Lifetime(_)) { return Err(Error::new_spanned( p, - "a generic #[bstack_block] currently supports only type parameters (no \ - lifetime or const generics)", + "a generic #[bstack_block] currently supports type and const parameters, \ + not lifetimes", )); } } @@ -203,19 +207,37 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result .filter(|(_, u)| u.in_ondisk) .map(|(p, _)| p.clone()) .collect(); + // A const parameter appears only as an array length (`[T; N]`), which always + // sizes the `OnDisk`, so any const parameter used in a field is an `OnDisk` + // parameter. + let mut ondisk_const_idents: Vec = Vec::new(); + for cp in &const_params { + if field_list + .iter() + .any(|(_, f)| type_mentions_any(&f.ty, &[*cp])) + { + ondisk_const_idents.push((*cp).clone()); + } + } + let ondisk_empty = ondisk_idents.is_empty() && ondisk_const_idents.is_empty(); let ondisk_generics: syn::Generics = { let mut g = syn::Generics::default(); - for tp in aug_generics.type_params() { - if ondisk_idents.contains(&tp.ident) { - // Inherits the `Pod`/`BStackBlock` + `'static` bounds from - // `aug_generics` above. - g.params.push(syn::GenericParam::Type(tp.clone())); + // Preserve declaration order (Rust requires types before consts). Inherits + // the `Pod`/`BStackBlock` + `'static` bounds from `aug_generics`. + for p in &aug_generics.params { + let keep = match p { + syn::GenericParam::Type(tp) => ondisk_idents.contains(&tp.ident), + syn::GenericParam::Const(cp) => ondisk_const_idents.contains(&cp.ident), + syn::GenericParam::Lifetime(_) => false, + }; + if keep { + g.params.push(p.clone()); } } g }; let (od_impl_g, od_ty_g, od_where) = ondisk_generics.split_for_impl(); - let on_disk_ty = if ondisk_idents.is_empty() { + let on_disk_ty = if ondisk_empty { quote!(#on_disk) } else { quote!(#on_disk #od_ty_g) @@ -223,17 +245,22 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // For a struct *literal* `XOnDisk { .. }`: bare when non-generic (or when the // fields determine the parameters, as for a POD field), but an `#[embed]` // field is `::OnDisk`, which does NOT determine `T` — so use a turbofish - // `XOnDisk:: { .. }` whenever generic. - let on_disk_ctor = if ondisk_idents.is_empty() { + // `XOnDisk:: { .. }` whenever generic. + let on_disk_ctor = if ondisk_empty { quote!(#on_disk) } else { quote!(#on_disk::#od_ty_g) }; - let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = if type_params.is_empty() { + let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = if type_params.is_empty() + && const_params.is_empty() + { (quote!(), quote!()) } else { + // Const parameters are held via `[(); N]` so they count as "used". + let const_markers = const_params.iter().map(|c| quote!([(); #c])); ( - quote!(, ::core::marker::PhantomData (#(#type_params,)*)>), + quote!(, ::core::marker::PhantomData< + fn() -> (#(#type_params,)* #(#const_markers,)*)>), quote!(, ::core::marker::PhantomData), ) }; @@ -1616,7 +1643,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // For a generic block, fold each type argument's tag into the discriminant // so distinct instantiations get distinct tags (the `eightcc()` body — always // called at runtime — mixes them; the readable prefix stays the outer name's). - let data_eightcc = if type_params.is_empty() { + let data_eightcc = if type_params.is_empty() && const_params.is_empty() { data_eightcc } else { // A block parameter has its own `eightcc`; a POD one does not, so fold in @@ -1630,7 +1657,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result quote!(.mix(<#p as ::bstack_raii::BStackCast>::eightcc())) } }); - quote!(#data_eightcc #(#mixes)*) + // A const parameter changes the array width (the layout), so fold its value + // in — distinct `N` gives distinct tags. + let const_mixes = const_params.iter().map(|c| { + quote!(.mix(::bstack_raii::EightCC::new((#c as u64).to_le_bytes()))) + }); + quote!(#data_eightcc #(#mixes)* #(#const_mixes)*) }; // The warnings use the `deprecated` mechanism, so a real `#[allow(deprecated)]` @@ -1898,7 +1930,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // The handle: a `BStackRange` newtype (plus a phantom over the type // parameters when generic). `Clone`/`Copy` hold regardless of `T` — for the // generic case they're hand-written (no `T: Copy` bound) rather than derived. - let handle_def = if type_params.is_empty() { + let handle_def = if type_params.is_empty() && const_params.is_empty() { quote! { #[derive(::core::clone::Clone, ::core::marker::Copy)] #vis struct #name(::bstack_raii::BStackRange); @@ -1917,9 +1949,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // derived `T: Copy` bound isn't implied by `T: BStackBlock`, though the fields // (`::OnDisk` / `T: Pod`) always are — so hand-write `Clone`/`Copy` with the // `OnDisk`'s own bounds. A non-generic `OnDisk` keeps the derive. - let (on_disk_derive, on_disk_clonecopy): (TokenStream, TokenStream) = if ondisk_idents - .is_empty() - { + let (on_disk_derive, on_disk_clonecopy): (TokenStream, TokenStream) = if ondisk_empty { ( quote!(#[derive(::core::clone::Clone, ::core::marker::Copy)]), quote!(), @@ -1938,9 +1968,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // The `Pod` assertion can only name concrete field types — a generic parameter // in a POD field carries a `T: Pod` bound instead (and the `OnDisk`'s own `Pod` // impl checks the composite). + let all_param_idents: Vec<&Ident> = type_params + .iter() + .copied() + .chain(const_params.iter().copied()) + .collect(); let concrete_pod_types: Vec<&Type> = pod_types .iter() - .filter(|t| !type_mentions_any(t, &type_params)) + .filter(|t| !type_mentions_any(t, &all_param_idents)) .copied() .collect(); @@ -2559,8 +2594,14 @@ fn dims_prod(dims: &[&Expr]) -> TokenStream { if dims.is_empty() { return quote!(1usize); } + // A SINGLE dimension is emitted bare (`N`, not `(N)`): as an array length a + // bare const parameter is legal on stable, whereas any operation — including a + // parenthesised or multiplied one — is not. So `[T; N]` (single, const `N`) + // works; nested `[[T; N]; M]` folds to `N * (M)`, which is only legal when the + // dimensions are concrete (a const-generic nested array is a stable-Rust + // limitation, surfacing as a const-operation error). let first = dims[0]; - let mut t = quote!((#first)); + let mut t = quote!(#first); for d in &dims[1..] { t = quote!(#t * (#d)); } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e82a0d0..5412622 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -4107,3 +4107,80 @@ fn macro_generic_enum_strong() { assert_eq!(strong_of(stack, data), 1); drop(keep); } + +// -------------------------------------------------------------------------- +// Const generics: `[T; N]` / `[Pod; N]` with a generic `const N: usize`. +// -------------------------------------------------------------------------- + +#[bstack_block] +struct RefArrN { + #[bstack_ref] + arr: [T; N], + tag: u64, +} + +#[bstack_block] +struct OwnArrN { + #[bstack_owned] + arr: [T; N], +} + +#[bstack_block] +struct PodArrN { + xs: [u16; N], + tag: u32, +} + +#[test] +fn macro_generic_const_ref_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let leaves: Vec<_> = (0..3).map(|v| MacroLeaf::new(&alloc, v).unwrap()).collect(); + let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; + let b = RefArrN::::new(&alloc, [r(0), r(1), r(2)], 9).unwrap(); + assert_eq!(b.handle().tag(stack).unwrap(), 9); + let arr = b.handle().arr(stack).unwrap(); // [MacroLeaf; 3] + assert_eq!(arr[0].val(stack).unwrap(), 0); + assert_eq!(arr[2].val(stack).unwrap(), 2); + + // Distinct N → distinct on-disk layout → distinct tags. + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + + // A ref array owns nothing: dropping leaves the targets alive. + b.bstack_drop(&alloc).unwrap(); + for l in leaves { + assert!(l.handle().val(stack).unwrap() < 3); + l.bstack_drop(&alloc).unwrap(); + } +} + +#[test] +fn macro_generic_const_owned_pod_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Owned const array: deep-clone + teardown reuse the concrete paths. + let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); + let o = OwnArrN::::new(&alloc, [mk(10), mk(20)]).unwrap(); + let a = o.handle().arr(stack).unwrap(); + assert_eq!(a[1].val(stack).unwrap(), 20); + let clone = o.try_clone_in(&alloc).unwrap(); + assert_ne!( + clone.handle().arr(stack).unwrap()[0].range().start(), + a[0].range().start() + ); + clone.bstack_drop(&alloc).unwrap(); + o.bstack_drop(&alloc).unwrap(); + + // POD const array. + let p = PodArrN::<4>::new(&alloc, [1u16, 2, 3, 4], 7).unwrap(); + assert_eq!(p.handle().xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().tag(stack).unwrap(), 7); + p.bstack_drop(&alloc).unwrap(); +} From 7b8719b8a8614bef5eef363b93ff8aaa85e5fde6 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 15:55:54 -0700 Subject: [PATCH 067/140] Enhance error handling --- bstack_raii/derive/src/block.rs | 43 +++++++++++++++++++++++++++------ bstack_raii/src/block.rs | 25 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 08f4c18..ce37f22 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -362,6 +362,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result && let Type::Array(_) = velem { let (dims, elem_ty, leaf_nullable) = array_shape(velem)?; + reject_nested_const_dims(&dims, &const_params, &field.ty)?; let total = dims_prod(&dims); let elem_ts = quote!(#elem_ty); let size_elem = quote!(::core::mem::size_of::< @@ -720,6 +721,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // the inner vectors' element ownership, exactly like a scalar `Vec`. if let Type::Array(_) = opt_inner { let (dims, leaf, leaf_nullable) = array_shape(opt_inner)?; + reject_nested_const_dims(&dims, &const_params, &field.ty)?; let leaf_vinfo = if is_str(leaf) { Some(VecInfo { elem: quote!(u8), @@ -949,6 +951,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result )); } let (dims, elem, elem_nullable) = array_shape(opt_inner)?; + reject_nested_const_dims(&dims, &const_params, &field.ty)?; let total = dims_prod(&dims); // `#[embed] [Child; N]` (or nested): N verbatim child on-disk forms @@ -2102,15 +2105,41 @@ fn is_str(ty: &Type) -> bool { /// Whether `ty` mentions any of the given (generic type-parameter) identifiers /// anywhere in its token tree. Used to enforce that a generic parameter is only /// ever used in a `#[bstack_ref]` field. +fn tokens_mention(ts: TokenStream, params: &[&Ident]) -> bool { + ts.into_iter().any(|t| match t { + proc_macro2::TokenTree::Ident(id) => params.iter().any(|p| **p == id), + proc_macro2::TokenTree::Group(g) => tokens_mention(g.stream(), params), + _ => false, + }) +} + fn type_mentions_any(ty: &Type, params: &[&Ident]) -> bool { - fn walk(ts: TokenStream, params: &[&Ident]) -> bool { - ts.into_iter().any(|t| match t { - proc_macro2::TokenTree::Ident(id) => params.iter().any(|p| **p == id), - proc_macro2::TokenTree::Group(g) => walk(g.stream(), params), - _ => false, - }) + tokens_mention(quote!(#ty), params) +} + +/// Reject a *nested* inline reference array (`[[T; N]; M]`, …) whose flattened +/// length would be a product `N * (M)` referencing a const parameter — Rust bars +/// generic parameters in an array-length *operation* on stable (a single `[T; N]` +/// with a direct const `N` is fine). POD arrays keep the nested type verbatim, so +/// this applies only where the array is flattened. `dims` is outer→inner. +fn reject_nested_const_dims( + dims: &[&Expr], + const_params: &[&Ident], + span: &Type, +) -> syn::Result<()> { + if dims.len() > 1 + && !const_params.is_empty() + && dims.iter().any(|d| tokens_mention(quote!(#d), const_params)) + { + return Err(Error::new_spanned( + span, + "a nested array `[[T; N]; M]` with a const-parameter dimension is not supported: \ + its flattened length would be a const expression (`N * M`), which stable Rust \ + forbids from using a generic parameter. Use a single `[T; N]`, or make the \ + dimensions concrete.", + )); } - walk(quote!(#ty), params) + Ok(()) } /// The element type `T` of a `Vec`, if `ty` is a `Vec`. Used to reject diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index 63a05b3..a397e31 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -26,6 +26,19 @@ pub trait BStackCast { /// /// `OnDisk` is the generated `#[repr(C, packed)]` payload struct; it must be /// [`Pod`] so it can be read back with `bytemuck::from_bytes`. +/// +/// The `#[diagnostic::on_unimplemented]` message turns the trait-bound failure +/// that a bad *generic* instantiation produces (which the macro cannot catch, as +/// the type parameter is opaque at expansion) into a readable one — e.g. a +/// `#[bstack_owned] Vec` field instantiated with `T = Vec`. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a `#[bstack_block]` / `#[bstack_enum]` type", + label = "not a bstack block", + note = "`#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` / `#[bstack_ref]` fields \ + (and generic parameters used as them) require a block type. Primitives, `Vec`, \ + `String`, tuples and `Option` are not blocks — a nested `Vec`/`Option` or a tuple \ + needs its own named `#[bstack_block]` wrapper." +)] pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// The on-disk payload layout (the generated `XOnDisk`). type OnDisk: Pod; @@ -134,6 +147,12 @@ pub trait BStackMoveExpr { /// plain `(rc)` block (inline refcount, [`crate::StrongRef`]) or an /// `(rc, weak)` block (control block, [`crate::StrongWeakRef`]). The child's own /// `#[bstack_block]` expansion picks the right implementation. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a reference-counted block (`#[bstack_block(rc)]` / `(rc, weak)`)", + label = "not a shared block", + note = "`#[bstack_strong]` fields (and generic parameters used as them) require an \ + `#[bstack_block(rc)]` or `#[bstack_block(rc, weak)]` type." +)] pub trait BStackShared: BStackBlock { /// Drop one strong reference to a block of this type located at `data`, /// freeing it (and, for `(rc, weak)`, releasing the control block) when the @@ -160,6 +179,12 @@ pub trait BStackShared: BStackBlock { /// `XOnDiskRef` control-block payload holding the `strong`/`weak` counters. /// Plain `#[bstack_block(rc)]` blocks do not implement it, so weak references to /// them are a compile error rather than a runtime hazard. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a weak-observable block (`#[bstack_block(rc, weak)]`)", + label = "not a weakable block", + note = "`#[bstack_weak]` fields (and generic parameters used as them) require an \ + `#[bstack_block(rc, weak)]` type; a plain `#[bstack_block(rc)]` is not weak-observable." +)] pub trait BStackWeakable: BStackBlock { /// The on-disk control-block payload (the generated `XOnDiskRef`). type Control: Pod; From 45e2936cac487edbf21fc2ea0d021af720e24e9b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 15:59:28 -0700 Subject: [PATCH 068/140] Update README --- bstack_raii/README.md | 77 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 98c6675..1d9edc5 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -36,6 +36,7 @@ object model on top. - [Nullable fields: `Option`](#nullable-fields-option) - [Enums: `#[bstack_enum]`](#enums-bstack_enum) - [Field types](#field-types) + - [Generic blocks](#generic-blocks) - [Moving out: `bstack_move!`](#moving-out-bstack_move) - [Cloning: `TryCloneIn` / `TryClone`](#cloning-tryclonein--tryclone) - [Casting: `bstack_cast!`](#casting-bstack_cast) @@ -580,6 +581,66 @@ In the same spirit, a field written `&T` is coerced to owned `T` (and `&str` to you're nudged to write the owned type. Silence it with `#[bstack_block(allow(coerced_ref))]`. +### Generic blocks + +A `#[bstack_block]` / `#[bstack_enum]` may be **generic** — over type parameters +(and, for arrays, `const` parameters). Each concrete instantiation is its own +block type, with its own `XOnDisk` layout and its own [type tag](#type-tags-eightcc). + +```rust +#[bstack_block] +struct Node { + #[bstack_owned] child: T, // owns a child of any block type + #[bstack_ref] refs: [T; 3], // an array of references to T + weight: u32, +} + +#[bstack_block] +struct Buf { data: [u16; N], len: u32 } // const-length POD array + +let n = Node::::new(&alloc, leaf, [r0, r1, r2], 5)?; +let b = Buf::<8>::new(&alloc, [0; 8], 0)?; +``` + +A type parameter works in **every** field shape it makes sense in: + +- **Reference kinds** — `#[bstack_owned/strong/weak/ref]` (scalar, `Vec`, + `[T; N]`, `Vec<[T; N]>`, …): the on-disk form is a bare `u64` offset, so the + layout is independent of `T`. The parameter is auto-bounded `BStackBlock` (plus + `BStackShared` / `BStackWeakable` for a `#[bstack_strong]` / `#[bstack_weak]` + use). +- **Inline kinds** — a POD field (`item: T`, bounded `T: Pod`) or `#[embed] item: + T` (bounded `BStackBlock`): here `T` is stored *inline*, so `XOnDisk` becomes + generic over it. A parameter can't be used **both** as POD and as a reference — + those are incompatible bounds, and the macro says so. +- **`const N`** in an array length — `[T; N]` (single dimension). A nested + `[[T; N]; M]` with a const dimension is rejected (its flattened length would be + the const expression `N * M`, which stable Rust bars a generic parameter from); + make one dimension concrete or use a single array. + +Each instantiation folds its arguments into the tag, so +[`bstack_cast!`](#casting-bstack_cast) can't confuse `Node` with `Node`, or +`Buf<8>` with `Buf<16>`. A generic **enum** is supported in the layout-preserving +case (type parameters only in reference variants — a POD/`#[embed]` variant +storing `T` inline would make the payload width depend on it): + +```rust +#[bstack_enum] +enum Tree { + Leaf(u32), + #[bstack_owned] Branch(T), +} +``` + +When a *concrete* argument violates a rule the macro couldn't see through the +parameter — instantiating `Node>`, say — the failing trait bound carries +a directed message (`` `Vec` is not a `#[bstack_block]` type … a nested +`Vec`/`Option` or a tuple needs its own named `#[bstack_block]` wrapper``) via +`#[diagnostic::on_unimplemented]`. + +Currently unsupported (a clear compile error): lifetime parameters, a generic +block in `rc` / `rc, weak` mode, and const parameters in a generic *enum*. + ## Moving out: `bstack_move!` `bstack_move!` destructures a handle, transferring each field/variant out and @@ -745,18 +806,10 @@ This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG") `Option<…>` forms. - **Requires a freeing allocator** that reserves offset 0 — not `LinearBStackAllocator` (see [Concepts](#concepts)). -- **Generic block types** are supported in the *layout-preserving* case: a type - parameter may appear in `#[bstack_owned]` / `#[bstack_strong]` / `#[bstack_weak]` - / `#[bstack_ref]` fields (scalar, `Vec`, `[T; N]`, …), each a bare `u64` - offset so `XOnDisk` stays independent of the parameter and its teardown/clone - recurse through traits — e.g. `#[bstack_block] struct OwnedBox { - #[bstack_owned] item: T, tag: u64 }`. The parameter is bounded `BStackBlock` - (plus `BStackShared` / `BStackWeakable` for strong / weak uses); each - instantiation gets its own type tag (so `bstack_cast!` can't confuse - `OwnedBox` with `OwnedBox`). An **embed / POD** use — which stores the - type *inline*, changing the layout — is rejected for now, as are lifetime / - const parameters and `rc` / `rc, weak` mode. Non-`Pod` fields must still carry - an annotation. +- **[Generic blocks](#generic-blocks)** work over type parameters (in every field + kind — reference, POD, and `#[embed]`) and `const` array lengths; the exceptions + are lifetime parameters, `rc` / `rc, weak` mode, and const parameters in a + generic enum. Non-`Pod` fields must still carry an annotation. - **`Vec` / `Option` nesting** is capped at a single leaf / one `Option` layer (see [Field types](#field-types)); deeper nesting or a tuple element must be named as a `#[bstack_block]` / `#[bstack_enum]`. From fc0183379a7335bcdda14dc8319677874dc8f53d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 16:12:21 -0700 Subject: [PATCH 069/140] stdlib: Cow --- bstack_raii/src/lib.rs | 2 + bstack_raii/src/stdlib/cow.rs | 174 ++++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/mod.rs | 20 ++++ bstack_raii/src/tests.rs | 100 ++++++++++++++++++- 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 bstack_raii/src/stdlib/cow.rs create mode 100644 bstack_raii/src/stdlib/mod.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index c29c0e0..b709fd4 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -59,6 +59,7 @@ mod owned; mod refcount; mod reference; mod shared; +mod stdlib; mod teardown; mod vec; mod wal; @@ -80,6 +81,7 @@ pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; +pub use stdlib::BStackCow; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs new file mode 100644 index 0000000..3b4e8b0 --- /dev/null +++ b/bstack_raii/src/stdlib/cow.rs @@ -0,0 +1,174 @@ +//! [`BStackCow`]: clone-on-write ownership of a block. +//! +//! The on-disk analogue of [`std::borrow::Cow`]. A `BStackCow` is *either* a +//! non-owning [`BStackRef`] into a block someone else owns, *or* a +//! [`BStackOwned`] block it owns outright. Reads work identically through +//! both; the first time the caller needs to *own* the block — [`into_owned`] or +//! [`to_mut`] — a borrowed `Cow` deep-copies the referenced block into a fresh +//! owned one (via [`TryCloneIn`]) and becomes owned. An already-owned `Cow` +//! pays nothing. +//! +//! This is the persistent-storage version of the borrow-until-you-mutate +//! pattern: hand out a cheap `Borrowed` view of a shared block, and only spend +//! an allocation + deep copy at the point a mutation actually needs a private +//! copy. +//! +//! [`into_owned`]: BStackCow::into_owned +//! [`to_mut`]: BStackCow::to_mut + +use std::io; + +use bstack::{BStackOwnedSliceAllocator, BStackRange}; + +use crate::block::BStackBlock; +use crate::clone::TryCloneIn; +use crate::owned::BStackOwned; +use crate::reference::BStackRef; +use crate::teardown::{AutoDrop, BStackDrop}; + +/// Clone-on-write ownership of a block of type `T`. +/// +/// * [`Borrowed`](BStackCow::Borrowed) — a non-owning [`BStackRef`]. Dropping +/// it frees nothing; the owner lives elsewhere. +/// * [`Owned`](BStackCow::Owned) — a [`BStackOwned`] this handle owns. +/// Dropping it (via [`bstack_drop`](BStackDrop::bstack_drop) or an +/// [`AutoDrop`] guard) recursively frees the block. +/// +/// The write path ([`into_owned`](Self::into_owned) / [`to_mut`](Self::to_mut)) +/// requires `T: TryCloneIn`, i.e. a **plain** (uniquely-owned) block — the same +/// blocks that can be deep-copied. Construction and all read access need only +/// `T: BStackBlock`, so a borrowed `Cow` over any block kind is fine as long as +/// you never ask it to become owned. +pub enum BStackCow { + /// A non-owning reference to a block owned elsewhere. + Borrowed(BStackRef), + /// A block this handle owns outright. + Owned(BStackOwned), +} + +impl BStackCow { + /// Wrap a non-owning reference: a `Borrowed` `Cow` that frees nothing on + /// teardown and deep-copies on first write. + pub fn borrowed(reference: BStackRef) -> Self { + BStackCow::Borrowed(reference) + } + + /// Wrap an owned block: an `Owned` `Cow` that already holds a private copy, + /// so the write path is free. + pub fn owned(owned: BStackOwned) -> Self { + BStackCow::Owned(owned) + } + + /// `true` if this is a [`Borrowed`](Self::Borrowed) reference (no private + /// copy yet). + pub fn is_borrowed(&self) -> bool { + matches!(self, BStackCow::Borrowed(_)) + } + + /// `true` if this already [`Owned`](Self::Owned)s its block. + pub fn is_owned(&self) -> bool { + matches!(self, BStackCow::Owned(_)) + } + + /// A non-owning [`BStackRef`] to the current block, whichever variant is + /// held — the uniform read handle. Cheap (a copied range); it does **not** + /// change ownership. + pub fn as_ref(&self) -> BStackRef { + match self { + // SAFETY: an owned block is a live allocation of type `T`, exactly + // what `BStackRef::from_range` asserts. + BStackCow::Owned(o) => unsafe { BStackRef::from_range(o.handle().range()) }, + BStackCow::Borrowed(r) => *r, + } + } + + /// The underlying block range, whichever variant is held. + pub fn range(&self) -> BStackRange { + self.as_ref().into_range() + } + + /// Materialize a fresh, bare `T` handle over the current block for calling + /// the block's generated field accessors — e.g. + /// `cow.handle().field(stack)`. Works for both variants; carries no + /// ownership (dropping it frees nothing). + pub fn handle(&self) -> T { + ::from_range(self.range()) + } + + /// Collapse to an owned block, deep-copying if currently borrowed. + /// + /// * `Owned` — returned as-is; no I/O. + /// * `Borrowed` — the referenced block is deep-cloned into a fresh + /// independent [`BStackOwned`] allocated with `allocator`. + pub fn into_owned( + self, + allocator: &A, + ) -> io::Result> + where + T: TryCloneIn, + { + match self { + BStackCow::Owned(o) => Ok(o), + BStackCow::Borrowed(r) => { + ::from_range(r.into_range()).try_clone_in(allocator) + } + } + } + + /// Ensure this `Cow` owns its block and return a mutable handle to it, + /// deep-copying first if it was borrowed. + /// + /// After this call the `Cow` is [`Owned`](Self::Owned); mutations applied + /// through the returned handle (the block's setters + `allocator`) never + /// touch the originally borrowed block. A no-op (beyond the ownership + /// check) when already owned. + pub fn to_mut( + &mut self, + allocator: &A, + ) -> io::Result<&mut BStackOwned> + where + T: TryCloneIn, + { + if let BStackCow::Borrowed(r) = self { + // `BStackRef` is `Copy`; take the range out before we overwrite it. + let owned = ::from_range((*r).into_range()).try_clone_in(allocator)?; + *self = BStackCow::Owned(owned); + } + match self { + BStackCow::Owned(o) => Ok(o), + // The block above converted any `Borrowed` into `Owned`. + BStackCow::Borrowed(_) => unreachable!("to_mut just ensured Owned"), + } + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping + /// the returned value runs this `Cow`'s teardown (a no-op when borrowed). + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: an `Owned` variant asserts sole ownership of a live block; a + // `Borrowed` variant frees nothing, so the assertion is trivially met. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackDrop for BStackCow { + /// Free the block **only** when owned; a borrowed `Cow` has no claim on its + /// target and frees nothing. + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + match self { + BStackCow::Owned(o) => o.bstack_drop(allocator), + BStackCow::Borrowed(_) => Ok(()), + } + } +} + +impl From> for BStackCow { + fn from(owned: BStackOwned) -> Self { + BStackCow::Owned(owned) + } +} + +impl From> for BStackCow { + fn from(reference: BStackRef) -> Self { + BStackCow::Borrowed(reference) + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs new file mode 100644 index 0000000..69f8b65 --- /dev/null +++ b/bstack_raii/src/stdlib/mod.rs @@ -0,0 +1,20 @@ +//! `bstack_raii`'s standard library: small, ergonomic handle types built +//! **entirely** on the crate's ownership primitives ([`crate::BStackOwned`], +//! [`crate::BStackRef`], [`crate::BStackRc`], the [`crate::TryCloneIn`] / +//! [`crate::BStackDrop`] contracts) and the `#[bstack_block]` macro. +//! +//! Nothing here reaches below those primitives — the stdlib is a *consumer* of +//! the same public surface downstream crates use, so each type doubles as a +//! worked example of composing the ownership model. It is deliberately kept +//! separate from the low-level modules: the runtime and macro define *what a +//! block is*; the stdlib defines *convenient ways to hold one*. +//! +//! ## Contents +//! +//! | Type | Rust analogue | What it holds | +//! |---------------------|--------------------|----------------------------------------| +//! | [`BStackCow`] | [`std::borrow::Cow`] | either a borrowed [`crate::BStackRef`] or an owned [`crate::BStackOwned`] block, deep-copying on first write. | + +mod cow; + +pub use cow::BStackCow; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 5412622..842a2fc 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,9 +14,10 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - AutoDrop, BStackBlock, BStackBlockVec, BStackCast, BStackCastAs, BStackCastInto, BStackDrop, - BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, - alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + AutoDrop, BStackBlock, BStackBlockVec, BStackCast, BStackCastAs, BStackCastInto, BStackCow, + BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, + TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, + dealloc_range, }; // -------------------------------------------------------------------------- @@ -4184,3 +4185,96 @@ fn macro_generic_const_owned_pod_array() { assert_eq!(p.handle().tag(stack).unwrap(), 7); p.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackCow — clone-on-write ownership of a block +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_cow_borrowed_into_owned_deep_copies() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A block owned elsewhere; the Cow only borrows it. + let base = MacroLeaf::new(&alloc, 7).unwrap(); + let base_start = base.handle().range().start(); + let cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + + assert!(cow.is_borrowed()); + // Reads go through the borrowed block, at its address. + assert_eq!(cow.handle().val(stack).unwrap(), 7); + assert_eq!(cow.range().start(), base_start); + + // into_owned deep-copies: a fresh block at a different address, same value. + let owned = cow.into_owned(&alloc).unwrap(); + assert_ne!(owned.handle().range().start(), base_start); + assert_eq!(owned.handle().val(stack).unwrap(), 7); + owned.bstack_drop(&alloc).unwrap(); + + // The borrowed source is untouched. + assert_eq!(base.handle().val(stack).unwrap(), 7); + base.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_cow_owned_into_owned_is_free() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let src = MacroLeaf::new(&alloc, 5).unwrap(); + let start = src.handle().range().start(); + let cow = BStackCow::owned(src); + assert!(cow.is_owned()); + + // Already owned: into_owned hands back the *same* block, no copy. + let owned = cow.into_owned(&alloc).unwrap(); + assert_eq!(owned.handle().range().start(), start); + assert_eq!(owned.handle().val(stack).unwrap(), 5); + owned.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_cow_to_mut_copies_then_owns() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let base = MacroLeaf::new(&alloc, 9).unwrap(); + let base_start = base.handle().range().start(); + let mut cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + + // First write forces a private copy and flips to Owned. + { + let m = cow.to_mut(&alloc).unwrap(); + assert_ne!(m.handle().range().start(), base_start); + assert_eq!(m.handle().val(stack).unwrap(), 9); + } + assert!(cow.is_owned()); + + // A second to_mut is a no-op: still the same owned copy. + let owned_start = cow.range().start(); + let _ = cow.to_mut(&alloc).unwrap(); + assert_eq!(cow.range().start(), owned_start); + + // Dropping the Cow frees only the copy; the borrowed source survives. + cow.bstack_drop(&alloc).unwrap(); + assert_eq!(base.handle().val(stack).unwrap(), 9); + base.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_cow_borrowed_drop_frees_nothing() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let base = MacroLeaf::new(&alloc, 3).unwrap(); + let cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + + // Dropping a borrowed Cow has no claim on the target. + cow.bstack_drop(&alloc).unwrap(); + assert_eq!(base.handle().val(stack).unwrap(), 3); + base.bstack_drop(&alloc).unwrap(); +} From ac78f0d34262190960630c9b733c86dd121e41a3 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 16:54:45 -0700 Subject: [PATCH 070/140] stdlib: Box --- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/stdlib/boxed.rs | 187 ++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/mod.rs | 7 +- bstack_raii/src/tests.rs | 148 ++++++++++++++++++++++++- 4 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 bstack_raii/src/stdlib/boxed.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index b709fd4..148632a 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -81,7 +81,7 @@ pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; -pub use stdlib::BStackCow; +pub use stdlib::{BStackBox, BStackCow, BoxOnDisk}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ diff --git a/bstack_raii/src/stdlib/boxed.rs b/bstack_raii/src/stdlib/boxed.rs new file mode 100644 index 0000000..b947dc1 --- /dev/null +++ b/bstack_raii/src/stdlib/boxed.rs @@ -0,0 +1,187 @@ +//! [`BStackBox`]: an owned, single-value block for a plain [`Pod`] `T`. +//! +//! The on-disk analogue of [`std::boxed::Box`] — but, unlike `Box`, it is only +//! useful for **`Pod`** payloads. A `#[bstack_block]` type is *already* an owned +//! block: you hold it as a [`BStackOwned`], embed it, reference it, put it in a +//! [`crate::BStackCow`]. There is nothing left for a `Box` to add. What has *no* +//! owned form is a bare scalar or plain `#[repr(C)]` struct: you cannot own a +//! lone `u64` on disk without first wrapping it in a block, which today means +//! hand-writing a one-field `#[bstack_block]`. `BStackBox` fills exactly that +//! gap — a generic, macro-free, childless block whose whole payload is one `T`. +//! +//! Because the payload is `Pod` the block has no children, so the deep-clone and +//! teardown reduce to a byte copy / a single free — the childless defaults on +//! [`BStackBlock`] already do the right thing. `BStackBox` is a first-class +//! block: it implements [`BStackBlock`], [`TryCloneIn`], [`BStackDrop`], and +//! [`BStackMove`], so it composes as a `#[bstack_owned]` / `#[bstack_ref]` field +//! and drops into a [`crate::BStackCow`] like any generated block. + +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use crate::block::{BStackBlock, BStackCast, BStackMove}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::reference::BStackRef; +use crate::teardown::{BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackBox`]: the standard [`BlockHeader`] followed +/// by the boxed value. `#[repr(C, packed)]` (like every generated `XOnDisk`) so +/// there is no padding between the header and `value` — a requirement for the +/// hand-written [`Pod`] impl and for reading the whole image back with +/// `bytemuck`. +#[repr(C, packed)] +#[derive(Clone, Copy)] +pub struct BoxOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// The boxed value. + pub value: T, +} + +// SAFETY: `BlockHeader` is `Pod` and `T: Pod`; `#[repr(C, packed)]` removes all +// inter-field padding, so every byte of `BoxOnDisk` is initialized and every +// bit pattern is valid. `T: Pod` also carries `Copy + 'static`. +unsafe impl Zeroable for BoxOnDisk {} +unsafe impl Pod for BoxOnDisk {} + +/// An owned, single-value block wrapping a plain [`Pod`] `T`. +/// +/// A typed handle (a newtype over a [`BStackRange`], like every generated block +/// handle), so it is `Copy` and carries no allocator. Ownership is expressed the +/// usual way: [`new`](Self::new) hands back a bare [`BStackOwned>`] +/// that frees nothing on scope exit — free it with +/// [`bstack_drop`](BStackDrop::bstack_drop) or wrap it in an +/// [`crate::AutoDrop`]/[`crate::BStackCow`]. +pub struct BStackBox { + range: BStackRange, + _marker: PhantomData T>, +} + +impl BStackBox { + /// The on-disk size of a boxed `T` (header + value). + const SIZE: u64 = size_of::>() as u64; + + /// Allocate a fresh block holding `value` and return an owning handle. + /// + /// The header and payload are written as a single image, so the block is + /// created with one write (and released without leaking on write failure). + pub fn new( + allocator: &A, + value: T, + ) -> io::Result> { + let od = BoxOnDisk { + header: BlockHeader { + size: Self::SIZE, + tag: Self::eightcc(), + }, + value, + }; + let mut slice = allocator.alloc(Self::SIZE)?; + if let Err(e) = slice.write_range(0, bytemuck::bytes_of(&od)) { + let _ = allocator.dealloc(slice); + return Err(e); + } + // SAFETY: a freshly allocated block that no other handle owns — exactly + // the sole-ownership invariant `BStackOwned::from_raw` requires. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(slice.as_range())) }) + } + + /// Read the boxed value out of the block. + pub fn get(&self, stack: &BStack) -> io::Result { + let mut buf = std::vec![0u8; size_of::>()]; + // SAFETY: `self.range` is a live `BStackBox` block. + let r = unsafe { BStackRef::::from_range(self.range) }; + r.read_on_disk(stack, &mut buf)?; + // Copy the value out of the packed image without forming a reference to + // the (alignment-1) `value` field. + let off = HEADER_SIZE as usize; + Ok(bytemuck::pod_read_unaligned::(&buf[off..off + size_of::()])) + } + + /// Overwrite the boxed value in place. + pub fn set(&self, allocator: &A, value: T) -> io::Result<()> { + allocator + .stack() + .set(self.range.start() + HEADER_SIZE, bytemuck::bytes_of(&value)) + } +} + +impl BStackCast for BStackBox { + /// A `"Box"` prefix over hash bytes perturbed by `size_of::()`, so boxes of + /// differently-sized payloads never share a tag (matching the generic + /// `#[bstack_block]` POD tag scheme, which also distinguishes by size). + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'B', b'o', b'x', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + } +} + +impl BStackBlock for BStackBox { + type OnDisk = BoxOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackBox { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + // A `Pod` box is childless: the `__bstack_drop_children` / + // `__bstack_clone_*` defaults (free nothing / byte-copy the OnDisk) are + // exactly correct, so they are deliberately not overridden. +} + +impl BStackDrop for BStackBox { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // Childless: just free the block. + // SAFETY: sole ownership was asserted when this handle was created. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackBox { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + // Mirror the generated `try_clone_in`: build the plan (a byte copy, via + // the childless `__bstack_clone_into` default), then commit atomically. + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} + +impl BStackMove for BStackBox { + /// Moving a box out yields the plain value. + type Fields<'a, A: BStackOwnedSliceAllocator> = T; + + fn bstack_move<'a, A: BStackOwnedSliceAllocator>( + owned: BStackOwned, + allocator: &'a A, + ) -> io::Result { + let me = owned.into_inner(); + let value = me.get(allocator.stack())?; + // Childless: free the shell after reading the value out. + // SAFETY: `me` was the sole owner (it came from a `BStackOwned`). + unsafe { dealloc_range(allocator, me.range)? }; + Ok(value) + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 69f8b65..6e7f80a 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -11,10 +11,13 @@ //! //! ## Contents //! -//! | Type | Rust analogue | What it holds | -//! |---------------------|--------------------|----------------------------------------| +//! | Type | Rust analogue | What it holds | +//! |---------------------|----------------------|----------------------------------------| //! | [`BStackCow`] | [`std::borrow::Cow`] | either a borrowed [`crate::BStackRef`] or an owned [`crate::BStackOwned`] block, deep-copying on first write. | +//! | [`BStackBox`] | [`std::boxed::Box`] | a single owned [`Pod`](bytemuck::Pod) value in its own block — the macro-free way to own a bare scalar/POD struct. | +mod boxed; mod cow; +pub use boxed::{BStackBox, BoxOnDisk}; pub use cow::BStackCow; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 842a2fc..23779ff 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,10 +14,10 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - AutoDrop, BStackBlock, BStackBlockVec, BStackCast, BStackCastAs, BStackCastInto, BStackCow, - BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, - TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, - dealloc_range, + AutoDrop, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, + BStackCow, BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, + TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, + bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -4278,3 +4278,143 @@ fn stdlib_cow_borrowed_drop_frees_nothing() { assert_eq!(base.handle().val(stack).unwrap(), 3); base.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackBox — an owned single-value block for Pod T +// -------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct Point3 { + x: i32, + y: i32, + z: i32, +} + +// A block that owns a box as a child, proving BStackBox composes as a field. +#[bstack_block] +struct BoxHolder { + #[bstack_owned] + boxed: BStackBox, + tag: u32, +} + +#[test] +fn stdlib_box_roundtrip_and_set() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A bare scalar owned as its own block — no macro struct needed. + let b = BStackBox::new(&alloc, 42u64).unwrap(); + assert_eq!(b.handle().get(stack).unwrap(), 42); + + // In-place overwrite. + b.handle().set(&alloc, 99).unwrap(); + assert_eq!(b.handle().get(stack).unwrap(), 99); + + // A plain POD struct payload works too (the point of the Pod bound). + let p = BStackBox::new(&alloc, Point3 { x: 1, y: 2, z: 3 }).unwrap(); + assert_eq!(p.handle().get(stack).unwrap(), Point3 { x: 1, y: 2, z: 3 }); + + b.bstack_drop(&alloc).unwrap(); + p.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_box_clone_is_a_byte_copy() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let b = BStackBox::new(&alloc, 7u32).unwrap(); + let clone = b.try_clone_in(&alloc).unwrap(); + + // Fresh, independent block, same value. + assert_ne!( + clone.handle().range().start(), + b.handle().range().start() + ); + assert_eq!(clone.handle().get(stack).unwrap(), 7); + + // Mutating the clone leaves the original untouched. + clone.handle().set(&alloc, 8).unwrap(); + assert_eq!(b.handle().get(stack).unwrap(), 7); + + b.bstack_drop(&alloc).unwrap(); + clone.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_box_move_yields_the_value() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let b = BStackBox::new(&alloc, 123u64).unwrap(); + let start = b.handle().range().start(); + let value = bstack_move!(b, &alloc).unwrap(); + assert_eq!(value, 123); + + // The shell was freed: its slot is reused by the next allocation. + let b2 = BStackBox::new(&alloc, 5u64).unwrap(); + assert_eq!(b2.handle().range().start(), start); + b2.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_box_distinct_tags_by_size() { + // Boxes of differently-sized payloads get distinct tags. + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + // Same size => same tag (the generic-POD tag scheme distinguishes by size). + assert_eq!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} + +#[test] +fn stdlib_box_composes_as_owned_field() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let inner = BStackBox::new(&alloc, 500u64).unwrap(); + let holder = BoxHolder::new(&alloc, inner, 9).unwrap(); + assert_eq!(holder.handle().boxed(stack).unwrap().get(stack).unwrap(), 500); + assert_eq!(holder.handle().tag(stack).unwrap(), 9); + + // Deep-cloning the parent recurses into the child box (fresh child block). + let clone = holder.try_clone_in(&alloc).unwrap(); + assert_ne!( + clone.handle().boxed(stack).unwrap().range().start(), + holder.handle().boxed(stack).unwrap().range().start(), + ); + assert_eq!(clone.handle().boxed(stack).unwrap().get(stack).unwrap(), 500); + + clone.bstack_drop(&alloc).unwrap(); + holder.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_box_in_cow() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A borrowed Cow over a box; first write deep-copies the box. + let base = BStackBox::new(&alloc, 11u64).unwrap(); + let mut cow = + BStackCow::borrowed(unsafe { BStackRef::>::from_range(base.handle().range()) }); + assert_eq!(cow.handle().get(stack).unwrap(), 11); + + let owned = cow.to_mut(&alloc).unwrap(); + owned.handle().set(&alloc, 22).unwrap(); + assert_ne!(cow.range().start(), base.handle().range().start()); + assert_eq!(base.handle().get(stack).unwrap(), 11); // source untouched + + cow.bstack_drop(&alloc).unwrap(); + base.bstack_drop(&alloc).unwrap(); +} From 49fd2df3382b2bc78c827b2a4d26d08c99ec73c4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 17:08:32 -0700 Subject: [PATCH 071/140] stdlib: LinkedList --- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/stdlib/list.rs | 477 +++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/mod.rs | 3 + bstack_raii/src/tests.rs | 125 ++++++++- 4 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 bstack_raii/src/stdlib/list.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 148632a..5c7223a 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -81,7 +81,7 @@ pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; -pub use stdlib::{BStackBox, BStackCow, BoxOnDisk}; +pub use stdlib::{BStackBox, BStackCow, BStackLinkedList, BoxOnDisk, ListOnDisk, NodeOnDisk}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs new file mode 100644 index 0000000..e447961 --- /dev/null +++ b/bstack_raii/src/stdlib/list.rs @@ -0,0 +1,477 @@ +//! [`BStackLinkedList`]: an owned, doubly-linked list of block values. +//! +//! # Prefer a vector unless you actually need a list +//! +//! On disk a linked list is usually the *wrong* choice. Every traversal step +//! chases a pointer to a physically unrelated block — a random on-disk seek per +//! element — whereas a [`crate::BStackBlockVec`] keeps its element offsets in one +//! contiguous block and its values need no per-step indirection. For iteration, +//! indexing, and bulk reads a vector is faster and denser; reach for a linked +//! list only when you genuinely need O(1) splice / push / pop at *both* ends +//! without disturbing the other elements' identities (their on-disk offsets stay +//! put across insert/remove, which a vector cannot promise). +//! +//! # Non-intrusive, single-ref nodes +//! +//! This list is deliberately **not** intrusive: the links do not live inside `T`. +//! An intrusive list would have to weave `prev`/`next` into each value type, +//! which for a generic `T` means the codegen must understand `T`'s layout and +//! inject fields — a lot of per-`T` machinery for no real payoff here. Instead +//! every node is its own small block holding just `{ prev, next, value }`, where +//! `value` is a **single `u64` reference** to an ordinary, unmodified `T` block. +//! +//! The payoff of the single ref is that a node's on-disk layout is +//! [`NodeOnDisk`] — three `u64`s after the header — **identical for every `T`**. +//! There is no generic on-disk struct, the tag is the only thing that varies by +//! `T`, and the read/write/teardown/clone code is one fixed shape rather than a +//! monomorphized family. The list *owns* its nodes and, through each node's +//! single ref, the value blocks: teardown frees both, and a deep clone +//! reproduces the whole chain with freshly deep-cloned values. + +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackLinkedList`]: the block header followed by the +/// `head`/`tail` node offsets (`0` = empty) and the element count. `#[repr(C)]` +/// with only `u64` fields after a 16-byte header, so it is naturally padding-free +/// and **non-generic** — the same layout for every element type. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct ListOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the first node, or `0` when the list is empty. + pub head: u64, + /// Offset of the last node, or `0` when the list is empty. + pub tail: u64, + /// Number of elements. + pub len: u64, +} + +/// The on-disk image of one list node: the block header followed by the +/// `prev`/`next` node offsets (`0` = none) and a **single** `u64` reference to +/// the value block. Non-generic: the same layout for every element type. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct NodeOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the previous node, or `0` at the head. + pub prev: u64, + /// Offset of the next node, or `0` at the tail. + pub next: u64, + /// The single reference to this node's value block. + pub value: u64, +} + +// Field offsets within a list block. +const HEAD_OFF: u64 = HEADER_SIZE; // 16 +const TAIL_OFF: u64 = HEADER_SIZE + 8; // 24 +const LEN_OFF: u64 = HEADER_SIZE + 16; // 32 +// Field offsets within a node block (same shape, different meaning). +const NPREV_OFF: u64 = HEADER_SIZE; // 16 +const NNEXT_OFF: u64 = HEADER_SIZE + 8; // 24 +const NVAL_OFF: u64 = HEADER_SIZE + 16; // 32 + +const LIST_SIZE: u64 = size_of::() as u64; +const NODE_SIZE: u64 = size_of::() as u64; + +/// Read a little-endian `u64` at absolute offset `off`. +fn get_u64(stack: &BStack, off: u64) -> io::Result { + let mut b = [0u8; 8]; + stack.get_into(off, &mut b)?; + Ok(u64::from_le_bytes(b)) +} + +/// Write a little-endian `u64` at absolute offset `off`. +fn set_u64(allocator: &A, off: u64, val: u64) -> io::Result<()> { + allocator.stack().set(off, val.to_le_bytes()) +} + +/// Allocate a block and write `bytes` as its whole image (one write; released +/// without leaking on write failure). +fn alloc_image( + allocator: &A, + bytes: &[u8], +) -> io::Result { + let mut slice = allocator.alloc(bytes.len() as u64)?; + if let Err(e) = slice.write_range(0, bytes) { + let _ = allocator.dealloc(slice); + return Err(e); + } + Ok(slice.as_range()) +} + +/// An owned, doubly-linked list of `T` blocks. +/// +/// A typed handle (a newtype over a [`BStackRange`], like every block handle), +/// carrying no allocator. [`new`](Self::new) returns a bare +/// [`BStackOwned>`] that frees nothing on scope exit; free it +/// with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it +/// ([`crate::AutoDrop`] / [`crate::BStackCow`]). +/// +/// The list owns its nodes and their value blocks: pushing takes a +/// [`BStackOwned`] (transferring ownership into a node), popping hands one +/// back, and teardown recursively frees every value and node. +pub struct BStackLinkedList { + range: BStackRange, + _marker: PhantomData T>, +} + +impl BStackLinkedList { + /// The fixed on-disk size of one `T` value block. + fn value_size() -> u64 { + size_of::<::OnDisk>() as u64 + } + + /// The tag stamped on this list's internal node blocks — a `"LNd"` prefix + /// perturbed by `T`'s tag, so a node is never mistaken for another type. + fn node_tag() -> EightCC { + const BASE: EightCC = EightCC::new([b'L', b'N', b'd', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(::eightcc()) + } + + /// A `T`-value handle over the block at `off` (fixed-size-block model). + fn value_at(off: u64) -> T { + ::from_range(BStackRange::new(off, Self::value_size())) + } + + /// Build a node image with the given links and value ref. + fn node_image(prev: u64, next: u64, value: u64) -> NodeOnDisk { + NodeOnDisk { + header: BlockHeader { + size: NODE_SIZE, + tag: Self::node_tag(), + }, + prev, + next, + value, + } + } + + /// Allocate an empty list. + pub fn new(allocator: &A) -> io::Result> { + let od = ListOnDisk { + header: BlockHeader { + size: LIST_SIZE, + tag: Self::eightcc(), + }, + head: 0, + tail: 0, + len: 0, + }; + let range = alloc_image(allocator, bytemuck::bytes_of(&od))?; + // SAFETY: a freshly allocated block owned by no other handle. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }) + } + + /// Number of elements. + pub fn len(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the list has no elements. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Append a value to the back, taking ownership of its block. + pub fn push_back( + &self, + allocator: &A, + value: BStackOwned, + ) -> io::Result<()> { + let list = self.range.start(); + let val_off = value.into_inner().range().start(); + let old_tail = get_u64(allocator.stack(), list + TAIL_OFF)?; + + let node = alloc_image( + allocator, + bytemuck::bytes_of(&Self::node_image(old_tail, 0, val_off)), + )? + .start(); + + if old_tail != 0 { + set_u64(allocator, old_tail + NNEXT_OFF, node)?; + } else { + set_u64(allocator, list + HEAD_OFF, node)?; + } + set_u64(allocator, list + TAIL_OFF, node)?; + let len = get_u64(allocator.stack(), list + LEN_OFF)?; + set_u64(allocator, list + LEN_OFF, len + 1) + } + + /// Prepend a value to the front, taking ownership of its block. + pub fn push_front( + &self, + allocator: &A, + value: BStackOwned, + ) -> io::Result<()> { + let list = self.range.start(); + let val_off = value.into_inner().range().start(); + let old_head = get_u64(allocator.stack(), list + HEAD_OFF)?; + + let node = alloc_image( + allocator, + bytemuck::bytes_of(&Self::node_image(0, old_head, val_off)), + )? + .start(); + + if old_head != 0 { + set_u64(allocator, old_head + NPREV_OFF, node)?; + } else { + set_u64(allocator, list + TAIL_OFF, node)?; + } + set_u64(allocator, list + HEAD_OFF, node)?; + let len = get_u64(allocator.stack(), list + LEN_OFF)?; + set_u64(allocator, list + LEN_OFF, len + 1) + } + + /// Remove and return the last element (as an owned value block), or `None` + /// if the list is empty. The node shell is freed; the value block is handed + /// back to the caller. + pub fn pop_back( + &self, + allocator: &A, + ) -> io::Result>> { + let list = self.range.start(); + let tail = get_u64(allocator.stack(), list + TAIL_OFF)?; + if tail == 0 { + return Ok(None); + } + let prev = get_u64(allocator.stack(), tail + NPREV_OFF)?; + let val = get_u64(allocator.stack(), tail + NVAL_OFF)?; + + if prev != 0 { + set_u64(allocator, prev + NNEXT_OFF, 0)?; + set_u64(allocator, list + TAIL_OFF, prev)?; + } else { + set_u64(allocator, list + HEAD_OFF, 0)?; + set_u64(allocator, list + TAIL_OFF, 0)?; + } + let len = get_u64(allocator.stack(), list + LEN_OFF)?; + set_u64(allocator, list + LEN_OFF, len - 1)?; + + // SAFETY: the node was our block; nothing else references it now. + unsafe { dealloc_range(allocator, BStackRange::new(tail, NODE_SIZE))? }; + // SAFETY: the value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val)) })) + } + + /// Remove and return the first element (as an owned value block), or `None` + /// if the list is empty. + pub fn pop_front( + &self, + allocator: &A, + ) -> io::Result>> { + let list = self.range.start(); + let head = get_u64(allocator.stack(), list + HEAD_OFF)?; + if head == 0 { + return Ok(None); + } + let next = get_u64(allocator.stack(), head + NNEXT_OFF)?; + let val = get_u64(allocator.stack(), head + NVAL_OFF)?; + + if next != 0 { + set_u64(allocator, next + NPREV_OFF, 0)?; + set_u64(allocator, list + HEAD_OFF, next)?; + } else { + set_u64(allocator, list + HEAD_OFF, 0)?; + set_u64(allocator, list + TAIL_OFF, 0)?; + } + let len = get_u64(allocator.stack(), list + LEN_OFF)?; + set_u64(allocator, list + LEN_OFF, len - 1)?; + + // SAFETY: the node was our block; nothing else references it now. + unsafe { dealloc_range(allocator, BStackRange::new(head, NODE_SIZE))? }; + // SAFETY: the value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val)) })) + } + + /// A **borrowed** handle to the first value (no ownership; frees nothing), or + /// `None` if empty. + pub fn front(&self, stack: &BStack) -> io::Result> { + let head = get_u64(stack, self.range.start() + HEAD_OFF)?; + if head == 0 { + return Ok(None); + } + Ok(Some(Self::value_at(get_u64(stack, head + NVAL_OFF)?))) + } + + /// A **borrowed** handle to the last value (no ownership; frees nothing), or + /// `None` if empty. + pub fn back(&self, stack: &BStack) -> io::Result> { + let tail = get_u64(stack, self.range.start() + TAIL_OFF)?; + if tail == 0 { + return Ok(None); + } + Ok(Some(Self::value_at(get_u64(stack, tail + NVAL_OFF)?))) + } + + /// Collect **borrowed** handles to every value, front to back. The handles + /// alias the list's blocks — do not free them; they stay valid only while the + /// list does. + pub fn to_vec(&self, stack: &BStack) -> io::Result> { + let mut out = Vec::new(); + let mut cur = get_u64(stack, self.range.start() + HEAD_OFF)?; + while cur != 0 { + out.push(Self::value_at(get_u64(stack, cur + NVAL_OFF)?)); + cur = get_u64(stack, cur + NNEXT_OFF)?; + } + Ok(out) + } + + /// Attach an allocator to make an auto-freeing [`crate::AutoDrop`] guard. + pub fn auto( + self, + allocator: &A, + ) -> crate::teardown::AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the list was created. + unsafe { crate::teardown::AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackLinkedList { + /// A `"List"` prefix over hash bytes perturbed by `T`'s tag, so lists of + /// different element types never share a discriminant even though their + /// on-disk layout is identical. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'L', b'i', b's', b't', 0x80, 0x81, 0x82, 0x83]); + BASE.mix(::eightcc()) + } +} + +impl BStackBlock for BStackLinkedList { + type OnDisk = ListOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackLinkedList { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every value block and node, **without** freeing the list + /// block itself (its embedding parent, or [`bstack_drop`](BStackDrop), does + /// that). + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let mut cur = get_u64(allocator.stack(), range.start() + HEAD_OFF)?; + while cur != 0 { + let next = get_u64(allocator.stack(), cur + NNEXT_OFF)?; + let val = get_u64(allocator.stack(), cur + NVAL_OFF)?; + if val != 0 { + // Recursively free the value block (its own children, then it). + // SAFETY: the list solely owns each value block. + let owned = unsafe { BStackOwned::from_raw(Self::value_at(val)) }; + owned.bstack_drop(allocator)?; + } + // SAFETY: the list solely owns each node block. + unsafe { dealloc_range(allocator, BStackRange::new(cur, NODE_SIZE))? }; + cur = next; + } + Ok(()) + } + + /// Deep-clone the whole chain into `plan`: every value is deep-cloned (via + /// `T`'s own clone hook), fresh nodes are allocated and wired, and the list + /// block is staged — all as part of the parent plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let src = self.range.start(); + + // 1. Gather the source value offsets in order. + let mut vals = Vec::new(); + let mut cur = get_u64(allocator.stack(), src + HEAD_OFF)?; + while cur != 0 { + vals.push(get_u64(allocator.stack(), cur + NVAL_OFF)?); + cur = get_u64(allocator.stack(), cur + NNEXT_OFF)?; + } + let n = vals.len(); + + // 2. Deep-clone each value into the plan. + let mut val_dsts = Vec::with_capacity(n); + for &v in &vals { + let dst = if v != 0 { + Self::value_at(v).__bstack_clone_into(allocator, plan)?.start() + } else { + 0 + }; + val_dsts.push(dst); + } + + // 3. Reserve the node blocks up front so their offsets are known for wiring. + let mut node_dsts = Vec::with_capacity(n); + for _ in 0..n { + node_dsts.push(plan.alloc_raw(allocator, NODE_SIZE)?.start()); + } + + // 4. Stage each node image with links resolved. + for i in 0..n { + let prev = if i > 0 { node_dsts[i - 1] } else { 0 }; + let next = if i + 1 < n { node_dsts[i + 1] } else { 0 }; + let od = Self::node_image(prev, next, val_dsts[i]); + plan.write(node_dsts[i], bytemuck::bytes_of(&od).to_vec()); + } + + // 5. Stage the list block. + let list_dst = plan.alloc_raw(allocator, LIST_SIZE)?; + let od = ListOnDisk { + header: BlockHeader { + size: LIST_SIZE, + tag: Self::eightcc(), + }, + head: node_dsts.first().copied().unwrap_or(0), + tail: node_dsts.last().copied().unwrap_or(0), + len: n as u64, + }; + plan.write(list_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(list_dst) + } +} + +impl BStackDrop for BStackLinkedList { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the list block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackLinkedList { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 6e7f80a..6288a65 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -15,9 +15,12 @@ //! |---------------------|----------------------|----------------------------------------| //! | [`BStackCow`] | [`std::borrow::Cow`] | either a borrowed [`crate::BStackRef`] or an owned [`crate::BStackOwned`] block, deep-copying on first write. | //! | [`BStackBox`] | [`std::boxed::Box`] | a single owned [`Pod`](bytemuck::Pod) value in its own block — the macro-free way to own a bare scalar/POD struct. | +//! | [`BStackLinkedList`] | [`std::collections::LinkedList`] | an owned doubly-linked list of block values (non-intrusive, single-ref nodes). Prefer [`crate::BStackBlockVec`] unless you need O(1) end/splice ops. | mod boxed; mod cow; +mod list; pub use boxed::{BStackBox, BoxOnDisk}; pub use cow::BStackCow; +pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 23779ff..4ed3f01 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -15,9 +15,9 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, - BStackCow, BStackDrop, BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, - TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, - bstack_move, dealloc_range, + BStackCow, BStackDrop, BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, + BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, + bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -4418,3 +4418,122 @@ fn stdlib_box_in_cow() { cow.bstack_drop(&alloc).unwrap(); base.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackLinkedList — owned doubly-linked list of block values +// -------------------------------------------------------------------------- + +fn list_values(list: &BStackLinkedList, stack: &BStack) -> Vec { + list.to_vec(stack) + .unwrap() + .iter() + .map(|h| h.val(stack).unwrap()) + .collect() +} + +#[test] +fn stdlib_list_push_back_pop_front() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let list = BStackLinkedList::::new(&alloc).unwrap(); + assert!(list.is_empty(stack).unwrap()); + + for v in [1u32, 2, 3] { + list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + } + assert_eq!(list.len(stack).unwrap(), 3); + assert_eq!(list_values(&list, stack), vec![1, 2, 3]); + assert_eq!(list.front(stack).unwrap().unwrap().val(stack).unwrap(), 1); + assert_eq!(list.back(stack).unwrap().unwrap().val(stack).unwrap(), 3); + + // FIFO drain from the front. + let a = list.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(a.handle().val(stack).unwrap(), 1); + a.bstack_drop(&alloc).unwrap(); + assert_eq!(list.len(stack).unwrap(), 2); + assert_eq!(list_values(&list, stack), vec![2, 3]); + + list.bstack_drop(&alloc).unwrap(); // frees remaining nodes + values +} + +#[test] +fn stdlib_list_both_ends() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let list = BStackLinkedList::::new(&alloc).unwrap(); + list.push_front(&alloc, MacroLeaf::new(&alloc, 2).unwrap()).unwrap(); + list.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()).unwrap(); + list.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()).unwrap(); + assert_eq!(list_values(&list, stack), vec![1, 2, 3]); + + let back = list.pop_back(&alloc).unwrap().unwrap(); + assert_eq!(back.handle().val(stack).unwrap(), 3); + back.bstack_drop(&alloc).unwrap(); + + let front = list.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(front.handle().val(stack).unwrap(), 1); + front.bstack_drop(&alloc).unwrap(); + + assert_eq!(list_values(&list, stack), vec![2]); + assert!(list.pop_back(&alloc).unwrap().is_some()); + assert!(list.is_empty(stack).unwrap()); + assert!(list.pop_front(&alloc).unwrap().is_none()); + + list.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_list_drop_is_recursive() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + // A value type that itself owns a child, to prove teardown recurses through + // the node's single value ref into the value's own children. + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let leaf_start = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let list = BStackLinkedList::::new(&alloc).unwrap(); + list.push_back(&alloc, parent).unwrap(); + list.bstack_drop(&alloc).unwrap(); + + // The leaf (a grandchild, freed only via full recursion) slot is reclaimed. + let reused = MacroLeaf::new(&alloc, 0).unwrap(); + assert_eq!(reused.handle().range().start(), leaf_start); + reused.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_list_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let list = BStackLinkedList::::new(&alloc).unwrap(); + for v in [1u32, 2, 3] { + list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + } + + let clone = list.try_clone_in(&alloc).unwrap(); + assert_eq!(list_values(&clone, stack), vec![1, 2, 3]); + + // The clone's values are fresh blocks, not aliases of the source's. + assert_ne!( + clone.front(stack).unwrap().unwrap().range().start(), + list.front(stack).unwrap().unwrap().range().start(), + ); + + // Mutating the clone leaves the original intact. + let popped = clone.pop_back(&alloc).unwrap().unwrap(); + popped.bstack_drop(&alloc).unwrap(); + assert_eq!(clone.len(stack).unwrap(), 2); + assert_eq!(list.len(stack).unwrap(), 3); + assert_eq!(list_values(&list, stack), vec![1, 2, 3]); + + clone.bstack_drop(&alloc).unwrap(); + list.bstack_drop(&alloc).unwrap(); +} From 384350e7797f5aa87d7018c06b29840658ab115e Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 1 Aug 2026 17:27:14 -0700 Subject: [PATCH 072/140] stdlib: Make LinkedList ops atomic --- bstack_raii/src/stdlib/list.rs | 308 +++++++++++++++++++++++++-------- bstack_raii/src/tests.rs | 67 +++++++ 2 files changed, 307 insertions(+), 68 deletions(-) diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index e447961..ff70702 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -28,11 +28,12 @@ //! single ref, the value blocks: teardown frees both, and a deep clone //! reproduces the whole chain with freshly deep-cloned values. +use core::cell::Cell; use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackCast}; @@ -93,9 +94,94 @@ fn get_u64(stack: &BStack, off: u64) -> io::Result { Ok(u64::from_le_bytes(b)) } -/// Write a little-endian `u64` at absolute offset `off`. -fn set_u64(allocator: &A, off: u64, val: u64) -> io::Result<()> { - allocator.stack().set(off, val.to_le_bytes()) +/// Commit an atomic, **external-lock-free** read-modify-write to the list's +/// on-disk pointers via [`BStack::inplace_gen`]. +/// +/// `reads1` are absolute offsets of `u64` slots read in a first round; the values +/// are handed to `reads2` to compute a second round of offsets that may *depend* +/// on the first (e.g. the `prev`/`value` slots of the node found via the tail +/// pointer). `plan` then turns both read rounds into the writes to commit. +/// +/// The point of routing every mutator through this: all reads happen **inside** +/// the generator, under bstack's single write lock, so the values reflect the +/// committed state at the one commit point and no other thread can interleave +/// between the reads and the dependent writes — no external lock, no torn +/// structure. Every write lands as one crash-atomic batch (all-or-nothing). +/// +/// Only in-place reads/writes ride the generator; allocations and frees, which +/// change the stack's size, are done by the caller *around* it (a freshly +/// allocated node is an orphan until the commit links it; a freed node is already +/// unlinked), so a crash can at worst leak, never tear the list. +fn atomic_update(allocator: &A, reads1: &[u64], reads2: R2, plan: W) -> io::Result<()> +where + A: BStackOwnedSliceAllocator, + R2: FnOnce(&[u64]) -> Vec, + W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, +{ + // Buffers that must outlive the whole `inplace_gen` call (bstack's documented + // generator pattern): read-back values and the computed writes. + let mut buf1: Vec<[u8; 8]> = vec![[0u8; 8]; reads1.len()]; + let mut vals1: Vec = Vec::new(); + let mut offs2: Vec = Vec::new(); + let mut buf2: Vec<[u8; 8]> = Vec::new(); + let mut writes: Vec<(u64, Vec)> = Vec::new(); + + let mut reads2 = Some(reads2); + let mut plan = Some(plan); + + let mut r1 = 0usize; + let mut did_a = false; + let mut r2 = 0usize; + let mut did_b = false; + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + // Round 1 — read the fixed offsets. + if r1 < reads1.len() { + let i = r1; + r1 += 1; + // SAFETY: `buf1` outlives this call; one read op uses slot `i` at a time. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf1[i][..]) }; + return Some(BStackGenOp::Read { + offset: reads1[i], + buf: b, + }); + } + // Transition A — compute the (possibly dependent) round-2 offsets. + if !did_a { + did_a = true; + vals1 = buf1.iter().map(|x| u64::from_le_bytes(*x)).collect(); + offs2 = (reads2.take().unwrap())(&vals1); + buf2 = vec![[0u8; 8]; offs2.len()]; + } + // Round 2 — read the dependent offsets. + if r2 < offs2.len() { + let i = r2; + r2 += 1; + // SAFETY: `buf2` outlives this call and is not resized after Transition A. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf2[i][..]) }; + return Some(BStackGenOp::Read { + offset: offs2[i], + buf: b, + }); + } + // Transition B — compute the writes from both read rounds. + if !did_b { + did_b = true; + let vals2: Vec = buf2.iter().map(|x| u64::from_le_bytes(*x)).collect(); + writes = (plan.take().unwrap())(&vals1, &vals2); + } + // Commit phase — emit every write; they land together atomically. + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives this call and is not mutated after Transition B. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data: d }); + } + None + }) } /// Allocate a block and write `bytes` as its whole image (one write; released @@ -186,6 +272,11 @@ impl BStackLinkedList { } /// Append a value to the back, taking ownership of its block. + /// + /// Atomic and external-lock-free: the node is allocated first (an orphan), + /// then the tail read, node-image write, tail/`prev.next` relink and length + /// bump all commit as one crash-atomic [`atomic_update`]. A crash before the + /// commit leaks the orphan node; it never tears the list. pub fn push_back( &self, allocator: &A, @@ -193,25 +284,43 @@ impl BStackLinkedList { ) -> io::Result<()> { let list = self.range.start(); let val_off = value.into_inner().range().start(); - let old_tail = get_u64(allocator.stack(), list + TAIL_OFF)?; + // Allocate the node up front; it stays an orphan until the commit links it. + let node = allocator.alloc(NODE_SIZE)?.as_range().start(); - let node = alloc_image( + let res = atomic_update( allocator, - bytemuck::bytes_of(&Self::node_image(old_tail, 0, val_off)), - )? - .start(); - - if old_tail != 0 { - set_u64(allocator, old_tail + NNEXT_OFF, node)?; - } else { - set_u64(allocator, list + HEAD_OFF, node)?; + &[list + TAIL_OFF, list + LEN_OFF], + |_v1| Vec::new(), + |v1, _v2| { + let (old_tail, len) = (v1[0], v1[1]); + let mut w = Vec::with_capacity(4); + // The node's full image (with `prev` wired to the read tail). + w.push(( + node, + bytemuck::bytes_of(&Self::node_image(old_tail, 0, val_off)).to_vec(), + )); + // Link the old tail (or the head, if the list was empty) to it. + let link = if old_tail != 0 { + old_tail + NNEXT_OFF + } else { + list + HEAD_OFF + }; + w.push((link, node.to_le_bytes().to_vec())); + w.push((list + TAIL_OFF, node.to_le_bytes().to_vec())); + w.push((list + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + w + }, + ); + if res.is_err() { + // The node was never linked in; reclaim the orphan. + // SAFETY: freshly allocated, referenced by nobody. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(node, NODE_SIZE)) }; } - set_u64(allocator, list + TAIL_OFF, node)?; - let len = get_u64(allocator.stack(), list + LEN_OFF)?; - set_u64(allocator, list + LEN_OFF, len + 1) + res } - /// Prepend a value to the front, taking ownership of its block. + /// Prepend a value to the front, taking ownership of its block. Atomic and + /// external-lock-free (see [`push_back`](Self::push_back)). pub fn push_front( &self, allocator: &A, @@ -219,83 +328,146 @@ impl BStackLinkedList { ) -> io::Result<()> { let list = self.range.start(); let val_off = value.into_inner().range().start(); - let old_head = get_u64(allocator.stack(), list + HEAD_OFF)?; + let node = allocator.alloc(NODE_SIZE)?.as_range().start(); - let node = alloc_image( + let res = atomic_update( allocator, - bytemuck::bytes_of(&Self::node_image(0, old_head, val_off)), - )? - .start(); - - if old_head != 0 { - set_u64(allocator, old_head + NPREV_OFF, node)?; - } else { - set_u64(allocator, list + TAIL_OFF, node)?; + &[list + HEAD_OFF, list + LEN_OFF], + |_v1| Vec::new(), + |v1, _v2| { + let (old_head, len) = (v1[0], v1[1]); + let mut w = Vec::with_capacity(4); + w.push(( + node, + bytemuck::bytes_of(&Self::node_image(0, old_head, val_off)).to_vec(), + )); + let link = if old_head != 0 { + old_head + NPREV_OFF + } else { + list + TAIL_OFF + }; + w.push((link, node.to_le_bytes().to_vec())); + w.push((list + HEAD_OFF, node.to_le_bytes().to_vec())); + w.push((list + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + w + }, + ); + if res.is_err() { + // SAFETY: freshly allocated, referenced by nobody. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(node, NODE_SIZE)) }; } - set_u64(allocator, list + HEAD_OFF, node)?; - let len = get_u64(allocator.stack(), list + LEN_OFF)?; - set_u64(allocator, list + LEN_OFF, len + 1) + res } /// Remove and return the last element (as an owned value block), or `None` /// if the list is empty. The node shell is freed; the value block is handed /// back to the caller. + /// + /// Atomic and external-lock-free: the tail is read, the target node's + /// `prev`/`value` read (a dependent second round), and the relink + length + /// decrement commit as one [`atomic_update`]. Only *after* the node is + /// unlinked is its shell freed — a crash between leaks the shell, never a + /// dangling link. pub fn pop_back( &self, allocator: &A, ) -> io::Result>> { let list = self.range.start(); - let tail = get_u64(allocator.stack(), list + TAIL_OFF)?; - if tail == 0 { + let node = Cell::new(0u64); + let val = Cell::new(0u64); + + atomic_update( + allocator, + &[list + TAIL_OFF, list + LEN_OFF], + |v1| { + let tail = v1[0]; + if tail == 0 { + Vec::new() + } else { + vec![tail + NPREV_OFF, tail + NVAL_OFF] + } + }, + |v1, v2| { + let (tail, len) = (v1[0], v1[1]); + if tail == 0 { + return Vec::new(); + } + let (prev, value) = (v2[0], v2[1]); + node.set(tail); + val.set(value); + let mut w = Vec::with_capacity(3); + if prev != 0 { + w.push((prev + NNEXT_OFF, 0u64.to_le_bytes().to_vec())); + w.push((list + TAIL_OFF, prev.to_le_bytes().to_vec())); + } else { + w.push((list + HEAD_OFF, 0u64.to_le_bytes().to_vec())); + w.push((list + TAIL_OFF, 0u64.to_le_bytes().to_vec())); + } + w.push((list + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + w + }, + )?; + + if node.get() == 0 { return Ok(None); } - let prev = get_u64(allocator.stack(), tail + NPREV_OFF)?; - let val = get_u64(allocator.stack(), tail + NVAL_OFF)?; - - if prev != 0 { - set_u64(allocator, prev + NNEXT_OFF, 0)?; - set_u64(allocator, list + TAIL_OFF, prev)?; - } else { - set_u64(allocator, list + HEAD_OFF, 0)?; - set_u64(allocator, list + TAIL_OFF, 0)?; - } - let len = get_u64(allocator.stack(), list + LEN_OFF)?; - set_u64(allocator, list + LEN_OFF, len - 1)?; - - // SAFETY: the node was our block; nothing else references it now. - unsafe { dealloc_range(allocator, BStackRange::new(tail, NODE_SIZE))? }; + // Unlinked above; free the (now unreachable) node shell. + // SAFETY: the node is unlinked and solely ours. + unsafe { dealloc_range(allocator, BStackRange::new(node.get(), NODE_SIZE))? }; // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val)) })) + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) } /// Remove and return the first element (as an owned value block), or `None` - /// if the list is empty. + /// if the list is empty. Atomic and external-lock-free (see + /// [`pop_back`](Self::pop_back)). pub fn pop_front( &self, allocator: &A, ) -> io::Result>> { let list = self.range.start(); - let head = get_u64(allocator.stack(), list + HEAD_OFF)?; - if head == 0 { + let node = Cell::new(0u64); + let val = Cell::new(0u64); + + atomic_update( + allocator, + &[list + HEAD_OFF, list + LEN_OFF], + |v1| { + let head = v1[0]; + if head == 0 { + Vec::new() + } else { + vec![head + NNEXT_OFF, head + NVAL_OFF] + } + }, + |v1, v2| { + let (head, len) = (v1[0], v1[1]); + if head == 0 { + return Vec::new(); + } + let (next, value) = (v2[0], v2[1]); + node.set(head); + val.set(value); + let mut w = Vec::with_capacity(3); + if next != 0 { + w.push((next + NPREV_OFF, 0u64.to_le_bytes().to_vec())); + w.push((list + HEAD_OFF, next.to_le_bytes().to_vec())); + } else { + w.push((list + HEAD_OFF, 0u64.to_le_bytes().to_vec())); + w.push((list + TAIL_OFF, 0u64.to_le_bytes().to_vec())); + } + w.push((list + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + w + }, + )?; + + if node.get() == 0 { return Ok(None); } - let next = get_u64(allocator.stack(), head + NNEXT_OFF)?; - let val = get_u64(allocator.stack(), head + NVAL_OFF)?; - - if next != 0 { - set_u64(allocator, next + NPREV_OFF, 0)?; - set_u64(allocator, list + HEAD_OFF, next)?; - } else { - set_u64(allocator, list + HEAD_OFF, 0)?; - set_u64(allocator, list + TAIL_OFF, 0)?; - } - let len = get_u64(allocator.stack(), list + LEN_OFF)?; - set_u64(allocator, list + LEN_OFF, len - 1)?; - - // SAFETY: the node was our block; nothing else references it now. - unsafe { dealloc_range(allocator, BStackRange::new(head, NODE_SIZE))? }; + // SAFETY: the node is unlinked and solely ours. + unsafe { dealloc_range(allocator, BStackRange::new(node.get(), NODE_SIZE))? }; // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val)) })) + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) } /// A **borrowed** handle to the first value (no ownership; frees nothing), or diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 4ed3f01..6fd54f9 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -4537,3 +4537,70 @@ fn stdlib_list_deep_clone_is_independent() { clone.bstack_drop(&alloc).unwrap(); list.bstack_drop(&alloc).unwrap(); } + +/// Many threads hammering one shared list. Phase 1 is concurrent `push_back` +/// only; phase 2 is concurrent `pop_front` only. If the relink/len RMW were not +/// atomic under contention, lost updates would corrupt the chain (a wrong length, +/// a broken `next` walk, or duplicate/missing values). The `inplace_gen`-based +/// [`crate::BStackLinkedList`] mutators need no external lock around them. +#[test] +fn stdlib_list_concurrent_push_pop() { + // Kept modest: each op is a durable `inplace_gen` commit (an fsync), so the + // cost is in the op count, not the thread count — the contention that would + // expose a non-atomic RMW comes from the parallel threads, not from more + // iterations. + const THREADS: u32 = 8; + const ITERS: u32 = 8; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let list = BStackLinkedList::::new(&alloc).unwrap(); + let total = (THREADS * ITERS) as u64; + + // Phase 1 — concurrent pushes of distinct values. + std::thread::scope(|s| { + for t in 0..THREADS { + let list = &list; + let alloc = &alloc; + s.spawn(move || { + for i in 0..ITERS { + let leaf = MacroLeaf::new(alloc, t * ITERS + i).unwrap(); + list.push_back(alloc, leaf).unwrap(); + } + }); + } + }); + + assert_eq!(list.len(alloc.stack()).unwrap(), total); + // Chain integrity: walking `next` yields exactly the distinct values 0..total. + let mut seen: Vec = list + .to_vec(alloc.stack()) + .unwrap() + .iter() + .map(|h| h.val(alloc.stack()).unwrap()) + .collect(); + seen.sort_unstable(); + assert_eq!(seen.len() as u64, total); + seen.dedup(); + assert_eq!(seen.len() as u64, total, "no lost/duplicated nodes"); + assert_eq!(seen.first().copied(), Some(0)); + assert_eq!(seen.last().copied(), Some(total as u32 - 1)); + + // Phase 2 — concurrent pops (exactly `total` across threads, so none miss). + std::thread::scope(|s| { + for _ in 0..THREADS { + let list = &list; + let alloc = &alloc; + s.spawn(move || { + for _ in 0..ITERS { + let v = list.pop_front(alloc).unwrap().expect("list non-empty"); + v.bstack_drop(alloc).unwrap(); + } + }); + } + }); + + assert_eq!(list.len(alloc.stack()).unwrap(), 0); + assert!(list.front(alloc.stack()).unwrap().is_none()); + list.bstack_drop(&alloc).unwrap(); +} From 3162a53dbeb56e97b81c89d6a40a434eac2a561b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 02:18:24 -0700 Subject: [PATCH 073/140] stdlib: Deque --- bstack_raii/src/lib.rs | 5 +- bstack_raii/src/stdlib/deque.rs | 585 ++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/list.rs | 114 +------ bstack_raii/src/stdlib/mod.rs | 6 +- bstack_raii/src/stdlib/util.rs | 127 +++++++ bstack_raii/src/tests.rs | 195 ++++++++++- 6 files changed, 915 insertions(+), 117 deletions(-) create mode 100644 bstack_raii/src/stdlib/deque.rs create mode 100644 bstack_raii/src/stdlib/util.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 5c7223a..0eec438 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -81,7 +81,10 @@ pub use layout::{BlockHeader, EightCC}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; -pub use stdlib::{BStackBox, BStackCow, BStackLinkedList, BoxOnDisk, ListOnDisk, NodeOnDisk}; +pub use stdlib::{ + BStackBox, BStackCow, BStackDeque, BStackLinkedList, BoxOnDisk, DequeOnDisk, ListOnDisk, + NodeOnDisk, +}; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs new file mode 100644 index 0000000..9e4fb45 --- /dev/null +++ b/bstack_raii/src/stdlib/deque.rs @@ -0,0 +1,585 @@ +//! [`BStackDeque`]: an owned double-ended queue over a contiguous ring. +//! +//! The on-disk answer to [`std::collections::VecDeque`], and the container most +//! callers reaching for [`crate::BStackLinkedList`] actually want. Its elements' +//! references live in **one contiguous ring block** — `[u64; cap]` slots indexed +//! circularly — so traversing the structure is a single sequential scan rather +//! than a pointer chase per element (each *value* still lives in its own block, so +//! resolving a value seeks once, but finding the next element does not). +//! +//! Push/pop at **both** ends are O(1) amortized. The ring's `head`/`len`/`cap` +//! and its data pointer live in the fixed handle block, so the handle never +//! moves; growth reallocates only the ring. +//! +//! # Single-ref slots, non-generic ring +//! +//! Like [`crate::BStackLinkedList`], each slot is a **single `u64` reference** to +//! an ordinary `T` block the deque owns — not `T` inlined. So the ring's on-disk +//! shape is the same for every `T` (a plain `u64` array), the handle layout +//! [`DequeOnDisk`] is non-generic, and only the tag varies by element type. +//! +//! # Atomicity +//! +//! Every push/pop is atomic per call and external-lock-free on the fast path: the +//! `head`/`len`/`cap`/`data` metadata and the target slot are read *and* written +//! inside one [`bstack::BStack::inplace_gen`] run (see +//! [`atomic_update`](super::util::atomic_update)), so a concurrent writer never +//! observes a torn ring and a crash never corrupts it. **Growth** is also atomic +//! and consistent: the new ring is allocated first (an orphan), then one +//! `inplace_gen` snapshots every live element, copies it into the new ring, and +//! swaps the descriptor — all under bstack's write lock, so it composes correctly +//! with concurrent pushes (a push that finds the ring full simply grows and +//! retries). A crash mid-growth leaks a ring, never tears the deque. + +use core::cell::Cell; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::util::{alloc_image, atomic_update, get_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackDeque`]: the block header, a pointer to the +/// ring data block (`0` = none), its capacity in slots, and the circular +/// `head`/`len`. `#[repr(C)]` with only `u64` fields after the header, so it is +/// padding-free and **non-generic** — the same layout for every element type. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct DequeOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the ring data block (`[u64; cap]`), or `0` when unallocated. + pub data: u64, + /// Number of slots in the ring. + pub cap: u64, + /// Index (into the ring) of the front element. + pub head: u64, + /// Number of elements currently held. + pub len: u64, +} + +// Field offsets within the handle block. +const DATA_OFF: u64 = HEADER_SIZE; // 16 +const CAP_OFF: u64 = HEADER_SIZE + 8; // 24 +const HEAD_OFF: u64 = HEADER_SIZE + 16; // 32 +const LEN_OFF: u64 = HEADER_SIZE + 24; // 40 + +const DEQUE_SIZE: u64 = size_of::() as u64; +/// The capacity a freshly grown empty ring starts at. +const MIN_CAP: u64 = 4; + +/// An owned double-ended queue of `T` blocks. +/// +/// A typed handle (a newtype over a [`BStackRange`]); [`new`](Self::new) returns +/// a bare [`BStackOwned>`] that frees nothing on scope exit — free +/// it with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it +/// ([`AutoDrop`] / [`crate::BStackCow`]). +/// +/// The deque owns its elements' blocks: pushing takes a [`BStackOwned`], +/// popping hands one back, and teardown recursively frees every element and the +/// ring. +pub struct BStackDeque { + range: BStackRange, + _marker: PhantomData T>, +} + +impl BStackDeque { + fn value_size() -> u64 { + size_of::<::OnDisk>() as u64 + } + + /// A `T`-value handle over the block at `off` (fixed-size-block model). + fn value_at(off: u64) -> T { + ::from_range(BStackRange::new(off, Self::value_size())) + } + + /// Read the four `(head, len, cap, data)` metadata fields of the handle at + /// `handle`. + fn read_meta(stack: &BStack, handle: u64) -> io::Result<(u64, u64, u64, u64)> { + Ok(( + get_u64(stack, handle + HEAD_OFF)?, + get_u64(stack, handle + LEN_OFF)?, + get_u64(stack, handle + CAP_OFF)?, + get_u64(stack, handle + DATA_OFF)?, + )) + } + + /// Allocate an empty deque (no ring is allocated until the first push). + pub fn new(allocator: &A) -> io::Result> { + Self::with_image(allocator, 0, 0) + } + + /// Allocate an empty deque with room for `cap` elements pre-reserved (so the + /// first `cap` pushes never grow). `cap == 0` behaves like [`new`](Self::new). + pub fn with_capacity( + allocator: &A, + cap: u64, + ) -> io::Result> { + if cap == 0 { + return Self::new(allocator); + } + // Allocate the ring first (an orphan); its slots are empty (len == 0), so + // their contents are never read before being written. + let ring = allocator.alloc(cap * 8)?.as_range().start(); + match Self::with_image(allocator, ring, cap) { + Ok(owned) => Ok(owned), + Err(e) => { + // SAFETY: the ring was just allocated and linked to nothing. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(ring, cap * 8)) }; + Err(e) + } + } + } + + fn with_image( + allocator: &A, + data: u64, + cap: u64, + ) -> io::Result> { + let od = DequeOnDisk { + header: BlockHeader { + size: DEQUE_SIZE, + tag: Self::eightcc(), + }, + data, + cap, + head: 0, + len: 0, + }; + let range = alloc_image(allocator, bytemuck::bytes_of(&od))?; + // SAFETY: a freshly allocated block owned by no other handle. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }) + } + + /// Number of elements. + pub fn len(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the deque has no elements. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Current ring capacity (elements storable before the next growth). + pub fn capacity(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + CAP_OFF) + } + + /// Append a value to the back, taking ownership of its block. Grows the ring + /// (once) if it is full, then commits the slot write + length bump atomically. + pub fn push_back( + &self, + allocator: &A, + value: BStackOwned, + ) -> io::Result<()> { + let handle = self.range.start(); + let val_off = value.into_inner().range().start(); + loop { + let full = Cell::new(false); + atomic_update( + allocator, + &[ + handle + HEAD_OFF, + handle + LEN_OFF, + handle + CAP_OFF, + handle + DATA_OFF, + ], + |_v1| Vec::new(), + |v1, _v2| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if len < cap { + let slot = data + ((head + len) % cap) * 8; + vec![ + (slot, val_off.to_le_bytes().to_vec()), + (handle + LEN_OFF, (len + 1).to_le_bytes().to_vec()), + ] + } else { + full.set(true); + Vec::new() + } + }, + )?; + if !full.get() { + return Ok(()); + } + self.grow(allocator)?; + } + } + + /// Prepend a value to the front, taking ownership of its block. + pub fn push_front( + &self, + allocator: &A, + value: BStackOwned, + ) -> io::Result<()> { + let handle = self.range.start(); + let val_off = value.into_inner().range().start(); + loop { + let full = Cell::new(false); + atomic_update( + allocator, + &[ + handle + HEAD_OFF, + handle + LEN_OFF, + handle + CAP_OFF, + handle + DATA_OFF, + ], + |_v1| Vec::new(), + |v1, _v2| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if len < cap { + let idx = (head + cap - 1) % cap; + let slot = data + idx * 8; + vec![ + (slot, val_off.to_le_bytes().to_vec()), + (handle + HEAD_OFF, idx.to_le_bytes().to_vec()), + (handle + LEN_OFF, (len + 1).to_le_bytes().to_vec()), + ] + } else { + full.set(true); + Vec::new() + } + }, + )?; + if !full.get() { + return Ok(()); + } + self.grow(allocator)?; + } + } + + /// Remove and return the last element (as an owned value block), or `None` if + /// empty. Atomic: the slot is read and the length decremented in one commit; + /// the value block's ownership transfers to the caller (its ring slot is left + /// stale and reused by a later push). + pub fn pop_back( + &self, + allocator: &A, + ) -> io::Result>> { + let handle = self.range.start(); + let got = Cell::new(false); + let val = Cell::new(0u64); + atomic_update( + allocator, + &[ + handle + HEAD_OFF, + handle + LEN_OFF, + handle + CAP_OFF, + handle + DATA_OFF, + ], + |v1| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if len == 0 { + Vec::new() + } else { + vec![data + ((head + len - 1) % cap) * 8] + } + }, + |v1, v2| { + let len = v1[1]; + if len == 0 { + return Vec::new(); + } + got.set(true); + val.set(v2[0]); + vec![(handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())] + }, + )?; + if !got.get() { + return Ok(None); + } + // SAFETY: the value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + } + + /// Remove and return the first element (as an owned value block), or `None` + /// if empty. + pub fn pop_front( + &self, + allocator: &A, + ) -> io::Result>> { + let handle = self.range.start(); + let got = Cell::new(false); + let val = Cell::new(0u64); + atomic_update( + allocator, + &[ + handle + HEAD_OFF, + handle + LEN_OFF, + handle + CAP_OFF, + handle + DATA_OFF, + ], + |v1| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if len == 0 { + Vec::new() + } else { + vec![data + (head % cap) * 8] + } + }, + |v1, v2| { + let (head, len, cap) = (v1[0], v1[1], v1[2]); + if len == 0 { + return Vec::new(); + } + got.set(true); + val.set(v2[0]); + vec![ + (handle + HEAD_OFF, ((head + 1) % cap).to_le_bytes().to_vec()), + (handle + LEN_OFF, (len - 1).to_le_bytes().to_vec()), + ] + }, + )?; + if !got.get() { + return Ok(None); + } + // SAFETY: the value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + } + + /// Grow the ring to at least double its capacity, atomically snapshotting and + /// re-basing the live elements. A no-op (beyond a wasted allocation, freed + /// again) if another thread already made room. + fn grow(&self, allocator: &A) -> io::Result<()> { + let handle = self.range.start(); + let cap0 = get_u64(allocator.stack(), handle + CAP_OFF)?; + let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; + // Allocate the new ring up front (an orphan until the commit swaps to it). + let newring = allocator.alloc(newcap * 8)?.as_range().start(); + + let grown = Cell::new(false); + let old_ring = Cell::new(0u64); + let old_cap = Cell::new(0u64); + + // Abort if, at commit time, the ring already has room or is already at + // least this big (another thread grew it) — then our `newring` is wasted. + let abort = |head: u64, len: u64, cap: u64| (cap != 0 && len < cap) || newcap <= cap; + + atomic_update( + allocator, + &[ + handle + HEAD_OFF, + handle + LEN_OFF, + handle + CAP_OFF, + handle + DATA_OFF, + ], + |v1| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if abort(head, len, cap) { + Vec::new() + } else { + // The live elements, in logical order. + (0..len).map(|i| data + ((head + i) % cap) * 8).collect() + } + }, + |v1, v2| { + let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); + if abort(head, len, cap) { + return Vec::new(); + } + grown.set(true); + old_ring.set(data); + old_cap.set(cap); + let mut w = Vec::with_capacity(v2.len() + 3); + // Copy every live element to the front of the new ring. + for (i, &r) in v2.iter().enumerate() { + w.push((newring + (i as u64) * 8, r.to_le_bytes().to_vec())); + } + // Swap the descriptor to the new ring, re-based at head 0. + w.push((handle + DATA_OFF, newring.to_le_bytes().to_vec())); + w.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); + w.push((handle + HEAD_OFF, 0u64.to_le_bytes().to_vec())); + w + }, + )?; + + if grown.get() { + // The old ring is now unreferenced; free it (leak-only on crash). + if old_cap.get() > 0 { + // SAFETY: the descriptor no longer points at the old ring. + let _ = unsafe { + dealloc_range( + allocator, + BStackRange::new(old_ring.get(), old_cap.get() * 8), + ) + }; + } + } else { + // Growth was unnecessary; reclaim the unused new ring. + // SAFETY: `newring` was never linked into the descriptor. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(newring, newcap * 8)) }; + } + Ok(()) + } + + /// A **borrowed** handle to the front value (no ownership), or `None` if empty. + pub fn front(&self, stack: &BStack) -> io::Result> { + let (head, len, cap, data) = Self::read_meta(stack, self.range.start())?; + if len == 0 { + return Ok(None); + } + Ok(Some(Self::value_at(get_u64(stack, data + (head % cap) * 8)?))) + } + + /// A **borrowed** handle to the back value (no ownership), or `None` if empty. + pub fn back(&self, stack: &BStack) -> io::Result> { + let (head, len, cap, data) = Self::read_meta(stack, self.range.start())?; + if len == 0 { + return Ok(None); + } + Ok(Some(Self::value_at( + get_u64(stack, data + ((head + len - 1) % cap) * 8)?, + ))) + } + + /// Collect **borrowed** handles to every value, front to back. The handles + /// alias the deque's blocks — do not free them; they stay valid only while the + /// deque does. + pub fn to_vec(&self, stack: &BStack) -> io::Result> { + let (head, len, cap, data) = Self::read_meta(stack, self.range.start())?; + let mut out = Vec::with_capacity(len as usize); + for i in 0..len { + out.push(Self::value_at(get_u64(stack, data + ((head + i) % cap) * 8)?)); + } + Ok(out) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the deque was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackDeque { + /// A `"Deq"` prefix over hash bytes perturbed by `T`'s tag, so deques of + /// different element types never share a discriminant despite the identical + /// on-disk layout. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'D', b'e', b'q', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(::eightcc()) + } +} + +impl BStackBlock for BStackDeque { + type OnDisk = DequeOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackDeque { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every element block and the ring, **without** freeing the + /// handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let (head, len, cap, data) = Self::read_meta(allocator.stack(), range.start())?; + for i in 0..len { + let r = get_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; + if r != 0 { + // SAFETY: the deque solely owns each element block. + let owned = unsafe { BStackOwned::from_raw(Self::value_at(r)) }; + owned.bstack_drop(allocator)?; + } + } + if data != 0 { + // SAFETY: the deque solely owns its ring block. + unsafe { dealloc_range(allocator, BStackRange::new(data, cap * 8))? }; + } + Ok(()) + } + + /// Deep-clone the deque into `plan`: every element is deep-cloned (via `T`'s + /// own clone hook) and packed into a fresh, compacted ring (`head = 0`, + /// `cap = len`); the handle block is staged — all in the parent plan's single + /// atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let (head, len, cap, data) = Self::read_meta(allocator.stack(), self.range.start())?; + + // Deep-clone each element (in logical order) into the plan. + let mut dsts = Vec::with_capacity(len as usize); + for i in 0..len { + let r = get_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; + let dst = if r != 0 { + Self::value_at(r).__bstack_clone_into(allocator, plan)?.start() + } else { + 0 + }; + dsts.push(dst); + } + + // Pack the cloned refs into a fresh, exactly-sized ring. + let (new_data, new_cap) = if len > 0 { + let ring = plan.alloc_raw(allocator, len * 8)?; + let mut bytes = Vec::with_capacity(dsts.len() * 8); + for d in &dsts { + bytes.extend_from_slice(&d.to_le_bytes()); + } + plan.write(ring.start(), bytes); + (ring.start(), len) + } else { + (0, 0) + }; + + let handle_dst = plan.alloc_raw(allocator, DEQUE_SIZE)?; + let od = DequeOnDisk { + header: BlockHeader { + size: DEQUE_SIZE, + tag: Self::eightcc(), + }, + data: new_data, + cap: new_cap, + head: 0, + len, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackDeque { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackDeque { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index ff70702..c548b4b 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -33,9 +33,10 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; +use super::util::{alloc_image, atomic_update, get_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -87,117 +88,6 @@ const NVAL_OFF: u64 = HEADER_SIZE + 16; // 32 const LIST_SIZE: u64 = size_of::() as u64; const NODE_SIZE: u64 = size_of::() as u64; -/// Read a little-endian `u64` at absolute offset `off`. -fn get_u64(stack: &BStack, off: u64) -> io::Result { - let mut b = [0u8; 8]; - stack.get_into(off, &mut b)?; - Ok(u64::from_le_bytes(b)) -} - -/// Commit an atomic, **external-lock-free** read-modify-write to the list's -/// on-disk pointers via [`BStack::inplace_gen`]. -/// -/// `reads1` are absolute offsets of `u64` slots read in a first round; the values -/// are handed to `reads2` to compute a second round of offsets that may *depend* -/// on the first (e.g. the `prev`/`value` slots of the node found via the tail -/// pointer). `plan` then turns both read rounds into the writes to commit. -/// -/// The point of routing every mutator through this: all reads happen **inside** -/// the generator, under bstack's single write lock, so the values reflect the -/// committed state at the one commit point and no other thread can interleave -/// between the reads and the dependent writes — no external lock, no torn -/// structure. Every write lands as one crash-atomic batch (all-or-nothing). -/// -/// Only in-place reads/writes ride the generator; allocations and frees, which -/// change the stack's size, are done by the caller *around* it (a freshly -/// allocated node is an orphan until the commit links it; a freed node is already -/// unlinked), so a crash can at worst leak, never tear the list. -fn atomic_update(allocator: &A, reads1: &[u64], reads2: R2, plan: W) -> io::Result<()> -where - A: BStackOwnedSliceAllocator, - R2: FnOnce(&[u64]) -> Vec, - W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, -{ - // Buffers that must outlive the whole `inplace_gen` call (bstack's documented - // generator pattern): read-back values and the computed writes. - let mut buf1: Vec<[u8; 8]> = vec![[0u8; 8]; reads1.len()]; - let mut vals1: Vec = Vec::new(); - let mut offs2: Vec = Vec::new(); - let mut buf2: Vec<[u8; 8]> = Vec::new(); - let mut writes: Vec<(u64, Vec)> = Vec::new(); - - let mut reads2 = Some(reads2); - let mut plan = Some(plan); - - let mut r1 = 0usize; - let mut did_a = false; - let mut r2 = 0usize; - let mut did_b = false; - let mut w = 0usize; - - allocator.stack().inplace_gen(|_feedback| { - // Round 1 — read the fixed offsets. - if r1 < reads1.len() { - let i = r1; - r1 += 1; - // SAFETY: `buf1` outlives this call; one read op uses slot `i` at a time. - let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf1[i][..]) }; - return Some(BStackGenOp::Read { - offset: reads1[i], - buf: b, - }); - } - // Transition A — compute the (possibly dependent) round-2 offsets. - if !did_a { - did_a = true; - vals1 = buf1.iter().map(|x| u64::from_le_bytes(*x)).collect(); - offs2 = (reads2.take().unwrap())(&vals1); - buf2 = vec![[0u8; 8]; offs2.len()]; - } - // Round 2 — read the dependent offsets. - if r2 < offs2.len() { - let i = r2; - r2 += 1; - // SAFETY: `buf2` outlives this call and is not resized after Transition A. - let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf2[i][..]) }; - return Some(BStackGenOp::Read { - offset: offs2[i], - buf: b, - }); - } - // Transition B — compute the writes from both read rounds. - if !did_b { - did_b = true; - let vals2: Vec = buf2.iter().map(|x| u64::from_le_bytes(*x)).collect(); - writes = (plan.take().unwrap())(&vals1, &vals2); - } - // Commit phase — emit every write; they land together atomically. - if w < writes.len() { - let i = w; - w += 1; - let (off, ref bytes) = writes[i]; - // SAFETY: `writes` outlives this call and is not mutated after Transition B. - let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { offset: off, data: d }); - } - None - }) -} - -/// Allocate a block and write `bytes` as its whole image (one write; released -/// without leaking on write failure). -fn alloc_image( - allocator: &A, - bytes: &[u8], -) -> io::Result { - let mut slice = allocator.alloc(bytes.len() as u64)?; - if let Err(e) = slice.write_range(0, bytes) { - let _ = allocator.dealloc(slice); - return Err(e); - } - Ok(slice.as_range()) -} - /// An owned, doubly-linked list of `T` blocks. /// /// A typed handle (a newtype over a [`BStackRange`], like every block handle), diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 6288a65..a2d8396 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -15,12 +15,16 @@ //! |---------------------|----------------------|----------------------------------------| //! | [`BStackCow`] | [`std::borrow::Cow`] | either a borrowed [`crate::BStackRef`] or an owned [`crate::BStackOwned`] block, deep-copying on first write. | //! | [`BStackBox`] | [`std::boxed::Box`] | a single owned [`Pod`](bytemuck::Pod) value in its own block — the macro-free way to own a bare scalar/POD struct. | -//! | [`BStackLinkedList`] | [`std::collections::LinkedList`] | an owned doubly-linked list of block values (non-intrusive, single-ref nodes). Prefer [`crate::BStackBlockVec`] unless you need O(1) end/splice ops. | +//! | [`BStackLinkedList`] | [`std::collections::LinkedList`] | an owned doubly-linked list of block values (non-intrusive, single-ref nodes). Prefer [`BStackDeque`] / [`crate::BStackBlockVec`] unless you need O(1) end/splice ops. | +//! | [`BStackDeque`] | [`std::collections::VecDeque`] | an owned double-ended queue: a contiguous ring of value refs (no per-element pointer chasing), O(1) amortized push/pop at both ends. | mod boxed; mod cow; +mod deque; mod list; +mod util; pub use boxed::{BStackBox, BoxOnDisk}; pub use cow::BStackCow; +pub use deque::{BStackDeque, DequeOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs new file mode 100644 index 0000000..274f3fc --- /dev/null +++ b/bstack_raii/src/stdlib/util.rs @@ -0,0 +1,127 @@ +//! Shared on-disk plumbing for the stdlib collections. +//! +//! These helpers are the common core the pointer-based containers +//! ([`crate::BStackLinkedList`], [`crate::stdlib::BStackDeque`]) build their +//! atomic mutators on: a `u64` field read, a whole-image block allocation, and +//! the [`atomic_update`] read-modify-write generator. + +use std::io; + +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; + +/// Read a little-endian `u64` at absolute offset `off`. +pub(super) fn get_u64(stack: &BStack, off: u64) -> io::Result { + let mut b = [0u8; 8]; + stack.get_into(off, &mut b)?; + Ok(u64::from_le_bytes(b)) +} + +/// Allocate a block and write `bytes` as its whole image (one write; released +/// without leaking on write failure). +pub(super) fn alloc_image( + allocator: &A, + bytes: &[u8], +) -> io::Result { + let mut slice = allocator.alloc(bytes.len() as u64)?; + if let Err(e) = slice.write_range(0, bytes) { + let _ = allocator.dealloc(slice); + return Err(e); + } + Ok(slice.as_range()) +} + +/// Commit an atomic, **external-lock-free** read-modify-write to a container's +/// on-disk metadata via [`BStack::inplace_gen`]. +/// +/// `reads1` are absolute offsets of `u64` slots read in a first round; the values +/// are handed to `reads2` to compute a second round of offsets that may *depend* +/// on the first (e.g. the `prev`/`value` slots of a node found via a pointer, or +/// the live element slots of a ring found via `head`/`cap`). `plan` then turns +/// both read rounds into the writes to commit. +/// +/// The point of routing every mutator through this: all reads happen **inside** +/// the generator, under bstack's single write lock, so the values reflect the +/// committed state at the one commit point and no other thread can interleave +/// between the reads and the dependent writes — no external lock, no torn +/// structure. Every write lands as one crash-atomic batch (all-or-nothing). +/// +/// Only in-place reads/writes ride the generator; allocations and frees, which +/// change the stack's size, are done by the caller *around* it (a freshly +/// allocated block is an orphan until the commit links it; a freed block is +/// already unlinked), so a crash can at worst leak, never tear the structure. +pub(super) fn atomic_update( + allocator: &A, + reads1: &[u64], + reads2: R2, + plan: W, +) -> io::Result<()> +where + A: BStackOwnedSliceAllocator, + R2: FnOnce(&[u64]) -> Vec, + W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, +{ + // Buffers that must outlive the whole `inplace_gen` call (bstack's documented + // generator pattern): read-back values and the computed writes. + let mut buf1: Vec<[u8; 8]> = vec![[0u8; 8]; reads1.len()]; + let mut vals1: Vec = Vec::new(); + let mut offs2: Vec = Vec::new(); + let mut buf2: Vec<[u8; 8]> = Vec::new(); + let mut writes: Vec<(u64, Vec)> = Vec::new(); + + let mut reads2 = Some(reads2); + let mut plan = Some(plan); + + let mut r1 = 0usize; + let mut did_a = false; + let mut r2 = 0usize; + let mut did_b = false; + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + // Round 1 — read the fixed offsets. + if r1 < reads1.len() { + let i = r1; + r1 += 1; + // SAFETY: `buf1` outlives this call; one read op uses slot `i` at a time. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf1[i][..]) }; + return Some(BStackGenOp::Read { + offset: reads1[i], + buf: b, + }); + } + // Transition A — compute the (possibly dependent) round-2 offsets. + if !did_a { + did_a = true; + vals1 = buf1.iter().map(|x| u64::from_le_bytes(*x)).collect(); + offs2 = (reads2.take().unwrap())(&vals1); + buf2 = vec![[0u8; 8]; offs2.len()]; + } + // Round 2 — read the dependent offsets. + if r2 < offs2.len() { + let i = r2; + r2 += 1; + // SAFETY: `buf2` outlives this call and is not resized after Transition A. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf2[i][..]) }; + return Some(BStackGenOp::Read { + offset: offs2[i], + buf: b, + }); + } + // Transition B — compute the writes from both read rounds. + if !did_b { + did_b = true; + let vals2: Vec = buf2.iter().map(|x| u64::from_le_bytes(*x)).collect(); + writes = (plan.take().unwrap())(&vals1, &vals2); + } + // Commit phase — emit every write; they land together atomically. + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives this call and is not mutated after Transition B. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data: d }); + } + None + }) +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 6fd54f9..22776d6 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -15,9 +15,9 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, - BStackCow, BStackDrop, BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, - BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, - bstack_cast, bstack_enum, bstack_move, dealloc_range, + BStackCow, BStackDeque, BStackDrop, BStackLinkedList, BStackOwned, BStackRc, BStackRef, + BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, + bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -4604,3 +4604,192 @@ fn stdlib_list_concurrent_push_pop() { assert!(list.front(alloc.stack()).unwrap().is_none()); list.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackDeque — owned double-ended queue over a contiguous ring +// -------------------------------------------------------------------------- + +fn deque_values(dq: &BStackDeque, stack: &BStack) -> Vec { + dq.to_vec(stack) + .unwrap() + .iter() + .map(|h| h.val(stack).unwrap()) + .collect() +} + +#[test] +fn stdlib_deque_push_back_grows() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let dq = BStackDeque::::new(&alloc).unwrap(); + assert!(dq.is_empty(stack).unwrap()); + + // Push past the initial capacity to force at least one growth. + for v in 0..10u32 { + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + } + assert_eq!(dq.len(stack).unwrap(), 10); + assert!(dq.capacity(stack).unwrap() >= 10); + assert_eq!(deque_values(&dq, stack), (0..10).collect::>()); + assert_eq!(dq.front(stack).unwrap().unwrap().val(stack).unwrap(), 0); + assert_eq!(dq.back(stack).unwrap().unwrap().val(stack).unwrap(), 9); + + // FIFO drain from the front. + for v in 0..10u32 { + let x = dq.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(x.handle().val(stack).unwrap(), v); + x.bstack_drop(&alloc).unwrap(); + } + assert!(dq.is_empty(stack).unwrap()); + assert!(dq.pop_front(&alloc).unwrap().is_none()); + dq.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_deque_wraparound_no_growth() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // Fixed capacity 4; exercise circular indexing without any growth. + let dq = BStackDeque::::with_capacity(&alloc, 4).unwrap(); + for v in [1u32, 2, 3, 4] { + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + } + // Drop the front two: head advances into the ring. + for _ in 0..2 { + dq.pop_front(&alloc).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + } + // Two more push_backs wrap around the physical slots 0,1. + dq.push_back(&alloc, MacroLeaf::new(&alloc, 5).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 6).unwrap()).unwrap(); + assert_eq!(dq.capacity(stack).unwrap(), 4); // never grew + assert_eq!(deque_values(&dq, stack), vec![3, 4, 5, 6]); + + dq.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_deque_both_ends() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let dq = BStackDeque::::new(&alloc).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 2).unwrap()).unwrap(); + dq.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()).unwrap(); + assert_eq!(deque_values(&dq, stack), vec![1, 2, 3]); + + let back = dq.pop_back(&alloc).unwrap().unwrap(); + assert_eq!(back.handle().val(stack).unwrap(), 3); + back.bstack_drop(&alloc).unwrap(); + + let front = dq.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(front.handle().val(stack).unwrap(), 1); + front.bstack_drop(&alloc).unwrap(); + + assert_eq!(deque_values(&dq, stack), vec![2]); + dq.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_deque_drop_is_recursive() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let leaf_start = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let dq = BStackDeque::::new(&alloc).unwrap(); + dq.push_back(&alloc, parent).unwrap(); + dq.bstack_drop(&alloc).unwrap(); + + // The leaf grandchild's slot is reclaimed — full recursion through the ring. + let reused = MacroLeaf::new(&alloc, 0).unwrap(); + assert_eq!(reused.handle().range().start(), leaf_start); + reused.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_deque_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let dq = BStackDeque::::new(&alloc).unwrap(); + for v in [1u32, 2, 3, 4, 5] { + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + } + + let clone = dq.try_clone_in(&alloc).unwrap(); + assert_eq!(deque_values(&clone, stack), vec![1, 2, 3, 4, 5]); + // Clone is compacted to exactly `len` slots. + assert_eq!(clone.capacity(stack).unwrap(), 5); + // Fresh element blocks, not aliases. + assert_ne!( + clone.front(stack).unwrap().unwrap().range().start(), + dq.front(stack).unwrap().unwrap().range().start(), + ); + + // Mutating the clone leaves the original intact. + clone.pop_back(&alloc).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + assert_eq!(clone.len(stack).unwrap(), 4); + assert_eq!(deque_values(&dq, stack), vec![1, 2, 3, 4, 5]); + + clone.bstack_drop(&alloc).unwrap(); + dq.bstack_drop(&alloc).unwrap(); +} + +/// Many threads hammering one shared deque, including concurrent growth. Phase 1 +/// is concurrent `push_back`; phase 2 is concurrent `pop_front`. A non-atomic +/// slot/metadata RMW (or a racy growth) would drop or duplicate elements. +#[test] +fn stdlib_deque_concurrent_push_pop() { + const THREADS: u32 = 8; + const ITERS: u32 = 8; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let dq = BStackDeque::::new(&alloc).unwrap(); + let total = (THREADS * ITERS) as u64; + + std::thread::scope(|s| { + for t in 0..THREADS { + let dq = &dq; + let alloc = &alloc; + s.spawn(move || { + for i in 0..ITERS { + let leaf = MacroLeaf::new(alloc, t * ITERS + i).unwrap(); + dq.push_back(alloc, leaf).unwrap(); + } + }); + } + }); + + assert_eq!(dq.len(alloc.stack()).unwrap(), total); + let mut seen = deque_values(&dq, alloc.stack()); + seen.sort_unstable(); + assert_eq!(seen.len() as u64, total); + seen.dedup(); + assert_eq!(seen.len() as u64, total, "no lost/duplicated elements"); + + std::thread::scope(|s| { + for _ in 0..THREADS { + let dq = &dq; + let alloc = &alloc; + s.spawn(move || { + for _ in 0..ITERS { + let v = dq.pop_front(alloc).unwrap().expect("deque non-empty"); + v.bstack_drop(alloc).unwrap(); + } + }); + } + }); + + assert_eq!(dq.len(alloc.stack()).unwrap(), 0); + dq.bstack_drop(&alloc).unwrap(); +} From c0753dbbfdce6a4c5464690afb26dbf2f8191a37 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 02:33:30 -0700 Subject: [PATCH 074/140] stdlib: HashMap --- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/stdlib/map.rs | 760 ++++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/mod.rs | 3 + bstack_raii/src/tests.rs | 194 ++++++++- 4 files changed, 956 insertions(+), 5 deletions(-) create mode 100644 bstack_raii/src/stdlib/map.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 0eec438..5f2fd66 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,8 +82,8 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBox, BStackCow, BStackDeque, BStackLinkedList, BoxOnDisk, DequeOnDisk, ListOnDisk, - NodeOnDisk, + BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, BoxOnDisk, DequeOnDisk, + ListOnDisk, MapOnDisk, NodeOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs new file mode 100644 index 0000000..b246a0b --- /dev/null +++ b/bstack_raii/src/stdlib/map.rs @@ -0,0 +1,760 @@ +//! [`BStackHashMap`]: an owned open-addressing hash map. +//! +//! The on-disk answer to [`std::collections::HashMap`], and the way to look a +//! value up by key without a linear scan. Keys are **`Pod`** (`K: Pod`), stored +//! inline in the bucket and hashed by their raw bytes; values are blocks +//! (`V: BStackBlock`) the map owns, referenced by a single `u64` per bucket. +//! +//! # Layout +//! +//! The fixed handle block ([`MapOnDisk`]) holds a pointer to a **contiguous +//! bucket block**, the bucket count `cap` (a power of two), the live-entry count +//! `len`, and `used` (occupied + tombstone slots, which drives growth). Each +//! bucket is `state: u64` (`EMPTY` / `OCCUPIED` / `TOMBSTONE`), then the inline +//! key `K`, then a `u64` value reference — a stride of `16 + size_of::()` +//! bytes. Probing is linear (`cap` a power of two, `mask = cap - 1`), so the +//! whole probe sequence is a contiguous scan, not a pointer chase. +//! +//! # Atomicity +//! +//! Each `insert` / `remove` is atomic per call and external-lock-free: the entire +//! probe *and* the resulting bucket + metadata writes run inside one +//! [`bstack::BStack::inplace_gen`] (see [`probe_commit`]), so a concurrent writer +//! never observes a torn table and a crash never corrupts it. **Growth / rehash** +//! is likewise atomic: a bigger bucket block is allocated first (an orphan), then +//! one `inplace_gen` snapshots every live bucket, rebuilds the table into the new +//! block (dropping tombstones), and swaps the descriptor — all under bstack's +//! write lock, composing with concurrent inserts (an insert that can't place +//! grows and retries). A crash mid-rehash leaks a bucket block, never tears the +//! map. +//! +//! `get` / `contains_key` are plain probes (no write lock); each bucket read is +//! atomic, but the probe is not linearized against a concurrent mutation, so a +//! borrowed value handle it returns is valid only while that entry is not removed. + +use core::cell::Cell; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::util::{alloc_image, get_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackHashMap`]: header, bucket-block pointer (`0` = +/// none), bucket count `cap`, live-entry count `len`, and `used` (occupied + +/// tombstone). `#[repr(C)]`, only `u64` fields after the header — non-generic. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct MapOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the bucket block, or `0` when unallocated. + pub table: u64, + /// Number of buckets (a power of two). + pub cap: u64, + /// Number of live entries. + pub len: u64, + /// Occupied + tombstone slots (drives growth). + pub used: u64, +} + +// Field offsets within the handle block. `table..used` are contiguous so all +// four load in one 32-byte read. +const TABLE_OFF: u64 = HEADER_SIZE; // 16 +const CAP_OFF: u64 = HEADER_SIZE + 8; // 24 +const LEN_OFF: u64 = HEADER_SIZE + 16; // 32 +const USED_OFF: u64 = HEADER_SIZE + 24; // 40 + +const MAP_SIZE: u64 = size_of::() as u64; +/// Bucket count of a freshly grown empty table (a power of two). +const MIN_CAP: u64 = 4; + +// Bucket states. +const EMPTY: u64 = 0; +const OCCUPIED: u64 = 1; +const TOMBSTONE: u64 = 2; + +/// Read a little-endian `u64` from the first 8 bytes of `b`. +fn u64le(b: &[u8]) -> u64 { + u64::from_le_bytes(b[..8].try_into().unwrap()) +} + +/// 64-bit FNV-1a over `bytes`. Deterministic (so it is stable on disk). +fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// A snapshot of the four handle metadata fields, read inside a generator. +struct Meta { + table: u64, + cap: u64, + len: u64, + used: u64, +} + +/// A probe step returned by the `inspect` closure of [`probe_commit`]. +enum ProbeStep { + /// This bucket isn't the target — keep probing. + Continue, + /// Stop here and commit these writes (empty = commit nothing). + Stop(Vec<(u64, Vec)>), +} + +/// Run an atomic, external-lock-free probe over the bucket table under one +/// [`BStack::inplace_gen`]. +/// +/// Reads the handle metadata, then linearly probes buckets from `hash & (cap-1)`, +/// reading the full `stride`-byte bucket each step and handing it to `inspect`. +/// The first `inspect` returning [`ProbeStep::Stop`] commits its writes and ends; +/// if all `cap` buckets are probed without a stop, `exhausted` produces the final +/// writes. Every read and write rides the one generator, so the probe sees a +/// consistent snapshot and the writes land as one crash-atomic batch. +fn probe_commit( + allocator: &A, + handle: u64, + stride: u64, + hash: u64, + mut inspect: I, + exhausted: E, +) -> io::Result<()> +where + A: BStackOwnedSliceAllocator, + I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, + E: FnOnce(&Meta) -> Vec<(u64, Vec)>, +{ + let mut meta_buf = [0u8; 32]; + let mut bucket_buf = vec![0u8; stride as usize]; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + + let mut meta_issued = false; + let mut meta: Option = None; + let mut mask = 0u64; + let mut cur = 0u64; + let mut idx_at_read = 0u64; + let mut probe_pending = false; + let mut probed = 0u64; + let mut decided = false; + let mut exhausted = Some(exhausted); + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + // 1. Read the 32-byte metadata block. + if !meta_issued { + meta_issued = true; + // SAFETY: `meta_buf` outlives the call; used by this one read. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + TABLE_OFF, + buf: b, + }); + } + // 2. Parse it once. + if meta.is_none() { + let m = Meta { + table: u64le(&meta_buf[0..8]), + cap: u64le(&meta_buf[8..16]), + len: u64le(&meta_buf[16..24]), + used: u64le(&meta_buf[24..32]), + }; + mask = m.cap.wrapping_sub(1); + cur = if m.cap == 0 { 0 } else { hash & mask }; + meta = Some(m); + } + let m = meta.as_ref().unwrap(); + + // 3a. Inspect a completed bucket read. + if probe_pending { + probe_pending = false; + if let ProbeStep::Stop(ws) = inspect(m, idx_at_read, &bucket_buf) { + writes = ws; + decided = true; + } + } + // 3b. Issue the next probe, or finish by exhaustion. + if !decided { + if m.cap == 0 || probed >= m.cap { + writes = (exhausted.take().unwrap())(m); + decided = true; + } else { + idx_at_read = cur; + probe_pending = true; + probed += 1; + cur = (cur + 1) & mask; + let off = m.table + idx_at_read * stride; + // SAFETY: `bucket_buf` outlives the call; each read completes + // (and is inspected) before the next is issued. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut bucket_buf[..]) }; + return Some(BStackGenOp::Read { offset: off, buf: b }); + } + } + // 4. Commit the chosen writes together. + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives the call and is not mutated after this point. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data: d }); + } + None + }) +} + +/// Build the writes that place a *new* entry (state, key, value) at bucket +/// `target`, bumping `len` (and `used` when the slot was previously `EMPTY`). +fn new_bucket_writes( + handle: u64, + stride: u64, + ksz: usize, + m: &Meta, + target: u64, + slot_was_empty: bool, + key_bytes: &[u8], + val_ref: u64, +) -> Vec<(u64, Vec)> { + let mut img = Vec::with_capacity(16 + ksz); + img.extend_from_slice(&OCCUPIED.to_le_bytes()); + img.extend_from_slice(key_bytes); + img.extend_from_slice(&val_ref.to_le_bytes()); + + let mut w = vec![ + (m.table + target * stride, img), + (handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), + ]; + if slot_was_empty { + w.push((handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); + } + w +} + +/// An owned open-addressing hash map from a `Pod` key to a block value. +/// +/// A typed handle (a newtype over a [`BStackRange`]); [`new`](Self::new) returns +/// a bare [`BStackOwned>`] that frees nothing on scope exit — +/// free it with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it +/// ([`AutoDrop`] / [`crate::BStackCow`]). +/// +/// The map owns its values' blocks: [`insert`](Self::insert) takes a +/// [`BStackOwned`], [`remove`](Self::remove) hands one back (as does an +/// overwriting `insert`, returning the replaced value), and teardown recursively +/// frees every value and the bucket block. +pub struct BStackHashMap { + range: BStackRange, + _marker: PhantomData (K, V)>, +} + +impl BStackHashMap { + /// Bytes of an inline key. + fn ksize() -> usize { + size_of::() + } + + /// Bytes of one bucket: `state (8) + key (ksize) + value ref (8)`. + fn stride() -> u64 { + 16 + Self::ksize() as u64 + } + + /// On-disk size of one `V` value block. + fn value_size() -> u64 { + size_of::<::OnDisk>() as u64 + } + + /// A `V`-value handle over the block at `off`. + fn value_at(off: u64) -> V { + ::from_range(BStackRange::new(off, Self::value_size())) + } + + /// Allocate an empty map (no bucket block until the first insert). + pub fn new(allocator: &A) -> io::Result> { + let od = MapOnDisk { + header: BlockHeader { + size: MAP_SIZE, + tag: Self::eightcc(), + }, + table: 0, + cap: 0, + len: 0, + used: 0, + }; + let range = alloc_image(allocator, bytemuck::bytes_of(&od))?; + // SAFETY: a freshly allocated block owned by no other handle. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }) + } + + /// Number of live entries. + pub fn len(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the map has no entries. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Insert `key -> value`, taking ownership of the value block. Returns the + /// previously-mapped value (owned) if `key` was already present, else `None`. + /// + /// Atomic and external-lock-free: grows the table first if the load factor + /// would be exceeded, then probes and commits the bucket + metadata writes in + /// one [`probe_commit`]. + pub fn insert( + &self, + allocator: &A, + key: K, + value: BStackOwned, + ) -> io::Result>> { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let key_bytes = bytemuck::bytes_of(&key).to_vec(); + let val_ref = value.into_inner().range().start(); + let hash = fnv1a(&key_bytes); + + loop { + // Proactively keep the load factor under 3/4 (also clears tombstones). + let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; + let used = get_u64(allocator.stack(), handle + USED_OFF)?; + if cap == 0 || (used + 1) * 4 > cap * 3 { + self.grow(allocator)?; + continue; + } + + let first_tomb: Cell> = Cell::new(None); + let is_new = Cell::new(false); + let need_grow = Cell::new(false); + let old_value = Cell::new(0u64); + + probe_commit( + allocator, + handle, + stride, + hash, + |m, idx, buf| { + let state = u64le(&buf[0..8]); + if state == EMPTY { + let target = first_tomb.get().unwrap_or(idx); + let slot_was_empty = first_tomb.get().is_none(); + is_new.set(true); + ProbeStep::Stop(new_bucket_writes( + handle, + stride, + ksz, + m, + target, + slot_was_empty, + &key_bytes, + val_ref, + )) + } else if state == OCCUPIED && buf[8..8 + ksz] == key_bytes[..] { + // Overwrite: replace the value ref, hand back the old one. + old_value.set(u64le(&buf[8 + ksz..8 + ksz + 8])); + is_new.set(false); + let value_off = m.table + idx * stride + 8 + ksz as u64; + ProbeStep::Stop(vec![(value_off, val_ref.to_le_bytes().to_vec())]) + } else { + if state == TOMBSTONE && first_tomb.get().is_none() { + first_tomb.set(Some(idx)); + } + ProbeStep::Continue + } + }, + |m| { + if let Some(t) = first_tomb.get() { + is_new.set(true); + new_bucket_writes(handle, stride, ksz, m, t, false, &key_bytes, val_ref) + } else { + need_grow.set(true); + Vec::new() + } + }, + )?; + + if need_grow.get() { + self.grow(allocator)?; + continue; + } + return if is_new.get() { + Ok(None) + } else { + // SAFETY: the replaced value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(old_value.get())) })) + }; + } + } + + /// Remove `key`, returning its value (owned) if present, else `None`. The + /// bucket becomes a tombstone; the value block's ownership transfers out. + pub fn remove( + &self, + allocator: &A, + key: &K, + ) -> io::Result>> { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let key_bytes = bytemuck::bytes_of(key).to_vec(); + let hash = fnv1a(&key_bytes); + + let found = Cell::new(false); + let old_value = Cell::new(0u64); + + probe_commit( + allocator, + handle, + stride, + hash, + |m, idx, buf| { + let state = u64le(&buf[0..8]); + if state == EMPTY { + ProbeStep::Stop(Vec::new()) // absent: commit nothing + } else if state == OCCUPIED && buf[8..8 + ksz] == key_bytes[..] { + found.set(true); + old_value.set(u64le(&buf[8 + ksz..8 + ksz + 8])); + ProbeStep::Stop(vec![ + (m.table + idx * stride, TOMBSTONE.to_le_bytes().to_vec()), + (handle + LEN_OFF, (m.len - 1).to_le_bytes().to_vec()), + ]) + } else { + ProbeStep::Continue + } + }, + |_m| Vec::new(), + )?; + + if found.get() { + // SAFETY: the removed value block's ownership transfers to the caller. + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(old_value.get())) })) + } else { + Ok(None) + } + } + + /// A **borrowed** handle to the value mapped by `key` (no ownership; valid + /// only while the entry is not removed), or `None` if absent. + /// + /// A plain probe: correct with external synchronization or a single writer, + /// but not linearized against a concurrent mutation. + pub fn get(&self, stack: &BStack, key: &K) -> io::Result> { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let table = get_u64(stack, handle + TABLE_OFF)?; + let cap = get_u64(stack, handle + CAP_OFF)?; + if cap == 0 { + return Ok(None); + } + let key_bytes = bytemuck::bytes_of(key); + let mask = cap - 1; + let mut idx = fnv1a(key_bytes) & mask; + let mut kb = vec![0u8; ksz]; + for _ in 0..cap { + let bucket = table + idx * stride; + let state = get_u64(stack, bucket)?; + if state == EMPTY { + return Ok(None); + } + if state == OCCUPIED { + stack.get_into(bucket + 8, &mut kb)?; + if kb == key_bytes { + let vref = get_u64(stack, bucket + 8 + ksz as u64)?; + return Ok(Some(Self::value_at(vref))); + } + } + idx = (idx + 1) & mask; + } + Ok(None) + } + + /// Whether `key` is present. + pub fn contains_key(&self, stack: &BStack, key: &K) -> io::Result { + Ok(self.get(stack, key)?.is_some()) + } + + /// Grow the table to at least double its capacity, rehashing every live entry + /// (and dropping tombstones) atomically. A no-op (beyond a freed spare block) + /// if another thread already grew it. + fn grow(&self, allocator: &A) -> io::Result<()> { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let cap0 = get_u64(allocator.stack(), handle + CAP_OFF)?; + let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; + // Allocate the new bucket block up front (an orphan until the swap). + let newtable = allocator.alloc(newcap * stride)?.as_range().start(); + + let mut meta_buf = [0u8; 32]; + let mut old_buf = vec![0u8; (cap0 * stride) as usize]; + let mut new_image: Vec = Vec::new(); + let grown = Cell::new(false); + let old_table = Cell::new(0u64); + let old_cap = Cell::new(0u64); + + let mut meta_issued = false; + let mut meta: Option = None; + let mut abort = false; + let mut read_i = 0u64; + let mut built = false; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + if !meta_issued { + meta_issued = true; + // SAFETY: `meta_buf` outlives the call. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + TABLE_OFF, + buf: b, + }); + } + if meta.is_none() { + let m = Meta { + table: u64le(&meta_buf[0..8]), + cap: u64le(&meta_buf[8..16]), + len: u64le(&meta_buf[16..24]), + used: u64le(&meta_buf[24..32]), + }; + // Abort if someone already grew to at least this size. + if newcap <= m.cap { + abort = true; + } + meta = Some(m); + } + if abort { + return None; // commit nothing + } + let m = meta.as_ref().unwrap(); + + // Snapshot every old bucket. + if read_i < m.cap { + let i = read_i; + read_i += 1; + let lo = (i * stride) as usize; + let hi = lo + stride as usize; + // SAFETY: `old_buf` outlives the call; each slice read once. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut old_buf[lo..hi]) }; + return Some(BStackGenOp::Read { + offset: m.table + i * stride, + buf: b, + }); + } + + // Rebuild the table into the new block (dropping tombstones). + if !built { + built = true; + grown.set(true); + old_table.set(m.table); + old_cap.set(m.cap); + + new_image = vec![0u8; (newcap * stride) as usize]; // all EMPTY + let newmask = newcap - 1; + for j in 0..m.cap { + let lo = (j * stride) as usize; + if u64le(&old_buf[lo..lo + 8]) != OCCUPIED { + continue; + } + let kb = &old_buf[lo + 8..lo + 8 + ksz]; + let vref = &old_buf[lo + 8 + ksz..lo + 16 + ksz]; + let mut idx = fnv1a(kb) & newmask; + loop { + let nlo = (idx * stride) as usize; + if u64le(&new_image[nlo..nlo + 8]) == EMPTY { + new_image[nlo..nlo + 8].copy_from_slice(&OCCUPIED.to_le_bytes()); + new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); + new_image[nlo + 8 + ksz..nlo + 16 + ksz].copy_from_slice(vref); + break; + } + idx = (idx + 1) & newmask; + } + } + writes.push((newtable, std::mem::take(&mut new_image))); + writes.push((handle + TABLE_OFF, newtable.to_le_bytes().to_vec())); + writes.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); + // Tombstones dropped: used == len now. + writes.push((handle + USED_OFF, m.len.to_le_bytes().to_vec())); + } + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives the call and is not mutated after build. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data: d }); + } + None + })?; + + if grown.get() { + if old_cap.get() > 0 { + // SAFETY: the descriptor no longer points at the old table. + let _ = unsafe { + dealloc_range( + allocator, + BStackRange::new(old_table.get(), old_cap.get() * stride), + ) + }; + } + } else { + // SAFETY: `newtable` was never linked into the descriptor. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; + } + Ok(()) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the map was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackHashMap { + /// A `"Map"` prefix over hash bytes perturbed by the key size and the value + /// type's tag, so maps of different key/value types never share a + /// discriminant despite the identical handle layout. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'M', b'a', b'p', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + .mix(::eightcc()) + } +} + +impl BStackBlock for BStackHashMap { + type OnDisk = MapOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackHashMap { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every value block and the bucket block, **without** + /// freeing the handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let stride = Self::stride(); + let ksz = Self::ksize() as u64; + let handle = range.start(); + let table = get_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; + for j in 0..cap { + let bucket = table + j * stride; + if get_u64(allocator.stack(), bucket)? == OCCUPIED { + let vref = get_u64(allocator.stack(), bucket + 8 + ksz)?; + if vref != 0 { + // SAFETY: the map solely owns each value block. + let owned = unsafe { BStackOwned::from_raw(Self::value_at(vref)) }; + owned.bstack_drop(allocator)?; + } + } + } + if table != 0 { + // SAFETY: the map solely owns its bucket block. + unsafe { dealloc_range(allocator, BStackRange::new(table, cap * stride))? }; + } + Ok(()) + } + + /// Deep-clone the map into `plan`: copy the bucket block verbatim (keeping + /// every key's position), deep-clone each occupied value (via `V`'s clone + /// hook) and swap in the clone's ref; stage the handle — all in the parent + /// plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let stride = Self::stride(); + let ksz = Self::ksize(); + let handle = self.range.start(); + let table = get_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; + let len = get_u64(allocator.stack(), handle + LEN_OFF)?; + let used = get_u64(allocator.stack(), handle + USED_OFF)?; + + let (new_table, new_cap, new_used) = if cap == 0 { + (0, 0, 0) + } else { + // Copy the whole bucket block, then deep-clone the occupied values. + let mut image = vec![0u8; (cap * stride) as usize]; + allocator.stack().get_into(table, &mut image)?; + for j in 0..cap as usize { + let lo = j * stride as usize; + if u64le(&image[lo..lo + 8]) != OCCUPIED { + continue; + } + let vref = u64le(&image[lo + 8 + ksz..lo + 16 + ksz]); + let cloned = Self::value_at(vref) + .__bstack_clone_into(allocator, plan)? + .start(); + image[lo + 8 + ksz..lo + 16 + ksz].copy_from_slice(&cloned.to_le_bytes()); + } + let dst = plan.alloc_raw(allocator, cap * stride)?; + plan.write(dst.start(), image); + (dst.start(), cap, used) + }; + + let handle_dst = plan.alloc_raw(allocator, MAP_SIZE)?; + let od = MapOnDisk { + header: BlockHeader { + size: MAP_SIZE, + tag: Self::eightcc(), + }, + table: new_table, + cap: new_cap, + len, + used: new_used, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackHashMap { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackHashMap { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index a2d8396..cdfe401 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -17,14 +17,17 @@ //! | [`BStackBox`] | [`std::boxed::Box`] | a single owned [`Pod`](bytemuck::Pod) value in its own block — the macro-free way to own a bare scalar/POD struct. | //! | [`BStackLinkedList`] | [`std::collections::LinkedList`] | an owned doubly-linked list of block values (non-intrusive, single-ref nodes). Prefer [`BStackDeque`] / [`crate::BStackBlockVec`] unless you need O(1) end/splice ops. | //! | [`BStackDeque`] | [`std::collections::VecDeque`] | an owned double-ended queue: a contiguous ring of value refs (no per-element pointer chasing), O(1) amortized push/pop at both ends. | +//! | [`BStackHashMap`] | [`std::collections::HashMap`] | an owned open-addressing map from a [`Pod`](bytemuck::Pod) key to a block value — keyed lookup without a linear scan. | mod boxed; mod cow; mod deque; mod list; +mod map; mod util; pub use boxed::{BStackBox, BoxOnDisk}; pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; +pub use map::{BStackHashMap, MapOnDisk}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 22776d6..e676214 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -15,9 +15,9 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, - BStackCow, BStackDeque, BStackDrop, BStackLinkedList, BStackOwned, BStackRc, BStackRef, - BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, - bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackLinkedList, BStackOwned, BStackRc, + BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, + alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -4793,3 +4793,191 @@ fn stdlib_deque_concurrent_push_pop() { assert_eq!(dq.len(alloc.stack()).unwrap(), 0); dq.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackHashMap — owned open-addressing hash map +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_map_insert_get_remove() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let map = BStackHashMap::::new(&alloc).unwrap(); + assert!(map.is_empty(stack).unwrap()); + assert!(map.get(stack, &7).unwrap().is_none()); + + // Insert of a new key returns no previous value. + assert!(map.insert(&alloc, 7, MacroLeaf::new(&alloc, 700).unwrap()).unwrap().is_none()); + assert!(map.insert(&alloc, 9, MacroLeaf::new(&alloc, 900).unwrap()).unwrap().is_none()); + assert_eq!(map.len(stack).unwrap(), 2); + assert_eq!(map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), 700); + assert_eq!(map.get(stack, &9).unwrap().unwrap().val(stack).unwrap(), 900); + assert!(map.contains_key(stack, &7).unwrap()); + assert!(!map.contains_key(stack, &8).unwrap()); + + // Overwrite returns the previous value (owned) and does not change len. + let old = map.insert(&alloc, 7, MacroLeaf::new(&alloc, 701).unwrap()).unwrap().unwrap(); + assert_eq!(old.handle().val(stack).unwrap(), 700); + old.bstack_drop(&alloc).unwrap(); + assert_eq!(map.len(stack).unwrap(), 2); + assert_eq!(map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), 701); + + // Remove returns the value (owned); the key is then absent. + let removed = map.remove(&alloc, &9).unwrap().unwrap(); + assert_eq!(removed.handle().val(stack).unwrap(), 900); + removed.bstack_drop(&alloc).unwrap(); + assert!(map.get(stack, &9).unwrap().is_none()); + assert!(map.remove(&alloc, &9).unwrap().is_none()); + assert_eq!(map.len(stack).unwrap(), 1); + + map.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_grows_and_keeps_all() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let map = BStackHashMap::::new(&alloc).unwrap(); + // Enough entries to force several rehashes (cap 4 -> ... ). + for k in 0..100u32 { + assert!(map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap().is_none()); + } + assert_eq!(map.len(stack).unwrap(), 100); + // Every key survives the rehashes with its value. + for k in 0..100u32 { + assert_eq!(map.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k * 10); + } + + // Remove the evens; odds remain (exercises tombstones + probing past them). + for k in (0..100u32).step_by(2) { + map.remove(&alloc, &k).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + } + assert_eq!(map.len(stack).unwrap(), 50); + for k in 0..100u32 { + let got = map.get(stack, &k).unwrap(); + if k % 2 == 0 { + assert!(got.is_none()); + } else { + assert_eq!(got.unwrap().val(stack).unwrap(), k * 10); + } + } + + map.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_pod_struct_key() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A composite Pod key (see Point3, defined for the box tests). + let map = BStackHashMap::::new(&alloc).unwrap(); + let a = Point3 { x: 1, y: 2, z: 3 }; + let b = Point3 { x: 1, y: 2, z: 4 }; + map.insert(&alloc, a, MacroLeaf::new(&alloc, 11).unwrap()).unwrap(); + map.insert(&alloc, b, MacroLeaf::new(&alloc, 22).unwrap()).unwrap(); + assert_eq!(map.get(stack, &a).unwrap().unwrap().val(stack).unwrap(), 11); + assert_eq!(map.get(stack, &b).unwrap().unwrap().val(stack).unwrap(), 22); + assert!(map.get(stack, &Point3 { x: 9, y: 9, z: 9 }).unwrap().is_none()); + + map.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} + +#[test] +fn stdlib_map_drop_is_recursive() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let leaf_start = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let map = BStackHashMap::::new(&alloc).unwrap(); + map.insert(&alloc, 42, parent).unwrap(); + map.bstack_drop(&alloc).unwrap(); + + // The leaf grandchild's slot is reclaimed — full recursion through a value. + let reused = MacroLeaf::new(&alloc, 0).unwrap(); + assert_eq!(reused.handle().range().start(), leaf_start); + reused.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let map = BStackHashMap::::new(&alloc).unwrap(); + for k in 0..8u32 { + map.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()).unwrap(); + } + + let clone = map.try_clone_in(&alloc).unwrap(); + for k in 0..8u32 { + assert_eq!(clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k + 100); + } + // Clone's value blocks are fresh, not aliases. + assert_ne!( + clone.get(stack, &3).unwrap().unwrap().range().start(), + map.get(stack, &3).unwrap().unwrap().range().start(), + ); + + // Mutating the clone leaves the original intact. + clone.remove(&alloc, &3).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + assert!(clone.get(stack, &3).unwrap().is_none()); + assert_eq!(map.get(stack, &3).unwrap().unwrap().val(stack).unwrap(), 103); + + clone.bstack_drop(&alloc).unwrap(); + map.bstack_drop(&alloc).unwrap(); +} + +/// Many threads inserting distinct keys into one shared map, driving concurrent +/// growth/rehash. A non-atomic probe/write or a racy rehash would drop entries. +#[test] +fn stdlib_map_concurrent_insert() { + const THREADS: u32 = 8; + const ITERS: u32 = 8; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let map = BStackHashMap::::new(&alloc).unwrap(); + let total = (THREADS * ITERS) as u64; + + std::thread::scope(|s| { + for t in 0..THREADS { + let map = ↦ + let alloc = &alloc; + s.spawn(move || { + for i in 0..ITERS { + let k = t * ITERS + i; + map.insert(alloc, k, MacroLeaf::new(alloc, k).unwrap()).unwrap(); + } + }); + } + }); + + assert_eq!(map.len(alloc.stack()).unwrap(), total); + for k in 0..(THREADS * ITERS) { + assert_eq!(map.get(alloc.stack(), &k).unwrap().unwrap().val(alloc.stack()).unwrap(), k); + } + + map.bstack_drop(&alloc).unwrap(); +} From 893b6610ce6f57493a21e26139d4c58488449109 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 02:48:45 -0700 Subject: [PATCH 075/140] stdlib: BTreeMap --- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/stdlib/mod.rs | 3 + bstack_raii/src/stdlib/tree.rs | 682 +++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 150 +++++++- 4 files changed, 833 insertions(+), 6 deletions(-) create mode 100644 bstack_raii/src/stdlib/tree.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 5f2fd66..9f73e75 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,8 +82,8 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, BoxOnDisk, DequeOnDisk, - ListOnDisk, MapOnDisk, NodeOnDisk, + BStackBTreeMap, BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, BoxOnDisk, + DequeOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, TreeOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index cdfe401..71f765f 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -18,12 +18,14 @@ //! | [`BStackLinkedList`] | [`std::collections::LinkedList`] | an owned doubly-linked list of block values (non-intrusive, single-ref nodes). Prefer [`BStackDeque`] / [`crate::BStackBlockVec`] unless you need O(1) end/splice ops. | //! | [`BStackDeque`] | [`std::collections::VecDeque`] | an owned double-ended queue: a contiguous ring of value refs (no per-element pointer chasing), O(1) amortized push/pop at both ends. | //! | [`BStackHashMap`] | [`std::collections::HashMap`] | an owned open-addressing map from a [`Pod`](bytemuck::Pod) key to a block value — keyed lookup without a linear scan. | +//! | [`BStackBTreeMap`] | [`std::collections::BTreeMap`] | an owned **ordered** map: a copy-on-write B-tree (wide contiguous nodes, few seeks per lookup) with sorted iteration. Keys are `Pod + Ord`. | mod boxed; mod cow; mod deque; mod list; mod map; +mod tree; mod util; pub use boxed::{BStackBox, BoxOnDisk}; @@ -31,3 +33,4 @@ pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; pub use map::{BStackHashMap, MapOnDisk}; +pub use tree::{BStackBTreeMap, TreeOnDisk}; diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs new file mode 100644 index 0000000..83af17a --- /dev/null +++ b/bstack_raii/src/stdlib/tree.rs @@ -0,0 +1,682 @@ +//! [`BStackBTreeMap`]: an owned, ordered map backed by a copy-on-write +//! B-tree. +//! +//! # Why a B-tree, not a binary tree +//! +//! On disk, pointer chasing is the enemy: every edge you follow is a seek to a +//! physically unrelated block. A red-black or AVL tree stores **one** key per +//! node, so a lookup in a million-entry tree chases ~20 pointers. A B-tree packs +//! many keys into each wide, **contiguous** node, so the same lookup reads only a +//! handful of nodes (with minimum degree `T = 8`, up to 15 keys per node, height +//! ~5 at a million entries). Same `O(log n)`, far fewer seeks — the on-disk +//! ordered-map you actually want, giving sorted iteration and range scans that a +//! [`crate::BStackHashMap`] cannot. +//! +//! Keys are **`Pod + Ord`** (`K`): stored inline in the node and compared by +//! value; values are blocks (`V: BStackBlock`) the tree owns via a `u64` ref. +//! +//! # Copy-on-write, single-writer +//! +//! Mutation is **path-copying** (the LMDB model): an insert rewrites only the +//! root-to-leaf path into freshly allocated nodes (splitting as needed), leaving +//! every untouched subtree shared, then commits all the new nodes **and** the new +//! root pointer as one atomic [`bstack::BStack::set_batched`] batch. The commit +//! point is the root swap: before it the tree is entirely the old version, after +//! it entirely the new one — so it is crash-atomic, and any number of readers +//! traversing the old root are unaffected. +//! +//! Unlike the lock-free [`crate::BStackDeque`] / [`crate::BStackHashMap`] +//! mutators, a B-tree write reads a whole path to build the new one, so +//! **concurrent writers need external synchronization** (one writer at a time); +//! concurrent *readers* are always fine. This is the same trade LMDB makes, and +//! it keeps each write atomic and crash-safe. (A path is short — a handful of +//! nodes — so the copy cost is small.) +//! +//! Not yet implemented: `remove` (B-tree deletion with rebalancing is the natural +//! next step; the path-copy + `set_batched` commit machinery here carries over). + +use core::cmp::Ordering; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::util::{alloc_image, get_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackBTreeMap`]: header, root node pointer (`0` = +/// empty), and entry count. Non-generic. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct TreeOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the root node, or `0` when the tree is empty. + pub root: u64, + /// Number of entries. + pub len: u64, +} + +const ROOT_OFF: u64 = HEADER_SIZE; // 16 +const LEN_OFF: u64 = HEADER_SIZE + 8; // 24 +const TREE_SIZE: u64 = size_of::() as u64; + +/// Minimum degree: a node holds `T-1..=2T-1` keys (the root may hold fewer). +const T: usize = 8; +const MAXKEYS: usize = 2 * T - 1; // 15 +const MAXCHILDREN: usize = 2 * T; // 16 + +// Node field offsets (keys/vals/children arrays follow, sized by `K`). +const NKEYS_OFF: usize = HEADER_SIZE as usize; // 16 +const LEAF_OFF: usize = HEADER_SIZE as usize + 8; // 24 +const KEYS_OFF: usize = HEADER_SIZE as usize + 16; // 32 + +/// Read a little-endian `u64` from the first 8 bytes of `b`. +fn u64le(b: &[u8]) -> u64 { + u64::from_le_bytes(b[..8].try_into().unwrap()) +} + +/// A node decoded for building/mutation: keys as raw `K` bytes, value refs, and +/// (for an internal node) child node offsets (`keys.len() + 1` of them). +struct BNode { + leaf: bool, + keys: Vec>, + vals: Vec, + children: Vec, +} + +/// A median lifted out of a split child: its key/value plus the new right node. +struct Split { + key: Vec, + val: u64, + right: u64, +} + +/// Accumulates a path-copy insert's new-node writes and the old path nodes to +/// free, so the whole insert commits as one [`BStack::set_batched`] batch. +struct Build<'a, A: BStackOwnedSliceAllocator> { + allocator: &'a A, + node_size: u64, + ksize: usize, + vals_off: usize, + children_off: usize, + /// New node images `(offset, bytes)`, committed together. + writes: Vec<(u64, Vec)>, + /// Old path nodes, freed after the commit succeeds. + freed: Vec, +} + +impl<'a, A: BStackOwnedSliceAllocator> Build<'a, A> { + /// Serialize `nb` and allocate a fresh block for it (an orphan until the + /// commit links it), returning its offset. + fn emit(&mut self, nb: &BNode) -> io::Result { + let mut b = vec![0u8; self.node_size as usize]; + b[NKEYS_OFF..NKEYS_OFF + 8].copy_from_slice(&(nb.keys.len() as u64).to_le_bytes()); + b[LEAF_OFF..LEAF_OFF + 8].copy_from_slice(&(nb.leaf as u64).to_le_bytes()); + for (i, k) in nb.keys.iter().enumerate() { + let ko = KEYS_OFF + i * self.ksize; + b[ko..ko + self.ksize].copy_from_slice(k); + } + for (i, v) in nb.vals.iter().enumerate() { + let vo = self.vals_off + i * 8; + b[vo..vo + 8].copy_from_slice(&v.to_le_bytes()); + } + for (i, c) in nb.children.iter().enumerate() { + let co = self.children_off + i * 8; + b[co..co + 8].copy_from_slice(&c.to_le_bytes()); + } + let off = self.allocator.alloc(self.node_size)?.as_range().start(); + self.writes.push((off, b)); + Ok(off) + } +} + +/// An owned, ordered map backed by a copy-on-write B-tree. +/// +/// A typed handle (a newtype over a [`BStackRange`]); [`new`](Self::new) returns a +/// bare [`BStackOwned>`] that frees nothing on scope exit — +/// free it with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it +/// ([`AutoDrop`] / [`crate::BStackCow`]). +pub struct BStackBTreeMap { + range: BStackRange, + _marker: PhantomData (K, V)>, +} + +impl BStackBTreeMap { + const fn ksize() -> usize { + size_of::() + } + const fn vals_off() -> usize { + KEYS_OFF + MAXKEYS * Self::ksize() + } + const fn children_off() -> usize { + Self::vals_off() + MAXKEYS * 8 + } + const fn node_size() -> u64 { + (Self::children_off() + MAXCHILDREN * 8) as u64 + } + + fn value_size() -> u64 { + size_of::<::OnDisk>() as u64 + } + fn value_at(off: u64) -> V { + ::from_range(BStackRange::new(off, Self::value_size())) + } + + fn read_key(bytes: &[u8]) -> K { + bytemuck::pod_read_unaligned::(&bytes[..Self::ksize()]) + } + + /// Allocate an empty tree. + pub fn new(allocator: &A) -> io::Result> { + let od = TreeOnDisk { + header: BlockHeader { + size: TREE_SIZE, + tag: Self::eightcc(), + }, + root: 0, + len: 0, + }; + let range = alloc_image(allocator, bytemuck::bytes_of(&od))?; + // SAFETY: a freshly allocated block owned by no other handle. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }) + } + + /// Number of entries. + pub fn len(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the tree has no entries. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Decode the node at `off`. + fn read_node(stack: &BStack, off: u64) -> io::Result { + let mut b = vec![0u8; Self::node_size() as usize]; + stack.get_into(off, &mut b)?; + let nkeys = u64le(&b[NKEYS_OFF..]) as usize; + let leaf = u64le(&b[LEAF_OFF..]) != 0; + let ksize = Self::ksize(); + let vals_off = Self::vals_off(); + let children_off = Self::children_off(); + let mut keys = Vec::with_capacity(nkeys); + let mut vals = Vec::with_capacity(nkeys); + for i in 0..nkeys { + let ko = KEYS_OFF + i * ksize; + keys.push(b[ko..ko + ksize].to_vec()); + vals.push(u64le(&b[vals_off + i * 8..])); + } + let mut children = Vec::new(); + if !leaf { + for i in 0..=nkeys { + children.push(u64le(&b[children_off + i * 8..])); + } + } + Ok(BNode { + leaf, + keys, + vals, + children, + }) + } + + /// Locate `target` among a node's keys: the first index `i` with + /// `target <= keys[i]`, and whether it is an exact match. + fn search(nb: &BNode, target: &K) -> (usize, bool) { + for (j, kb) in nb.keys.iter().enumerate() { + match target.cmp(&Self::read_key(kb)) { + Ordering::Less => return (j, false), + Ordering::Equal => return (j, true), + Ordering::Greater => {} + } + } + (nb.keys.len(), false) + } + + /// Split an over-full node (`keys.len() == 2T`) around its median: returns the + /// left node and the lifted median; the caller emits the right node. + fn split(mut nb: BNode) -> (BNode, Split, BNode) { + let m = nb.keys.len() / 2; + let right_children = if nb.leaf { + Vec::new() + } else { + nb.children.split_off(m + 1) + }; + let right_keys = nb.keys.split_off(m + 1); + let right_vals = nb.vals.split_off(m + 1); + let med_key = nb.keys.pop().unwrap(); + let med_val = nb.vals.pop().unwrap(); + let right = BNode { + leaf: nb.leaf, + keys: right_keys, + vals: right_vals, + children: right_children, + }; + let split = Split { + key: med_key, + val: med_val, + right: 0, // filled in by the caller after emitting `right` + }; + (nb, split, right) + } + + /// Recursively path-copy the subtree at `off`, inserting `key -> val`. + /// Returns the new subtree offset, an optional lifted split, whether a new + /// entry was added, and any replaced value. + fn insert_rec( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + off: u64, + key: &K, + key_bytes: &[u8], + val: u64, + ) -> io::Result<(u64, Option, bool, Option)> { + let mut nb = Self::read_node(stack, off)?; + build.freed.push(off); + let (i, exact) = Self::search(&nb, key); + + if exact { + let old = nb.vals[i]; + nb.vals[i] = val; + let new_off = build.emit(&nb)?; + return Ok((new_off, None, false, Some(old))); + } + + let (added, old) = if nb.leaf { + nb.keys.insert(i, key_bytes.to_vec()); + nb.vals.insert(i, val); + (true, None) + } else { + let child = nb.children[i]; + let (new_child, child_split, added, old) = + Self::insert_rec(build, stack, child, key, key_bytes, val)?; + nb.children[i] = new_child; + if let Some(s) = child_split { + // The child split and already emitted its right node into `s.right`. + nb.keys.insert(i, s.key); + nb.vals.insert(i, s.val); + nb.children.insert(i + 1, s.right); + } + (added, old) + }; + + if nb.keys.len() <= MAXKEYS { + let new_off = build.emit(&nb)?; + Ok((new_off, None, added, old)) + } else { + let (left, mut split, right) = Self::split(nb); + split.right = build.emit(&right)?; + let left_off = build.emit(&left)?; + Ok((left_off, Some(split), added, old)) + } + } + + /// Insert `key -> value`, taking ownership of the value block. Returns the + /// previously-mapped value (owned) if `key` was already present, else `None`. + /// + /// Path-copies the affected path and commits every new node plus the root + /// swap as one crash-atomic batch. **Single-writer** (see the module docs). + pub fn insert( + &self, + allocator: &A, + key: K, + value: BStackOwned, + ) -> io::Result>> { + let handle = self.range.start(); + let stack = allocator.stack(); + let key_bytes = bytemuck::bytes_of(&key).to_vec(); + let val_ref = value.into_inner().range().start(); + + let root = get_u64(stack, handle + ROOT_OFF)?; + let len = get_u64(stack, handle + LEN_OFF)?; + + let mut build = Build { + allocator, + node_size: Self::node_size(), + ksize: Self::ksize(), + vals_off: Self::vals_off(), + children_off: Self::children_off(), + writes: Vec::new(), + freed: Vec::new(), + }; + + let built: io::Result<(u64, bool, Option)> = (|| { + if root == 0 { + // Empty tree: a single-entry leaf becomes the root. + let leaf = BNode { + leaf: true, + keys: vec![key_bytes.clone()], + vals: vec![val_ref], + children: Vec::new(), + }; + let new_root = build.emit(&leaf)?; + return Ok((new_root, true, None)); + } + let (new_root0, split, added, old) = + Self::insert_rec(&mut build, stack, root, &key, &key_bytes, val_ref)?; + let new_root = if let Some(s) = split { + // Root split: a fresh root holds the median over the two halves. + let root_node = BNode { + leaf: false, + keys: vec![s.key], + vals: vec![s.val], + children: vec![new_root0, s.right], + }; + build.emit(&root_node)? + } else { + new_root0 + }; + Ok((new_root, added, old)) + })(); + + match built { + Ok((new_root, added, old)) => { + let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); + let mut writes = core::mem::take(&mut build.writes); + writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); + if added { + writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + } + match stack.set_batched(writes) { + Ok(()) => { + // Free the old path nodes (leak-only on crash). + for off in &build.freed { + // SAFETY: replaced by the copy just committed; nothing + // else references it (single-writer). + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; + } + Ok(old + .map(|o| unsafe { BStackOwned::from_raw(Self::value_at(o)) })) + } + Err(e) => { + // Nothing committed: reclaim the new nodes we allocated. + for off in new_node_offs { + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(off, build.node_size)) + }; + } + Err(e) + } + } + } + Err(e) => { + for (off, _) in &build.writes { + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + } + Err(e) + } + } + } + + /// A **borrowed** handle to the value mapped by `key` (no ownership), or + /// `None` if absent. + pub fn get(&self, stack: &BStack, key: &K) -> io::Result> { + let mut off = get_u64(stack, self.range.start() + ROOT_OFF)?; + let ksize = Self::ksize(); + let vals_off = Self::vals_off(); + let children_off = Self::children_off(); + let mut buf = vec![0u8; Self::node_size() as usize]; + while off != 0 { + stack.get_into(off, &mut buf)?; + let nkeys = u64le(&buf[NKEYS_OFF..]) as usize; + let leaf = u64le(&buf[LEAF_OFF..]) != 0; + let mut i = nkeys; + let mut exact = false; + for j in 0..nkeys { + let ko = KEYS_OFF + j * ksize; + match key.cmp(&Self::read_key(&buf[ko..ko + ksize])) { + Ordering::Less => { + i = j; + break; + } + Ordering::Equal => { + i = j; + exact = true; + break; + } + Ordering::Greater => {} + } + } + if exact { + return Ok(Some(Self::value_at(u64le(&buf[vals_off + i * 8..])))); + } + if leaf { + return Ok(None); + } + off = u64le(&buf[children_off + i * 8..]); + } + Ok(None) + } + + /// Whether `key` is present. + pub fn contains_key(&self, stack: &BStack, key: &K) -> io::Result { + Ok(self.get(stack, key)?.is_some()) + } + + /// The smallest entry, or `None` if empty. Descends the leftmost path. + pub fn first(&self, stack: &BStack) -> io::Result> { + self.extreme(stack, true) + } + + /// The largest entry, or `None` if empty. Descends the rightmost path. + pub fn last(&self, stack: &BStack) -> io::Result> { + self.extreme(stack, false) + } + + fn extreme(&self, stack: &BStack, leftmost: bool) -> io::Result> { + let mut off = get_u64(stack, self.range.start() + ROOT_OFF)?; + if off == 0 { + return Ok(None); + } + loop { + let nb = Self::read_node(stack, off)?; + if nb.leaf { + let i = if leftmost { 0 } else { nb.keys.len() - 1 }; + return Ok(Some((Self::read_key(&nb.keys[i]), Self::value_at(nb.vals[i])))); + } + off = if leftmost { + nb.children[0] + } else { + nb.children[nb.keys.len()] + }; + } + } + + /// Collect every entry in ascending key order. The value handles are borrowed + /// (do not free them; valid only while the tree does). + pub fn to_vec(&self, stack: &BStack) -> io::Result> { + let mut out = Vec::new(); + let root = get_u64(stack, self.range.start() + ROOT_OFF)?; + Self::collect(stack, root, &mut out)?; + Ok(out) + } + + fn collect(stack: &BStack, off: u64, out: &mut Vec<(K, V)>) -> io::Result<()> { + if off == 0 { + return Ok(()); + } + let nb = Self::read_node(stack, off)?; + for i in 0..nb.keys.len() { + if !nb.leaf { + Self::collect(stack, nb.children[i], out)?; + } + out.push((Self::read_key(&nb.keys[i]), Self::value_at(nb.vals[i]))); + } + if !nb.leaf { + Self::collect(stack, nb.children[nb.keys.len()], out)?; + } + Ok(()) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the tree was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } + + /// Recursively free the subtree at `off` (values then nodes). + fn drop_subtree( + stack: &BStack, + off: u64, + allocator: &A, + ) -> io::Result<()> { + if off == 0 { + return Ok(()); + } + let nb = Self::read_node(stack, off)?; + if !nb.leaf { + for &c in &nb.children { + Self::drop_subtree(stack, c, allocator)?; + } + } + for &v in &nb.vals { + if v != 0 { + // SAFETY: the tree solely owns each value block. + let owned = unsafe { BStackOwned::from_raw(Self::value_at(v)) }; + owned.bstack_drop(allocator)?; + } + } + // SAFETY: the tree solely owns each node block. + unsafe { dealloc_range(allocator, BStackRange::new(off, Self::node_size()))? }; + Ok(()) + } + + /// Recursively deep-clone the subtree at `off` into `plan`, returning the new + /// subtree offset. + fn clone_subtree( + stack: &BStack, + off: u64, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + if off == 0 { + return Ok(0); + } + let mut buf = vec![0u8; Self::node_size() as usize]; + stack.get_into(off, &mut buf)?; + let nkeys = u64le(&buf[NKEYS_OFF..]) as usize; + let leaf = u64le(&buf[LEAF_OFF..]) != 0; + let vals_off = Self::vals_off(); + let children_off = Self::children_off(); + + // Deep-clone each value and repoint it in the copy. + for i in 0..nkeys { + let vo = vals_off + i * 8; + let vref = u64le(&buf[vo..]); + let cloned = Self::value_at(vref) + .__bstack_clone_into(allocator, plan)? + .start(); + buf[vo..vo + 8].copy_from_slice(&cloned.to_le_bytes()); + } + // Recurse into children and repoint them. + if !leaf { + for i in 0..=nkeys { + let co = children_off + i * 8; + let child = u64le(&buf[co..]); + let new_child = Self::clone_subtree(stack, child, allocator, plan)?; + buf[co..co + 8].copy_from_slice(&new_child.to_le_bytes()); + } + } + let dst = plan.alloc_raw(allocator, Self::node_size())?; + plan.write(dst.start(), buf); + Ok(dst.start()) + } +} + +impl BStackCast for BStackBTreeMap { + /// A `"Tree"` prefix perturbed by the key size and the value type's tag. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'T', b'r', b'e', b'e', 0x80, 0x81, 0x82, 0x83]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + .mix(::eightcc()) + } +} + +impl BStackBlock for BStackBTreeMap { + type OnDisk = TreeOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackBTreeMap { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every value block and node, **without** freeing the + /// handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let root = get_u64(allocator.stack(), range.start() + ROOT_OFF)?; + Self::drop_subtree(allocator.stack(), root, allocator) + } + + /// Deep-clone the whole tree into `plan`: every node copied, every value + /// deep-cloned via `V`'s clone hook, the handle staged — all in the parent + /// plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let root = get_u64(allocator.stack(), handle + ROOT_OFF)?; + let len = get_u64(allocator.stack(), handle + LEN_OFF)?; + let new_root = Self::clone_subtree(allocator.stack(), root, allocator, plan)?; + + let handle_dst = plan.alloc_raw(allocator, TREE_SIZE)?; + let od = TreeOnDisk { + header: BlockHeader { + size: TREE_SIZE, + tag: Self::eightcc(), + }, + root: new_root, + len, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackBTreeMap { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackBTreeMap { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e676214..7cb23f3 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,10 +14,10 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - AutoDrop, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, - BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackLinkedList, BStackOwned, BStackRc, - BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, - alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + AutoDrop, BStackBTreeMap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, + BStackCastInto, BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackLinkedList, + BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, + alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -4949,6 +4949,148 @@ fn stdlib_map_deep_clone_is_independent() { map.bstack_drop(&alloc).unwrap(); } +// -------------------------------------------------------------------------- +// stdlib: BStackBTreeMap — owned ordered map (copy-on-write B-tree) +// -------------------------------------------------------------------------- + +fn tree_pairs(tree: &BStackBTreeMap, stack: &BStack) -> Vec<(u32, u32)> { + tree.to_vec(stack) + .unwrap() + .iter() + .map(|(k, v)| (*k, v.val(stack).unwrap())) + .collect() +} + +#[test] +fn stdlib_tree_insert_get_ordered() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + assert!(tree.is_empty(stack).unwrap()); + assert!(tree.get(stack, &5).unwrap().is_none()); + assert!(tree.first(stack).unwrap().is_none()); + + // Insert 0..50 in a scrambled (but bijective) order to exercise splits. + for i in 0..50u32 { + let k = (i * 17) % 50; + assert!(tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap().is_none()); + } + assert_eq!(tree.len(stack).unwrap(), 50); + + // Every key present with its value. + for k in 0..50u32 { + assert_eq!(tree.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k * 10); + } + assert!(tree.get(stack, &999).unwrap().is_none()); + + // Ordered iteration is sorted; first/last are the extremes. + let pairs = tree_pairs(&tree, stack); + let expected: Vec<(u32, u32)> = (0..50u32).map(|k| (k, k * 10)).collect(); + assert_eq!(pairs, expected); + assert_eq!(tree.first(stack).unwrap().unwrap().0, 0); + assert_eq!(tree.last(stack).unwrap().unwrap().0, 49); + + // Overwrite returns the previous value; len unchanged; order preserved. + let old = tree.insert(&alloc, 25, MacroLeaf::new(&alloc, 9999).unwrap()).unwrap().unwrap(); + assert_eq!(old.handle().val(stack).unwrap(), 250); + old.bstack_drop(&alloc).unwrap(); + assert_eq!(tree.len(stack).unwrap(), 50); + assert_eq!(tree.get(stack, &25).unwrap().unwrap().val(stack).unwrap(), 9999); + + tree.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_tree_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} + +#[test] +fn stdlib_tree_drop_is_recursive() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let leaf_start = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + tree.insert(&alloc, 42, parent).unwrap(); + tree.bstack_drop(&alloc).unwrap(); + + // The leaf grandchild's slot is reclaimed — full recursion through a value. + let reused = MacroLeaf::new(&alloc, 0).unwrap(); + assert_eq!(reused.handle().range().start(), leaf_start); + reused.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_tree_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + for k in 0..30u32 { + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()).unwrap(); + } + + let clone = tree.try_clone_in(&alloc).unwrap(); + for k in 0..30u32 { + assert_eq!(clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k + 100); + } + // Fresh value blocks, not aliases. + assert_ne!( + clone.get(stack, &10).unwrap().unwrap().range().start(), + tree.get(stack, &10).unwrap().unwrap().range().start(), + ); + + // Overwriting in the clone leaves the original intact. + tree.insert(&alloc, 10, MacroLeaf::new(&alloc, 7).unwrap()).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + // (`clone` and `tree` share no nodes: the clone deep-copied every node.) + assert_eq!(clone.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), 110); + assert_eq!(tree.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), 7); + + clone.bstack_drop(&alloc).unwrap(); + tree.bstack_drop(&alloc).unwrap(); +} + +/// Many threads reading one shared tree concurrently (the B-tree is +/// single-writer / multi-reader: no writes race here, only lookups). +#[test] +fn stdlib_tree_concurrent_readers() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + for k in 0..64u32 { + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 2).unwrap()).unwrap(); + } + + std::thread::scope(|s| { + for _ in 0..8 { + let tree = &tree; + let alloc = &alloc; + s.spawn(move || { + for k in 0..64u32 { + assert_eq!(tree.get(alloc.stack(), &k).unwrap().unwrap().val(alloc.stack()).unwrap(), k * 2); + } + }); + } + }); + + tree.bstack_drop(&alloc).unwrap(); +} + /// Many threads inserting distinct keys into one shared map, driving concurrent /// growth/rehash. A non-atomic probe/write or a racy rehash would drop entries. #[test] From 72a15bc09585664792e3d0ca8a824ea1f03b3037 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 02:54:22 -0700 Subject: [PATCH 076/140] stdlib: String --- bstack_raii/src/lib.rs | 5 +- bstack_raii/src/stdlib/mod.rs | 3 + bstack_raii/src/stdlib/string.rs | 275 +++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 93 ++++++++++- 4 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 bstack_raii/src/stdlib/string.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 9f73e75..6c06479 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,8 +82,9 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBTreeMap, BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, BoxOnDisk, - DequeOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, TreeOnDisk, + BStackBTreeMap, BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, + BStackString, BoxOnDisk, DequeOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, + TreeOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 71f765f..cc10dde 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -19,12 +19,14 @@ //! | [`BStackDeque`] | [`std::collections::VecDeque`] | an owned double-ended queue: a contiguous ring of value refs (no per-element pointer chasing), O(1) amortized push/pop at both ends. | //! | [`BStackHashMap`] | [`std::collections::HashMap`] | an owned open-addressing map from a [`Pod`](bytemuck::Pod) key to a block value — keyed lookup without a linear scan. | //! | [`BStackBTreeMap`] | [`std::collections::BTreeMap`] | an owned **ordered** map: a copy-on-write B-tree (wide contiguous nodes, few seeks per lookup) with sorted iteration. Keys are `Pod + Ord`. | +//! | [`BStackString`] | [`std::string::String`] | a standalone owned, growable UTF-8 string block — the first-class way to own text (a deque element, a map value). | mod boxed; mod cow; mod deque; mod list; mod map; +mod string; mod tree; mod util; @@ -33,4 +35,5 @@ pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; pub use map::{BStackHashMap, MapOnDisk}; +pub use string::{BStackString, StringOnDisk}; pub use tree::{BStackBTreeMap, TreeOnDisk}; diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs new file mode 100644 index 0000000..03e5d25 --- /dev/null +++ b/bstack_raii/src/stdlib/string.rs @@ -0,0 +1,275 @@ +//! [`BStackString`]: a standalone, owned, growable UTF-8 string block. +//! +//! The on-disk analogue of [`std::string::String`]. Where the `#[bstack_block]` +//! macro only lets a `String` live *inside* a struct field, `BStackString` is a +//! first-class owned block you can hold on its own, put in a +//! [`crate::BStackDeque`], or store as a value in a [`crate::BStackHashMap`] / +//! [`crate::BStackBTreeMap`]. +//! +//! Like every variable-length container here, it is a fixed handle block +//! ([`StringOnDisk`]) — header + a pointer to a separate bytes block + the byte +//! length — so the handle never moves and the type is a normal +//! [`BStackBlock`] (composable as a field, referenced, cloned). The UTF-8 bytes +//! live in their own block; mutating the contents reallocates only that block and +//! swaps the handle's `{data, len}` in one atomic write. + +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::util::{alloc_image, get_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackString`]: header, a pointer to the UTF-8 bytes +/// block (`0` = empty), and the byte length. `#[repr(C)]`, `u64` fields only — +/// fixed-size and non-generic, so the handle is a normal block. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct StringOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the UTF-8 bytes block, or `0` when the string is empty. + pub data: u64, + /// Length of the string in bytes. + pub len: u64, +} + +const DATA_OFF: u64 = HEADER_SIZE; // 16 +const LEN_OFF: u64 = HEADER_SIZE + 8; // 24 +const STRING_SIZE: u64 = size_of::() as u64; + +/// A standalone owned UTF-8 string block. +/// +/// A typed handle (a newtype over a [`BStackRange`]); [`new`](Self::new) returns a +/// bare [`BStackOwned`] that frees nothing on scope exit — free it +/// with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it ([`AutoDrop`] / +/// [`crate::BStackCow`]). +pub struct BStackString { + range: BStackRange, +} + +impl BStackString { + /// Allocate a bytes block holding `bytes` (or return `0` for an empty slice), + /// releasing it without leaking on write failure. + fn alloc_bytes( + allocator: &A, + bytes: &[u8], + ) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let mut slice = allocator.alloc(bytes.len() as u64)?; + if let Err(e) = slice.write_range(0, bytes) { + let _ = allocator.dealloc(slice); + return Err(e); + } + Ok(slice.as_range().start()) + } + + /// Create a string from `s`. + pub fn new( + allocator: &A, + s: &str, + ) -> io::Result> { + let len = s.len() as u64; + let data = Self::alloc_bytes(allocator, s.as_bytes())?; + let od = StringOnDisk { + header: BlockHeader { + size: STRING_SIZE, + tag: Self::eightcc(), + }, + data, + len, + }; + match alloc_image(allocator, bytemuck::bytes_of(&od)) { + // SAFETY: a freshly allocated block owned by no other handle. + Ok(range) => Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }), + Err(e) => { + if data != 0 { + // SAFETY: the bytes block was just allocated, referenced by nobody. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(data, len)) }; + } + Err(e) + } + } + } + + /// Length in bytes. + pub fn len(&self, stack: &BStack) -> io::Result { + get_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the string is empty. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Read the raw UTF-8 bytes. + pub fn read_bytes(&self, stack: &BStack) -> io::Result> { + let data = get_u64(stack, self.range.start() + DATA_OFF)?; + let len = get_u64(stack, self.range.start() + LEN_OFF)? as usize; + let mut buf = vec![0u8; len]; + if len != 0 { + stack.get_into(data, &mut buf)?; + } + Ok(buf) + } + + /// Read the contents as a `String` (validating UTF-8). + pub fn to_string(&self, stack: &BStack) -> io::Result { + String::from_utf8(self.read_bytes(stack)?) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } + + /// Replace the contents with `s`, atomically swapping in the new bytes block + /// and freeing the old one. + /// + /// The new bytes are written to a fresh block first, then the handle's + /// `{data, len}` pair is updated in one atomic write (a crash before it leaves + /// the old string intact; after it, the new). The old bytes block is then + /// freed (leak-only on a crash in between). + pub fn set(&self, allocator: &A, s: &str) -> io::Result<()> { + let handle = self.range.start(); + let stack = allocator.stack(); + let newlen = s.len() as u64; + let newdata = Self::alloc_bytes(allocator, s.as_bytes())?; + + let old_data = get_u64(stack, handle + DATA_OFF)?; + let old_len = get_u64(stack, handle + LEN_OFF)?; + + // `data` and `len` are contiguous — swap both in one 16-byte write. + let mut buf = [0u8; 16]; + buf[0..8].copy_from_slice(&newdata.to_le_bytes()); + buf[8..16].copy_from_slice(&newlen.to_le_bytes()); + if let Err(e) = stack.set(handle + DATA_OFF, buf) { + if newdata != 0 { + // SAFETY: never linked into the handle; reclaim it. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(newdata, newlen)) }; + } + return Err(e); + } + + if old_data != 0 { + // SAFETY: the handle no longer points at the old bytes block. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(old_data, old_len)) }; + } + Ok(()) + } + + /// Append `s` to the string. + pub fn push_str( + &self, + allocator: &A, + s: &str, + ) -> io::Result<()> { + let mut cur = self.to_string(allocator.stack())?; + cur.push_str(s); + self.set(allocator, &cur) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the string was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackString { + /// A fixed `"Str"` tag (non-generic type). + fn eightcc() -> EightCC { + EightCC::new([b'S', b't', b'r', 0x80, 0x81, 0x82, 0x83, 0x84]) + } +} + +impl BStackBlock for BStackString { + type OnDisk = StringOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackString { range } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Free the bytes block, **without** freeing the handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let data = get_u64(allocator.stack(), range.start() + DATA_OFF)?; + let len = get_u64(allocator.stack(), range.start() + LEN_OFF)?; + if data != 0 { + // SAFETY: the string solely owns its bytes block. + unsafe { dealloc_range(allocator, BStackRange::new(data, len))? }; + } + Ok(()) + } + + /// Deep-clone: copy the bytes into a fresh block and stage the handle, in the + /// parent plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let data = get_u64(allocator.stack(), handle + DATA_OFF)?; + let len = get_u64(allocator.stack(), handle + LEN_OFF)?; + + let new_data = if len != 0 { + let mut bytes = vec![0u8; len as usize]; + allocator.stack().get_into(data, &mut bytes)?; + let dst = plan.alloc_raw(allocator, len)?; + plan.write(dst.start(), bytes); + dst.start() + } else { + 0 + }; + + let handle_dst = plan.alloc_raw(allocator, STRING_SIZE)?; + let od = StringOnDisk { + header: BlockHeader { + size: STRING_SIZE, + tag: Self::eightcc(), + }, + data: new_data, + len, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackString { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackString { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 7cb23f3..e2d5a72 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -16,8 +16,9 @@ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBTreeMap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackLinkedList, - BStackOwned, BStackRc, BStackRef, BStackShared, BStackWeakable, EightCC, TryClone, TryCloneIn, - alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + BStackOwned, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, + TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, + bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -5123,3 +5124,91 @@ fn stdlib_map_concurrent_insert() { map.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackString — standalone owned UTF-8 string +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_string_roundtrip_set_push() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let s = BStackString::new(&alloc, "hello").unwrap(); + assert_eq!(s.handle().len(stack).unwrap(), 5); + assert_eq!(s.handle().to_string(stack).unwrap(), "hello"); + + // Replace with something longer, then shorter. + s.handle().set(&alloc, "hello, world").unwrap(); + assert_eq!(s.handle().to_string(stack).unwrap(), "hello, world"); + s.handle().set(&alloc, "hi").unwrap(); + assert_eq!(s.handle().to_string(stack).unwrap(), "hi"); + + // Append. + s.handle().push_str(&alloc, " there").unwrap(); + assert_eq!(s.handle().to_string(stack).unwrap(), "hi there"); + + // Empty string has no bytes block. + let e = BStackString::new(&alloc, "").unwrap(); + assert!(e.handle().is_empty(stack).unwrap()); + assert_eq!(e.handle().to_string(stack).unwrap(), ""); + + s.bstack_drop(&alloc).unwrap(); + e.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_string_unicode() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let text = "héllo — 世界 🦀"; + let s = BStackString::new(&alloc, text).unwrap(); + assert_eq!(s.handle().len(stack).unwrap(), text.len() as u64); // byte length + assert_eq!(s.handle().to_string(stack).unwrap(), text); + s.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_string_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let s = BStackString::new(&alloc, "original").unwrap(); + let clone = s.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().to_string(stack).unwrap(), "original"); + + // Mutating the clone leaves the original intact. + clone.handle().set(&alloc, "changed").unwrap(); + assert_eq!(clone.handle().to_string(stack).unwrap(), "changed"); + assert_eq!(s.handle().to_string(stack).unwrap(), "original"); + + clone.bstack_drop(&alloc).unwrap(); + s.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_string_as_map_value() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // The headline use: strings as owned map values. + let map = BStackHashMap::::new(&alloc).unwrap(); + map.insert(&alloc, 1, BStackString::new(&alloc, "one").unwrap()).unwrap(); + map.insert(&alloc, 2, BStackString::new(&alloc, "two").unwrap()).unwrap(); + assert_eq!(map.get(stack, &1).unwrap().unwrap().to_string(stack).unwrap(), "one"); + assert_eq!(map.get(stack, &2).unwrap().unwrap().to_string(stack).unwrap(), "two"); + + // Overwrite returns the old string (owned), which we free. + let old = map.insert(&alloc, 1, BStackString::new(&alloc, "uno").unwrap()).unwrap().unwrap(); + assert_eq!(old.handle().to_string(stack).unwrap(), "one"); + old.bstack_drop(&alloc).unwrap(); + assert_eq!(map.get(stack, &1).unwrap().unwrap().to_string(stack).unwrap(), "uno"); + + // Dropping the map recursively frees every string value (and its bytes block). + map.bstack_drop(&alloc).unwrap(); +} From 454753930f7960e16dbe03c8f4a036f201c33cfc Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 03:01:01 -0700 Subject: [PATCH 077/140] stdlib: Optimization using stack allocated buffers --- bstack_raii/src/stdlib/map.rs | 8 ++++--- bstack_raii/src/stdlib/tree.rs | 10 +++++--- bstack_raii/src/stdlib/util.rs | 43 ++++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 29 +++++++++++++++++++++++ 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index b246a0b..c91b663 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -40,7 +40,7 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, get_u64}; +use super::util::{Scratch, alloc_image, get_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -459,7 +459,8 @@ impl BStackHashMap { let key_bytes = bytemuck::bytes_of(key); let mask = cap - 1; let mut idx = fnv1a(key_bytes) & mask; - let mut kb = vec![0u8; ksz]; + // Stack buffer for the probed key (no heap alloc for typical key sizes). + let mut scratch = Scratch::new(); for _ in 0..cap { let bucket = table + idx * stride; let state = get_u64(stack, bucket)?; @@ -467,7 +468,8 @@ impl BStackHashMap { return Ok(None); } if state == OCCUPIED { - stack.get_into(bucket + 8, &mut kb)?; + let kb = scratch.buf(ksz); + stack.get_into(bucket + 8, kb)?; if kb == key_bytes { let vref = get_u64(stack, bucket + 8 + ksz as u64)?; return Ok(Some(Self::value_at(vref))); diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 83af17a..ca672b0 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -43,7 +43,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, get_u64}; +use super::util::{Scratch, alloc_image, get_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -426,9 +426,13 @@ impl BStackBTreeMap { let ksize = Self::ksize(); let vals_off = Self::vals_off(); let children_off = Self::children_off(); - let mut buf = vec![0u8; Self::node_size() as usize]; + // Stack buffer for the node (reused down the descent; no heap alloc for + // typical key sizes — see `Scratch`). + let mut scratch = Scratch::new(); + let node_size = Self::node_size() as usize; while off != 0 { - stack.get_into(off, &mut buf)?; + let buf = scratch.buf(node_size); + stack.get_into(off, buf)?; let nkeys = u64le(&buf[NKEYS_OFF..]) as usize; let leaf = u64le(&buf[LEAF_OFF..]) != 0; let mut i = nkeys; diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 274f3fc..43946e2 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -16,6 +16,49 @@ pub(super) fn get_u64(stack: &BStack, off: u64) -> io::Result { Ok(u64::from_le_bytes(b)) } +/// Inline capacity of a [`Scratch`] buffer. Sized to hold a B-tree node (or a +/// map bucket / key) for any reasonably-small `Pod` key entirely on the stack; +/// unusually large keys spill to the heap. A `BStackBTreeMap` node with minimum +/// degree 8 is `280 + 15 * size_of::()` bytes, so this covers keys up to +/// ~49 bytes without a heap allocation. +const SCRATCH_INLINE: usize = 1024; + +/// A reusable read buffer whose storage is **inline (stack)** for the common +/// small case and spills to the heap only when a requested length exceeds +/// [`SCRATCH_INLINE`]. +/// +/// This lets the hot lookup paths (`get`) read a whole node/bucket without a heap +/// allocation for typical `Pod` keys, while imposing **no** compile-time cap on +/// the key size — the generic-dependent buffer length rules out a plain stack +/// array (`[u8; node_size()]` is rejected as "constant expression depends on a +/// generic parameter"), so this hand-rolled small-buffer stands in for it. +pub(super) struct Scratch { + inline: [u8; SCRATCH_INLINE], + spill: Vec, +} + +impl Scratch { + pub(super) fn new() -> Self { + Scratch { + inline: [0u8; SCRATCH_INLINE], + spill: Vec::new(), + } + } + + /// A `&mut [u8]` of length `n` to read into. The bytes are not cleared — the + /// caller overwrites the whole slice with a `get_into`. + pub(super) fn buf(&mut self, n: usize) -> &mut [u8] { + if n <= SCRATCH_INLINE { + &mut self.inline[..n] + } else { + if self.spill.len() < n { + self.spill.resize(n, 0); + } + &mut self.spill[..n] + } + } +} + /// Allocate a block and write `bytes` as its whole image (one write; released /// without leaking on write failure). pub(super) fn alloc_image( diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e2d5a72..4a7bd4c 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -5003,6 +5003,35 @@ fn stdlib_tree_insert_get_ordered() { tree.bstack_drop(&alloc).unwrap(); } +// A 64-byte Pod key: a B-tree node is 280 + 15*64 = 1240 bytes, past the 1024 +// inline `Scratch` buffer, so `get` exercises the heap-spill fallback. +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, bytemuck::Pod, bytemuck::Zeroable)] +struct BigKey([u64; 8]); + +#[test] +fn stdlib_tree_large_key_spills() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + for i in 0..20u64 { + let mut k = [0u64; 8]; + k[0] = i; + tree.insert(&alloc, BigKey(k), MacroLeaf::new(&alloc, i as u32).unwrap()).unwrap(); + } + for i in 0..20u64 { + let mut k = [0u64; 8]; + k[0] = i; + assert_eq!( + tree.get(stack, &BigKey(k)).unwrap().unwrap().val(stack).unwrap(), + i as u32 + ); + } + tree.bstack_drop(&alloc).unwrap(); +} + #[test] fn stdlib_tree_distinct_tags() { assert_ne!( From 018d0f1cab6c2054756be60e473e5d9821ed7770 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 03:53:14 -0700 Subject: [PATCH 078/140] chore: standarizing reading and writing u64; ran fmt and clippy --- bstack_raii/derive/src/block.rs | 172 ++++++++----- bstack_raii/src/construct.rs | 23 +- bstack_raii/src/layout.rs | 26 +- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/refcount.rs | 11 +- bstack_raii/src/stdlib/boxed.rs | 8 +- bstack_raii/src/stdlib/cow.rs | 3 +- bstack_raii/src/stdlib/deque.rs | 49 ++-- bstack_raii/src/stdlib/list.rs | 42 ++-- bstack_raii/src/stdlib/map.rs | 99 ++++---- bstack_raii/src/stdlib/string.rs | 31 +-- bstack_raii/src/stdlib/tree.rs | 64 +++-- bstack_raii/src/stdlib/util.rs | 7 +- bstack_raii/src/tests.rs | 418 ++++++++++++++++++++++++------- bstack_raii/src/vec.rs | 10 +- bstack_raii/src/wal.rs | 30 ++- 16 files changed, 659 insertions(+), 338 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index ce37f22..dfd9edd 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -178,13 +178,16 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result if u.is_some_and(|u| u.pod) { tp.bounds.push(syn::parse_quote!(::bstack_raii::Pod)); } else { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackBlock)); + tp.bounds + .push(syn::parse_quote!(::bstack_raii::BStackBlock)); if let Some(u) = u { if u.strong { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackShared)); + tp.bounds + .push(syn::parse_quote!(::bstack_raii::BStackShared)); } if u.weak { - tp.bounds.push(syn::parse_quote!(::bstack_raii::BStackWeakable)); + tp.bounds + .push(syn::parse_quote!(::bstack_raii::BStackWeakable)); } } } @@ -251,19 +254,18 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } else { quote!(#on_disk::#od_ty_g) }; - let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = if type_params.is_empty() - && const_params.is_empty() - { - (quote!(), quote!()) - } else { - // Const parameters are held via `[(); N]` so they count as "used". - let const_markers = const_params.iter().map(|c| quote!([(); #c])); - ( - quote!(, ::core::marker::PhantomData< + let (phantom_field, phantom_ctor): (TokenStream, TokenStream) = + if type_params.is_empty() && const_params.is_empty() { + (quote!(), quote!()) + } else { + // Const parameters are held via `[(); N]` so they count as "used". + let const_markers = const_params.iter().map(|c| quote!([(); #c])); + ( + quote!(, ::core::marker::PhantomData< fn() -> (#(#type_params,)* #(#const_markers,)*)>), - quote!(, ::core::marker::PhantomData), - ) - }; + quote!(, ::core::marker::PhantomData), + ) + }; // On-disk fields: header, then the injected refcount/ctrl (if any), then user // fields lowered per annotation. @@ -543,10 +545,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::bstack_raii::BStackRef::<#elem_ty>::from_range( ::bstack_raii::BStackRange::new(__off, #size_elem)) }).bstack_drop(allocator)?;), - Kind::Strong => quote!(<#elem_ty as ::bstack_raii::BStackShared>::drop_strong_ref( + Kind::Strong => { + quote!(<#elem_ty as ::bstack_raii::BStackShared>::drop_strong_ref( unsafe { ::bstack_raii::BStackRef::<#elem_ty>::from_range( ::bstack_raii::BStackRange::new(__off, #size_elem)) }, - allocator)?;), + allocator)?;) + } Kind::Weak => quote!(::bstack_raii::WeakRef::<#elem_ty>(unsafe { ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( ::bstack_raii::BStackRange::new(__off, #ctrl_size)) @@ -652,7 +656,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Owned => ( block_vec_drop_stmt(fname, quote!(BStackBlockVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackBlockVec), nullable), + block_vec_accessor( + vis, + fname, + elem, + &on_disk_ty, + quote!(BStackBlockVec), + nullable, + ), block_vec_ctor( fname, elem, @@ -664,7 +675,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Strong => ( block_vec_drop_stmt(fname, quote!(BStackStrongVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackStrongVec), nullable), + block_vec_accessor( + vis, + fname, + elem, + &on_disk_ty, + quote!(BStackStrongVec), + nullable, + ), block_vec_ctor( fname, elem, @@ -676,7 +694,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Weak => ( block_vec_drop_stmt(fname, quote!(BStackWeakVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackWeakVec), nullable), + block_vec_accessor( + vis, + fname, + elem, + &on_disk_ty, + quote!(BStackWeakVec), + nullable, + ), block_vec_ctor( fname, elem, @@ -688,7 +713,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ), Kind::Ref => ( block_vec_drop_stmt(fname, quote!(BStackRefVec), elem, nullable), - block_vec_accessor(vis, fname, elem, &on_disk_ty, quote!(BStackRefVec), nullable), + block_vec_accessor( + vis, + fname, + elem, + &on_disk_ty, + quote!(BStackRefVec), + nullable, + ), block_vec_ctor( fname, elem, @@ -942,7 +974,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // array of `Pod` is `Pod`.) Stored **flat** as `[u64; N0*..*Nk]` inline // (no data block), one offset per leaf, with per-element ownership; the // accessor / ctor / move traffic in the nested `[[Handle; ..]; ..]` shape. - if kind != Kind::Pod && let Type::Array(_) = opt_inner { + if kind != Kind::Pod + && let Type::Array(_) = opt_inner + { if nullable { return Err(Error::new_spanned( &field.ty, @@ -1040,7 +1074,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // Move: re-home each embedded child to a fresh standalone allocation. let cap = format_ident!("__cap_{}", fname); mv_caps.push(quote!(let #cap = __od.#fname;)); - mv_types.push(nested_ty(&dims, "e!(::bstack_raii::BStackOwned<#child>))); + mv_types.push(nested_ty( + &dims, + "e!(::bstack_raii::BStackOwned<#child>), + )); let mv_read = |k: &Ident| { quote! {{ let __cod = #cap[#k]; @@ -1418,7 +1455,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // packed struct of its (POD) elements is — alignment is irrelevant on disk // — so store it through a generated wrapper and rebuild the tuple on read. // `bstack_move!` hands back the tuple as one element (not flattened). - if kind == Kind::Pod && let Type::Tuple(tup) = inner_ty { + if kind == Kind::Pod + && let Type::Tuple(tup) = inner_ty + { let elems: Vec<&Type> = tup.elems.iter().collect(); let wrapper = format_ident!("__BstackTup_{}_{}", name, fname); let idx: Vec = (0..elems.len()).map(syn::Index::from).collect(); @@ -1662,9 +1701,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }); // A const parameter changes the array width (the layout), so fold its value // in — distinct `N` gives distinct tags. - let const_mixes = const_params.iter().map(|c| { - quote!(.mix(::bstack_raii::EightCC::new((#c as u64).to_le_bytes()))) - }); + let const_mixes = const_params + .iter() + .map(|c| quote!(.mix(::bstack_raii::EightCC::new((#c as u64).to_le_bytes())))); quote!(#data_eightcc #(#mixes)* #(#const_mixes)*) }; @@ -2129,7 +2168,9 @@ fn reject_nested_const_dims( ) -> syn::Result<()> { if dims.len() > 1 && !const_params.is_empty() - && dims.iter().any(|d| tokens_mention(quote!(#d), const_params)) + && dims + .iter() + .any(|d| tokens_mention(quote!(#d), const_params)) { return Err(Error::new_spanned( span, @@ -2333,7 +2374,11 @@ fn vec_accessor( /// Constructor `(param, prep, init)` for a `Vec` / `String` field: allocate /// the data block and store its descriptor inline. A nullable field takes an /// `Option` (`None` => the `0` niche, no allocation). -fn vec_ctor(fname: &Ident, vinfo: &VecInfo, nullable: bool) -> (TokenStream, TokenStream, TokenStream) { +fn vec_ctor( + fname: &Ident, + vinfo: &VecInfo, + nullable: bool, +) -> (TokenStream, TokenStream, TokenStream) { let elem = &vinfo.elem; let base_param: TokenStream = if vinfo.is_string { quote!(&str) @@ -2957,7 +3002,12 @@ fn wrap_move( /// Generate the `set_` method for a `#[bstack_weak]` field: point it at a /// weak target (consumed), releasing whatever it held before. -fn weak_setter(vis: &syn::Visibility, fname: &Ident, fty: &Type, on_disk: &TokenStream) -> TokenStream { +fn weak_setter( + vis: &syn::Visibility, + fname: &Ident, + fty: &Type, + on_disk: &TokenStream, +) -> TokenStream { let setter = format_ident!("set_{}", fname); quote! { #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( @@ -3655,13 +3705,16 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result::from_range(::bstack_raii::BStackRange::new( - u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::bstack_raii::get_u64(&__pl), ::core::mem::size_of::<<#ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, )) } @@ -3782,7 +3835,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::from_range(::bstack_raii::BStackRange::new( - u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::bstack_raii::get_u64(&__pl), ::core::mem::size_of::<<#ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64, )) } @@ -3818,7 +3871,8 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result())); let read_desc = quote!(::bstack_raii::bytemuck::pod_read_unaligned::< - ::bstack_raii::VecDesc>(&__pl[..16])); + ::bstack_raii::VecDesc, + >(&__pl[..16])); // A POD `V(Vec)` / `V(String)` (un-annotated): a plain // `BStackVec` (elem = the whole vec element type, itself @@ -4180,10 +4234,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result); let data_ty_nested = nested_ty(&dims, &data_leaf); data_variants.push(quote!(#vname(#data_ty_nested),)); - let view_leaf = - quote!(::core::option::Option<::bstack_raii::BStackRc<'__e, #elem, __A>>); + let view_leaf = quote!(::core::option::Option<::bstack_raii::BStackRc<'__e, #elem, __A>>); let view_ty_nested = nested_ty(&dims, &view_leaf); view_variants.push(quote!(#vname(#view_ty_nested),)); @@ -4368,8 +4418,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { for __k in 0usize..(#total) { - let __off = u64::from_le_bytes( - __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl[__k * 8..]); let __ctrl = unsafe { ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( ::bstack_raii::BStackRange::new(__off, #ctrl_size)) }; @@ -4381,8 +4430,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { for __k in 0usize..(#total) { - let __off = u64::from_le_bytes( - __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl[__k * 8..]); __plan.bump_weak(__off); } } @@ -4534,8 +4582,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { for __k in 0usize..(#total) { - let __off = u64::from_le_bytes( - __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl[__k * 8..]); if __off != 0 { let __child = unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( @@ -4552,8 +4599,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result clone_arms.push(quote! { #disc => { for __k in 0usize..(#total) { - let __off = u64::from_le_bytes( - __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl[__k * 8..]); if __off != 0 { let __child = <#elem as ::bstack_raii::BStackBlock>::from_range( @@ -4568,8 +4614,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result clone_arms.push(quote! { #disc => { for __k in 0usize..(#total) { - let __off = u64::from_le_bytes( - __pl[__k * 8..__k * 8 + 8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl[__k * 8..]); if __off != 0 { let __data = unsafe { ::bstack_raii::BStackRef::<#elem>::from_range( @@ -4612,7 +4657,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::from_range( ::bstack_raii::BStackRange::new( - u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::bstack_raii::get_u64(&__pl), ::core::mem::size_of::< <#ty as ::bstack_raii::BStackBlock>::OnDisk >() as u64, @@ -4624,7 +4669,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let __off = u64::from_le_bytes(__pl[..8].try_into().unwrap()); + let __off = ::bstack_raii::get_u64(&__pl); let __child = <#ty as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new( __off, @@ -4710,7 +4755,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::Control >::from_range(::bstack_raii::BStackRange::new( - u64::from_le_bytes(__pl[..8].try_into().unwrap()), + ::bstack_raii::get_u64(&__pl), ::core::mem::size_of::< <#ty as ::bstack_raii::BStackWeakable>::Control >() as u64, @@ -4761,7 +4806,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result { - let __ctrl_off = u64::from_le_bytes(__pl[..8].try_into().unwrap()); + let __ctrl_off = ::bstack_raii::get_u64(&__pl); __plan.bump_weak(__ctrl_off); } }); @@ -4946,10 +4991,10 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result>()); + let prefix = attr.tag.as_ref().map_or_else( + || auto_prefix(&type_name), + |t| t.bytes().collect::>(), + ); let tag = build_tag(hash, &prefix); let eightcc = eightcc_expr(&tag.bytes); // For a generic enum, fold each type argument's tag into the discriminant so @@ -4966,7 +5011,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result>(), + || { + prefix + .iter() + .map(u8::to_ascii_lowercase) + .collect::>() + }, |t| t.bytes().collect::>(), ); let ctrl_tag = build_tag(hash, &ctrl_prefix); diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 85ba8d5..1753ffa 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -11,7 +11,7 @@ use core::mem::size_of; use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::block::BStackWeakable; use crate::handle::WeakRef; @@ -20,6 +20,13 @@ use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; use crate::teardown::{BStackDrop, dealloc_range}; +#[inline(always)] +fn read_u64_at(stack: &BStack, off: u64) -> io::Result { + let mut buf = [0u8; 8]; + stack.get_into(off, &mut buf)?; + Ok(layout::get_u64(&buf)) +} + /// Allocate a `size`-byte block and stamp its `BlockHeader { size, tag }`. /// /// Returns the block's range. The bytes after the header are left as the @@ -98,9 +105,9 @@ pub fn build_control_payload(ctrl_tag: EightCC, data_start: u64, control_size: u tag: ctrl_tag, }; payload[..layout::HEADER_SIZE as usize].copy_from_slice(bytemuck::bytes_of(&header)); - put_u64!(payload, layout::CTRL_STRONG_OFFSET, 1); - put_u64!(payload, layout::CTRL_WEAK_OFFSET, 1); - put_u64!(payload, layout::CTRL_DATA_OFFSET, data_start); + put_u64(&mut payload, layout::CTRL_STRONG_OFFSET, 1); + put_u64(&mut payload, layout::CTRL_WEAK_OFFSET, 1); + put_u64(&mut payload, layout::CTRL_DATA_OFFSET, data_start); payload } @@ -121,9 +128,7 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( let stack = allocator.stack(); // Read the old target before overwriting it. - let mut buf = [0u8; 8]; - stack.get_into(field_off, &mut buf)?; - let old = u64::from_le_bytes(buf); + let old = read_u64_at(stack, field_off)?; // Commit the new pointer FIRST, as a single atomic write: the live field // transitions directly from the old target to the new one and is never @@ -155,9 +160,7 @@ pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator>( allocator: &'a A, field_off: u64, ) -> io::Result>> { - let mut buf = [0u8; 8]; - allocator.stack().get_into(field_off, &mut buf)?; - let off = u64::from_le_bytes(buf); + let off = read_u64_at(allocator.stack(), field_off)?; if off == 0 { return Ok(None); } diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index 7828214..7238650 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -5,17 +5,23 @@ use bytemuck::{Pod, Zeroable}; -/// Write `$val` as a little-endian `u64` at byte offset `$off` in the slice -/// `$buf`. The one place the crate builds on-disk integer fields by hand, instead -/// of the ad-hoc `copy_from_slice(&x.to_le_bytes())` pattern repeated across the -/// image builders (control payloads, byte-vec headers, WAL records). -macro_rules! put_u64 { - ($buf:expr, $off:expr, $val:expr) => {{ - let __o = ($off) as usize; - $buf[__o..__o + 8].copy_from_slice(&($val as u64).to_le_bytes()); - }}; +/// Write `val` as a little-endian `u64` at byte offset `off` in `buf`. +/// +/// The one place the crate builds on-disk integer fields by hand, instead of +/// repeating `copy_from_slice(&x.to_le_bytes())` at every image builder. +#[inline(always)] +pub(crate) fn put_u64(buf: &mut [u8], off: u64, val: u64) { + let o = off as usize; + buf[o..o + 8].copy_from_slice(&val.to_le_bytes()); +} + +/// Read a little-endian `u64` from the first 8 bytes of `buf`. +/// +/// This centralizes the crate's fixed-width on-disk `u64` decode pattern. +#[inline(always)] +pub fn get_u64(buf: &[u8]) -> u64 { + u64::from_le_bytes(buf[..8].try_into().unwrap()) } -pub(crate) use put_u64; /// An 8-byte type tag stored in every [`BlockHeader`]. /// diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 6c06479..f6d394e 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -70,14 +70,14 @@ mod tests; pub use block::{ BStackBlock, BStackCast, BStackMove, BStackMoveExpr, BStackShared, BStackWeakable, }; -pub use cast::{BStackCastAs, BStackCastInto}; pub use bulk::{alloc_many, free_many}; +pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::{ClonePlan, TryClone, TryCloneIn}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; -pub use layout::{BlockHeader, EightCC}; +pub use layout::{BlockHeader, EightCC, get_u64}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; diff --git a/bstack_raii/src/refcount.rs b/bstack_raii/src/refcount.rs index 836aa37..20d7bb0 100644 --- a/bstack_raii/src/refcount.rs +++ b/bstack_raii/src/refcount.rs @@ -16,12 +16,9 @@ use std::io; +use crate::layout::get_u64; use bstack::BStack; -fn read_u64(buf: &[u8]) -> u64 { - u64::from_le_bytes(buf[..8].try_into().unwrap()) -} - fn overflow_err() -> io::Error { io::Error::new(io::ErrorKind::InvalidData, "refcount overflow") } @@ -51,7 +48,7 @@ pub fn fetch_add(stack: &BStack, offset: u64, delta: u64) -> io::Result { let mut prev = 0u64; let mut overflow = false; stack.process(offset, offset + 8, |buf| { - let cur = read_u64(buf); + let cur = get_u64(buf); prev = cur; match cur.checked_add(delta) { Some(new) => buf.copy_from_slice(&new.to_le_bytes()), @@ -70,7 +67,7 @@ pub fn fetch_sub(stack: &BStack, offset: u64, delta: u64) -> io::Result { let mut prev = 0u64; let mut underflow = false; stack.process(offset, offset + 8, |buf| { - let cur = read_u64(buf); + let cur = get_u64(buf); prev = cur; match cur.checked_sub(delta) { Some(new) => buf.copy_from_slice(&new.to_le_bytes()), @@ -100,7 +97,7 @@ pub fn increment_if_nonzero(stack: &BStack, offset: u64) -> io::Result BStackBox { // Copy the value out of the packed image without forming a reference to // the (alignment-1) `value` field. let off = HEADER_SIZE as usize; - Ok(bytemuck::pod_read_unaligned::(&buf[off..off + size_of::()])) + Ok(bytemuck::pod_read_unaligned::( + &buf[off..off + size_of::()], + )) } /// Overwrite the boxed value in place. @@ -173,9 +175,9 @@ impl BStackMove for BStackBox { /// Moving a box out yields the plain value. type Fields<'a, A: BStackOwnedSliceAllocator> = T; - fn bstack_move<'a, A: BStackOwnedSliceAllocator>( + fn bstack_move( owned: BStackOwned, - allocator: &'a A, + allocator: &A, ) -> io::Result { let me = owned.into_inner(); let value = me.get(allocator.stack())?; diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs index 3b4e8b0..d5c33ab 100644 --- a/bstack_raii/src/stdlib/cow.rs +++ b/bstack_raii/src/stdlib/cow.rs @@ -131,7 +131,8 @@ impl BStackCow { { if let BStackCow::Borrowed(r) = self { // `BStackRef` is `Copy`; take the range out before we overwrite it. - let owned = ::from_range((*r).into_range()).try_clone_in(allocator)?; + let owned = + ::from_range((*r).into_range()).try_clone_in(allocator)?; *self = BStackCow::Owned(owned); } match self { diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index 9e4fb45..4c6439b 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -39,7 +39,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, get_u64}; +use super::util::{alloc_image, atomic_update, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -104,10 +104,10 @@ impl BStackDeque { /// `handle`. fn read_meta(stack: &BStack, handle: u64) -> io::Result<(u64, u64, u64, u64)> { Ok(( - get_u64(stack, handle + HEAD_OFF)?, - get_u64(stack, handle + LEN_OFF)?, - get_u64(stack, handle + CAP_OFF)?, - get_u64(stack, handle + DATA_OFF)?, + read_u64(stack, handle + HEAD_OFF)?, + read_u64(stack, handle + LEN_OFF)?, + read_u64(stack, handle + CAP_OFF)?, + read_u64(stack, handle + DATA_OFF)?, )) } @@ -160,7 +160,7 @@ impl BStackDeque { /// Number of elements. pub fn len(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + LEN_OFF) + read_u64(stack, self.range.start() + LEN_OFF) } /// Whether the deque has no elements. @@ -170,7 +170,7 @@ impl BStackDeque { /// Current ring capacity (elements storable before the next growth). pub fn capacity(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + CAP_OFF) + read_u64(stack, self.range.start() + CAP_OFF) } /// Append a value to the back, taking ownership of its block. Grows the ring @@ -297,7 +297,9 @@ impl BStackDeque { return Ok(None); } // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(val.get())) + })) } /// Remove and return the first element (as an owned value block), or `None` @@ -342,7 +344,9 @@ impl BStackDeque { return Ok(None); } // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(val.get())) + })) } /// Grow the ring to at least double its capacity, atomically snapshotting and @@ -350,7 +354,7 @@ impl BStackDeque { /// again) if another thread already made room. fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); - let cap0 = get_u64(allocator.stack(), handle + CAP_OFF)?; + let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; // Allocate the new ring up front (an orphan until the commit swaps to it). let newring = allocator.alloc(newcap * 8)?.as_range().start(); @@ -426,7 +430,10 @@ impl BStackDeque { if len == 0 { return Ok(None); } - Ok(Some(Self::value_at(get_u64(stack, data + (head % cap) * 8)?))) + Ok(Some(Self::value_at(read_u64( + stack, + data + (head % cap) * 8, + )?))) } /// A **borrowed** handle to the back value (no ownership), or `None` if empty. @@ -435,9 +442,10 @@ impl BStackDeque { if len == 0 { return Ok(None); } - Ok(Some(Self::value_at( - get_u64(stack, data + ((head + len - 1) % cap) * 8)?, - ))) + Ok(Some(Self::value_at(read_u64( + stack, + data + ((head + len - 1) % cap) * 8, + )?))) } /// Collect **borrowed** handles to every value, front to back. The handles @@ -447,7 +455,10 @@ impl BStackDeque { let (head, len, cap, data) = Self::read_meta(stack, self.range.start())?; let mut out = Vec::with_capacity(len as usize); for i in 0..len { - out.push(Self::value_at(get_u64(stack, data + ((head + i) % cap) * 8)?)); + out.push(Self::value_at(read_u64( + stack, + data + ((head + i) % cap) * 8, + )?)); } Ok(out) } @@ -491,7 +502,7 @@ impl BStackBlock for BStackDeque { ) -> io::Result<()> { let (head, len, cap, data) = Self::read_meta(allocator.stack(), range.start())?; for i in 0..len { - let r = get_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; + let r = read_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; if r != 0 { // SAFETY: the deque solely owns each element block. let owned = unsafe { BStackOwned::from_raw(Self::value_at(r)) }; @@ -519,9 +530,11 @@ impl BStackBlock for BStackDeque { // Deep-clone each element (in logical order) into the plan. let mut dsts = Vec::with_capacity(len as usize); for i in 0..len { - let r = get_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; + let r = read_u64(allocator.stack(), data + ((head + i) % cap) * 8)?; let dst = if r != 0 { - Self::value_at(r).__bstack_clone_into(allocator, plan)?.start() + Self::value_at(r) + .__bstack_clone_into(allocator, plan)? + .start() } else { 0 }; diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index c548b4b..8ce64d1 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -36,7 +36,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, get_u64}; +use super::util::{alloc_image, atomic_update, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -153,7 +153,7 @@ impl BStackLinkedList { /// Number of elements. pub fn len(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + LEN_OFF) + read_u64(stack, self.range.start() + LEN_OFF) } /// Whether the list has no elements. @@ -305,7 +305,9 @@ impl BStackLinkedList { // SAFETY: the node is unlinked and solely ours. unsafe { dealloc_range(allocator, BStackRange::new(node.get(), NODE_SIZE))? }; // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(val.get())) + })) } /// Remove and return the first element (as an owned value block), or `None` @@ -357,27 +359,29 @@ impl BStackLinkedList { // SAFETY: the node is unlinked and solely ours. unsafe { dealloc_range(allocator, BStackRange::new(node.get(), NODE_SIZE))? }; // SAFETY: the value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(val.get())) + })) } /// A **borrowed** handle to the first value (no ownership; frees nothing), or /// `None` if empty. pub fn front(&self, stack: &BStack) -> io::Result> { - let head = get_u64(stack, self.range.start() + HEAD_OFF)?; + let head = read_u64(stack, self.range.start() + HEAD_OFF)?; if head == 0 { return Ok(None); } - Ok(Some(Self::value_at(get_u64(stack, head + NVAL_OFF)?))) + Ok(Some(Self::value_at(read_u64(stack, head + NVAL_OFF)?))) } /// A **borrowed** handle to the last value (no ownership; frees nothing), or /// `None` if empty. pub fn back(&self, stack: &BStack) -> io::Result> { - let tail = get_u64(stack, self.range.start() + TAIL_OFF)?; + let tail = read_u64(stack, self.range.start() + TAIL_OFF)?; if tail == 0 { return Ok(None); } - Ok(Some(Self::value_at(get_u64(stack, tail + NVAL_OFF)?))) + Ok(Some(Self::value_at(read_u64(stack, tail + NVAL_OFF)?))) } /// Collect **borrowed** handles to every value, front to back. The handles @@ -385,10 +389,10 @@ impl BStackLinkedList { /// list does. pub fn to_vec(&self, stack: &BStack) -> io::Result> { let mut out = Vec::new(); - let mut cur = get_u64(stack, self.range.start() + HEAD_OFF)?; + let mut cur = read_u64(stack, self.range.start() + HEAD_OFF)?; while cur != 0 { - out.push(Self::value_at(get_u64(stack, cur + NVAL_OFF)?)); - cur = get_u64(stack, cur + NNEXT_OFF)?; + out.push(Self::value_at(read_u64(stack, cur + NVAL_OFF)?)); + cur = read_u64(stack, cur + NNEXT_OFF)?; } Ok(out) } @@ -434,10 +438,10 @@ impl BStackBlock for BStackLinkedList { range: BStackRange, allocator: &A, ) -> io::Result<()> { - let mut cur = get_u64(allocator.stack(), range.start() + HEAD_OFF)?; + let mut cur = read_u64(allocator.stack(), range.start() + HEAD_OFF)?; while cur != 0 { - let next = get_u64(allocator.stack(), cur + NNEXT_OFF)?; - let val = get_u64(allocator.stack(), cur + NVAL_OFF)?; + let next = read_u64(allocator.stack(), cur + NNEXT_OFF)?; + let val = read_u64(allocator.stack(), cur + NVAL_OFF)?; if val != 0 { // Recursively free the value block (its own children, then it). // SAFETY: the list solely owns each value block. @@ -463,10 +467,10 @@ impl BStackBlock for BStackLinkedList { // 1. Gather the source value offsets in order. let mut vals = Vec::new(); - let mut cur = get_u64(allocator.stack(), src + HEAD_OFF)?; + let mut cur = read_u64(allocator.stack(), src + HEAD_OFF)?; while cur != 0 { - vals.push(get_u64(allocator.stack(), cur + NVAL_OFF)?); - cur = get_u64(allocator.stack(), cur + NNEXT_OFF)?; + vals.push(read_u64(allocator.stack(), cur + NVAL_OFF)?); + cur = read_u64(allocator.stack(), cur + NNEXT_OFF)?; } let n = vals.len(); @@ -474,7 +478,9 @@ impl BStackBlock for BStackLinkedList { let mut val_dsts = Vec::with_capacity(n); for &v in &vals { let dst = if v != 0 { - Self::value_at(v).__bstack_clone_into(allocator, plan)?.start() + Self::value_at(v) + .__bstack_clone_into(allocator, plan)? + .start() } else { 0 }; diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index c91b663..6494b56 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -40,10 +40,10 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{Scratch, alloc_image, get_u64}; +use super::util::{Scratch, alloc_image, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; -use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; use crate::owned::BStackOwned; use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; @@ -81,11 +81,6 @@ const EMPTY: u64 = 0; const OCCUPIED: u64 = 1; const TOMBSTONE: u64 = 2; -/// Read a little-endian `u64` from the first 8 bytes of `b`. -fn u64le(b: &[u8]) -> u64 { - u64::from_le_bytes(b[..8].try_into().unwrap()) -} - /// 64-bit FNV-1a over `bytes`. Deterministic (so it is stable on disk). fn fnv1a(bytes: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; @@ -163,10 +158,10 @@ where // 2. Parse it once. if meta.is_none() { let m = Meta { - table: u64le(&meta_buf[0..8]), - cap: u64le(&meta_buf[8..16]), - len: u64le(&meta_buf[16..24]), - used: u64le(&meta_buf[24..32]), + table: get_u64(&meta_buf[0..8]), + cap: get_u64(&meta_buf[8..16]), + len: get_u64(&meta_buf[16..24]), + used: get_u64(&meta_buf[24..32]), }; mask = m.cap.wrapping_sub(1); cur = if m.cap == 0 { 0 } else { hash & mask }; @@ -197,7 +192,10 @@ where // (and is inspected) before the next is issued. let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut bucket_buf[..]) }; - return Some(BStackGenOp::Read { offset: off, buf: b }); + return Some(BStackGenOp::Read { + offset: off, + buf: b, + }); } } // 4. Commit the chosen writes together. @@ -207,7 +205,10 @@ where let (off, ref bytes) = writes[i]; // SAFETY: `writes` outlives the call and is not mutated after this point. let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { offset: off, data: d }); + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); } None }) @@ -296,7 +297,7 @@ impl BStackHashMap { /// Number of live entries. pub fn len(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + LEN_OFF) + read_u64(stack, self.range.start() + LEN_OFF) } /// Whether the map has no entries. @@ -325,8 +326,8 @@ impl BStackHashMap { loop { // Proactively keep the load factor under 3/4 (also clears tombstones). - let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; - let used = get_u64(allocator.stack(), handle + USED_OFF)?; + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; + let used = read_u64(allocator.stack(), handle + USED_OFF)?; if cap == 0 || (used + 1) * 4 > cap * 3 { self.grow(allocator)?; continue; @@ -343,7 +344,7 @@ impl BStackHashMap { stride, hash, |m, idx, buf| { - let state = u64le(&buf[0..8]); + let state = get_u64(&buf[0..8]); if state == EMPTY { let target = first_tomb.get().unwrap_or(idx); let slot_was_empty = first_tomb.get().is_none(); @@ -360,7 +361,7 @@ impl BStackHashMap { )) } else if state == OCCUPIED && buf[8..8 + ksz] == key_bytes[..] { // Overwrite: replace the value ref, hand back the old one. - old_value.set(u64le(&buf[8 + ksz..8 + ksz + 8])); + old_value.set(get_u64(&buf[8 + ksz..8 + ksz + 8])); is_new.set(false); let value_off = m.table + idx * stride + 8 + ksz as u64; ProbeStep::Stop(vec![(value_off, val_ref.to_le_bytes().to_vec())]) @@ -390,7 +391,9 @@ impl BStackHashMap { Ok(None) } else { // SAFETY: the replaced value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(old_value.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(old_value.get())) + })) }; } } @@ -417,12 +420,12 @@ impl BStackHashMap { stride, hash, |m, idx, buf| { - let state = u64le(&buf[0..8]); + let state = get_u64(&buf[0..8]); if state == EMPTY { ProbeStep::Stop(Vec::new()) // absent: commit nothing } else if state == OCCUPIED && buf[8..8 + ksz] == key_bytes[..] { found.set(true); - old_value.set(u64le(&buf[8 + ksz..8 + ksz + 8])); + old_value.set(get_u64(&buf[8 + ksz..8 + ksz + 8])); ProbeStep::Stop(vec![ (m.table + idx * stride, TOMBSTONE.to_le_bytes().to_vec()), (handle + LEN_OFF, (m.len - 1).to_le_bytes().to_vec()), @@ -436,7 +439,9 @@ impl BStackHashMap { if found.get() { // SAFETY: the removed value block's ownership transfers to the caller. - Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(old_value.get())) })) + Ok(Some(unsafe { + BStackOwned::from_raw(Self::value_at(old_value.get())) + })) } else { Ok(None) } @@ -451,8 +456,8 @@ impl BStackHashMap { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); - let table = get_u64(stack, handle + TABLE_OFF)?; - let cap = get_u64(stack, handle + CAP_OFF)?; + let table = read_u64(stack, handle + TABLE_OFF)?; + let cap = read_u64(stack, handle + CAP_OFF)?; if cap == 0 { return Ok(None); } @@ -463,7 +468,7 @@ impl BStackHashMap { let mut scratch = Scratch::new(); for _ in 0..cap { let bucket = table + idx * stride; - let state = get_u64(stack, bucket)?; + let state = read_u64(stack, bucket)?; if state == EMPTY { return Ok(None); } @@ -471,7 +476,7 @@ impl BStackHashMap { let kb = scratch.buf(ksz); stack.get_into(bucket + 8, kb)?; if kb == key_bytes { - let vref = get_u64(stack, bucket + 8 + ksz as u64)?; + let vref = read_u64(stack, bucket + 8 + ksz as u64)?; return Ok(Some(Self::value_at(vref))); } } @@ -492,7 +497,7 @@ impl BStackHashMap { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); - let cap0 = get_u64(allocator.stack(), handle + CAP_OFF)?; + let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; // Allocate the new bucket block up front (an orphan until the swap). let newtable = allocator.alloc(newcap * stride)?.as_range().start(); @@ -525,10 +530,10 @@ impl BStackHashMap { } if meta.is_none() { let m = Meta { - table: u64le(&meta_buf[0..8]), - cap: u64le(&meta_buf[8..16]), - len: u64le(&meta_buf[16..24]), - used: u64le(&meta_buf[24..32]), + table: get_u64(&meta_buf[0..8]), + cap: get_u64(&meta_buf[8..16]), + len: get_u64(&meta_buf[16..24]), + used: get_u64(&meta_buf[24..32]), }; // Abort if someone already grew to at least this size. if newcap <= m.cap { @@ -567,7 +572,7 @@ impl BStackHashMap { let newmask = newcap - 1; for j in 0..m.cap { let lo = (j * stride) as usize; - if u64le(&old_buf[lo..lo + 8]) != OCCUPIED { + if get_u64(&old_buf[lo..lo + 8]) != OCCUPIED { continue; } let kb = &old_buf[lo + 8..lo + 8 + ksz]; @@ -575,7 +580,7 @@ impl BStackHashMap { let mut idx = fnv1a(kb) & newmask; loop { let nlo = (idx * stride) as usize; - if u64le(&new_image[nlo..nlo + 8]) == EMPTY { + if get_u64(&new_image[nlo..nlo + 8]) == EMPTY { new_image[nlo..nlo + 8].copy_from_slice(&OCCUPIED.to_le_bytes()); new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); new_image[nlo + 8 + ksz..nlo + 16 + ksz].copy_from_slice(vref); @@ -596,7 +601,10 @@ impl BStackHashMap { let (off, ref bytes) = writes[i]; // SAFETY: `writes` outlives the call and is not mutated after build. let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { offset: off, data: d }); + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); } None })?; @@ -613,7 +621,8 @@ impl BStackHashMap { } } else { // SAFETY: `newtable` was never linked into the descriptor. - let _ = unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; } Ok(()) } @@ -659,12 +668,12 @@ impl BStackBlock for BStackHashMap { let stride = Self::stride(); let ksz = Self::ksize() as u64; let handle = range.start(); - let table = get_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; + let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; for j in 0..cap { let bucket = table + j * stride; - if get_u64(allocator.stack(), bucket)? == OCCUPIED { - let vref = get_u64(allocator.stack(), bucket + 8 + ksz)?; + if read_u64(allocator.stack(), bucket)? == OCCUPIED { + let vref = read_u64(allocator.stack(), bucket + 8 + ksz)?; if vref != 0 { // SAFETY: the map solely owns each value block. let owned = unsafe { BStackOwned::from_raw(Self::value_at(vref)) }; @@ -691,10 +700,10 @@ impl BStackBlock for BStackHashMap { let stride = Self::stride(); let ksz = Self::ksize(); let handle = self.range.start(); - let table = get_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = get_u64(allocator.stack(), handle + CAP_OFF)?; - let len = get_u64(allocator.stack(), handle + LEN_OFF)?; - let used = get_u64(allocator.stack(), handle + USED_OFF)?; + let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; + let len = read_u64(allocator.stack(), handle + LEN_OFF)?; + let used = read_u64(allocator.stack(), handle + USED_OFF)?; let (new_table, new_cap, new_used) = if cap == 0 { (0, 0, 0) @@ -704,10 +713,10 @@ impl BStackBlock for BStackHashMap { allocator.stack().get_into(table, &mut image)?; for j in 0..cap as usize { let lo = j * stride as usize; - if u64le(&image[lo..lo + 8]) != OCCUPIED { + if get_u64(&image[lo..lo + 8]) != OCCUPIED { continue; } - let vref = u64le(&image[lo + 8 + ksz..lo + 16 + ksz]); + let vref = get_u64(&image[lo + 8 + ksz..lo + 16 + ksz]); let cloned = Self::value_at(vref) .__bstack_clone_into(allocator, plan)? .start(); diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index 03e5d25..4dee228 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -19,7 +19,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, get_u64}; +use super::util::{alloc_image, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -57,10 +57,7 @@ pub struct BStackString { impl BStackString { /// Allocate a bytes block holding `bytes` (or return `0` for an empty slice), /// releasing it without leaking on write failure. - fn alloc_bytes( - allocator: &A, - bytes: &[u8], - ) -> io::Result { + fn alloc_bytes(allocator: &A, bytes: &[u8]) -> io::Result { if bytes.is_empty() { return Ok(0); } @@ -102,7 +99,7 @@ impl BStackString { /// Length in bytes. pub fn len(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + LEN_OFF) + read_u64(stack, self.range.start() + LEN_OFF) } /// Whether the string is empty. @@ -112,8 +109,8 @@ impl BStackString { /// Read the raw UTF-8 bytes. pub fn read_bytes(&self, stack: &BStack) -> io::Result> { - let data = get_u64(stack, self.range.start() + DATA_OFF)?; - let len = get_u64(stack, self.range.start() + LEN_OFF)? as usize; + let data = read_u64(stack, self.range.start() + DATA_OFF)?; + let len = read_u64(stack, self.range.start() + LEN_OFF)? as usize; let mut buf = vec![0u8; len]; if len != 0 { stack.get_into(data, &mut buf)?; @@ -140,8 +137,8 @@ impl BStackString { let newlen = s.len() as u64; let newdata = Self::alloc_bytes(allocator, s.as_bytes())?; - let old_data = get_u64(stack, handle + DATA_OFF)?; - let old_len = get_u64(stack, handle + LEN_OFF)?; + let old_data = read_u64(stack, handle + DATA_OFF)?; + let old_len = read_u64(stack, handle + LEN_OFF)?; // `data` and `len` are contiguous — swap both in one 16-byte write. let mut buf = [0u8; 16]; @@ -163,11 +160,7 @@ impl BStackString { } /// Append `s` to the string. - pub fn push_str( - &self, - allocator: &A, - s: &str, - ) -> io::Result<()> { + pub fn push_str(&self, allocator: &A, s: &str) -> io::Result<()> { let mut cur = self.to_string(allocator.stack())?; cur.push_str(s); self.set(allocator, &cur) @@ -203,8 +196,8 @@ impl BStackBlock for BStackString { range: BStackRange, allocator: &A, ) -> io::Result<()> { - let data = get_u64(allocator.stack(), range.start() + DATA_OFF)?; - let len = get_u64(allocator.stack(), range.start() + LEN_OFF)?; + let data = read_u64(allocator.stack(), range.start() + DATA_OFF)?; + let len = read_u64(allocator.stack(), range.start() + LEN_OFF)?; if data != 0 { // SAFETY: the string solely owns its bytes block. unsafe { dealloc_range(allocator, BStackRange::new(data, len))? }; @@ -220,8 +213,8 @@ impl BStackBlock for BStackString { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let data = get_u64(allocator.stack(), handle + DATA_OFF)?; - let len = get_u64(allocator.stack(), handle + LEN_OFF)?; + let data = read_u64(allocator.stack(), handle + DATA_OFF)?; + let len = read_u64(allocator.stack(), handle + LEN_OFF)?; let new_data = if len != 0 { let mut bytes = vec![0u8; len as usize]; diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index ca672b0..d09627a 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -43,10 +43,10 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{Scratch, alloc_image, get_u64}; +use super::util::{Scratch, alloc_image, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; -use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; use crate::owned::BStackOwned; use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; @@ -77,11 +77,6 @@ const NKEYS_OFF: usize = HEADER_SIZE as usize; // 16 const LEAF_OFF: usize = HEADER_SIZE as usize + 8; // 24 const KEYS_OFF: usize = HEADER_SIZE as usize + 16; // 32 -/// Read a little-endian `u64` from the first 8 bytes of `b`. -fn u64le(b: &[u8]) -> u64 { - u64::from_le_bytes(b[..8].try_into().unwrap()) -} - /// A node decoded for building/mutation: keys as raw `K` bytes, value refs, and /// (for an internal node) child node offsets (`keys.len() + 1` of them). struct BNode { @@ -190,7 +185,7 @@ impl BStackBTreeMap { /// Number of entries. pub fn len(&self, stack: &BStack) -> io::Result { - get_u64(stack, self.range.start() + LEN_OFF) + read_u64(stack, self.range.start() + LEN_OFF) } /// Whether the tree has no entries. @@ -202,8 +197,8 @@ impl BStackBTreeMap { fn read_node(stack: &BStack, off: u64) -> io::Result { let mut b = vec![0u8; Self::node_size() as usize]; stack.get_into(off, &mut b)?; - let nkeys = u64le(&b[NKEYS_OFF..]) as usize; - let leaf = u64le(&b[LEAF_OFF..]) != 0; + let nkeys = get_u64(&b[NKEYS_OFF..]) as usize; + let leaf = get_u64(&b[LEAF_OFF..]) != 0; let ksize = Self::ksize(); let vals_off = Self::vals_off(); let children_off = Self::children_off(); @@ -212,12 +207,12 @@ impl BStackBTreeMap { for i in 0..nkeys { let ko = KEYS_OFF + i * ksize; keys.push(b[ko..ko + ksize].to_vec()); - vals.push(u64le(&b[vals_off + i * 8..])); + vals.push(get_u64(&b[vals_off + i * 8..])); } let mut children = Vec::new(); if !leaf { for i in 0..=nkeys { - children.push(u64le(&b[children_off + i * 8..])); + children.push(get_u64(&b[children_off + i * 8..])); } } Ok(BNode { @@ -335,8 +330,8 @@ impl BStackBTreeMap { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let val_ref = value.into_inner().range().start(); - let root = get_u64(stack, handle + ROOT_OFF)?; - let len = get_u64(stack, handle + LEN_OFF)?; + let root = read_u64(stack, handle + ROOT_OFF)?; + let len = read_u64(stack, handle + LEN_OFF)?; let mut build = Build { allocator, @@ -395,8 +390,7 @@ impl BStackBTreeMap { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; } - Ok(old - .map(|o| unsafe { BStackOwned::from_raw(Self::value_at(o)) })) + Ok(old.map(|o| unsafe { BStackOwned::from_raw(Self::value_at(o)) })) } Err(e) => { // Nothing committed: reclaim the new nodes we allocated. @@ -411,8 +405,9 @@ impl BStackBTreeMap { } Err(e) => { for (off, _) in &build.writes { - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; } Err(e) } @@ -422,7 +417,7 @@ impl BStackBTreeMap { /// A **borrowed** handle to the value mapped by `key` (no ownership), or /// `None` if absent. pub fn get(&self, stack: &BStack, key: &K) -> io::Result> { - let mut off = get_u64(stack, self.range.start() + ROOT_OFF)?; + let mut off = read_u64(stack, self.range.start() + ROOT_OFF)?; let ksize = Self::ksize(); let vals_off = Self::vals_off(); let children_off = Self::children_off(); @@ -433,8 +428,8 @@ impl BStackBTreeMap { while off != 0 { let buf = scratch.buf(node_size); stack.get_into(off, buf)?; - let nkeys = u64le(&buf[NKEYS_OFF..]) as usize; - let leaf = u64le(&buf[LEAF_OFF..]) != 0; + let nkeys = get_u64(&buf[NKEYS_OFF..]) as usize; + let leaf = get_u64(&buf[LEAF_OFF..]) != 0; let mut i = nkeys; let mut exact = false; for j in 0..nkeys { @@ -453,12 +448,12 @@ impl BStackBTreeMap { } } if exact { - return Ok(Some(Self::value_at(u64le(&buf[vals_off + i * 8..])))); + return Ok(Some(Self::value_at(get_u64(&buf[vals_off + i * 8..])))); } if leaf { return Ok(None); } - off = u64le(&buf[children_off + i * 8..]); + off = get_u64(&buf[children_off + i * 8..]); } Ok(None) } @@ -479,7 +474,7 @@ impl BStackBTreeMap { } fn extreme(&self, stack: &BStack, leftmost: bool) -> io::Result> { - let mut off = get_u64(stack, self.range.start() + ROOT_OFF)?; + let mut off = read_u64(stack, self.range.start() + ROOT_OFF)?; if off == 0 { return Ok(None); } @@ -487,7 +482,10 @@ impl BStackBTreeMap { let nb = Self::read_node(stack, off)?; if nb.leaf { let i = if leftmost { 0 } else { nb.keys.len() - 1 }; - return Ok(Some((Self::read_key(&nb.keys[i]), Self::value_at(nb.vals[i])))); + return Ok(Some(( + Self::read_key(&nb.keys[i]), + Self::value_at(nb.vals[i]), + ))); } off = if leftmost { nb.children[0] @@ -501,7 +499,7 @@ impl BStackBTreeMap { /// (do not free them; valid only while the tree does). pub fn to_vec(&self, stack: &BStack) -> io::Result> { let mut out = Vec::new(); - let root = get_u64(stack, self.range.start() + ROOT_OFF)?; + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; Self::collect(stack, root, &mut out)?; Ok(out) } @@ -569,15 +567,15 @@ impl BStackBTreeMap { } let mut buf = vec![0u8; Self::node_size() as usize]; stack.get_into(off, &mut buf)?; - let nkeys = u64le(&buf[NKEYS_OFF..]) as usize; - let leaf = u64le(&buf[LEAF_OFF..]) != 0; + let nkeys = get_u64(&buf[NKEYS_OFF..]) as usize; + let leaf = get_u64(&buf[LEAF_OFF..]) != 0; let vals_off = Self::vals_off(); let children_off = Self::children_off(); // Deep-clone each value and repoint it in the copy. for i in 0..nkeys { let vo = vals_off + i * 8; - let vref = u64le(&buf[vo..]); + let vref = get_u64(&buf[vo..]); let cloned = Self::value_at(vref) .__bstack_clone_into(allocator, plan)? .start(); @@ -587,7 +585,7 @@ impl BStackBTreeMap { if !leaf { for i in 0..=nkeys { let co = children_off + i * 8; - let child = u64le(&buf[co..]); + let child = get_u64(&buf[co..]); let new_child = Self::clone_subtree(stack, child, allocator, plan)?; buf[co..co + 8].copy_from_slice(&new_child.to_le_bytes()); } @@ -627,7 +625,7 @@ impl BStackBlock for BStackBTreeMap { range: BStackRange, allocator: &A, ) -> io::Result<()> { - let root = get_u64(allocator.stack(), range.start() + ROOT_OFF)?; + let root = read_u64(allocator.stack(), range.start() + ROOT_OFF)?; Self::drop_subtree(allocator.stack(), root, allocator) } @@ -640,8 +638,8 @@ impl BStackBlock for BStackBTreeMap { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let root = get_u64(allocator.stack(), handle + ROOT_OFF)?; - let len = get_u64(allocator.stack(), handle + LEN_OFF)?; + let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; + let len = read_u64(allocator.stack(), handle + LEN_OFF)?; let new_root = Self::clone_subtree(allocator.stack(), root, allocator, plan)?; let handle_dst = plan.alloc_raw(allocator, TREE_SIZE)?; diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 43946e2..ef227f8 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -10,7 +10,7 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; /// Read a little-endian `u64` at absolute offset `off`. -pub(super) fn get_u64(stack: &BStack, off: u64) -> io::Result { +pub(super) fn read_u64(stack: &BStack, off: u64) -> io::Result { let mut b = [0u8; 8]; stack.get_into(off, &mut b)?; Ok(u64::from_le_bytes(b)) @@ -163,7 +163,10 @@ where let (off, ref bytes) = writes[i]; // SAFETY: `writes` outlives this call and is not mutated after Transition B. let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { offset: off, data: d }); + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); } None }) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 4a7bd4c..40ff01f 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -573,7 +573,10 @@ fn macro_clone_deep_owned() { // Freeing the clone frees only the clone's subtree; the original stays intact. clone.bstack_drop(&alloc).unwrap(); - assert_eq!(parent.handle().child(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!( + parent.handle().child(stack).unwrap().val(stack).unwrap(), + 42 + ); parent.bstack_drop(&alloc).unwrap(); } @@ -591,8 +594,9 @@ fn macro_clone_bumps_shared_refcount() { // Resolve the child's strong-count offset: parent.s (first user field) -> // data block -> ctrl back-pointer -> strong counter. - let s_data = crate::refcount::load(stack, parent.handle().range().start() + layout::HEADER_SIZE) - .unwrap(); + let s_data = + crate::refcount::load(stack, parent.handle().range().start() + layout::HEADER_SIZE) + .unwrap(); let ctrl = crate::refcount::load(stack, s_data + layout::CTRL_BACKPTR_OFFSET).unwrap(); let strong_off = ctrl + layout::CTRL_STRONG_OFFSET; assert_eq!(crate::refcount::load(stack, strong_off).unwrap(), 2); @@ -2377,7 +2381,13 @@ fn macro_clone_embed() { // Freeing the clone frees only the clone's leaf; the original stays intact. clone.bstack_drop(&alloc).unwrap(); assert_eq!( - holder.handle().child().leaf(stack).unwrap().val(stack).unwrap(), + holder + .handle() + .child() + .leaf(stack) + .unwrap() + .val(stack) + .unwrap(), 42 ); holder.bstack_drop(&alloc).unwrap(); @@ -2653,8 +2663,12 @@ fn macro_weak_array() { assert!(arr[0].is_none() && arr[1].is_none()); // Wire each element via the per-index setter. - h.handle().set_weaks(&alloc, 0, c0.downgrade().unwrap()).unwrap(); - h.handle().set_weaks(&alloc, 1, c1.downgrade().unwrap()).unwrap(); + h.handle() + .set_weaks(&alloc, 0, c0.downgrade().unwrap()) + .unwrap(); + h.handle() + .set_weaks(&alloc, 1, c1.downgrade().unwrap()) + .unwrap(); // The accessor upgrades each live element. let arr = h.handle().weaks(&alloc).unwrap(); @@ -2810,12 +2824,22 @@ fn macro_embed_array() { kids[0].leaf(stack).unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().kids()[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + assert_eq!( + h.handle().kids()[1] + .leaf(stack) + .unwrap() + .val(stack) + .unwrap(), + 20 + ); // Move re-homes each embedded child to a fresh standalone allocation. let (moved, tag) = bstack_move!(h, &alloc).unwrap(); assert_eq!(tag, 99); - assert_eq!(moved[0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + moved[0].handle().leaf(stack).unwrap().val(stack).unwrap(), + 10 + ); for m in moved { m.bstack_drop(&alloc).unwrap(); } @@ -3001,12 +3025,7 @@ fn macro_owned_nested_array() { let stack = alloc.stack(); let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); - let h = OwnedGrid::new( - &alloc, - [[mk(1), mk(2)], [mk(3), mk(4)]], - 7, - ) - .unwrap(); + let h = OwnedGrid::new(&alloc, [[mk(1), mk(2)], [mk(3), mk(4)]], 7).unwrap(); assert_eq!(h.handle().tag(stack).unwrap(), 7); let g = h.handle().grid(stack).unwrap(); // [[MacroLeaf; 2]; 2] @@ -3096,7 +3115,15 @@ fn macro_embed_nested_array() { let (moved, tag) = bstack_move!(h, &alloc).unwrap(); assert_eq!(tag, 5); - assert_eq!(moved[0][0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + moved[0][0] + .handle() + .leaf(stack) + .unwrap() + .val(stack) + .unwrap(), + 10 + ); for row in moved { for m in row { m.bstack_drop(&alloc).unwrap(); @@ -3151,7 +3178,10 @@ fn macro_enum_owned_option_array() { match bstack_move!(e, &alloc).unwrap() { OptArrEnumData::Slots(arr) => { - assert_eq!(arr[0].as_ref().map(|h| h.handle().val(stack).unwrap()), Some(10)); + assert_eq!( + arr[0].as_ref().map(|h| h.handle().val(stack).unwrap()), + Some(10) + ); assert!(arr[1].is_none()); for slot in arr.into_iter().flatten() { slot.bstack_drop(&alloc).unwrap(); @@ -3187,7 +3217,9 @@ fn macro_enum_embed_array() { let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { - EmbArrEnumView::Kids(arr) => assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20), + EmbArrEnumView::Kids(arr) => { + assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20) + } _ => panic!("expected Kids"), } clone.bstack_drop(&alloc).unwrap(); @@ -3272,15 +3304,24 @@ fn macro_pod_vec_array() { // Each slot is an independent, growable vector. let mut rows_mut = h.handle().rows(&alloc).unwrap(); rows_mut[0].push(99).unwrap(); - assert_eq!(h.handle().rows(&alloc).unwrap()[0].to_vec().unwrap(), vec![1u32, 2, 99]); - assert_eq!(h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), vec![3u32, 4, 5]); + assert_eq!( + h.handle().rows(&alloc).unwrap()[0].to_vec().unwrap(), + vec![1u32, 2, 99] + ); + assert_eq!( + h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), + vec![3u32, 4, 5] + ); // Clone deep-copies both data blocks. let clone = h.try_clone_in(&alloc).unwrap(); let crows = clone.handle().rows(&alloc).unwrap(); assert_eq!(crows[1].to_vec().unwrap(), vec![3u32, 4, 5]); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), vec![3u32, 4, 5]); + assert_eq!( + h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), + vec![3u32, 4, 5] + ); // Move yields the two vec handles. let (moved, tag) = bstack_move!(h, &alloc).unwrap(); @@ -3333,7 +3374,10 @@ fn macro_owned_vec_array() { let alloc = tmp.allocator(); let stack = alloc.stack(); - let g0 = vec![MacroLeaf::new(&alloc, 10).unwrap(), MacroLeaf::new(&alloc, 11).unwrap()]; + let g0 = vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 11).unwrap(), + ]; let g1 = vec![MacroLeaf::new(&alloc, 20).unwrap()]; let h = OwnedVecArr::new(&alloc, [g0, g1], 7).unwrap(); assert_eq!(h.handle().tag(stack).unwrap(), 7); @@ -3352,7 +3396,15 @@ fn macro_owned_vec_array() { gs[0].get(0).unwrap().unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().groups(&alloc).unwrap()[1].get(0).unwrap().unwrap().val(stack).unwrap(), 20); + assert_eq!( + h.handle().groups(&alloc).unwrap()[1] + .get(0) + .unwrap() + .unwrap() + .val(stack) + .unwrap(), + 20 + ); h.bstack_drop(&alloc).unwrap(); } @@ -3413,7 +3465,10 @@ fn macro_ref_vec_of_array() { ); clone.bstack_drop(&alloc).unwrap(); // Original + targets still alive after clone teardown. - assert_eq!(h.handle().rows(&alloc).unwrap()[0][1].val(stack).unwrap(), 1); + assert_eq!( + h.handle().rows(&alloc).unwrap()[0][1].val(stack).unwrap(), + 1 + ); // Dropping the holder frees only the offset array, not the targets. h.bstack_drop(&alloc).unwrap(); @@ -3453,7 +3508,10 @@ fn macro_owned_vec_of_array() { assert_eq!(crows[1][1].val(stack).unwrap(), 4); assert_ne!(crows[0][0].range().start(), rows[0][0].range().start()); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().rows(&alloc).unwrap()[1][0].val(stack).unwrap(), 3); + assert_eq!( + h.handle().rows(&alloc).unwrap()[1][0].val(stack).unwrap(), + 3 + ); h.bstack_drop(&alloc).unwrap(); } @@ -3549,7 +3607,10 @@ fn macro_enum_owned_vec() { let items = BStackBlockVec::from_handles( &alloc, - vec![MacroLeaf::new(&alloc, 10).unwrap(), MacroLeaf::new(&alloc, 20).unwrap()], + vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + ], ) .unwrap(); let e = OwnedVecEnum::new(&alloc, OwnedVecEnumData::Items(items)).unwrap(); @@ -3566,7 +3627,9 @@ fn macro_enum_owned_vec() { // Clone deep-copies the vector + its children. let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { - OwnedVecEnumView::Items(v) => assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20), + OwnedVecEnumView::Items(v) => { + assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20) + } _ => panic!("expected Items"), } clone.bstack_drop(&alloc).unwrap(); @@ -3809,7 +3872,9 @@ fn macro_generic_move_cast() { // bstack_cast!: an untyped slice back to the typed generic block (tag checked). let sl = b.handle().as_slice(stack); - let back = bstack_cast!(sl as RefBox).unwrap().expect("same tag"); + let back = bstack_cast!(sl as RefBox) + .unwrap() + .expect("same tag"); assert_eq!(back.item(stack).unwrap().val(stack).unwrap(), 9); // bstack_move!: hand out the ref + pod fields, freeing the box shell. @@ -3897,7 +3962,10 @@ fn macro_generic_owned_box() { let clone = b.try_clone_in(&alloc).unwrap(); let citem = clone.handle().item(stack).unwrap(); assert_eq!(citem.val(stack).unwrap(), 42); - assert_ne!(citem.range().start(), b.handle().item(stack).unwrap().range().start()); + assert_ne!( + citem.range().start(), + b.handle().item(stack).unwrap().range().start() + ); clone.bstack_drop(&alloc).unwrap(); // Original child survives the clone's teardown. assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); @@ -4012,12 +4080,24 @@ fn macro_generic_emb_box() { let b = EmbBoxG::::new(&alloc, child, 99).unwrap(); assert_eq!(b.handle().tag(stack).unwrap(), 99); // Accessor: an EmbChild handle into the inline slot (pure offset math). - assert_eq!(b.handle().item().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + b.handle().item().leaf(stack).unwrap().val(stack).unwrap(), + 10 + ); // Clone folds the embedded child inline, deep-cloning its owned leaf — via the // generic `T`'s `BStackBlock` clone hook (a trait method, not inherent). let clone = b.try_clone_in(&alloc).unwrap(); - assert_eq!(clone.handle().item().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + clone + .handle() + .item() + .leaf(stack) + .unwrap() + .val(stack) + .unwrap(), + 10 + ); assert_ne!( clone.handle().item().leaf(stack).unwrap().range().start(), b.handle().item().leaf(stack).unwrap().range().start() @@ -4200,7 +4280,8 @@ fn stdlib_cow_borrowed_into_owned_deep_copies() { // A block owned elsewhere; the Cow only borrows it. let base = MacroLeaf::new(&alloc, 7).unwrap(); let base_start = base.handle().range().start(); - let cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + let cow = + BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); assert!(cow.is_borrowed()); // Reads go through the borrowed block, at its address. @@ -4244,7 +4325,8 @@ fn stdlib_cow_to_mut_copies_then_owns() { let base = MacroLeaf::new(&alloc, 9).unwrap(); let base_start = base.handle().range().start(); - let mut cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + let mut cow = + BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); // First write forces a private copy and flips to Owned. { @@ -4272,7 +4354,8 @@ fn stdlib_cow_borrowed_drop_frees_nothing() { let stack = alloc.stack(); let base = MacroLeaf::new(&alloc, 3).unwrap(); - let cow = BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); + let cow = + BStackCow::borrowed(unsafe { BStackRef::::from_range(base.handle().range()) }); // Dropping a borrowed Cow has no claim on the target. cow.bstack_drop(&alloc).unwrap(); @@ -4332,10 +4415,7 @@ fn stdlib_box_clone_is_a_byte_copy() { let clone = b.try_clone_in(&alloc).unwrap(); // Fresh, independent block, same value. - assert_ne!( - clone.handle().range().start(), - b.handle().range().start() - ); + assert_ne!(clone.handle().range().start(), b.handle().range().start()); assert_eq!(clone.handle().get(stack).unwrap(), 7); // Mutating the clone leaves the original untouched. @@ -4384,7 +4464,10 @@ fn stdlib_box_composes_as_owned_field() { let inner = BStackBox::new(&alloc, 500u64).unwrap(); let holder = BoxHolder::new(&alloc, inner, 9).unwrap(); - assert_eq!(holder.handle().boxed(stack).unwrap().get(stack).unwrap(), 500); + assert_eq!( + holder.handle().boxed(stack).unwrap().get(stack).unwrap(), + 500 + ); assert_eq!(holder.handle().tag(stack).unwrap(), 9); // Deep-cloning the parent recurses into the child box (fresh child block). @@ -4393,7 +4476,10 @@ fn stdlib_box_composes_as_owned_field() { clone.handle().boxed(stack).unwrap().range().start(), holder.handle().boxed(stack).unwrap().range().start(), ); - assert_eq!(clone.handle().boxed(stack).unwrap().get(stack).unwrap(), 500); + assert_eq!( + clone.handle().boxed(stack).unwrap().get(stack).unwrap(), + 500 + ); clone.bstack_drop(&alloc).unwrap(); holder.bstack_drop(&alloc).unwrap(); @@ -4407,8 +4493,9 @@ fn stdlib_box_in_cow() { // A borrowed Cow over a box; first write deep-copies the box. let base = BStackBox::new(&alloc, 11u64).unwrap(); - let mut cow = - BStackCow::borrowed(unsafe { BStackRef::>::from_range(base.handle().range()) }); + let mut cow = BStackCow::borrowed(unsafe { + BStackRef::>::from_range(base.handle().range()) + }); assert_eq!(cow.handle().get(stack).unwrap(), 11); let owned = cow.to_mut(&alloc).unwrap(); @@ -4442,7 +4529,8 @@ fn stdlib_list_push_back_pop_front() { assert!(list.is_empty(stack).unwrap()); for v in [1u32, 2, 3] { - list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); } assert_eq!(list.len(stack).unwrap(), 3); assert_eq!(list_values(&list, stack), vec![1, 2, 3]); @@ -4466,9 +4554,12 @@ fn stdlib_list_both_ends() { let stack = alloc.stack(); let list = BStackLinkedList::::new(&alloc).unwrap(); - list.push_front(&alloc, MacroLeaf::new(&alloc, 2).unwrap()).unwrap(); - list.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()).unwrap(); - list.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()).unwrap(); + list.push_front(&alloc, MacroLeaf::new(&alloc, 2).unwrap()) + .unwrap(); + list.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()) + .unwrap(); + list.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()) + .unwrap(); assert_eq!(list_values(&list, stack), vec![1, 2, 3]); let back = list.pop_back(&alloc).unwrap().unwrap(); @@ -4516,7 +4607,8 @@ fn stdlib_list_deep_clone_is_independent() { let list = BStackLinkedList::::new(&alloc).unwrap(); for v in [1u32, 2, 3] { - list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); } let clone = list.try_clone_in(&alloc).unwrap(); @@ -4629,7 +4721,8 @@ fn stdlib_deque_push_back_grows() { // Push past the initial capacity to force at least one growth. for v in 0..10u32 { - dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); } assert_eq!(dq.len(stack).unwrap(), 10); assert!(dq.capacity(stack).unwrap() >= 10); @@ -4657,15 +4750,22 @@ fn stdlib_deque_wraparound_no_growth() { // Fixed capacity 4; exercise circular indexing without any growth. let dq = BStackDeque::::with_capacity(&alloc, 4).unwrap(); for v in [1u32, 2, 3, 4] { - dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); } // Drop the front two: head advances into the ring. for _ in 0..2 { - dq.pop_front(&alloc).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + dq.pop_front(&alloc) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); } // Two more push_backs wrap around the physical slots 0,1. - dq.push_back(&alloc, MacroLeaf::new(&alloc, 5).unwrap()).unwrap(); - dq.push_back(&alloc, MacroLeaf::new(&alloc, 6).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 5).unwrap()) + .unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 6).unwrap()) + .unwrap(); assert_eq!(dq.capacity(stack).unwrap(), 4); // never grew assert_eq!(deque_values(&dq, stack), vec![3, 4, 5, 6]); @@ -4679,9 +4779,12 @@ fn stdlib_deque_both_ends() { let stack = alloc.stack(); let dq = BStackDeque::::new(&alloc).unwrap(); - dq.push_back(&alloc, MacroLeaf::new(&alloc, 2).unwrap()).unwrap(); - dq.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()).unwrap(); - dq.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 2).unwrap()) + .unwrap(); + dq.push_front(&alloc, MacroLeaf::new(&alloc, 1).unwrap()) + .unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, 3).unwrap()) + .unwrap(); assert_eq!(deque_values(&dq, stack), vec![1, 2, 3]); let back = dq.pop_back(&alloc).unwrap().unwrap(); @@ -4723,7 +4826,8 @@ fn stdlib_deque_deep_clone_is_independent() { let dq = BStackDeque::::new(&alloc).unwrap(); for v in [1u32, 2, 3, 4, 5] { - dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()).unwrap(); + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); } let clone = dq.try_clone_in(&alloc).unwrap(); @@ -4737,7 +4841,12 @@ fn stdlib_deque_deep_clone_is_independent() { ); // Mutating the clone leaves the original intact. - clone.pop_back(&alloc).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + clone + .pop_back(&alloc) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); assert_eq!(clone.len(stack).unwrap(), 4); assert_eq!(deque_values(&dq, stack), vec![1, 2, 3, 4, 5]); @@ -4810,20 +4919,40 @@ fn stdlib_map_insert_get_remove() { assert!(map.get(stack, &7).unwrap().is_none()); // Insert of a new key returns no previous value. - assert!(map.insert(&alloc, 7, MacroLeaf::new(&alloc, 700).unwrap()).unwrap().is_none()); - assert!(map.insert(&alloc, 9, MacroLeaf::new(&alloc, 900).unwrap()).unwrap().is_none()); + assert!( + map.insert(&alloc, 7, MacroLeaf::new(&alloc, 700).unwrap()) + .unwrap() + .is_none() + ); + assert!( + map.insert(&alloc, 9, MacroLeaf::new(&alloc, 900).unwrap()) + .unwrap() + .is_none() + ); assert_eq!(map.len(stack).unwrap(), 2); - assert_eq!(map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), 700); - assert_eq!(map.get(stack, &9).unwrap().unwrap().val(stack).unwrap(), 900); + assert_eq!( + map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), + 700 + ); + assert_eq!( + map.get(stack, &9).unwrap().unwrap().val(stack).unwrap(), + 900 + ); assert!(map.contains_key(stack, &7).unwrap()); assert!(!map.contains_key(stack, &8).unwrap()); // Overwrite returns the previous value (owned) and does not change len. - let old = map.insert(&alloc, 7, MacroLeaf::new(&alloc, 701).unwrap()).unwrap().unwrap(); + let old = map + .insert(&alloc, 7, MacroLeaf::new(&alloc, 701).unwrap()) + .unwrap() + .unwrap(); assert_eq!(old.handle().val(stack).unwrap(), 700); old.bstack_drop(&alloc).unwrap(); assert_eq!(map.len(stack).unwrap(), 2); - assert_eq!(map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), 701); + assert_eq!( + map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), + 701 + ); // Remove returns the value (owned); the key is then absent. let removed = map.remove(&alloc, &9).unwrap().unwrap(); @@ -4845,17 +4974,28 @@ fn stdlib_map_grows_and_keeps_all() { let map = BStackHashMap::::new(&alloc).unwrap(); // Enough entries to force several rehashes (cap 4 -> ... ). for k in 0..100u32 { - assert!(map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap().is_none()); + assert!( + map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap() + .is_none() + ); } assert_eq!(map.len(stack).unwrap(), 100); // Every key survives the rehashes with its value. for k in 0..100u32 { - assert_eq!(map.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k * 10); + assert_eq!( + map.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + k * 10 + ); } // Remove the evens; odds remain (exercises tombstones + probing past them). for k in (0..100u32).step_by(2) { - map.remove(&alloc, &k).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + map.remove(&alloc, &k) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); } assert_eq!(map.len(stack).unwrap(), 50); for k in 0..100u32 { @@ -4880,11 +5020,17 @@ fn stdlib_map_pod_struct_key() { let map = BStackHashMap::::new(&alloc).unwrap(); let a = Point3 { x: 1, y: 2, z: 3 }; let b = Point3 { x: 1, y: 2, z: 4 }; - map.insert(&alloc, a, MacroLeaf::new(&alloc, 11).unwrap()).unwrap(); - map.insert(&alloc, b, MacroLeaf::new(&alloc, 22).unwrap()).unwrap(); + map.insert(&alloc, a, MacroLeaf::new(&alloc, 11).unwrap()) + .unwrap(); + map.insert(&alloc, b, MacroLeaf::new(&alloc, 22).unwrap()) + .unwrap(); assert_eq!(map.get(stack, &a).unwrap().unwrap().val(stack).unwrap(), 11); assert_eq!(map.get(stack, &b).unwrap().unwrap().val(stack).unwrap(), 22); - assert!(map.get(stack, &Point3 { x: 9, y: 9, z: 9 }).unwrap().is_none()); + assert!( + map.get(stack, &Point3 { x: 9, y: 9, z: 9 }) + .unwrap() + .is_none() + ); map.bstack_drop(&alloc).unwrap(); } @@ -4928,12 +5074,16 @@ fn stdlib_map_deep_clone_is_independent() { let map = BStackHashMap::::new(&alloc).unwrap(); for k in 0..8u32 { - map.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()).unwrap(); + map.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()) + .unwrap(); } let clone = map.try_clone_in(&alloc).unwrap(); for k in 0..8u32 { - assert_eq!(clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k + 100); + assert_eq!( + clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + k + 100 + ); } // Clone's value blocks are fresh, not aliases. assert_ne!( @@ -4942,9 +5092,17 @@ fn stdlib_map_deep_clone_is_independent() { ); // Mutating the clone leaves the original intact. - clone.remove(&alloc, &3).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + clone + .remove(&alloc, &3) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); assert!(clone.get(stack, &3).unwrap().is_none()); - assert_eq!(map.get(stack, &3).unwrap().unwrap().val(stack).unwrap(), 103); + assert_eq!( + map.get(stack, &3).unwrap().unwrap().val(stack).unwrap(), + 103 + ); clone.bstack_drop(&alloc).unwrap(); map.bstack_drop(&alloc).unwrap(); @@ -4976,13 +5134,20 @@ fn stdlib_tree_insert_get_ordered() { // Insert 0..50 in a scrambled (but bijective) order to exercise splits. for i in 0..50u32 { let k = (i * 17) % 50; - assert!(tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap().is_none()); + assert!( + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap() + .is_none() + ); } assert_eq!(tree.len(stack).unwrap(), 50); // Every key present with its value. for k in 0..50u32 { - assert_eq!(tree.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k * 10); + assert_eq!( + tree.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + k * 10 + ); } assert!(tree.get(stack, &999).unwrap().is_none()); @@ -4994,11 +5159,17 @@ fn stdlib_tree_insert_get_ordered() { assert_eq!(tree.last(stack).unwrap().unwrap().0, 49); // Overwrite returns the previous value; len unchanged; order preserved. - let old = tree.insert(&alloc, 25, MacroLeaf::new(&alloc, 9999).unwrap()).unwrap().unwrap(); + let old = tree + .insert(&alloc, 25, MacroLeaf::new(&alloc, 9999).unwrap()) + .unwrap() + .unwrap(); assert_eq!(old.handle().val(stack).unwrap(), 250); old.bstack_drop(&alloc).unwrap(); assert_eq!(tree.len(stack).unwrap(), 50); - assert_eq!(tree.get(stack, &25).unwrap().unwrap().val(stack).unwrap(), 9999); + assert_eq!( + tree.get(stack, &25).unwrap().unwrap().val(stack).unwrap(), + 9999 + ); tree.bstack_drop(&alloc).unwrap(); } @@ -5019,13 +5190,18 @@ fn stdlib_tree_large_key_spills() { for i in 0..20u64 { let mut k = [0u64; 8]; k[0] = i; - tree.insert(&alloc, BigKey(k), MacroLeaf::new(&alloc, i as u32).unwrap()).unwrap(); + tree.insert(&alloc, BigKey(k), MacroLeaf::new(&alloc, i as u32).unwrap()) + .unwrap(); } for i in 0..20u64 { let mut k = [0u64; 8]; k[0] = i; assert_eq!( - tree.get(stack, &BigKey(k)).unwrap().unwrap().val(stack).unwrap(), + tree.get(stack, &BigKey(k)) + .unwrap() + .unwrap() + .val(stack) + .unwrap(), i as u32 ); } @@ -5071,12 +5247,16 @@ fn stdlib_tree_deep_clone_is_independent() { let tree = BStackBTreeMap::::new(&alloc).unwrap(); for k in 0..30u32 { - tree.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()).unwrap(); + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k + 100).unwrap()) + .unwrap(); } let clone = tree.try_clone_in(&alloc).unwrap(); for k in 0..30u32 { - assert_eq!(clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), k + 100); + assert_eq!( + clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + k + 100 + ); } // Fresh value blocks, not aliases. assert_ne!( @@ -5085,10 +5265,20 @@ fn stdlib_tree_deep_clone_is_independent() { ); // Overwriting in the clone leaves the original intact. - tree.insert(&alloc, 10, MacroLeaf::new(&alloc, 7).unwrap()).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + tree.insert(&alloc, 10, MacroLeaf::new(&alloc, 7).unwrap()) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); // (`clone` and `tree` share no nodes: the clone deep-copied every node.) - assert_eq!(clone.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), 110); - assert_eq!(tree.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), 7); + assert_eq!( + clone.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), + 110 + ); + assert_eq!( + tree.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), + 7 + ); clone.bstack_drop(&alloc).unwrap(); tree.bstack_drop(&alloc).unwrap(); @@ -5103,7 +5293,8 @@ fn stdlib_tree_concurrent_readers() { let tree = BStackBTreeMap::::new(&alloc).unwrap(); for k in 0..64u32 { - tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 2).unwrap()).unwrap(); + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 2).unwrap()) + .unwrap(); } std::thread::scope(|s| { @@ -5112,7 +5303,14 @@ fn stdlib_tree_concurrent_readers() { let alloc = &alloc; s.spawn(move || { for k in 0..64u32 { - assert_eq!(tree.get(alloc.stack(), &k).unwrap().unwrap().val(alloc.stack()).unwrap(), k * 2); + assert_eq!( + tree.get(alloc.stack(), &k) + .unwrap() + .unwrap() + .val(alloc.stack()) + .unwrap(), + k * 2 + ); } }); } @@ -5140,7 +5338,8 @@ fn stdlib_map_concurrent_insert() { s.spawn(move || { for i in 0..ITERS { let k = t * ITERS + i; - map.insert(alloc, k, MacroLeaf::new(alloc, k).unwrap()).unwrap(); + map.insert(alloc, k, MacroLeaf::new(alloc, k).unwrap()) + .unwrap(); } }); } @@ -5148,7 +5347,14 @@ fn stdlib_map_concurrent_insert() { assert_eq!(map.len(alloc.stack()).unwrap(), total); for k in 0..(THREADS * ITERS) { - assert_eq!(map.get(alloc.stack(), &k).unwrap().unwrap().val(alloc.stack()).unwrap(), k); + assert_eq!( + map.get(alloc.stack(), &k) + .unwrap() + .unwrap() + .val(alloc.stack()) + .unwrap(), + k + ); } map.bstack_drop(&alloc).unwrap(); @@ -5227,16 +5433,42 @@ fn stdlib_string_as_map_value() { // The headline use: strings as owned map values. let map = BStackHashMap::::new(&alloc).unwrap(); - map.insert(&alloc, 1, BStackString::new(&alloc, "one").unwrap()).unwrap(); - map.insert(&alloc, 2, BStackString::new(&alloc, "two").unwrap()).unwrap(); - assert_eq!(map.get(stack, &1).unwrap().unwrap().to_string(stack).unwrap(), "one"); - assert_eq!(map.get(stack, &2).unwrap().unwrap().to_string(stack).unwrap(), "two"); + map.insert(&alloc, 1, BStackString::new(&alloc, "one").unwrap()) + .unwrap(); + map.insert(&alloc, 2, BStackString::new(&alloc, "two").unwrap()) + .unwrap(); + assert_eq!( + map.get(stack, &1) + .unwrap() + .unwrap() + .to_string(stack) + .unwrap(), + "one" + ); + assert_eq!( + map.get(stack, &2) + .unwrap() + .unwrap() + .to_string(stack) + .unwrap(), + "two" + ); // Overwrite returns the old string (owned), which we free. - let old = map.insert(&alloc, 1, BStackString::new(&alloc, "uno").unwrap()).unwrap().unwrap(); + let old = map + .insert(&alloc, 1, BStackString::new(&alloc, "uno").unwrap()) + .unwrap() + .unwrap(); assert_eq!(old.handle().to_string(stack).unwrap(), "one"); old.bstack_drop(&alloc).unwrap(); - assert_eq!(map.get(stack, &1).unwrap().unwrap().to_string(stack).unwrap(), "uno"); + assert_eq!( + map.get(stack, &1) + .unwrap() + .unwrap() + .to_string(stack) + .unwrap(), + "uno" + ); // Dropping the map recursively frees every string value (and its bytes block). map.bstack_drop(&alloc).unwrap(); diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 7d67ce6..38a7b40 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -36,7 +36,7 @@ use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; use crate::clone::ClonePlan; use crate::handle::WeakRef; -use crate::layout::put_u64; +use crate::layout::{get_u64, put_u64}; use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; @@ -54,8 +54,8 @@ pub(crate) const BYTEVEC_HEADER: u64 = 16; /// [`push`](BStackVec::push). pub(crate) fn bytevec_image(len: u64, cap: u64, data: &[u8]) -> Vec { let mut img = vec![0u8; BYTEVEC_HEADER as usize + data.len()]; - put_u64!(img, 0, len); - put_u64!(img, 8, cap); + put_u64(&mut img, 0, len); + put_u64(&mut img, 8, cap); img[BYTEVEC_HEADER as usize..].copy_from_slice(data); img } @@ -91,8 +91,8 @@ fn read_vecdesc(stack: &BStack, loc: u64) -> io::Result { let mut buf = [0u8; size_of::()]; stack.get_into(loc, &mut buf)?; Ok(VecDesc { - data_off: u64::from_le_bytes(buf[0..8].try_into().unwrap()), - data_size: u64::from_le_bytes(buf[8..16].try_into().unwrap()), + data_off: get_u64(&buf[0..8]), + data_size: get_u64(&buf[8..16]), }) } diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 6ba1a0c..0ecbfd0 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -324,9 +324,8 @@ impl WalLog { _pad: [0; 7], count: self.entries.len() as u64, }; - let mut img = Vec::with_capacity( - size_of::() + self.entries.len() * size_of::(), - ); + let mut img = + Vec::with_capacity(size_of::() + self.entries.len() * size_of::()); img.extend_from_slice(bytemuck::bytes_of(&header)); img.extend_from_slice(bytemuck::cast_slice(&self.entries)); img @@ -388,7 +387,11 @@ fn load_at( stack.get_into(wal_off + size_of::() as u64, &mut ebuf)?; let entries = WalLog::entries_from_bytes(&ebuf); let block_size = size_of::() as u64 + ebytes as u64; - Ok(Some((BStackRange::new(wal_off, block_size), header, entries))) + Ok(Some(( + BStackRange::new(wal_off, block_size), + header, + entries, + ))) } /// **Complete** a crash-left transaction referenced by the anchor slot at @@ -415,7 +418,9 @@ pub fn finish_at(allocator: &A, anchor: u64) -> io let base = wal_range.start() + size_of::() as u64; for (i, e) in entries.iter().enumerate() { // `as_dealloc` is `Some` only for a `Dealloc` entry. - if e.status() == WalStatus::Pending && let Some(slice) = e.as_dealloc() { + if e.status() == WalStatus::Pending + && let Some(slice) = e.as_dealloc() + { // Persist Complete for this entry (its status is byte 0), THEN // free — so a second crash can't double-free it. let entry_off = base + (i * size_of::()) as u64; @@ -472,8 +477,14 @@ mod tests { fn wal_entry_is_24_bytes_and_pod_roundtrips() { assert_eq!(size_of::(), 24); let mut log = WalLog::with_capacity(2); - log.append(WalEntry::alloc(WalStatus::Pending, AllocReq { id: 0, len: 64 })); - log.append(WalEntry::dealloc(WalStatus::Pending, BStackRange::new(4096, 64))); + log.append(WalEntry::alloc( + WalStatus::Pending, + AllocReq { id: 0, len: 64 }, + )); + log.append(WalEntry::dealloc( + WalStatus::Pending, + BStackRange::new(4096, 64), + )); let bytes = log.as_bytes().to_vec(); let back = WalLog::entries_from_bytes(&bytes); assert_eq!(back.len(), 2); @@ -494,10 +505,7 @@ mod tests { #[test] fn reduce_cancels_equal_length_pairs() { // (Alloc 256, Alloc 600, Dealloc 256) → reuse the 256 slice, Alloc 600 left. - let allocs = vec![ - AllocReq { id: 0, len: 256 }, - AllocReq { id: 1, len: 600 }, - ]; + let allocs = vec![AllocReq { id: 0, len: 256 }, AllocReq { id: 1, len: 600 }]; let deallocs = vec![BStackRange::new(0x1FF0, 256)]; let r = reduce(allocs, deallocs); assert_eq!(r.reused.len(), 1); From 2b682b60768eb14e62c619ca6ef3930c44f8ba79 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 05:06:38 -0700 Subject: [PATCH 079/140] stdlib: Extract hash --- bstack_raii/src/stdlib/hash.rs | 11 +++++++++++ bstack_raii/src/stdlib/map.rs | 11 +---------- bstack_raii/src/stdlib/mod.rs | 1 + 3 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 bstack_raii/src/stdlib/hash.rs diff --git a/bstack_raii/src/stdlib/hash.rs b/bstack_raii/src/stdlib/hash.rs new file mode 100644 index 0000000..670c8f4 --- /dev/null +++ b/bstack_raii/src/stdlib/hash.rs @@ -0,0 +1,11 @@ +//! Small deterministic hash helpers shared by stdlib containers. + +/// 64-bit FNV-1a over `bytes`. Deterministic so map layout is stable on disk. +pub(super) fn fnv1a(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 6494b56..e533dd7 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -40,6 +40,7 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; +use super::hash::fnv1a; use super::util::{Scratch, alloc_image, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; @@ -81,16 +82,6 @@ const EMPTY: u64 = 0; const OCCUPIED: u64 = 1; const TOMBSTONE: u64 = 2; -/// 64-bit FNV-1a over `bytes`. Deterministic (so it is stable on disk). -fn fnv1a(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in bytes { - h ^= b as u64; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - h -} - /// A snapshot of the four handle metadata fields, read inside a generator. struct Meta { table: u64, diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index cc10dde..76ef02c 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -24,6 +24,7 @@ mod boxed; mod cow; mod deque; +mod hash; mod list; mod map; mod string; From ae246da7ac82e2969cf5ecad489a91c91252ca65 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 05:52:33 -0700 Subject: [PATCH 080/140] stdlib: HashmMap clippy fix --- bstack_raii/src/stdlib/map.rs | 48 +++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index e533dd7..9074e61 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -205,29 +205,35 @@ where }) } -/// Build the writes that place a *new* entry (state, key, value) at bucket -/// `target`, bumping `len` (and `used` when the slot was previously `EMPTY`). -fn new_bucket_writes( +/// The per-insert invariants shared by the probe closures that place the new +/// entry: where the map lives, its bucket geometry, and the entry to write. +struct NewEntry<'a> { handle: u64, stride: u64, ksz: usize, + key_bytes: &'a [u8], + val_ref: u64, +} + +/// Build the writes that place a *new* entry (state, key, value) at bucket +/// `target`, bumping `len` (and `used` when the slot was previously `EMPTY`). +fn new_bucket_writes( + e: &NewEntry, m: &Meta, target: u64, slot_was_empty: bool, - key_bytes: &[u8], - val_ref: u64, ) -> Vec<(u64, Vec)> { - let mut img = Vec::with_capacity(16 + ksz); + let mut img = Vec::with_capacity(16 + e.ksz); img.extend_from_slice(&OCCUPIED.to_le_bytes()); - img.extend_from_slice(key_bytes); - img.extend_from_slice(&val_ref.to_le_bytes()); + img.extend_from_slice(e.key_bytes); + img.extend_from_slice(&e.val_ref.to_le_bytes()); let mut w = vec![ - (m.table + target * stride, img), - (handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), + (m.table + target * e.stride, img), + (e.handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), ]; if slot_was_empty { - w.push((handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); + w.push((e.handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); } w } @@ -314,6 +320,13 @@ impl BStackHashMap { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let val_ref = value.into_inner().range().start(); let hash = fnv1a(&key_bytes); + let entry = NewEntry { + handle, + stride, + ksz, + key_bytes: &key_bytes, + val_ref, + }; loop { // Proactively keep the load factor under 3/4 (also clears tombstones). @@ -340,16 +353,7 @@ impl BStackHashMap { let target = first_tomb.get().unwrap_or(idx); let slot_was_empty = first_tomb.get().is_none(); is_new.set(true); - ProbeStep::Stop(new_bucket_writes( - handle, - stride, - ksz, - m, - target, - slot_was_empty, - &key_bytes, - val_ref, - )) + ProbeStep::Stop(new_bucket_writes(&entry, m, target, slot_was_empty)) } else if state == OCCUPIED && buf[8..8 + ksz] == key_bytes[..] { // Overwrite: replace the value ref, hand back the old one. old_value.set(get_u64(&buf[8 + ksz..8 + ksz + 8])); @@ -366,7 +370,7 @@ impl BStackHashMap { |m| { if let Some(t) = first_tomb.get() { is_new.set(true); - new_bucket_writes(handle, stride, ksz, m, t, false, &key_bytes, val_ref) + new_bucket_writes(&entry, m, t, false) } else { need_grow.set(true); Vec::new() From 885d3ab72d279f6e71335efd08712850da34ef74 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 06:04:11 -0700 Subject: [PATCH 081/140] stdlib: CountingBloomFilter --- bstack_raii/src/lib.rs | 6 +- bstack_raii/src/stdlib/bloom.rs | 436 ++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/hash.rs | 13 + bstack_raii/src/stdlib/mod.rs | 2 + bstack_raii/src/tests.rs | 142 ++++++++++- 5 files changed, 592 insertions(+), 7 deletions(-) create mode 100644 bstack_raii/src/stdlib/bloom.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index f6d394e..d490841 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,9 +82,9 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBTreeMap, BStackBox, BStackCow, BStackDeque, BStackHashMap, BStackLinkedList, - BStackString, BoxOnDisk, DequeOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, - TreeOnDisk, + BStackBTreeMap, BStackBox, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackHashMap, + BStackLinkedList, BStackString, BloomOnDisk, BoxOnDisk, DequeOnDisk, ListOnDisk, MapOnDisk, + NodeOnDisk, StringOnDisk, TreeOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs new file mode 100644 index 0000000..64c2fd7 --- /dev/null +++ b/bstack_raii/src/stdlib/bloom.rs @@ -0,0 +1,436 @@ +//! [`BStackCountingBloomFilter`]: an owned counting Bloom filter. +//! +//! A probabilistic set: [`contains`](BStackCountingBloomFilter::contains) never +//! yields a false negative (a key that was inserted always reports present) but +//! may yield a false positive (report present for a key that was not). The +//! **counting** variant uses small integer counters instead of single bits, so it +//! also supports [`remove`](BStackCountingBloomFilter::remove) — the classic use +//! being a cheap in-memory-ish guard *in front of* an expensive +//! [`crate::BStackHashMap`] / [`crate::BStackBTreeMap`] lookup, to skip the disk +//! probe for keys that are definitely absent. +//! +//! # Layout — one contiguous block, no pointers +//! +//! The fixed handle ([`BloomOnDisk`]) records the counter-array pointer, the +//! counter count `m`, the number of hash functions `k`, and the inserted-item +//! count `n`. The counters themselves are one contiguous `[u8; m]` block (byte +//! counters — trivially addressable and saturating at 255; ~8× a bit filter, a +//! deliberate simplicity-for-space trade). The `k` indices come from **double +//! hashing** ([`super::hash::double_hash`]) so a single key yields `k` +//! well-distributed positions with no per-`k` hashing cost. Keys are `Pod`, +//! hashed by their raw bytes. +//! +//! # Atomicity +//! +//! `insert` / `remove` read every touched counter and `n`, then write the +//! adjusted values, all inside one [`bstack::BStack::inplace_gen`] — so each is +//! atomic per call and external-lock-free (a concurrent writer never loses an +//! increment, which would otherwise let a `remove` wrongly zero a shared +//! counter). The filter is fixed-size (no growth), so `data`/`m`/`k` never change +//! after construction and need no synchronization. `contains` is a plain read. +//! +//! **Caveat (inherent to counting Bloom filters):** only `remove` keys that were +//! actually inserted. Removing an absent key may decrement counters shared with +//! present keys and introduce false negatives. + +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::hash::double_hash; +use super::util::{alloc_image, read_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackCountingBloomFilter`]: header, counter-array +/// pointer (`0` = none), counter count `m`, hash count `k`, and inserted-item +/// count `n`. `#[repr(C)]`, `u64` fields only — fixed-size, non-generic. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct BloomOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the `[u8; m]` counter block, or `0` when unallocated. + pub data: u64, + /// Number of counters. + pub m: u64, + /// Number of hash functions. + pub k: u64, + /// Number of items inserted (minus removed). + pub n: u64, +} + +const DATA_OFF: u64 = HEADER_SIZE; // 16 +const M_OFF: u64 = HEADER_SIZE + 8; // 24 +const K_OFF: u64 = HEADER_SIZE + 16; // 32 +const N_OFF: u64 = HEADER_SIZE + 24; // 40 +const BLOOM_SIZE: u64 = size_of::() as u64; + +/// An owned counting Bloom filter over `Pod` keys. +/// +/// A typed handle (a newtype over a [`BStackRange`]); [`new`](Self::new) / +/// [`with_capacity`](Self::with_capacity) return a bare +/// [`BStackOwned>`] that frees nothing on scope exit +/// — free it with [`bstack_drop`](BStackDrop::bstack_drop) or wrap it +/// ([`AutoDrop`] / [`crate::BStackCow`]). +pub struct BStackCountingBloomFilter { + range: BStackRange, + _marker: PhantomData K>, +} + +impl BStackCountingBloomFilter { + /// The `k` counter indices for `key_bytes` (with possible repeats). + fn indices(m: u64, k: u64, key_bytes: &[u8]) -> Vec { + let (h1, h2) = double_hash(key_bytes); + (0..k) + .map(|i| h1.wrapping_add(i.wrapping_mul(h2)) % m) + .collect() + } + + /// Collapse indices to distinct `(index, multiplicity)`, so a counter hit by + /// two of the `k` hashes is adjusted by two in one write. + fn aggregate(mut idxs: Vec) -> Vec<(u64, u32)> { + idxs.sort_unstable(); + let mut out: Vec<(u64, u32)> = Vec::new(); + for x in idxs { + match out.last_mut() { + Some(last) if last.0 == x => last.1 += 1, + _ => out.push((x, 1)), + } + } + out + } + + /// Allocate a filter with `m` counters and `k` hash functions (both forced to + /// at least 1). Prefer [`with_capacity`](Self::with_capacity) to size these. + pub fn new( + allocator: &A, + m: u64, + k: u64, + ) -> io::Result> { + let m = m.max(1); + let k = k.max(1); + // Allocate and zero the counter block (an orphan until the handle links it). + let data = { + let mut slice = allocator.alloc(m)?; + if let Err(e) = slice.write_range(0, vec![0u8; m as usize]) { + let _ = allocator.dealloc(slice); + return Err(e); + } + slice.as_range().start() + }; + let od = BloomOnDisk { + header: BlockHeader { + size: BLOOM_SIZE, + tag: Self::eightcc(), + }, + data, + m, + k, + n: 0, + }; + match alloc_image(allocator, bytemuck::bytes_of(&od)) { + // SAFETY: a freshly allocated block owned by no other handle. + Ok(range) => Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }), + Err(e) => { + // SAFETY: the counter block was just allocated, referenced by nobody. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(data, m)) }; + Err(e) + } + } + } + + /// Allocate a filter sized for `expected_items` at target false-positive rate + /// `fp_rate`, using the standard optimal `m = -n·ln p / (ln 2)²` and + /// `k = (m/n)·ln 2`. + pub fn with_capacity( + allocator: &A, + expected_items: u64, + fp_rate: f64, + ) -> io::Result> { + let n = expected_items.max(1) as f64; + let p = fp_rate.clamp(1e-9, 0.5); + let ln2 = core::f64::consts::LN_2; + let m = (-(n * p.ln()) / (ln2 * ln2)).ceil().max(1.0) as u64; + let k = ((m as f64 / n) * ln2).round().clamp(1.0, 32.0) as u64; + Self::new(allocator, m, k) + } + + /// Number of items inserted (minus removed). + pub fn count(&self, stack: &BStack) -> io::Result { + read_u64(stack, self.range.start() + N_OFF) + } + + /// Whether no items are currently present. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.count(stack)? == 0) + } + + /// The current estimated false-positive probability, `(1 - e^{-k n / m})^k`. + pub fn estimated_fp_rate(&self, stack: &BStack) -> io::Result { + let handle = self.range.start(); + let m = read_u64(stack, handle + M_OFF)? as f64; + let k = read_u64(stack, handle + K_OFF)? as f64; + let n = read_u64(stack, handle + N_OFF)? as f64; + Ok((1.0 - (-k * n / m).exp()).powf(k)) + } + + /// Insert `key`, bumping each of its `k` counters (saturating at 255). + pub fn insert( + &self, + allocator: &A, + key: &K, + ) -> io::Result<()> { + self.adjust(allocator, key, true) + } + + /// Remove `key`, decrementing each of its `k` counters (saturating at 0). + /// + /// Only call this for a key that was actually inserted (see the module docs) — + /// removing an absent key can introduce false negatives. + pub fn remove( + &self, + allocator: &A, + key: &K, + ) -> io::Result<()> { + self.adjust(allocator, key, false) + } + + /// Whether `key` is *possibly* present: `true` if all `k` counters are + /// non-zero (may be a false positive), `false` if any is zero (definitely + /// absent). A plain read. + pub fn contains(&self, stack: &BStack, key: &K) -> io::Result { + let handle = self.range.start(); + let data = read_u64(stack, handle + DATA_OFF)?; + let m = read_u64(stack, handle + M_OFF)?; + let k = read_u64(stack, handle + K_OFF)?; + let key_bytes = bytemuck::bytes_of(key); + for idx in Self::indices(m, k, key_bytes) { + let mut b = [0u8; 1]; + stack.get_into(data + idx, &mut b)?; + if b[0] == 0 { + return Ok(false); + } + } + Ok(true) + } + + /// Reset every counter and the item count to zero. + pub fn clear(&self, allocator: &A) -> io::Result<()> { + let handle = self.range.start(); + let data = read_u64(allocator.stack(), handle + DATA_OFF)?; + let m = read_u64(allocator.stack(), handle + M_OFF)?; + allocator.stack().set_batched([ + (data, vec![0u8; m as usize]), + (handle + N_OFF, 0u64.to_le_bytes().to_vec()), + ]) + } + + /// Atomically adjust the counters for `key` (and `n`) up or down, reading and + /// writing every touched counter in one `inplace_gen` (external-lock-free). + fn adjust( + &self, + allocator: &A, + key: &K, + add: bool, + ) -> io::Result<()> { + let handle = self.range.start(); + let data = read_u64(allocator.stack(), handle + DATA_OFF)?; + let m = read_u64(allocator.stack(), handle + M_OFF)?; + let k = read_u64(allocator.stack(), handle + K_OFF)?; + let agg = Self::aggregate(Self::indices(m, k, bytemuck::bytes_of(key))); + let cn = agg.len(); + + // Buffers that must outlive the whole `inplace_gen` call. + let mut read_c = vec![0u8; cn]; + let mut n_buf = [0u8; 8]; + let mut new_c = vec![0u8; cn]; + let mut new_n = [0u8; 8]; + + let mut rc = 0usize; + let mut n_read = false; + let mut computed = false; + let mut wc = 0usize; + let mut n_written = false; + + allocator.stack().inplace_gen(|_feedback| { + // Read each distinct counter (one byte). + if rc < cn { + let i = rc; + rc += 1; + // SAFETY: `read_c` outlives the call; each byte read once. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut read_c[i..i + 1]) }; + return Some(BStackGenOp::Read { + offset: data + agg[i].0, + buf: b, + }); + } + // Read `n`. + if !n_read { + n_read = true; + // SAFETY: `n_buf` outlives the call. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut n_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + N_OFF, + buf: b, + }); + } + // Compute the adjusted counters and item count. + if !computed { + computed = true; + for i in 0..cn { + let mult = agg[i].1.min(255) as u8; + new_c[i] = if add { + read_c[i].saturating_add(mult) + } else { + read_c[i].saturating_sub(mult) + }; + } + let n = u64::from_le_bytes(n_buf); + let nn = if add { + n.saturating_add(1) + } else { + n.saturating_sub(1) + }; + new_n = nn.to_le_bytes(); + } + // Write the adjusted counters. + if wc < cn { + let i = wc; + wc += 1; + // SAFETY: `new_c` outlives the call and is not mutated after compute. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(&new_c[i..i + 1]) }; + return Some(BStackGenOp::Write { + offset: data + agg[i].0, + data: d, + }); + } + // Write `n`. + if !n_written { + n_written = true; + // SAFETY: `new_n` outlives the call. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(&new_n[..]) }; + return Some(BStackGenOp::Write { + offset: handle + N_OFF, + data: d, + }); + } + None + }) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the filter was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackCountingBloomFilter { + /// A `"Blm"` prefix perturbed by the key size. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'B', b'l', b'm', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + } +} + +impl BStackBlock for BStackCountingBloomFilter { + type OnDisk = BloomOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackCountingBloomFilter { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Free the counter block, **without** freeing the handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let data = read_u64(allocator.stack(), range.start() + DATA_OFF)?; + let m = read_u64(allocator.stack(), range.start() + M_OFF)?; + if data != 0 { + // SAFETY: the filter solely owns its counter block. + unsafe { dealloc_range(allocator, BStackRange::new(data, m))? }; + } + Ok(()) + } + + /// Deep-clone: copy the counter block and stage the handle, in the parent + /// plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let data = read_u64(allocator.stack(), handle + DATA_OFF)?; + let m = read_u64(allocator.stack(), handle + M_OFF)?; + let k = read_u64(allocator.stack(), handle + K_OFF)?; + let n = read_u64(allocator.stack(), handle + N_OFF)?; + + let new_data = if m != 0 { + let mut bytes = vec![0u8; m as usize]; + allocator.stack().get_into(data, &mut bytes)?; + let dst = plan.alloc_raw(allocator, m)?; + plan.write(dst.start(), bytes); + dst.start() + } else { + 0 + }; + + let handle_dst = plan.alloc_raw(allocator, BLOOM_SIZE)?; + let od = BloomOnDisk { + header: BlockHeader { + size: BLOOM_SIZE, + tag: Self::eightcc(), + }, + data: new_data, + m, + k, + n, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackCountingBloomFilter { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackCountingBloomFilter { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/hash.rs b/bstack_raii/src/stdlib/hash.rs index 670c8f4..25067cc 100644 --- a/bstack_raii/src/stdlib/hash.rs +++ b/bstack_raii/src/stdlib/hash.rs @@ -9,3 +9,16 @@ pub(super) fn fnv1a(bytes: &[u8]) -> u64 { } h } + +/// Two independent 64-bit hashes of `bytes` for **double hashing** +/// (Kirsch–Mitzenmacher): the `i`-th derived hash is `h1 + i * h2`, which lets a +/// Bloom filter compute `k` indices from two base hashes with the same +/// distribution quality as `k` independent ones. `h2` is forced odd so, modulo +/// any table size, successive indices stride the whole array rather than cycling +/// a small subset. +pub(super) fn double_hash(bytes: &[u8]) -> (u64, u64) { + let h1 = fnv1a(bytes); + // Re-hash `h1`'s bytes for an independent second hash (cheap, deterministic). + let h2 = fnv1a(&h1.to_le_bytes()) | 1; + (h1, h2) +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 76ef02c..42cc470 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -21,6 +21,7 @@ //! | [`BStackBTreeMap`] | [`std::collections::BTreeMap`] | an owned **ordered** map: a copy-on-write B-tree (wide contiguous nodes, few seeks per lookup) with sorted iteration. Keys are `Pod + Ord`. | //! | [`BStackString`] | [`std::string::String`] | a standalone owned, growable UTF-8 string block — the first-class way to own text (a deque element, a map value). | +mod bloom; mod boxed; mod cow; mod deque; @@ -31,6 +32,7 @@ mod string; mod tree; mod util; +pub use bloom::{BStackCountingBloomFilter, BloomOnDisk}; pub use boxed::{BStackBox, BoxOnDisk}; pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 40ff01f..d5fc9d7 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -15,10 +15,10 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBTreeMap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, - BStackCastInto, BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackLinkedList, - BStackOwned, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, - TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, - bstack_move, dealloc_range, + BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, BStackHashMap, + BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, + EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, + bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -5473,3 +5473,137 @@ fn stdlib_string_as_map_value() { // Dropping the map recursively frees every string value (and its bytes block). map.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackCountingBloomFilter — probabilistic set +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_bloom_no_false_negatives() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let bloom = BStackCountingBloomFilter::::with_capacity(&alloc, 1000, 0.001).unwrap(); + assert!(bloom.is_empty(stack).unwrap()); + assert!(!bloom.contains(stack, &7).unwrap()); // fresh: everything absent + + for k in 0..50u32 { + bloom.insert(&alloc, &k).unwrap(); + } + assert_eq!(bloom.count(stack).unwrap(), 50); + + // No false negatives: every inserted key reports present. + for k in 0..50u32 { + assert!(bloom.contains(stack, &k).unwrap()); + } + + // Disjoint keys are (almost all) absent — allow a few false positives. + let absent = (1_000..1_050u32) + .filter(|k| !bloom.contains(stack, k).unwrap()) + .count(); + assert!(absent >= 45, "too many false positives: {}/50 absent", absent); + + // A positive FP estimate in (0, 1). + let fp = bloom.estimated_fp_rate(stack).unwrap(); + assert!(fp > 0.0 && fp < 1.0); + + bloom.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_bloom_remove_and_clear() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // A single inserted key: removing it drives its counters back to zero (no + // sharing), so `contains` becomes definitively false. + let bloom = BStackCountingBloomFilter::::with_capacity(&alloc, 100, 0.01).unwrap(); + bloom.insert(&alloc, &42).unwrap(); + assert!(bloom.contains(stack, &42).unwrap()); + assert_eq!(bloom.count(stack).unwrap(), 1); + bloom.remove(&alloc, &42).unwrap(); + assert!(!bloom.contains(stack, &42).unwrap()); + assert_eq!(bloom.count(stack).unwrap(), 0); + + // clear() zeroes everything. + for k in 0..20u32 { + bloom.insert(&alloc, &k).unwrap(); + } + assert_eq!(bloom.count(stack).unwrap(), 20); + bloom.clear(&alloc).unwrap(); + assert_eq!(bloom.count(stack).unwrap(), 0); + for k in 0..20u32 { + assert!(!bloom.contains(stack, &k).unwrap()); + } + + bloom.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_bloom_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let bloom = BStackCountingBloomFilter::::with_capacity(&alloc, 100, 0.01).unwrap(); + for k in 0..10u32 { + bloom.insert(&alloc, &k).unwrap(); + } + let clone = bloom.try_clone_in(&alloc).unwrap(); + for k in 0..10u32 { + assert!(clone.contains(stack, &k).unwrap()); + } + // Clearing the clone leaves the original intact. + clone.clear(&alloc).unwrap(); + assert_eq!(clone.count(stack).unwrap(), 0); + assert_eq!(bloom.count(stack).unwrap(), 10); + assert!(bloom.contains(stack, &5).unwrap()); + + clone.bstack_drop(&alloc).unwrap(); + bloom.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_bloom_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} + +#[test] +fn stdlib_bloom_guards_a_map() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // The headline pattern: a bloom filter in front of a map skips the disk probe + // for keys that are definitely absent. + let bloom = BStackCountingBloomFilter::::with_capacity(&alloc, 1000, 0.001).unwrap(); + let map = BStackHashMap::::new(&alloc).unwrap(); + for k in 0..40u32 { + map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 3).unwrap()).unwrap(); + bloom.insert(&alloc, &k).unwrap(); + } + + let lookup = |k: u32| -> Option { + // Fast-reject via the filter before touching the map. + if !bloom.contains(stack, &k).unwrap() { + return None; + } + map.get(stack, &k).unwrap().map(|v| v.val(stack).unwrap()) + }; + + for k in 0..40u32 { + assert_eq!(lookup(k), Some(k * 3)); + } + // Absent keys: the filter short-circuits (and the map agrees). + for k in 500..540u32 { + assert_eq!(lookup(k), None); + } + + bloom.bstack_drop(&alloc).unwrap(); + map.bstack_drop(&alloc).unwrap(); +} From 64df0198e921b5d574b0da10035ce866776afd40 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 06:30:01 -0700 Subject: [PATCH 082/140] stdlib: HashSet and TreeSet (with bloom filter in front) --- bstack_raii/src/lib.rs | 7 +- bstack_raii/src/stdlib/btreeset.rs | 607 ++++++++++++++++++++++++++++ bstack_raii/src/stdlib/hashset.rs | 609 +++++++++++++++++++++++++++++ bstack_raii/src/stdlib/map.rs | 125 +----- bstack_raii/src/stdlib/mod.rs | 7 + bstack_raii/src/stdlib/util.rs | 129 ++++++ bstack_raii/src/tests.rs | 147 ++++++- 7 files changed, 1499 insertions(+), 132 deletions(-) create mode 100644 bstack_raii/src/stdlib/btreeset.rs create mode 100644 bstack_raii/src/stdlib/hashset.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index d490841..05c4a34 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,9 +82,10 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBTreeMap, BStackBox, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackHashMap, - BStackLinkedList, BStackString, BloomOnDisk, BoxOnDisk, DequeOnDisk, ListOnDisk, MapOnDisk, - NodeOnDisk, StringOnDisk, TreeOnDisk, + BStackBTreeMap, BStackBTreeSet, BStackBox, BStackCountingBloomFilter, BStackCow, BStackDeque, + BStackHashMap, BStackHashSet, BStackLinkedList, BStackString, BloomOnDisk, BoxOnDisk, + DequeOnDisk, HashSetOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, TreeOnDisk, + TreeSetOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs new file mode 100644 index 0000000..476c702 --- /dev/null +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -0,0 +1,607 @@ +//! [`BStackBTreeSet`]: an owned ordered set of `Pod + Ord` keys, backed by a +//! copy-on-write B-tree, with an embedded counting Bloom filter front. +//! +//! The set analogue of [`crate::BStackBTreeMap`] — the same wide contiguous +//! nodes and path-copying insert, but each node stores only keys (no value +//! column). It gives sorted iteration and, like the map, is **single-writer / +//! multi-reader** (an insert path-copies the root-to-leaf path and commits the +//! new nodes plus the root swap as one atomic [`bstack::BStack::set_batched`]). +//! +//! # Bloom filter in front +//! +//! Like [`crate::BStackHashSet`], every set embeds a +//! [`crate::BStackCountingBloomFilter`] maintained as an over-approximation of +//! the tree, so [`contains`](BStackBTreeSet::contains) fast-rejects definitely- +//! absent keys without a tree descent. A key is added to the filter only when it +//! is genuinely new (an exact-membership check precedes the insert), so the +//! filter never over-counts and there are never false negatives. +//! +//! Not yet implemented: `remove` (B-tree deletion with rebalancing — the same gap +//! as [`crate::BStackBTreeMap`]). + +use core::cmp::Ordering; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; +use super::util::{Scratch, alloc_image, read_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackBTreeSet`]: header, root node pointer (`0` = +/// empty), key count, and the embedded Bloom filter's handle offset. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct TreeSetOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the root node, or `0` when the set is empty. + pub root: u64, + /// Number of keys. + pub len: u64, + /// Offset of the embedded counting Bloom filter's handle block. + pub bloom: u64, +} + +const ROOT_OFF: u64 = HEADER_SIZE; // 16 +const LEN_OFF: u64 = HEADER_SIZE + 8; // 24 +const BLOOM_OFF: u64 = HEADER_SIZE + 16; // 32 +const TREESET_SIZE: u64 = size_of::() as u64; +const BLOOM_SIZE: u64 = size_of::() as u64; + +const T: usize = 8; +const MAXKEYS: usize = 2 * T - 1; // 15 +const MAXCHILDREN: usize = 2 * T; // 16 + +const NKEYS_OFF: usize = HEADER_SIZE as usize; // 16 +const LEAF_OFF: usize = HEADER_SIZE as usize + 8; // 24 +const KEYS_OFF: usize = HEADER_SIZE as usize + 16; // 32 + +const DEFAULT_ITEMS: u64 = 1024; +const DEFAULT_FP: f64 = 0.01; + +/// A node decoded for building: keys as raw bytes and (internal) child offsets. +struct BNode { + leaf: bool, + keys: Vec>, + children: Vec, +} + +/// A median key lifted from a split, plus the new right node. +struct Split { + key: Vec, + right: u64, +} + +/// Accumulates a path-copy insert's new nodes and the old path nodes to free. +struct Build<'a, A: BStackOwnedSliceAllocator> { + allocator: &'a A, + node_size: u64, + ksize: usize, + children_off: usize, + writes: Vec<(u64, Vec)>, + freed: Vec, +} + +impl<'a, A: BStackOwnedSliceAllocator> Build<'a, A> { + fn emit(&mut self, nb: &BNode) -> io::Result { + let mut b = vec![0u8; self.node_size as usize]; + b[NKEYS_OFF..NKEYS_OFF + 8].copy_from_slice(&(nb.keys.len() as u64).to_le_bytes()); + b[LEAF_OFF..LEAF_OFF + 8].copy_from_slice(&(nb.leaf as u64).to_le_bytes()); + for (i, k) in nb.keys.iter().enumerate() { + let ko = KEYS_OFF + i * self.ksize; + b[ko..ko + self.ksize].copy_from_slice(k); + } + for (i, c) in nb.children.iter().enumerate() { + let co = self.children_off + i * 8; + b[co..co + 8].copy_from_slice(&c.to_le_bytes()); + } + let off = self.allocator.alloc(self.node_size)?.as_range().start(); + self.writes.push((off, b)); + Ok(off) + } +} + +/// An owned ordered set of `Pod + Ord` keys with an embedded Bloom filter. +pub struct BStackBTreeSet { + range: BStackRange, + _marker: PhantomData K>, +} + +impl BStackBTreeSet { + const fn ksize() -> usize { + size_of::() + } + const fn children_off() -> usize { + KEYS_OFF + MAXKEYS * Self::ksize() + } + const fn node_size() -> u64 { + (Self::children_off() + MAXCHILDREN * 8) as u64 + } + + fn read_key(bytes: &[u8]) -> K { + bytemuck::pod_read_unaligned::(&bytes[..Self::ksize()]) + } + + fn bloom(&self, stack: &BStack) -> io::Result> { + let off = read_u64(stack, self.range.start() + BLOOM_OFF)?; + Ok( as BStackBlock>::from_range( + BStackRange::new(off, BLOOM_SIZE), + )) + } + + /// Allocate an empty set with a default-sized Bloom filter. + pub fn new(allocator: &A) -> io::Result> { + Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) + } + + /// Allocate an empty set whose Bloom filter is sized for `expected_items` at + /// false-positive rate `fp_rate`. + pub fn with_capacity( + allocator: &A, + expected_items: u64, + fp_rate: f64, + ) -> io::Result> { + let bloom = + BStackCountingBloomFilter::::with_capacity(allocator, expected_items, fp_rate)?; + let bloom_off = bloom.into_inner().range().start(); + let od = TreeSetOnDisk { + header: BlockHeader { + size: TREESET_SIZE, + tag: Self::eightcc(), + }, + root: 0, + len: 0, + bloom: bloom_off, + }; + match alloc_image(allocator, bytemuck::bytes_of(&od)) { + // SAFETY: a freshly allocated block owned by no other handle. + Ok(range) => Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }), + Err(e) => { + let bloom = as BStackBlock>::from_range( + BStackRange::new(bloom_off, BLOOM_SIZE), + ); + let _ = unsafe { BStackOwned::from_raw(bloom) }.bstack_drop(allocator); + Err(e) + } + } + } + + /// Number of keys. + pub fn len(&self, stack: &BStack) -> io::Result { + read_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the set is empty. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + fn read_node(stack: &BStack, off: u64) -> io::Result { + let mut b = vec![0u8; Self::node_size() as usize]; + stack.get_into(off, &mut b)?; + let nkeys = get_u64(&b[NKEYS_OFF..]) as usize; + let leaf = get_u64(&b[LEAF_OFF..]) != 0; + let ksize = Self::ksize(); + let children_off = Self::children_off(); + let mut keys = Vec::with_capacity(nkeys); + for i in 0..nkeys { + let ko = KEYS_OFF + i * ksize; + keys.push(b[ko..ko + ksize].to_vec()); + } + let mut children = Vec::new(); + if !leaf { + for i in 0..=nkeys { + children.push(get_u64(&b[children_off + i * 8..])); + } + } + Ok(BNode { + leaf, + keys, + children, + }) + } + + /// First index `i` with `target <= keys[i]`, and whether it is exact. + fn search(nb: &BNode, target: &K) -> (usize, bool) { + for (j, kb) in nb.keys.iter().enumerate() { + match target.cmp(&Self::read_key(kb)) { + Ordering::Less => return (j, false), + Ordering::Equal => return (j, true), + Ordering::Greater => {} + } + } + (nb.keys.len(), false) + } + + /// Split an over-full node around its median. + fn split(mut nb: BNode) -> (BNode, Split, BNode) { + let m = nb.keys.len() / 2; + let right_children = if nb.leaf { + Vec::new() + } else { + nb.children.split_off(m + 1) + }; + let right_keys = nb.keys.split_off(m + 1); + let med_key = nb.keys.pop().unwrap(); + let right = BNode { + leaf: nb.leaf, + keys: right_keys, + children: right_children, + }; + (nb, Split { key: med_key, right: 0 }, right) + } + + /// Path-copy the subtree at `off`, inserting a **new** `key` (assumed absent). + fn insert_rec( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + off: u64, + key: &K, + key_bytes: &[u8], + ) -> io::Result<(u64, Option)> { + let mut nb = Self::read_node(stack, off)?; + build.freed.push(off); + let (i, _exact) = Self::search(&nb, key); + + if nb.leaf { + nb.keys.insert(i, key_bytes.to_vec()); + } else { + let child = nb.children[i]; + let (new_child, child_split) = Self::insert_rec(build, stack, child, key, key_bytes)?; + nb.children[i] = new_child; + if let Some(s) = child_split { + nb.keys.insert(i, s.key); + nb.children.insert(i + 1, s.right); + } + } + + if nb.keys.len() <= MAXKEYS { + Ok((build.emit(&nb)?, None)) + } else { + let (left, mut split, right) = Self::split(nb); + split.right = build.emit(&right)?; + Ok((build.emit(&left)?, Some(split))) + } + } + + /// Insert `key`; returns `true` if newly added, `false` if already present. + pub fn insert( + &self, + allocator: &A, + key: K, + ) -> io::Result { + // Exact check first, so the filter is only touched for genuinely new keys. + let key_bytes = bytemuck::bytes_of(&key).to_vec(); + if self.tree_contains(allocator.stack(), &key, &key_bytes)? { + return Ok(false); + } + self.bloom(allocator.stack())?.insert(allocator, &key)?; + + let handle = self.range.start(); + let stack = allocator.stack(); + let root = read_u64(stack, handle + ROOT_OFF)?; + let len = read_u64(stack, handle + LEN_OFF)?; + + let mut build = Build { + allocator, + node_size: Self::node_size(), + ksize: Self::ksize(), + children_off: Self::children_off(), + writes: Vec::new(), + freed: Vec::new(), + }; + + let built: io::Result = (|| { + if root == 0 { + let leaf = BNode { + leaf: true, + keys: vec![key_bytes.clone()], + children: Vec::new(), + }; + return build.emit(&leaf); + } + let (new_root0, split) = Self::insert_rec(&mut build, stack, root, &key, &key_bytes)?; + if let Some(s) = split { + let root_node = BNode { + leaf: false, + keys: vec![s.key], + children: vec![new_root0, s.right], + }; + build.emit(&root_node) + } else { + Ok(new_root0) + } + })(); + + match built { + Ok(new_root) => { + let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); + let mut writes = core::mem::take(&mut build.writes); + writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); + writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + match stack.set_batched(writes) { + Ok(()) => { + for off in &build.freed { + // SAFETY: replaced by the copy just committed (single-writer). + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; + } + Ok(true) + } + Err(e) => { + for off in new_node_offs { + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(off, build.node_size)) + }; + } + Err(e) + } + } + } + Err(e) => { + for (off, _) in &build.writes { + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + } + Err(e) + } + } + } + + /// Whether `key` is present. Fast-rejects via the Bloom filter first. + pub fn contains(&self, stack: &BStack, key: &K) -> io::Result { + if !self.bloom(stack)?.contains(stack, key)? { + return Ok(false); + } + self.tree_contains(stack, key, bytemuck::bytes_of(key)) + } + + /// Exact membership descent (no Bloom fast-reject). + fn tree_contains(&self, stack: &BStack, key: &K, _key_bytes: &[u8]) -> io::Result { + let mut off = read_u64(stack, self.range.start() + ROOT_OFF)?; + let ksize = Self::ksize(); + let children_off = Self::children_off(); + let mut scratch = Scratch::new(); + let node_size = Self::node_size() as usize; + while off != 0 { + let buf = scratch.buf(node_size); + stack.get_into(off, buf)?; + let nkeys = get_u64(&buf[NKEYS_OFF..]) as usize; + let leaf = get_u64(&buf[LEAF_OFF..]) != 0; + let mut i = nkeys; + for j in 0..nkeys { + let ko = KEYS_OFF + j * ksize; + match key.cmp(&Self::read_key(&buf[ko..ko + ksize])) { + Ordering::Less => { + i = j; + break; + } + Ordering::Equal => return Ok(true), + Ordering::Greater => {} + } + } + if leaf { + return Ok(false); + } + off = get_u64(&buf[children_off + i * 8..]); + } + Ok(false) + } + + /// The smallest key, or `None` if empty. + pub fn first(&self, stack: &BStack) -> io::Result> { + self.extreme(stack, true) + } + + /// The largest key, or `None` if empty. + pub fn last(&self, stack: &BStack) -> io::Result> { + self.extreme(stack, false) + } + + fn extreme(&self, stack: &BStack, leftmost: bool) -> io::Result> { + let mut off = read_u64(stack, self.range.start() + ROOT_OFF)?; + if off == 0 { + return Ok(None); + } + loop { + let nb = Self::read_node(stack, off)?; + if nb.leaf { + let i = if leftmost { 0 } else { nb.keys.len() - 1 }; + return Ok(Some(Self::read_key(&nb.keys[i]))); + } + off = if leftmost { + nb.children[0] + } else { + nb.children[nb.keys.len()] + }; + } + } + + /// Collect every key in ascending order. + pub fn to_vec(&self, stack: &BStack) -> io::Result> { + let mut out = Vec::new(); + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; + Self::collect(stack, root, &mut out)?; + Ok(out) + } + + fn collect(stack: &BStack, off: u64, out: &mut Vec) -> io::Result<()> { + if off == 0 { + return Ok(()); + } + let nb = Self::read_node(stack, off)?; + for i in 0..nb.keys.len() { + if !nb.leaf { + Self::collect(stack, nb.children[i], out)?; + } + out.push(Self::read_key(&nb.keys[i])); + } + if !nb.leaf { + Self::collect(stack, nb.children[nb.keys.len()], out)?; + } + Ok(()) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the set was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } + + fn drop_subtree( + stack: &BStack, + off: u64, + allocator: &A, + ) -> io::Result<()> { + if off == 0 { + return Ok(()); + } + let nb = Self::read_node(stack, off)?; + if !nb.leaf { + for &c in &nb.children { + Self::drop_subtree(stack, c, allocator)?; + } + } + // SAFETY: the set solely owns each node block. + unsafe { dealloc_range(allocator, BStackRange::new(off, Self::node_size()))? }; + Ok(()) + } + + fn clone_subtree( + stack: &BStack, + off: u64, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + if off == 0 { + return Ok(0); + } + let mut buf = vec![0u8; Self::node_size() as usize]; + stack.get_into(off, &mut buf)?; + let nkeys = get_u64(&buf[NKEYS_OFF..]) as usize; + let leaf = get_u64(&buf[LEAF_OFF..]) != 0; + let children_off = Self::children_off(); + if !leaf { + for i in 0..=nkeys { + let co = children_off + i * 8; + let child = get_u64(&buf[co..]); + let new_child = Self::clone_subtree(stack, child, allocator, plan)?; + buf[co..co + 8].copy_from_slice(&new_child.to_le_bytes()); + } + } + let dst = plan.alloc_raw(allocator, Self::node_size())?; + plan.write(dst.start(), buf); + Ok(dst.start()) + } +} + +impl BStackCast for BStackBTreeSet { + /// A `"TSt"` prefix perturbed by the key size. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'T', b'S', b't', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + } +} + +impl BStackBlock for BStackBTreeSet { + type OnDisk = TreeSetOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackBTreeSet { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every node and the embedded Bloom filter, **without** + /// freeing the handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let handle = range.start(); + let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; + Self::drop_subtree(allocator.stack(), root, allocator)?; + let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + if bloom_off != 0 { + // SAFETY: the set solely owns its embedded Bloom filter. + let bloom = as BStackBlock>::from_range( + BStackRange::new(bloom_off, BLOOM_SIZE), + ); + unsafe { BStackOwned::from_raw(bloom) }.bstack_drop(allocator)?; + } + Ok(()) + } + + /// Deep-clone every node and the Bloom filter into `plan`, then stage the + /// handle. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; + let len = read_u64(allocator.stack(), handle + LEN_OFF)?; + let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + + let new_root = Self::clone_subtree(allocator.stack(), root, allocator, plan)?; + let bloom = as BStackBlock>::from_range(BStackRange::new( + bloom_off, BLOOM_SIZE, + )); + let new_bloom = bloom.__bstack_clone_into(allocator, plan)?.start(); + + let handle_dst = plan.alloc_raw(allocator, TREESET_SIZE)?; + let od = TreeSetOnDisk { + header: BlockHeader { + size: TREESET_SIZE, + tag: Self::eightcc(), + }, + root: new_root, + len, + bloom: new_bloom, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackBTreeSet { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackBTreeSet { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs new file mode 100644 index 0000000..2ed39f8 --- /dev/null +++ b/bstack_raii/src/stdlib/hashset.rs @@ -0,0 +1,609 @@ +//! [`BStackHashSet`]: an owned open-addressing set of `Pod` keys, with an +//! embedded counting Bloom filter front. +//! +//! The set analogue of [`crate::BStackHashMap`] — the same linear-probe table and +//! [`probe_commit`] engine, but each bucket is just `state + key` (no value +//! column), so it is denser and never touches owned value blocks. Keys are `Pod`, +//! hashed by their raw bytes. +//! +//! # Bloom filter in front +//! +//! Every set embeds a [`crate::BStackCountingBloomFilter`] as a cheap fast-reject +//! guard: [`contains`](BStackHashSet::contains) checks the filter first and skips +//! the table probe entirely for keys it reports absent. The filter is maintained +//! as a strict **over-approximation** of the table (every key in the table is in +//! the filter), so a bloom "absent" is authoritative and there are never false +//! negatives. Consistency across the two blocks is by ordering: +//! +//! * `insert` adds to the filter *before* the table (and undoes the filter bump +//! if the key turned out to be a duplicate); +//! * `remove` deletes from the table *before* decrementing the filter, and only +//! decrements for a key that was actually present. +//! +//! A crash between the two steps can only leave the filter *more* permissive +//! (extra false positives), never less — the table stays the source of truth. +//! Because a set op spans two blocks it is **not** a single atomic commit; treat +//! the set as single-writer for the filter's accuracy (a concurrent writer may +//! over-count the filter — more false positives, never a false negative). The +//! filter is fixed-size, so a set far larger than its configured capacity keeps +//! working, just with a higher (still sound) false-positive rate. + +use core::cell::Cell; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; +use super::hash::fnv1a; +use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackHashSet`]: header, bucket-block pointer, +/// bucket count `cap`, key count `len`, `used` (occupied + tombstone), and the +/// embedded Bloom filter's handle offset. `#[repr(C)]`, `u64` fields only. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct HashSetOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the bucket block, or `0` when unallocated. + pub table: u64, + /// Number of buckets (a power of two). + pub cap: u64, + /// Number of live keys. + pub len: u64, + /// Occupied + tombstone slots (drives growth). + pub used: u64, + /// Offset of the embedded counting Bloom filter's handle block. + pub bloom: u64, +} + +const TABLE_OFF: u64 = HEADER_SIZE; // 16 +const CAP_OFF: u64 = HEADER_SIZE + 8; // 24 +const LEN_OFF: u64 = HEADER_SIZE + 16; // 32 +const USED_OFF: u64 = HEADER_SIZE + 24; // 40 +const BLOOM_OFF: u64 = HEADER_SIZE + 32; // 48 +const SET_SIZE: u64 = size_of::() as u64; +const BLOOM_SIZE: u64 = size_of::() as u64; +const MIN_CAP: u64 = 4; + +const EMPTY: u64 = 0; +const OCCUPIED: u64 = 1; +const TOMBSTONE: u64 = 2; + +// Default Bloom sizing when the caller does not specify one. +const DEFAULT_ITEMS: u64 = 1024; +const DEFAULT_FP: f64 = 0.01; + +/// Build the writes that place a key at bucket `target`, bumping `len` (and +/// `used` when the slot was previously `EMPTY`). +fn place_writes( + handle: u64, + stride: u64, + m: &Meta, + target: u64, + slot_was_empty: bool, + key_bytes: &[u8], +) -> Vec<(u64, Vec)> { + let mut img = Vec::with_capacity(8 + key_bytes.len()); + img.extend_from_slice(&OCCUPIED.to_le_bytes()); + img.extend_from_slice(key_bytes); + let mut w = vec![ + (m.table + target * stride, img), + (handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), + ]; + if slot_was_empty { + w.push((handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); + } + w +} + +/// An owned open-addressing set of `Pod` keys with an embedded Bloom filter. +pub struct BStackHashSet { + range: BStackRange, + _marker: PhantomData K>, +} + +impl BStackHashSet { + fn ksize() -> usize { + size_of::() + } + fn stride() -> u64 { + 8 + Self::ksize() as u64 + } + + /// The embedded Bloom filter (its handle offset is fixed after construction). + fn bloom(&self, stack: &BStack) -> io::Result> { + let off = read_u64(stack, self.range.start() + BLOOM_OFF)?; + Ok( as BStackBlock>::from_range( + BStackRange::new(off, BLOOM_SIZE), + )) + } + + /// Allocate an empty set with a default-sized Bloom filter. + pub fn new(allocator: &A) -> io::Result> { + Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) + } + + /// Allocate an empty set whose Bloom filter is sized for `expected_items` at + /// false-positive rate `fp_rate`. + pub fn with_capacity( + allocator: &A, + expected_items: u64, + fp_rate: f64, + ) -> io::Result> { + // The Bloom filter is a child block; take its handle offset. + let bloom = + BStackCountingBloomFilter::::with_capacity(allocator, expected_items, fp_rate)?; + let bloom_off = bloom.into_inner().range().start(); + let od = HashSetOnDisk { + header: BlockHeader { + size: SET_SIZE, + tag: Self::eightcc(), + }, + table: 0, + cap: 0, + len: 0, + used: 0, + bloom: bloom_off, + }; + match alloc_image(allocator, bytemuck::bytes_of(&od)) { + // SAFETY: a freshly allocated block owned by no other handle. + Ok(range) => Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }), + Err(e) => { + // SAFETY: the Bloom child was just allocated, referenced by nobody. + let bloom = as BStackBlock>::from_range( + BStackRange::new(bloom_off, BLOOM_SIZE), + ); + let _ = unsafe { BStackOwned::from_raw(bloom) }.bstack_drop(allocator); + Err(e) + } + } + } + + /// Number of keys. + pub fn len(&self, stack: &BStack) -> io::Result { + read_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the set is empty. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Insert `key`; returns `true` if it was newly added, `false` if already + /// present. + pub fn insert( + &self, + allocator: &A, + key: K, + ) -> io::Result { + let key_bytes = bytemuck::bytes_of(&key).to_vec(); + let hash = fnv1a(&key_bytes); + let bloom = self.bloom(allocator.stack())?; + // Add to the filter first so it always over-approximates the table. + bloom.insert(allocator, &key)?; + let was_new = self.table_insert(allocator, &key_bytes, hash)?; + if !was_new { + // Duplicate: undo the filter bump so counts don't drift upward. + bloom.remove(allocator, &key)?; + } + Ok(was_new) + } + + /// Remove `key`; returns `true` if it was present. + pub fn remove( + &self, + allocator: &A, + key: &K, + ) -> io::Result { + let key_bytes = bytemuck::bytes_of(key).to_vec(); + let hash = fnv1a(&key_bytes); + // Remove from the table first; only then decrement the filter (and only + // for a key that was actually present, per the counting-Bloom contract). + let was_present = self.table_remove(allocator, &key_bytes, hash)?; + if was_present { + self.bloom(allocator.stack())?.remove(allocator, key)?; + } + Ok(was_present) + } + + /// Whether `key` is present. Fast-rejects via the Bloom filter before probing. + pub fn contains(&self, stack: &BStack, key: &K) -> io::Result { + if !self.bloom(stack)?.contains(stack, key)? { + return Ok(false); + } + let key_bytes = bytemuck::bytes_of(key); + self.table_contains(stack, key_bytes, fnv1a(key_bytes)) + } + + /// Place `key` in the table if absent; returns whether it was newly added. + fn table_insert( + &self, + allocator: &A, + key_bytes: &[u8], + hash: u64, + ) -> io::Result { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + loop { + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; + let used = read_u64(allocator.stack(), handle + USED_OFF)?; + if cap == 0 || (used + 1) * 4 > cap * 3 { + self.grow(allocator)?; + continue; + } + let first_tomb: Cell> = Cell::new(None); + let is_new = Cell::new(false); + let need_grow = Cell::new(false); + + probe_commit( + allocator, + handle, + stride, + hash, + |m, idx, buf| { + let state = get_u64(&buf[0..8]); + if state == EMPTY { + let target = first_tomb.get().unwrap_or(idx); + let slot_was_empty = first_tomb.get().is_none(); + is_new.set(true); + ProbeStep::Stop(place_writes( + handle, + stride, + m, + target, + slot_was_empty, + key_bytes, + )) + } else if state == OCCUPIED && buf[8..8 + ksz] == *key_bytes { + ProbeStep::Stop(Vec::new()) // already present + } else { + if state == TOMBSTONE && first_tomb.get().is_none() { + first_tomb.set(Some(idx)); + } + ProbeStep::Continue + } + }, + |m| { + if let Some(t) = first_tomb.get() { + is_new.set(true); + place_writes(handle, stride, m, t, false, key_bytes) + } else { + need_grow.set(true); + Vec::new() + } + }, + )?; + + if need_grow.get() { + self.grow(allocator)?; + continue; + } + return Ok(is_new.get()); + } + } + + /// Tombstone `key` in the table if present; returns whether it was. + fn table_remove( + &self, + allocator: &A, + key_bytes: &[u8], + hash: u64, + ) -> io::Result { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let found = Cell::new(false); + probe_commit( + allocator, + handle, + stride, + hash, + |m, idx, buf| { + let state = get_u64(&buf[0..8]); + if state == EMPTY { + ProbeStep::Stop(Vec::new()) + } else if state == OCCUPIED && buf[8..8 + ksz] == *key_bytes { + found.set(true); + ProbeStep::Stop(vec![ + (m.table + idx * stride, TOMBSTONE.to_le_bytes().to_vec()), + (handle + LEN_OFF, (m.len - 1).to_le_bytes().to_vec()), + ]) + } else { + ProbeStep::Continue + } + }, + |_m| Vec::new(), + )?; + Ok(found.get()) + } + + /// Exact table membership probe (no Bloom fast-reject). + fn table_contains(&self, stack: &BStack, key_bytes: &[u8], hash: u64) -> io::Result { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let table = read_u64(stack, handle + TABLE_OFF)?; + let cap = read_u64(stack, handle + CAP_OFF)?; + if cap == 0 { + return Ok(false); + } + let mask = cap - 1; + let mut idx = hash & mask; + let mut scratch = Scratch::new(); + for _ in 0..cap { + let bucket = table + idx * stride; + let buf = scratch.buf(stride as usize); + stack.get_into(bucket, buf)?; + let state = get_u64(&buf[0..8]); + if state == EMPTY { + return Ok(false); + } + if state == OCCUPIED && buf[8..8 + ksz] == *key_bytes { + return Ok(true); + } + idx = (idx + 1) & mask; + } + Ok(false) + } + + /// Grow the table to at least double its capacity, rehashing every live key + /// (and dropping tombstones) atomically. + fn grow(&self, allocator: &A) -> io::Result<()> { + let handle = self.range.start(); + let stride = Self::stride(); + let ksz = Self::ksize(); + let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; + let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; + let newtable = allocator.alloc(newcap * stride)?.as_range().start(); + + let mut meta_buf = [0u8; 32]; + let mut old_buf = vec![0u8; (cap0 * stride) as usize]; + let mut new_image: Vec = Vec::new(); + let grown = Cell::new(false); + let old_table = Cell::new(0u64); + let old_cap = Cell::new(0u64); + + let mut meta_issued = false; + let mut meta: Option = None; + let mut abort = false; + let mut read_i = 0u64; + let mut built = false; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + if !meta_issued { + meta_issued = true; + // SAFETY: `meta_buf` outlives the call. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + TABLE_OFF, + buf: b, + }); + } + if meta.is_none() { + let m = Meta { + table: get_u64(&meta_buf[0..8]), + cap: get_u64(&meta_buf[8..16]), + len: get_u64(&meta_buf[16..24]), + used: get_u64(&meta_buf[24..32]), + }; + if newcap <= m.cap { + abort = true; + } + meta = Some(m); + } + if abort { + return None; + } + let m = meta.as_ref().unwrap(); + + if read_i < m.cap { + let i = read_i; + read_i += 1; + let lo = (i * stride) as usize; + let hi = lo + stride as usize; + // SAFETY: `old_buf` outlives the call; each slice read once. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut old_buf[lo..hi]) }; + return Some(BStackGenOp::Read { + offset: m.table + i * stride, + buf: b, + }); + } + + if !built { + built = true; + grown.set(true); + old_table.set(m.table); + old_cap.set(m.cap); + + new_image = vec![0u8; (newcap * stride) as usize]; + let newmask = newcap - 1; + for j in 0..m.cap { + let lo = (j * stride) as usize; + if get_u64(&old_buf[lo..lo + 8]) != OCCUPIED { + continue; + } + let kb = &old_buf[lo + 8..lo + 8 + ksz]; + let mut idx = fnv1a(kb) & newmask; + loop { + let nlo = (idx * stride) as usize; + if get_u64(&new_image[nlo..nlo + 8]) == EMPTY { + new_image[nlo..nlo + 8].copy_from_slice(&OCCUPIED.to_le_bytes()); + new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); + break; + } + idx = (idx + 1) & newmask; + } + } + writes.push((newtable, std::mem::take(&mut new_image))); + writes.push((handle + TABLE_OFF, newtable.to_le_bytes().to_vec())); + writes.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); + writes.push((handle + USED_OFF, m.len.to_le_bytes().to_vec())); + } + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives the call and is not mutated after build. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { offset: off, data: d }); + } + None + })?; + + if grown.get() { + if old_cap.get() > 0 { + // SAFETY: the descriptor no longer points at the old table. + let _ = unsafe { + dealloc_range( + allocator, + BStackRange::new(old_table.get(), old_cap.get() * stride), + ) + }; + } + } else { + // SAFETY: `newtable` was never linked into the descriptor. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; + } + Ok(()) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the set was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackHashSet { + /// An `"HSt"` prefix perturbed by the key size. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'H', b'S', b't', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + } +} + +impl BStackBlock for BStackHashSet { + type OnDisk = HashSetOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackHashSet { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Free the bucket block and the embedded Bloom filter, **without** freeing + /// the handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let handle = range.start(); + let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; + let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + if table != 0 { + // SAFETY: the set solely owns its bucket block. + unsafe { dealloc_range(allocator, BStackRange::new(table, cap * Self::stride()))? }; + } + if bloom_off != 0 { + // SAFETY: the set solely owns its embedded Bloom filter. + let bloom = as BStackBlock>::from_range( + BStackRange::new(bloom_off, BLOOM_SIZE), + ); + unsafe { BStackOwned::from_raw(bloom) }.bstack_drop(allocator)?; + } + Ok(()) + } + + /// Deep-clone: copy the bucket block, deep-clone the Bloom filter, and stage + /// the handle, in the parent plan's single atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let stride = Self::stride(); + let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; + let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; + let len = read_u64(allocator.stack(), handle + LEN_OFF)?; + let used = read_u64(allocator.stack(), handle + USED_OFF)?; + let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + + let new_table = if cap != 0 { + let mut image = vec![0u8; (cap * stride) as usize]; + allocator.stack().get_into(table, &mut image)?; + let dst = plan.alloc_raw(allocator, cap * stride)?; + plan.write(dst.start(), image); + dst.start() + } else { + 0 + }; + + let bloom = as BStackBlock>::from_range(BStackRange::new( + bloom_off, BLOOM_SIZE, + )); + let new_bloom = bloom.__bstack_clone_into(allocator, plan)?.start(); + + let handle_dst = plan.alloc_raw(allocator, SET_SIZE)?; + let od = HashSetOnDisk { + header: BlockHeader { + size: SET_SIZE, + tag: Self::eightcc(), + }, + table: new_table, + cap, + len, + used, + bloom: new_bloom, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackHashSet { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackHashSet { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 9074e61..61efa34 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -41,7 +41,7 @@ use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; -use super::util::{Scratch, alloc_image, read_u64}; +use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -82,129 +82,6 @@ const EMPTY: u64 = 0; const OCCUPIED: u64 = 1; const TOMBSTONE: u64 = 2; -/// A snapshot of the four handle metadata fields, read inside a generator. -struct Meta { - table: u64, - cap: u64, - len: u64, - used: u64, -} - -/// A probe step returned by the `inspect` closure of [`probe_commit`]. -enum ProbeStep { - /// This bucket isn't the target — keep probing. - Continue, - /// Stop here and commit these writes (empty = commit nothing). - Stop(Vec<(u64, Vec)>), -} - -/// Run an atomic, external-lock-free probe over the bucket table under one -/// [`BStack::inplace_gen`]. -/// -/// Reads the handle metadata, then linearly probes buckets from `hash & (cap-1)`, -/// reading the full `stride`-byte bucket each step and handing it to `inspect`. -/// The first `inspect` returning [`ProbeStep::Stop`] commits its writes and ends; -/// if all `cap` buckets are probed without a stop, `exhausted` produces the final -/// writes. Every read and write rides the one generator, so the probe sees a -/// consistent snapshot and the writes land as one crash-atomic batch. -fn probe_commit( - allocator: &A, - handle: u64, - stride: u64, - hash: u64, - mut inspect: I, - exhausted: E, -) -> io::Result<()> -where - A: BStackOwnedSliceAllocator, - I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, - E: FnOnce(&Meta) -> Vec<(u64, Vec)>, -{ - let mut meta_buf = [0u8; 32]; - let mut bucket_buf = vec![0u8; stride as usize]; - let mut writes: Vec<(u64, Vec)> = Vec::new(); - - let mut meta_issued = false; - let mut meta: Option = None; - let mut mask = 0u64; - let mut cur = 0u64; - let mut idx_at_read = 0u64; - let mut probe_pending = false; - let mut probed = 0u64; - let mut decided = false; - let mut exhausted = Some(exhausted); - let mut w = 0usize; - - allocator.stack().inplace_gen(|_feedback| { - // 1. Read the 32-byte metadata block. - if !meta_issued { - meta_issued = true; - // SAFETY: `meta_buf` outlives the call; used by this one read. - let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; - return Some(BStackGenOp::Read { - offset: handle + TABLE_OFF, - buf: b, - }); - } - // 2. Parse it once. - if meta.is_none() { - let m = Meta { - table: get_u64(&meta_buf[0..8]), - cap: get_u64(&meta_buf[8..16]), - len: get_u64(&meta_buf[16..24]), - used: get_u64(&meta_buf[24..32]), - }; - mask = m.cap.wrapping_sub(1); - cur = if m.cap == 0 { 0 } else { hash & mask }; - meta = Some(m); - } - let m = meta.as_ref().unwrap(); - - // 3a. Inspect a completed bucket read. - if probe_pending { - probe_pending = false; - if let ProbeStep::Stop(ws) = inspect(m, idx_at_read, &bucket_buf) { - writes = ws; - decided = true; - } - } - // 3b. Issue the next probe, or finish by exhaustion. - if !decided { - if m.cap == 0 || probed >= m.cap { - writes = (exhausted.take().unwrap())(m); - decided = true; - } else { - idx_at_read = cur; - probe_pending = true; - probed += 1; - cur = (cur + 1) & mask; - let off = m.table + idx_at_read * stride; - // SAFETY: `bucket_buf` outlives the call; each read completes - // (and is inspected) before the next is issued. - let b: &mut [u8] = - unsafe { core::mem::transmute::<&mut [u8], _>(&mut bucket_buf[..]) }; - return Some(BStackGenOp::Read { - offset: off, - buf: b, - }); - } - } - // 4. Commit the chosen writes together. - if w < writes.len() { - let i = w; - w += 1; - let (off, ref bytes) = writes[i]; - // SAFETY: `writes` outlives the call and is not mutated after this point. - let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { - offset: off, - data: d, - }); - } - None - }) -} - /// The per-insert invariants shared by the probe closures that place the new /// entry: where the map lives, its bucket geometry, and the entry to write. struct NewEntry<'a> { diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 42cc470..5444a4a 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -20,12 +20,17 @@ //! | [`BStackHashMap`] | [`std::collections::HashMap`] | an owned open-addressing map from a [`Pod`](bytemuck::Pod) key to a block value — keyed lookup without a linear scan. | //! | [`BStackBTreeMap`] | [`std::collections::BTreeMap`] | an owned **ordered** map: a copy-on-write B-tree (wide contiguous nodes, few seeks per lookup) with sorted iteration. Keys are `Pod + Ord`. | //! | [`BStackString`] | [`std::string::String`] | a standalone owned, growable UTF-8 string block — the first-class way to own text (a deque element, a map value). | +//! | [`BStackCountingBloomFilter`] | (Bloom filter) | a probabilistic set: no false negatives, supports removal; a cheap fast-reject front for exact lookups. | +//! | [`BStackHashSet`] | [`std::collections::HashSet`] | an owned open-addressing set of `Pod` keys, with an embedded Bloom-filter fast-reject front. | +//! | [`BStackBTreeSet`] | [`std::collections::BTreeSet`] | an owned **ordered** set (copy-on-write B-tree, sorted iteration), with an embedded Bloom-filter front. Keys are `Pod + Ord`. | mod bloom; mod boxed; +mod btreeset; mod cow; mod deque; mod hash; +mod hashset; mod list; mod map; mod string; @@ -34,8 +39,10 @@ mod util; pub use bloom::{BStackCountingBloomFilter, BloomOnDisk}; pub use boxed::{BStackBox, BoxOnDisk}; +pub use btreeset::{BStackBTreeSet, TreeSetOnDisk}; pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; +pub use hashset::{BStackHashSet, HashSetOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; pub use map::{BStackHashMap, MapOnDisk}; pub use string::{BStackString, StringOnDisk}; diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index ef227f8..2ff5221 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -9,6 +9,8 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::layout::{HEADER_SIZE, get_u64}; + /// Read a little-endian `u64` at absolute offset `off`. pub(super) fn read_u64(stack: &BStack, off: u64) -> io::Result { let mut b = [0u8; 8]; @@ -171,3 +173,130 @@ where None }) } + +/// A snapshot of an open-addressing table's four handle metadata fields +/// (`table`, `cap`, `len`, `used`), read inside a generator. Both +/// [`crate::BStackHashMap`] and [`crate::stdlib::BStackHashSet`] lay these out +/// contiguously at `handle + HEADER_SIZE`. +pub(super) struct Meta { + pub(super) table: u64, + pub(super) cap: u64, + pub(super) len: u64, + pub(super) used: u64, +} + +/// A probe step returned by the `inspect` closure of [`probe_commit`]. +pub(super) enum ProbeStep { + /// This bucket isn't the target — keep probing. + Continue, + /// Stop here and commit these writes (empty = commit nothing). + Stop(Vec<(u64, Vec)>), +} + +/// Run an atomic, external-lock-free linear probe over an open-addressing bucket +/// table under one [`BStack::inplace_gen`]. +/// +/// Reads the four-`u64` handle metadata (at `handle + HEADER_SIZE`), then linearly +/// probes buckets from `hash & (cap-1)`, reading the full `stride`-byte bucket +/// each step and handing it to `inspect`. The first `inspect` returning +/// [`ProbeStep::Stop`] commits its writes and ends; if all `cap` buckets are +/// probed without a stop, `exhausted` produces the final writes. Every read and +/// write rides the one generator, so the probe sees a consistent snapshot and the +/// writes land as one crash-atomic batch. Shared by the hash map and hash set. +pub(super) fn probe_commit( + allocator: &A, + handle: u64, + stride: u64, + hash: u64, + mut inspect: I, + exhausted: E, +) -> io::Result<()> +where + A: BStackOwnedSliceAllocator, + I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, + E: FnOnce(&Meta) -> Vec<(u64, Vec)>, +{ + let mut meta_buf = [0u8; 32]; + let mut bucket_buf = vec![0u8; stride as usize]; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + + let mut meta_issued = false; + let mut meta: Option = None; + let mut mask = 0u64; + let mut cur = 0u64; + let mut idx_at_read = 0u64; + let mut probe_pending = false; + let mut probed = 0u64; + let mut decided = false; + let mut exhausted = Some(exhausted); + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + // 1. Read the 32-byte metadata block. + if !meta_issued { + meta_issued = true; + // SAFETY: `meta_buf` outlives the call; used by this one read. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + HEADER_SIZE, + buf: b, + }); + } + // 2. Parse it once. + if meta.is_none() { + let m = Meta { + table: get_u64(&meta_buf[0..8]), + cap: get_u64(&meta_buf[8..16]), + len: get_u64(&meta_buf[16..24]), + used: get_u64(&meta_buf[24..32]), + }; + mask = m.cap.wrapping_sub(1); + cur = if m.cap == 0 { 0 } else { hash & mask }; + meta = Some(m); + } + let m = meta.as_ref().unwrap(); + + // 3a. Inspect a completed bucket read. + if probe_pending { + probe_pending = false; + if let ProbeStep::Stop(ws) = inspect(m, idx_at_read, &bucket_buf) { + writes = ws; + decided = true; + } + } + // 3b. Issue the next probe, or finish by exhaustion. + if !decided { + if m.cap == 0 || probed >= m.cap { + writes = (exhausted.take().unwrap())(m); + decided = true; + } else { + idx_at_read = cur; + probe_pending = true; + probed += 1; + cur = (cur + 1) & mask; + let off = m.table + idx_at_read * stride; + // SAFETY: `bucket_buf` outlives the call; each read completes + // (and is inspected) before the next is issued. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut bucket_buf[..]) }; + return Some(BStackGenOp::Read { + offset: off, + buf: b, + }); + } + } + // 4. Commit the chosen writes together. + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes[i]; + // SAFETY: `writes` outlives the call and is not mutated after this point. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); + } + None + }) +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index d5fc9d7..c568e90 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,11 +14,11 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - AutoDrop, BStackBTreeMap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, - BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, BStackHashMap, - BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, - EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, - bstack_enum, bstack_move, dealloc_range, + AutoDrop, BStackBTreeMap, BStackBTreeSet, BStackBlock, BStackBlockVec, BStackBox, BStackCast, + BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, + BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, + BStackString, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, + bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -5607,3 +5607,140 @@ fn stdlib_bloom_guards_a_map() { bloom.bstack_drop(&alloc).unwrap(); map.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackHashSet — set with an embedded bloom filter +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_hashset_basic() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let set = BStackHashSet::::new(&alloc).unwrap(); + assert!(set.is_empty(stack).unwrap()); + assert!(!set.contains(stack, &5).unwrap()); + + // Insert many to force table growth; insert reports newness. + for k in 0..100u32 { + assert!(set.insert(&alloc, k).unwrap()); + } + assert_eq!(set.len(stack).unwrap(), 100); + // Duplicate insert is a no-op returning false. + assert!(!set.insert(&alloc, 50).unwrap()); + assert_eq!(set.len(stack).unwrap(), 100); + + // No false negatives: every inserted key is present. + for k in 0..100u32 { + assert!(set.contains(stack, &k).unwrap()); + } + assert!(!set.contains(stack, &10_000).unwrap()); + + // Remove and re-check. + assert!(set.remove(&alloc, &50).unwrap()); + assert!(!set.remove(&alloc, &50).unwrap()); // already gone + assert!(!set.contains(stack, &50).unwrap()); + assert_eq!(set.len(stack).unwrap(), 99); + + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_hashset_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let set = BStackHashSet::::new(&alloc).unwrap(); + for k in 0..20u32 { + set.insert(&alloc, k).unwrap(); + } + let clone = set.try_clone_in(&alloc).unwrap(); + for k in 0..20u32 { + assert!(clone.contains(stack, &k).unwrap()); + } + // Mutating the clone (incl. its embedded bloom) leaves the original intact. + clone.remove(&alloc, &5).unwrap(); + assert!(!clone.contains(stack, &5).unwrap()); + assert!(set.contains(stack, &5).unwrap()); + assert_eq!(set.len(stack).unwrap(), 20); + assert_eq!(clone.len(stack).unwrap(), 19); + + clone.bstack_drop(&alloc).unwrap(); + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_hashset_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} + +// -------------------------------------------------------------------------- +// stdlib: BStackBTreeSet — ordered set with an embedded bloom filter +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_btreeset_ordered() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let set = BStackBTreeSet::::new(&alloc).unwrap(); + assert!(set.first(stack).unwrap().is_none()); + + // Scrambled but bijective insertion order to force node splits. + for i in 0..60u32 { + let k = (i * 23) % 60; + assert!(set.insert(&alloc, k).unwrap()); + } + assert_eq!(set.len(stack).unwrap(), 60); + assert!(!set.insert(&alloc, 30).unwrap()); // duplicate + assert_eq!(set.len(stack).unwrap(), 60); + + // No false negatives; ordered iteration is sorted. + for k in 0..60u32 { + assert!(set.contains(stack, &k).unwrap()); + } + assert!(!set.contains(stack, &999).unwrap()); + assert_eq!(set.to_vec(stack).unwrap(), (0..60u32).collect::>()); + assert_eq!(set.first(stack).unwrap().unwrap(), 0); + assert_eq!(set.last(stack).unwrap().unwrap(), 59); + + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_btreeset_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let set = BStackBTreeSet::::new(&alloc).unwrap(); + for k in 0..30u32 { + set.insert(&alloc, k).unwrap(); + } + let clone = set.try_clone_in(&alloc).unwrap(); + for k in 0..30u32 { + assert!(clone.contains(stack, &k).unwrap()); + } + // Inserting into the clone leaves the original unchanged. + assert!(clone.insert(&alloc, 100).unwrap()); + assert!(clone.contains(stack, &100).unwrap()); + assert!(!set.contains(stack, &100).unwrap()); + assert_eq!(set.len(stack).unwrap(), 30); + + clone.bstack_drop(&alloc).unwrap(); + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_btreeset_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} From 4509ae76e1c64d8e6e5bf567f0b118d91eaaebad Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 06:57:41 -0700 Subject: [PATCH 083/140] stdlib: delete in BTree --- bstack_raii/src/stdlib/btreeset.rs | 245 +++++++++++++++++++++++++ bstack_raii/src/stdlib/tree.rs | 280 +++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 50 ++++++ 3 files changed, 575 insertions(+) diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 476c702..1514c91 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -397,6 +397,251 @@ impl BStackBTreeSet { Ok(false) } + /// The number of keys in the node at `off`. + fn child_nkeys(stack: &BStack, off: u64) -> io::Result { + let mut b = [0u8; 8]; + stack.get_into(off + NKEYS_OFF as u64, &mut b)?; + Ok(get_u64(&b) as usize) + } + + /// The rightmost / leftmost key bytes in the subtree at `off`. + fn edge_key(stack: &BStack, off: u64, rightmost: bool) -> io::Result> { + let mut nb = Self::read_node(stack, off)?; + while !nb.leaf { + let c = if rightmost { + *nb.children.last().unwrap() + } else { + nb.children[0] + }; + nb = Self::read_node(stack, c)?; + } + let i = if rightmost { nb.keys.len() - 1 } else { 0 }; + Ok(nb.keys[i].clone()) + } + + /// Path-copy delete of `key` from the subtree at `off`; returns the new + /// subtree offset and whether the key was found. + fn delete_off( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + off: u64, + key: &K, + ) -> io::Result<(u64, bool)> { + let nb = Self::read_node(stack, off)?; + build.freed.push(off); + let (nb2, found) = Self::delete_bnode(build, stack, nb, key)?; + Ok((build.emit(&nb2)?, found)) + } + + /// Delete `key` from the in-memory node `nb`, rebalancing children to keep the + /// B-tree invariant. Returns the modified node (not yet emitted) and found. + fn delete_bnode( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + mut nb: BNode, + key: &K, + ) -> io::Result<(BNode, bool)> { + let (i, found) = Self::search(&nb, key); + + if found { + if nb.leaf { + nb.keys.remove(i); + return Ok((nb, true)); + } + let yc = Self::child_nkeys(stack, nb.children[i])?; + let zc = Self::child_nkeys(stack, nb.children[i + 1])?; + if yc >= T { + let pk = Self::edge_key(stack, nb.children[i], true)?; + nb.keys[i] = pk.clone(); + let (new_y, _) = Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; + nb.children[i] = new_y; + } else if zc >= T { + let sk = Self::edge_key(stack, nb.children[i + 1], false)?; + nb.keys[i] = sk.clone(); + let (new_z, _) = + Self::delete_off(build, stack, nb.children[i + 1], &Self::read_key(&sk))?; + nb.children[i + 1] = new_z; + } else { + let y_off = nb.children[i]; + let z_off = nb.children[i + 1]; + let mut y = Self::read_node(stack, y_off)?; + build.freed.push(y_off); + let mut z = Self::read_node(stack, z_off)?; + build.freed.push(z_off); + let sk = nb.keys.remove(i); + nb.children.remove(i + 1); + y.keys.push(sk); + y.keys.append(&mut z.keys); + if !y.leaf { + y.children.append(&mut z.children); + } + let (y2, _) = Self::delete_bnode(build, stack, y, key)?; + nb.children[i] = build.emit(&y2)?; + } + return Ok((nb, true)); + } + + if nb.leaf { + return Ok((nb, false)); + } + + if Self::child_nkeys(stack, nb.children[i])? >= T { + let (new_c, found) = Self::delete_off(build, stack, nb.children[i], key)?; + nb.children[i] = new_c; + return Ok((nb, found)); + } + + let n = nb.keys.len(); + if i > 0 && Self::child_nkeys(stack, nb.children[i - 1])? >= T { + let ci_off = nb.children[i]; + let ls_off = nb.children[i - 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut ls = Self::read_node(stack, ls_off)?; + build.freed.push(ls_off); + ci.keys.insert(0, nb.keys[i - 1].clone()); + if !ci.leaf { + ci.children.insert(0, ls.children.pop().unwrap()); + } + nb.keys[i - 1] = ls.keys.pop().unwrap(); + nb.children[i - 1] = build.emit(&ls)?; + let (ci2, found) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + return Ok((nb, found)); + } + if i < n && Self::child_nkeys(stack, nb.children[i + 1])? >= T { + let ci_off = nb.children[i]; + let rs_off = nb.children[i + 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut rs = Self::read_node(stack, rs_off)?; + build.freed.push(rs_off); + ci.keys.push(nb.keys[i].clone()); + if !ci.leaf { + ci.children.push(rs.children.remove(0)); + } + nb.keys[i] = rs.keys.remove(0); + nb.children[i + 1] = build.emit(&rs)?; + let (ci2, found) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + return Ok((nb, found)); + } + + if i < n { + let ci_off = nb.children[i]; + let rs_off = nb.children[i + 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut rs = Self::read_node(stack, rs_off)?; + build.freed.push(rs_off); + let sk = nb.keys.remove(i); + nb.children.remove(i + 1); + ci.keys.push(sk); + ci.keys.append(&mut rs.keys); + if !ci.leaf { + ci.children.append(&mut rs.children); + } + let (ci2, found) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + Ok((nb, found)) + } else { + let ls_off = nb.children[i - 1]; + let ci_off = nb.children[i]; + let mut ls = Self::read_node(stack, ls_off)?; + build.freed.push(ls_off); + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let sk = nb.keys.remove(i - 1); + nb.children.remove(i); + ls.keys.push(sk); + ls.keys.append(&mut ci.keys); + if !ls.leaf { + ls.children.append(&mut ci.children); + } + let (ls2, found) = Self::delete_bnode(build, stack, ls, key)?; + nb.children[i - 1] = build.emit(&ls2)?; + Ok((nb, found)) + } + } + + /// Remove `key`; returns `true` if it was present. Deletes from the tree + /// first, then decrements the Bloom filter (see the module docs). + pub fn remove( + &self, + allocator: &A, + key: &K, + ) -> io::Result { + let handle = self.range.start(); + let stack = allocator.stack(); + let key_bytes = bytemuck::bytes_of(key).to_vec(); + if !self.tree_contains(stack, key, &key_bytes)? { + return Ok(false); + } + let root = read_u64(stack, handle + ROOT_OFF)?; + let len = read_u64(stack, handle + LEN_OFF)?; + + let mut build = Build { + allocator, + node_size: Self::node_size(), + ksize: Self::ksize(), + children_off: Self::children_off(), + writes: Vec::new(), + freed: Vec::new(), + }; + + let built: io::Result = (|| { + let nb = Self::read_node(stack, root)?; + build.freed.push(root); + let (root_nb, _) = Self::delete_bnode(&mut build, stack, nb, key)?; + let new_root = if root_nb.keys.is_empty() { + if root_nb.leaf { + 0 + } else { + root_nb.children[0] + } + } else { + build.emit(&root_nb)? + }; + Ok(new_root) + })(); + + match built { + Ok(new_root) => { + let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); + let mut writes = core::mem::take(&mut build.writes); + writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); + writes.push((handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + match stack.set_batched(writes) { + Ok(()) => { + for off in &build.freed { + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; + } + // Tree updated; now decrement the filter. + self.bloom(stack)?.remove(allocator, key)?; + Ok(true) + } + Err(e) => { + for off in new_node_offs { + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(off, build.node_size)) + }; + } + Err(e) + } + } + } + Err(e) => { + for (off, _) in &build.writes { + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + } + Err(e) + } + } + } + /// The smallest key, or `None` if empty. pub fn first(&self, stack: &BStack) -> io::Result> { self.extreme(stack, true) diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index d09627a..744033b 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -463,6 +463,286 @@ impl BStackBTreeMap { Ok(self.get(stack, key)?.is_some()) } + /// The number of keys in the node at `off` (reads just the count field). + fn child_nkeys(stack: &BStack, off: u64) -> io::Result { + Ok(get_u64(&{ + let mut b = [0u8; 8]; + stack.get_into(off + NKEYS_OFF as u64, &mut b)?; + b + }) as usize) + } + + /// The rightmost (largest) `(key_bytes, value)` in the subtree at `off`. + fn max_entry(stack: &BStack, off: u64) -> io::Result<(Vec, u64)> { + let mut nb = Self::read_node(stack, off)?; + while !nb.leaf { + nb = Self::read_node(stack, *nb.children.last().unwrap())?; + } + let i = nb.keys.len() - 1; + Ok((nb.keys[i].clone(), nb.vals[i])) + } + + /// The leftmost (smallest) `(key_bytes, value)` in the subtree at `off`. + fn min_entry(stack: &BStack, off: u64) -> io::Result<(Vec, u64)> { + let mut nb = Self::read_node(stack, off)?; + while !nb.leaf { + nb = Self::read_node(stack, nb.children[0])?; + } + Ok((nb.keys[0].clone(), nb.vals[0])) + } + + /// Path-copy delete of `key` from the subtree at `off`; returns the new + /// subtree offset and the removed value (if the key was found). + fn delete_off( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + off: u64, + key: &K, + ) -> io::Result<(u64, Option)> { + let nb = Self::read_node(stack, off)?; + build.freed.push(off); + let (nb2, val) = Self::delete_bnode(build, stack, nb, key)?; + Ok((build.emit(&nb2)?, val)) + } + + /// Delete `key` from the in-memory node `nb` (its old block already recorded + /// for freeing), rebalancing children to keep the B-tree invariant. Returns + /// the modified node (not yet emitted) and the removed value. + fn delete_bnode( + build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + stack: &BStack, + mut nb: BNode, + key: &K, + ) -> io::Result<(BNode, Option)> { + let (i, found) = Self::search(&nb, key); + + if found { + if nb.leaf { + let v = nb.vals.remove(i); + nb.keys.remove(i); + return Ok((nb, Some(v))); + } + // Internal: the value at `i` is what we return. + let removed = nb.vals[i]; + let yc = Self::child_nkeys(stack, nb.children[i])?; + let zc = Self::child_nkeys(stack, nb.children[i + 1])?; + if yc >= T { + // Replace with predecessor, then delete it from the left child. + let (pk, pv) = Self::max_entry(stack, nb.children[i])?; + nb.keys[i] = pk.clone(); + nb.vals[i] = pv; + let (new_y, _) = Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; + nb.children[i] = new_y; + } else if zc >= T { + // Replace with successor, then delete it from the right child. + let (sk, sv) = Self::min_entry(stack, nb.children[i + 1])?; + nb.keys[i] = sk.clone(); + nb.vals[i] = sv; + let (new_z, _) = + Self::delete_off(build, stack, nb.children[i + 1], &Self::read_key(&sk))?; + nb.children[i + 1] = new_z; + } else { + // Merge children[i] + separator + children[i+1], then delete from it. + let y_off = nb.children[i]; + let z_off = nb.children[i + 1]; + let mut y = Self::read_node(stack, y_off)?; + build.freed.push(y_off); + let mut z = Self::read_node(stack, z_off)?; + build.freed.push(z_off); + let sk = nb.keys.remove(i); + let sv = nb.vals.remove(i); + nb.children.remove(i + 1); + y.keys.push(sk); + y.vals.push(sv); + y.keys.append(&mut z.keys); + y.vals.append(&mut z.vals); + if !y.leaf { + y.children.append(&mut z.children); + } + let (y2, _) = Self::delete_bnode(build, stack, y, key)?; + nb.children[i] = build.emit(&y2)?; + } + return Ok((nb, Some(removed))); + } + + if nb.leaf { + return Ok((nb, None)); // key absent + } + + // Key is in children[i]; ensure it has at least `T` keys before descending. + if Self::child_nkeys(stack, nb.children[i])? >= T { + let (new_c, val) = Self::delete_off(build, stack, nb.children[i], key)?; + nb.children[i] = new_c; + return Ok((nb, val)); + } + + let n = nb.keys.len(); + if i > 0 && Self::child_nkeys(stack, nb.children[i - 1])? >= T { + // Borrow from the left sibling (rotate right through the parent). + let ci_off = nb.children[i]; + let ls_off = nb.children[i - 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut ls = Self::read_node(stack, ls_off)?; + build.freed.push(ls_off); + ci.keys.insert(0, nb.keys[i - 1].clone()); + ci.vals.insert(0, nb.vals[i - 1]); + if !ci.leaf { + ci.children.insert(0, ls.children.pop().unwrap()); + } + nb.keys[i - 1] = ls.keys.pop().unwrap(); + nb.vals[i - 1] = ls.vals.pop().unwrap(); + nb.children[i - 1] = build.emit(&ls)?; + let (ci2, val) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + return Ok((nb, val)); + } + if i < n && Self::child_nkeys(stack, nb.children[i + 1])? >= T { + // Borrow from the right sibling (rotate left through the parent). + let ci_off = nb.children[i]; + let rs_off = nb.children[i + 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut rs = Self::read_node(stack, rs_off)?; + build.freed.push(rs_off); + ci.keys.push(nb.keys[i].clone()); + ci.vals.push(nb.vals[i]); + if !ci.leaf { + ci.children.push(rs.children.remove(0)); + } + nb.keys[i] = rs.keys.remove(0); + nb.vals[i] = rs.vals.remove(0); + nb.children[i + 1] = build.emit(&rs)?; + let (ci2, val) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + return Ok((nb, val)); + } + + // No lending sibling: merge with one (pulling a separator down). + if i < n { + let ci_off = nb.children[i]; + let rs_off = nb.children[i + 1]; + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let mut rs = Self::read_node(stack, rs_off)?; + build.freed.push(rs_off); + let sk = nb.keys.remove(i); + let sv = nb.vals.remove(i); + nb.children.remove(i + 1); + ci.keys.push(sk); + ci.vals.push(sv); + ci.keys.append(&mut rs.keys); + ci.vals.append(&mut rs.vals); + if !ci.leaf { + ci.children.append(&mut rs.children); + } + let (ci2, val) = Self::delete_bnode(build, stack, ci, key)?; + nb.children[i] = build.emit(&ci2)?; + Ok((nb, val)) + } else { + let ls_off = nb.children[i - 1]; + let ci_off = nb.children[i]; + let mut ls = Self::read_node(stack, ls_off)?; + build.freed.push(ls_off); + let mut ci = Self::read_node(stack, ci_off)?; + build.freed.push(ci_off); + let sk = nb.keys.remove(i - 1); + let sv = nb.vals.remove(i - 1); + nb.children.remove(i); + ls.keys.push(sk); + ls.vals.push(sv); + ls.keys.append(&mut ci.keys); + ls.vals.append(&mut ci.vals); + if !ls.leaf { + ls.children.append(&mut ci.children); + } + let (ls2, val) = Self::delete_bnode(build, stack, ls, key)?; + nb.children[i - 1] = build.emit(&ls2)?; + Ok((nb, val)) + } + } + + /// Remove `key`, returning its value (owned) if present, else `None`. + /// Path-copies the affected path (rebalancing as needed) and commits the new + /// nodes plus the root update as one crash-atomic batch. **Single-writer.** + pub fn remove( + &self, + allocator: &A, + key: &K, + ) -> io::Result>> { + let handle = self.range.start(); + let stack = allocator.stack(); + let root = read_u64(stack, handle + ROOT_OFF)?; + // Absent-key fast path avoids a wasted path copy. + if root == 0 || self.get(stack, key)?.is_none() { + return Ok(None); + } + let len = read_u64(stack, handle + LEN_OFF)?; + + let mut build = Build { + allocator, + node_size: Self::node_size(), + ksize: Self::ksize(), + vals_off: Self::vals_off(), + children_off: Self::children_off(), + writes: Vec::new(), + freed: Vec::new(), + }; + + let built: io::Result<(u64, u64)> = (|| { + let nb = Self::read_node(stack, root)?; + build.freed.push(root); + let (root_nb, val) = Self::delete_bnode(&mut build, stack, nb, key)?; + let val = val.expect("key was present"); + // Collapse an empty root: a leaf → empty tree; an internal → its child. + let new_root = if root_nb.keys.is_empty() { + if root_nb.leaf { + 0 + } else { + root_nb.children[0] + } + } else { + build.emit(&root_nb)? + }; + Ok((new_root, val)) + })(); + + match built { + Ok((new_root, val)) => { + let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); + let mut writes = core::mem::take(&mut build.writes); + writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); + writes.push((handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + match stack.set_batched(writes) { + Ok(()) => { + for off in &build.freed { + // SAFETY: replaced/merged away by the commit (single-writer). + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; + } + Ok(Some(unsafe { BStackOwned::from_raw(Self::value_at(val)) })) + } + Err(e) => { + for off in new_node_offs { + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(off, build.node_size)) + }; + } + Err(e) + } + } + } + Err(e) => { + for (off, _) in &build.writes { + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + } + Err(e) + } + } + } + /// The smallest entry, or `None` if empty. Descends the leftmost path. pub fn first(&self, stack: &BStack) -> io::Result> { self.extreme(stack, true) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index c568e90..61e4bdc 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -5744,3 +5744,53 @@ fn stdlib_btreeset_distinct_tags() { as BStackCast>::eightcc(), ); } + +#[test] +fn stdlib_tree_remove_rebalances() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + for k in 0..200u32 { + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap(); + } + assert_eq!(tree.len(stack).unwrap(), 200); + + // Remove-absent returns None. + assert!(tree.remove(&alloc, &1000).unwrap().is_none()); + + // Remove every even key (heavy borrow/merge across a multi-level tree). + for k in (0..200u32).step_by(2) { + let v = tree.remove(&alloc, &k).unwrap().unwrap(); + assert_eq!(v.handle().val(stack).unwrap(), k * 10); + v.bstack_drop(&alloc).unwrap(); + } + assert_eq!(tree.len(stack).unwrap(), 100); + + // Evens gone, odds intact, iteration still sorted. + for k in 0..200u32 { + let g = tree.get(stack, &k).unwrap(); + if k % 2 == 0 { + assert!(g.is_none()); + } else { + assert_eq!(g.unwrap().val(stack).unwrap(), k * 10); + } + } + let keys: Vec = tree.to_vec(stack).unwrap().iter().map(|(k, _)| *k).collect(); + assert_eq!(keys, (0..200u32).filter(|k| k % 2 == 1).collect::>()); + + // Drain the rest → empty (root collapses to 0). + for k in (1..200u32).step_by(2) { + tree.remove(&alloc, &k).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + } + assert_eq!(tree.len(stack).unwrap(), 0); + assert!(tree.is_empty(stack).unwrap()); + assert!(tree.first(stack).unwrap().is_none()); + + // Reinsert after collapse works. + tree.insert(&alloc, 42, MacroLeaf::new(&alloc, 420).unwrap()).unwrap(); + assert_eq!(tree.get(stack, &42).unwrap().unwrap().val(stack).unwrap(), 420); + + tree.bstack_drop(&alloc).unwrap(); +} From 98046208379275c6574dbcbcc740e1ac84436c40 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 06:58:39 -0700 Subject: [PATCH 084/140] stdlib: more String methods --- bstack_raii/src/stdlib/bloom.rs | 12 +--- bstack_raii/src/stdlib/btreeset.rs | 40 ++++++----- bstack_raii/src/stdlib/hashset.rs | 20 +++--- bstack_raii/src/stdlib/string.rs | 57 ++++++++++++++++ bstack_raii/src/stdlib/tree.rs | 14 ++-- bstack_raii/src/tests.rs | 104 +++++++++++++++++++++++++++-- 6 files changed, 189 insertions(+), 58 deletions(-) diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index 64c2fd7..d50c575 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -182,11 +182,7 @@ impl BStackCountingBloomFilter { } /// Insert `key`, bumping each of its `k` counters (saturating at 255). - pub fn insert( - &self, - allocator: &A, - key: &K, - ) -> io::Result<()> { + pub fn insert(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, true) } @@ -194,11 +190,7 @@ impl BStackCountingBloomFilter { /// /// Only call this for a key that was actually inserted (see the module docs) — /// removing an absent key can introduce false negatives. - pub fn remove( - &self, - allocator: &A, - key: &K, - ) -> io::Result<()> { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, false) } diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 1514c91..25741e8 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -236,7 +236,14 @@ impl BStackBTreeSet { keys: right_keys, children: right_children, }; - (nb, Split { key: med_key, right: 0 }, right) + ( + nb, + Split { + key: med_key, + right: 0, + }, + right, + ) } /// Path-copy the subtree at `off`, inserting a **new** `key` (assumed absent). @@ -273,11 +280,7 @@ impl BStackBTreeSet { } /// Insert `key`; returns `true` if newly added, `false` if already present. - pub fn insert( - &self, - allocator: &A, - key: K, - ) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { // Exact check first, so the filter is only touched for genuinely new keys. let key_bytes = bytemuck::bytes_of(&key).to_vec(); if self.tree_contains(allocator.stack(), &key, &key_bytes)? { @@ -349,8 +352,9 @@ impl BStackBTreeSet { } Err(e) => { for (off, _) in &build.writes { - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; } Err(e) } @@ -453,7 +457,8 @@ impl BStackBTreeSet { if yc >= T { let pk = Self::edge_key(stack, nb.children[i], true)?; nb.keys[i] = pk.clone(); - let (new_y, _) = Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; + let (new_y, _) = + Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; nb.children[i] = new_y; } else if zc >= T { let sk = Self::edge_key(stack, nb.children[i + 1], false)?; @@ -566,11 +571,7 @@ impl BStackBTreeSet { /// Remove `key`; returns `true` if it was present. Deletes from the tree /// first, then decrements the Bloom filter (see the module docs). - pub fn remove( - &self, - allocator: &A, - key: &K, - ) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let handle = self.range.start(); let stack = allocator.stack(); let key_bytes = bytemuck::bytes_of(key).to_vec(); @@ -594,11 +595,7 @@ impl BStackBTreeSet { build.freed.push(root); let (root_nb, _) = Self::delete_bnode(&mut build, stack, nb, key)?; let new_root = if root_nb.keys.is_empty() { - if root_nb.leaf { - 0 - } else { - root_nb.children[0] - } + if root_nb.leaf { 0 } else { root_nb.children[0] } } else { build.emit(&root_nb)? }; @@ -634,8 +631,9 @@ impl BStackBTreeSet { } Err(e) => { for (off, _) in &build.writes { - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; } Err(e) } diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index 2ed39f8..bea7035 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -180,11 +180,7 @@ impl BStackHashSet { /// Insert `key`; returns `true` if it was newly added, `false` if already /// present. - pub fn insert( - &self, - allocator: &A, - key: K, - ) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let hash = fnv1a(&key_bytes); let bloom = self.bloom(allocator.stack())?; @@ -199,11 +195,7 @@ impl BStackHashSet { } /// Remove `key`; returns `true` if it was present. - pub fn remove( - &self, - allocator: &A, - key: &K, - ) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let key_bytes = bytemuck::bytes_of(key).to_vec(); let hash = fnv1a(&key_bytes); // Remove from the table first; only then decrement the filter (and only @@ -459,7 +451,10 @@ impl BStackHashSet { let (off, ref bytes) = writes[i]; // SAFETY: `writes` outlives the call and is not mutated after build. let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { offset: off, data: d }); + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); } None })?; @@ -476,7 +471,8 @@ impl BStackHashSet { } } else { // SAFETY: `newtable` was never linked into the descriptor. - let _ = unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; + let _ = + unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; } Ok(()) } diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index 4dee228..3fed7b3 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -166,6 +166,63 @@ impl BStackString { self.set(allocator, &cur) } + /// Append a single character. + pub fn push(&self, allocator: &A, ch: char) -> io::Result<()> { + let mut buf = [0u8; 4]; + self.push_str(allocator, ch.encode_utf8(&mut buf)) + } + + /// Truncate to `new_len` **bytes**, which must be a UTF-8 char boundary and + /// not exceed the current length; longer values leave the string unchanged. + pub fn truncate( + &self, + allocator: &A, + new_len: usize, + ) -> io::Result<()> { + let mut cur = self.to_string(allocator.stack())?; + if new_len >= cur.len() { + return Ok(()); + } + if !cur.is_char_boundary(new_len) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "truncate: byte index is not a UTF-8 char boundary", + )); + } + cur.truncate(new_len); + self.set(allocator, &cur) + } + + /// Empty the string (frees its bytes block). + pub fn clear(&self, allocator: &A) -> io::Result<()> { + self.set(allocator, "") + } + + /// The number of Unicode scalar values (`char`s), not bytes. + pub fn char_count(&self, stack: &BStack) -> io::Result { + Ok(self.to_string(stack)?.chars().count()) + } + + /// Whether the contents equal `s`, byte-for-byte (no UTF-8 validation). + pub fn eq_str(&self, stack: &BStack, s: &str) -> io::Result { + Ok(self.read_bytes(stack)? == s.as_bytes()) + } + + /// Whether the contents begin with `prefix`. + pub fn starts_with(&self, stack: &BStack, prefix: &str) -> io::Result { + Ok(self.read_bytes(stack)?.starts_with(prefix.as_bytes())) + } + + /// Whether the contents end with `suffix`. + pub fn ends_with(&self, stack: &BStack, suffix: &str) -> io::Result { + Ok(self.read_bytes(stack)?.ends_with(suffix.as_bytes())) + } + + /// Whether the contents contain `needle`. + pub fn contains(&self, stack: &BStack, needle: &str) -> io::Result { + Ok(self.to_string(stack)?.contains(needle)) + } + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the string was created. diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 744033b..d07d69a 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -531,7 +531,8 @@ impl BStackBTreeMap { let (pk, pv) = Self::max_entry(stack, nb.children[i])?; nb.keys[i] = pk.clone(); nb.vals[i] = pv; - let (new_y, _) = Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; + let (new_y, _) = + Self::delete_off(build, stack, nb.children[i], &Self::read_key(&pk))?; nb.children[i] = new_y; } else if zc >= T { // Replace with successor, then delete it from the right child. @@ -696,11 +697,7 @@ impl BStackBTreeMap { let val = val.expect("key was present"); // Collapse an empty root: a leaf → empty tree; an internal → its child. let new_root = if root_nb.keys.is_empty() { - if root_nb.leaf { - 0 - } else { - root_nb.children[0] - } + if root_nb.leaf { 0 } else { root_nb.children[0] } } else { build.emit(&root_nb)? }; @@ -735,8 +732,9 @@ impl BStackBTreeMap { } Err(e) => { for (off, _) in &build.writes { - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(*off, build.node_size)) }; + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(*off, build.node_size)) + }; } Err(e) } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 61e4bdc..c55333a 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -5502,7 +5502,11 @@ fn stdlib_bloom_no_false_negatives() { let absent = (1_000..1_050u32) .filter(|k| !bloom.contains(stack, k).unwrap()) .count(); - assert!(absent >= 45, "too many false positives: {}/50 absent", absent); + assert!( + absent >= 45, + "too many false positives: {}/50 absent", + absent + ); // A positive FP estimate in (0, 1). let fp = bloom.estimated_fp_rate(stack).unwrap(); @@ -5584,7 +5588,8 @@ fn stdlib_bloom_guards_a_map() { let bloom = BStackCountingBloomFilter::::with_capacity(&alloc, 1000, 0.001).unwrap(); let map = BStackHashMap::::new(&alloc).unwrap(); for k in 0..40u32 { - map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 3).unwrap()).unwrap(); + map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 3).unwrap()) + .unwrap(); bloom.insert(&alloc, &k).unwrap(); } @@ -5753,7 +5758,8 @@ fn stdlib_tree_remove_rebalances() { let tree = BStackBTreeMap::::new(&alloc).unwrap(); for k in 0..200u32 { - tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()).unwrap(); + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap(); } assert_eq!(tree.len(stack).unwrap(), 200); @@ -5777,20 +5783,104 @@ fn stdlib_tree_remove_rebalances() { assert_eq!(g.unwrap().val(stack).unwrap(), k * 10); } } - let keys: Vec = tree.to_vec(stack).unwrap().iter().map(|(k, _)| *k).collect(); + let keys: Vec = tree + .to_vec(stack) + .unwrap() + .iter() + .map(|(k, _)| *k) + .collect(); assert_eq!(keys, (0..200u32).filter(|k| k % 2 == 1).collect::>()); // Drain the rest → empty (root collapses to 0). for k in (1..200u32).step_by(2) { - tree.remove(&alloc, &k).unwrap().unwrap().bstack_drop(&alloc).unwrap(); + tree.remove(&alloc, &k) + .unwrap() + .unwrap() + .bstack_drop(&alloc) + .unwrap(); } assert_eq!(tree.len(stack).unwrap(), 0); assert!(tree.is_empty(stack).unwrap()); assert!(tree.first(stack).unwrap().is_none()); // Reinsert after collapse works. - tree.insert(&alloc, 42, MacroLeaf::new(&alloc, 420).unwrap()).unwrap(); - assert_eq!(tree.get(stack, &42).unwrap().unwrap().val(stack).unwrap(), 420); + tree.insert(&alloc, 42, MacroLeaf::new(&alloc, 420).unwrap()) + .unwrap(); + assert_eq!( + tree.get(stack, &42).unwrap().unwrap().val(stack).unwrap(), + 420 + ); tree.bstack_drop(&alloc).unwrap(); } + +#[test] +fn stdlib_btreeset_remove_rebalances() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let set = BStackBTreeSet::::new(&alloc).unwrap(); + for k in 0..60u32 { + set.insert(&alloc, k).unwrap(); + } + assert_eq!(set.len(stack).unwrap(), 60); + assert!(!set.remove(&alloc, &999).unwrap()); // absent + + // Remove every even key (triggers borrow/merge across levels). + for k in (0..60u32).step_by(2) { + assert!(set.remove(&alloc, &k).unwrap()); + assert!(!set.remove(&alloc, &k).unwrap()); // already gone + } + assert_eq!(set.len(stack).unwrap(), 30); + for k in 0..60u32 { + assert_eq!(set.contains(stack, &k).unwrap(), k % 2 == 1); + } + assert_eq!( + set.to_vec(stack).unwrap(), + (0..60u32).filter(|k| k % 2 == 1).collect::>() + ); + + // Drain the rest → empty, then reinsert. + for k in (1..60u32).step_by(2) { + assert!(set.remove(&alloc, &k).unwrap()); + } + assert_eq!(set.len(stack).unwrap(), 0); + assert!(set.first(stack).unwrap().is_none()); + assert!(set.insert(&alloc, 7).unwrap()); + assert!(set.contains(stack, &7).unwrap()); + + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_string_extra_methods() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let s = BStackString::new(&alloc, "hello").unwrap(); + s.handle().push(&alloc, '!').unwrap(); + assert_eq!(s.handle().to_string(stack).unwrap(), "hello!"); + assert!(s.handle().starts_with(stack, "hell").unwrap()); + assert!(s.handle().ends_with(stack, "o!").unwrap()); + assert!(s.handle().contains(stack, "ell").unwrap()); + assert!(s.handle().eq_str(stack, "hello!").unwrap()); + assert!(!s.handle().eq_str(stack, "nope").unwrap()); + + // truncate to a boundary; non-boundary is an error. + s.handle().truncate(&alloc, 5).unwrap(); + assert_eq!(s.handle().to_string(stack).unwrap(), "hello"); + let u = BStackString::new(&alloc, "héllo").unwrap(); // 'é' is 2 bytes at [1,2] + assert_eq!(u.handle().char_count(stack).unwrap(), 5); + assert_eq!(u.handle().len(stack).unwrap(), 6); + assert!(u.handle().truncate(&alloc, 2).is_err()); // splits 'é' + u.bstack_drop(&alloc).unwrap(); + + // clear empties it. + s.handle().clear(&alloc).unwrap(); + assert!(s.handle().is_empty(stack).unwrap()); + assert_eq!(s.handle().char_count(stack).unwrap(), 0); + + s.bstack_drop(&alloc).unwrap(); +} From db129140b5c8e120273526095da5e48dd2d8fe06 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 22:13:43 -0700 Subject: [PATCH 085/140] documentation update --- bstack_raii/src/stdlib/btreeset.rs | 7 +++---- bstack_raii/src/stdlib/tree.rs | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 25741e8..6cc157b 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -14,10 +14,9 @@ //! the tree, so [`contains`](BStackBTreeSet::contains) fast-rejects definitely- //! absent keys without a tree descent. A key is added to the filter only when it //! is genuinely new (an exact-membership check precedes the insert), so the -//! filter never over-counts and there are never false negatives. -//! -//! Not yet implemented: `remove` (B-tree deletion with rebalancing — the same gap -//! as [`crate::BStackBTreeMap`]). +//! filter never over-counts and there are never false negatives. `remove` deletes +//! from the tree (rebalancing on the way down) and then decrements the filter, +//! the same ordering [`crate::BStackHashSet`] uses. use core::cmp::Ordering; use core::marker::PhantomData; diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index d07d69a..01754a1 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -32,8 +32,9 @@ //! it keeps each write atomic and crash-safe. (A path is short — a handful of //! nodes — so the copy cost is small.) //! -//! Not yet implemented: `remove` (B-tree deletion with rebalancing is the natural -//! next step; the path-copy + `set_batched` commit machinery here carries over). +//! `remove` uses the same path-copying commit, rebalancing on the way down +//! (borrow from a sibling, or merge) to keep every node at `≥ T-1` keys, and +//! collapses the root when it empties. use core::cmp::Ordering; use core::marker::PhantomData; From 8c089081b275c46154be50c5b691fb665c2ae012 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 22:39:38 -0700 Subject: [PATCH 086/140] stdlib: Heap --- bstack_raii/src/lib.rs | 8 +- bstack_raii/src/stdlib/heap.rs | 446 +++++++++++++++++++++++++++++++++ bstack_raii/src/stdlib/mod.rs | 3 + bstack_raii/src/tests.rs | 126 +++++++++- 4 files changed, 574 insertions(+), 9 deletions(-) create mode 100644 bstack_raii/src/stdlib/heap.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 05c4a34..e937ef4 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -82,10 +82,10 @@ pub use owned::BStackOwned; pub use reference::BStackRef; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ - BStackBTreeMap, BStackBTreeSet, BStackBox, BStackCountingBloomFilter, BStackCow, BStackDeque, - BStackHashMap, BStackHashSet, BStackLinkedList, BStackString, BloomOnDisk, BoxOnDisk, - DequeOnDisk, HashSetOnDisk, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, TreeOnDisk, - TreeSetOnDisk, + BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBox, BStackCountingBloomFilter, + BStackCow, BStackDeque, BStackHashMap, BStackHashSet, BStackLinkedList, BStackString, + BloomOnDisk, BoxOnDisk, DequeOnDisk, HashSetOnDisk, HeapOnDisk, ListOnDisk, MapOnDisk, + NodeOnDisk, StringOnDisk, TreeOnDisk, TreeSetOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs new file mode 100644 index 0000000..41b0569 --- /dev/null +++ b/bstack_raii/src/stdlib/heap.rs @@ -0,0 +1,446 @@ +//! [`BStackBinaryHeap`]: an owned priority queue (binary min-heap). +//! +//! A priority queue keyed by a `Pod + Ord` priority `K`, carrying a block value +//! `V` the heap owns. [`pop`](BStackBinaryHeap::pop) always returns the entry +//! with the **smallest** key. +//! +//! # Array-backed, pointer-free +//! +//! Despite being a tree, a binary heap needs **no pointers**: it is a single +//! contiguous array with the tree structure implicit in the indices — the +//! children of slot `i` are `2i+1` and `2i+2`. So, like [`crate::BStackDeque`], +//! the entries live in one contiguous block (`[ (K, value_ref) ; cap ]`); only +//! the block pointer, capacity, and length live in the fixed handle, and growth +//! reallocates just that array. Each slot is the inline priority followed by a +//! `u64` reference to the owned value block. +//! +//! # Atomicity — single-writer +//! +//! `push` sifts a new element up and `pop` sifts the last element down; both +//! touch an `O(log n)` path of slots whose shape depends on key comparisons. Each +//! operation reads that path, then commits *all* of its slot moves plus the +//! length change as one crash-atomic [`bstack::BStack::set_batched`] batch — so a +//! crash never leaves the heap half-sifted (the ordering invariant is preserved +//! all-or-nothing). Because the sift is computed from reads taken before the +//! commit, the heap is **single-writer / multi-reader**: concurrent writers need +//! external synchronization; concurrent readers (and `peek`) are always fine. +//! Growth reallocates the array and swaps the descriptor atomically. + +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::util::{alloc_image, read_u64}; +use crate::block::{BStackBlock, BStackCast}; +use crate::clone::{ClonePlan, TryCloneIn}; +use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; +use crate::owned::BStackOwned; +use crate::teardown::{AutoDrop, BStackDrop, dealloc_range}; + +/// The on-disk image of a [`BStackBinaryHeap`]: header, array-block pointer +/// (`0` = none), capacity in slots, and element count. Non-generic. +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +pub struct HeapOnDisk { + /// The 16-byte block header (size + type tag). + pub header: BlockHeader, + /// Offset of the `[(K, value_ref); cap]` array block, or `0` when unallocated. + pub data: u64, + /// Number of slots in the array. + pub cap: u64, + /// Number of elements currently held. + pub len: u64, +} + +const DATA_OFF: u64 = HEADER_SIZE; // 16 +const CAP_OFF: u64 = HEADER_SIZE + 8; // 24 +const LEN_OFF: u64 = HEADER_SIZE + 16; // 32 +const HEAP_SIZE: u64 = size_of::() as u64; +const MIN_CAP: u64 = 4; + +/// An owned binary min-heap of `(K, V)` entries. +pub struct BStackBinaryHeap { + range: BStackRange, + _marker: PhantomData (K, V)>, +} + +impl BStackBinaryHeap { + fn ksize() -> usize { + size_of::() + } + /// Bytes per slot: inline priority `K` + a `u64` value reference. + fn stride() -> u64 { + Self::ksize() as u64 + 8 + } + + fn value_size() -> u64 { + size_of::<::OnDisk>() as u64 + } + fn value_at(off: u64) -> V { + ::from_range(BStackRange::new(off, Self::value_size())) + } + fn read_key(slot: &[u8]) -> K { + bytemuck::pod_read_unaligned::(&slot[..Self::ksize()]) + } + fn slot_val(slot: &[u8]) -> u64 { + get_u64(&slot[Self::ksize()..Self::ksize() + 8]) + } + + fn read_meta(stack: &BStack, handle: u64) -> io::Result<(u64, u64, u64)> { + Ok(( + read_u64(stack, handle + DATA_OFF)?, + read_u64(stack, handle + CAP_OFF)?, + read_u64(stack, handle + LEN_OFF)?, + )) + } + + /// Read the `stride` raw bytes of slot `i`. + fn read_slot(stack: &BStack, data: u64, i: u64) -> io::Result> { + let mut buf = vec![0u8; Self::stride() as usize]; + stack.get_into(data + i * Self::stride(), &mut buf)?; + Ok(buf) + } + + /// Allocate an empty heap (no array until the first push). + pub fn new(allocator: &A) -> io::Result> { + Self::with_image(allocator, 0, 0) + } + + /// Allocate an empty heap with room for `cap` elements pre-reserved. + pub fn with_capacity( + allocator: &A, + cap: u64, + ) -> io::Result> { + if cap == 0 { + return Self::new(allocator); + } + let data = allocator.alloc(cap * Self::stride())?.as_range().start(); + match Self::with_image(allocator, data, cap) { + Ok(o) => Ok(o), + Err(e) => { + // SAFETY: the array was just allocated, linked to nothing. + let _ = unsafe { + dealloc_range(allocator, BStackRange::new(data, cap * Self::stride())) + }; + Err(e) + } + } + } + + fn with_image( + allocator: &A, + data: u64, + cap: u64, + ) -> io::Result> { + let od = HeapOnDisk { + header: BlockHeader { + size: HEAP_SIZE, + tag: Self::eightcc(), + }, + data, + cap, + len: 0, + }; + let range = alloc_image(allocator, bytemuck::bytes_of(&od))?; + // SAFETY: a freshly allocated block owned by no other handle. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(range)) }) + } + + /// Number of elements. + pub fn len(&self, stack: &BStack) -> io::Result { + read_u64(stack, self.range.start() + LEN_OFF) + } + + /// Whether the heap is empty. + pub fn is_empty(&self, stack: &BStack) -> io::Result { + Ok(self.len(stack)? == 0) + } + + /// Current array capacity. + pub fn capacity(&self, stack: &BStack) -> io::Result { + read_u64(stack, self.range.start() + CAP_OFF) + } + + /// A **borrowed** view of the minimum entry (no ownership), or `None` if + /// empty. + pub fn peek(&self, stack: &BStack) -> io::Result> { + let (data, _cap, len) = Self::read_meta(stack, self.range.start())?; + if len == 0 { + return Ok(None); + } + let slot = Self::read_slot(stack, data, 0)?; + Ok(Some(( + Self::read_key(&slot), + Self::value_at(Self::slot_val(&slot)), + ))) + } + + /// Insert `key -> value`, taking ownership of the value block. + /// + /// Sifts the new element up and commits the whole path atomically. + pub fn push( + &self, + allocator: &A, + key: K, + value: BStackOwned, + ) -> io::Result<()> { + let handle = self.range.start(); + let stride = Self::stride(); + let val_ref = value.into_inner().range().start(); + let key_bytes = bytemuck::bytes_of(&key).to_vec(); + + loop { + let (data, cap, len) = Self::read_meta(allocator.stack(), handle)?; + if data == 0 || len >= cap { + self.grow(allocator)?; + continue; + } + // The new element's slot bytes: priority then value ref. + let mut new_slot = Vec::with_capacity(stride as usize); + new_slot.extend_from_slice(&key_bytes); + new_slot.extend_from_slice(&val_ref.to_le_bytes()); + + // Sift up: walk toward the root, moving greater parents down into the + // hole, until the new key is `>=` its parent. + let mut hole = len; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + while hole > 0 { + let parent = (hole - 1) / 2; + let parent_slot = Self::read_slot(allocator.stack(), data, parent)?; + if Self::read_key(&parent_slot) > key { + writes.push((data + hole * stride, parent_slot)); + hole = parent; + } else { + break; + } + } + writes.push((data + hole * stride, new_slot)); + writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + allocator.stack().set_batched(writes)?; + return Ok(()); + } + } + + /// Remove and return the minimum entry (its value block owned), or `None` if + /// empty. Sifts the last element down and commits the whole path atomically. + pub fn pop( + &self, + allocator: &A, + ) -> io::Result)>> { + let handle = self.range.start(); + let stride = Self::stride(); + let (data, _cap, len) = Self::read_meta(allocator.stack(), handle)?; + if len == 0 { + return Ok(None); + } + let min_slot = Self::read_slot(allocator.stack(), data, 0)?; + let min_key = Self::read_key(&min_slot); + let min_val = Self::slot_val(&min_slot); + + if len == 1 { + allocator + .stack() + .set(handle + LEN_OFF, 0u64.to_le_bytes())?; + // SAFETY: the value block's ownership transfers to the caller. + return Ok(Some((min_key, unsafe { + BStackOwned::from_raw(Self::value_at(min_val)) + }))); + } + + // Re-place the last element from the root down. + let last_slot = Self::read_slot(allocator.stack(), data, len - 1)?; + let last_key = Self::read_key(&last_slot); + let newlen = len - 1; + let mut hole = 0u64; + let mut writes: Vec<(u64, Vec)> = Vec::new(); + loop { + let mut child = 2 * hole + 1; + if child >= newlen { + break; + } + let mut smaller = Self::read_slot(allocator.stack(), data, child)?; + let mut smaller_key = Self::read_key(&smaller); + if child + 1 < newlen { + let right = Self::read_slot(allocator.stack(), data, child + 1)?; + let right_key = Self::read_key(&right); + if right_key < smaller_key { + smaller = right; + smaller_key = right_key; + child += 1; + } + } + if smaller_key < last_key { + writes.push((data + hole * stride, smaller)); + hole = child; + } else { + break; + } + } + writes.push((data + hole * stride, last_slot)); + writes.push((handle + LEN_OFF, newlen.to_le_bytes().to_vec())); + allocator.stack().set_batched(writes)?; + + // SAFETY: the value block's ownership transfers to the caller. + Ok(Some((min_key, unsafe { + BStackOwned::from_raw(Self::value_at(min_val)) + }))) + } + + /// Grow the array to at least double its capacity, copying the elements and + /// atomically swapping the descriptor. + fn grow(&self, allocator: &A) -> io::Result<()> { + let handle = self.range.start(); + let stride = Self::stride(); + let (data, cap, len) = Self::read_meta(allocator.stack(), handle)?; + let newcap = if cap == 0 { MIN_CAP } else { cap * 2 }; + let newdata = allocator.alloc(newcap * stride)?.as_range().start(); + + // Copy the live elements into the new (orphan) array. + if len > 0 { + let mut buf = vec![0u8; (len * stride) as usize]; + allocator.stack().get_into(data, &mut buf)?; + allocator.stack().set(newdata, buf)?; + } + // Swap the descriptor's `data`/`cap` (contiguous) in one atomic write. + let mut meta = [0u8; 16]; + meta[0..8].copy_from_slice(&newdata.to_le_bytes()); + meta[8..16].copy_from_slice(&newcap.to_le_bytes()); + allocator.stack().set(handle + DATA_OFF, meta)?; + + if data != 0 { + // SAFETY: the descriptor no longer points at the old array. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(data, cap * stride)) }; + } + Ok(()) + } + + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + // SAFETY: sole ownership was asserted when the heap was created. + unsafe { AutoDrop::from_raw(self, allocator) } + } +} + +impl BStackCast for BStackBinaryHeap { + /// A `"Hep"` prefix perturbed by the key size and value type's tag. + fn eightcc() -> EightCC { + const BASE: EightCC = EightCC::new([b'H', b'e', b'p', 0x80, 0x81, 0x82, 0x83, 0x84]); + BASE.mix(EightCC::new((size_of::() as u64).to_le_bytes())) + .mix(::eightcc()) + } +} + +impl BStackBlock for BStackBinaryHeap { + type OnDisk = HeapOnDisk; + + fn from_range(range: BStackRange) -> Self { + BStackBinaryHeap { + range, + _marker: PhantomData, + } + } + + fn range(&self) -> BStackRange { + self.range + } + + /// Recursively free every value block and the array, **without** freeing the + /// handle block itself. + fn __bstack_drop_children( + range: BStackRange, + allocator: &A, + ) -> io::Result<()> { + let handle = range.start(); + let (data, cap, len) = Self::read_meta(allocator.stack(), handle)?; + for i in 0..len { + let slot = Self::read_slot(allocator.stack(), data, i)?; + let v = Self::slot_val(&slot); + if v != 0 { + // SAFETY: the heap solely owns each value block. + let owned = unsafe { BStackOwned::from_raw(Self::value_at(v)) }; + owned.bstack_drop(allocator)?; + } + } + if data != 0 { + // SAFETY: the heap solely owns its array block. + unsafe { dealloc_range(allocator, BStackRange::new(data, cap * Self::stride()))? }; + } + Ok(()) + } + + /// Deep-clone: pack the elements into a fresh, exactly-sized array with each + /// value deep-cloned, and stage the handle, in the parent plan's atomic commit. + fn __bstack_clone_into( + &self, + allocator: &A, + plan: &mut ClonePlan, + ) -> io::Result { + let handle = self.range.start(); + let stride = Self::stride(); + let ksize = Self::ksize(); + let (data, _cap, len) = Self::read_meta(allocator.stack(), handle)?; + + let (new_data, new_cap) = if len > 0 { + let mut image = vec![0u8; (len * stride) as usize]; + allocator.stack().get_into(data, &mut image)?; + // Deep-clone each value and repoint its ref in the copy (heap order + // is preserved, so the array stays a valid heap). + for i in 0..len as usize { + let vo = i * stride as usize + ksize; + let vref = get_u64(&image[vo..vo + 8]); + let cloned = Self::value_at(vref) + .__bstack_clone_into(allocator, plan)? + .start(); + image[vo..vo + 8].copy_from_slice(&cloned.to_le_bytes()); + } + let dst = plan.alloc_raw(allocator, len * stride)?; + plan.write(dst.start(), image); + (dst.start(), len) + } else { + (0, 0) + }; + + let handle_dst = plan.alloc_raw(allocator, HEAP_SIZE)?; + let od = HeapOnDisk { + header: BlockHeader { + size: HEAP_SIZE, + tag: Self::eightcc(), + }, + data: new_data, + cap: new_cap, + len, + }; + plan.write(handle_dst.start(), bytemuck::bytes_of(&od).to_vec()); + Ok(handle_dst) + } +} + +impl BStackDrop for BStackBinaryHeap { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + Self::__bstack_drop_children(self.range, allocator)?; + // SAFETY: sole ownership of the handle block was asserted at construction. + unsafe { dealloc_range(allocator, self.range) } + } +} + +impl TryCloneIn for BStackBinaryHeap { + fn try_clone_in( + &self, + allocator: &A, + ) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match self.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 5444a4a..73b337b 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -23,6 +23,7 @@ //! | [`BStackCountingBloomFilter`] | (Bloom filter) | a probabilistic set: no false negatives, supports removal; a cheap fast-reject front for exact lookups. | //! | [`BStackHashSet`] | [`std::collections::HashSet`] | an owned open-addressing set of `Pod` keys, with an embedded Bloom-filter fast-reject front. | //! | [`BStackBTreeSet`] | [`std::collections::BTreeSet`] | an owned **ordered** set (copy-on-write B-tree, sorted iteration), with an embedded Bloom-filter front. Keys are `Pod + Ord`. | +//! | [`BStackBinaryHeap`] | [`std::collections::BinaryHeap`] | an owned priority queue (array-backed binary **min**-heap, pointer-free): `pop` returns the smallest-key entry. Keys are `Pod + Ord`. | mod bloom; mod boxed; @@ -31,6 +32,7 @@ mod cow; mod deque; mod hash; mod hashset; +mod heap; mod list; mod map; mod string; @@ -43,6 +45,7 @@ pub use btreeset::{BStackBTreeSet, TreeSetOnDisk}; pub use cow::BStackCow; pub use deque::{BStackDeque, DequeOnDisk}; pub use hashset::{BStackHashSet, HashSetOnDisk}; +pub use heap::{BStackBinaryHeap, HeapOnDisk}; pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; pub use map::{BStackHashMap, MapOnDisk}; pub use string::{BStackString, StringOnDisk}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index c55333a..4921373 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -14,11 +14,11 @@ use bstack::{ use crate::layout::{self, BlockHeader}; use crate::{ - AutoDrop, BStackBTreeMap, BStackBTreeSet, BStackBlock, BStackBlockVec, BStackBox, BStackCast, - BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, - BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRc, BStackRef, BStackShared, - BStackString, BStackWeakable, EightCC, TryClone, TryCloneIn, alloc_block, alloc_control, - bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + AutoDrop, BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBlock, BStackBlockVec, + BStackBox, BStackCast, BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, + BStackDeque, BStackDrop, BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRc, + BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, TryClone, TryCloneIn, + alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -5884,3 +5884,119 @@ fn stdlib_string_extra_methods() { s.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: BStackBinaryHeap — priority queue (binary min-heap) +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_heap_pop_ascending() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let heap = BStackBinaryHeap::::new(&alloc).unwrap(); + assert!(heap.is_empty(stack).unwrap()); + assert!(heap.peek(stack).unwrap().is_none()); + assert!(heap.pop(&alloc).unwrap().is_none()); + + // Push 0..50 in a scrambled (bijective) order; forces growth. + for i in 0..50u32 { + let k = (i * 17) % 50; + heap.push(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap(); + } + assert_eq!(heap.len(stack).unwrap(), 50); + assert_eq!(heap.peek(stack).unwrap().unwrap().0, 0); // min on top + + // Pop drains in ascending key order, with the right values. + for expected in 0..50u32 { + let (k, v) = heap.pop(&alloc).unwrap().unwrap(); + assert_eq!(k, expected); + assert_eq!(v.handle().val(stack).unwrap(), expected * 10); + v.bstack_drop(&alloc).unwrap(); + } + assert!(heap.is_empty(stack).unwrap()); + assert!(heap.pop(&alloc).unwrap().is_none()); + + heap.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_heap_duplicate_keys() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let heap = BStackBinaryHeap::::new(&alloc).unwrap(); + for (k, v) in [(3u32, 30u32), (1, 10), (3, 31), (1, 11), (2, 20)] { + heap.push(&alloc, k, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); + } + // Keys come out sorted (values within equal keys unspecified). + let mut keys = Vec::new(); + while let Some((k, v)) = heap.pop(&alloc).unwrap() { + keys.push(k); + v.bstack_drop(&alloc).unwrap(); + } + assert_eq!(keys, vec![1, 1, 2, 3, 3]); + + heap.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_heap_drop_is_recursive() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let leaf_start = leaf.handle().range().start(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let heap = BStackBinaryHeap::::new(&alloc).unwrap(); + heap.push(&alloc, 5, parent).unwrap(); + heap.bstack_drop(&alloc).unwrap(); + + // The leaf grandchild's slot is reclaimed — full recursion through a value. + let reused = MacroLeaf::new(&alloc, 0).unwrap(); + assert_eq!(reused.handle().range().start(), leaf_start); + reused.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_heap_deep_clone_is_independent() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let heap = BStackBinaryHeap::::new(&alloc).unwrap(); + for k in [5u32, 1, 4, 2, 3] { + heap.push(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap(); + } + let clone = heap.try_clone_in(&alloc).unwrap(); + // Fresh value blocks. + assert_ne!( + clone.peek(stack).unwrap().unwrap().1.range().start(), + heap.peek(stack).unwrap().unwrap().1.range().start(), + ); + // Draining the clone leaves the original intact. + for expected in 1..=5u32 { + let (k, v) = clone.pop(&alloc).unwrap().unwrap(); + assert_eq!(k, expected); + v.bstack_drop(&alloc).unwrap(); + } + assert_eq!(heap.len(stack).unwrap(), 5); + assert_eq!(heap.peek(stack).unwrap().unwrap().0, 1); + + clone.bstack_drop(&alloc).unwrap(); + heap.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_heap_distinct_tags() { + assert_ne!( + as BStackCast>::eightcc(), + as BStackCast>::eightcc(), + ); +} From 509f4d51e3236ca5ba2e1cd858128affcf84f0cb Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 22:57:09 -0700 Subject: [PATCH 087/140] stdlib: Fix N io read pattern --- bstack_raii/src/stdlib/bloom.rs | 26 +++++--------- bstack_raii/src/stdlib/btreeset.rs | 15 +++------ bstack_raii/src/stdlib/deque.rs | 12 +++---- bstack_raii/src/stdlib/hashset.rs | 20 ++++------- bstack_raii/src/stdlib/heap.rs | 10 +++--- bstack_raii/src/stdlib/map.rs | 54 +++++++++++++----------------- bstack_raii/src/stdlib/string.rs | 15 ++++----- bstack_raii/src/stdlib/tree.rs | 11 +++--- bstack_raii/src/stdlib/util.rs | 15 +++++++++ 9 files changed, 77 insertions(+), 101 deletions(-) diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index d50c575..2927714 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -41,7 +41,7 @@ use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::double_hash; -use super::util::{alloc_image, read_u64}; +use super::util::{alloc_image, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -175,9 +175,8 @@ impl BStackCountingBloomFilter { /// The current estimated false-positive probability, `(1 - e^{-k n / m})^k`. pub fn estimated_fp_rate(&self, stack: &BStack) -> io::Result { let handle = self.range.start(); - let m = read_u64(stack, handle + M_OFF)? as f64; - let k = read_u64(stack, handle + K_OFF)? as f64; - let n = read_u64(stack, handle + N_OFF)? as f64; + let [m, k, n] = read_fields::<3>(stack, handle + M_OFF)?; + let (m, k, n) = (m as f64, k as f64, n as f64); Ok((1.0 - (-k * n / m).exp()).powf(k)) } @@ -199,9 +198,7 @@ impl BStackCountingBloomFilter { /// absent). A plain read. pub fn contains(&self, stack: &BStack, key: &K) -> io::Result { let handle = self.range.start(); - let data = read_u64(stack, handle + DATA_OFF)?; - let m = read_u64(stack, handle + M_OFF)?; - let k = read_u64(stack, handle + K_OFF)?; + let [data, m, k] = read_fields::<3>(stack, handle + DATA_OFF)?; let key_bytes = bytemuck::bytes_of(key); for idx in Self::indices(m, k, key_bytes) { let mut b = [0u8; 1]; @@ -216,8 +213,7 @@ impl BStackCountingBloomFilter { /// Reset every counter and the item count to zero. pub fn clear(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); - let data = read_u64(allocator.stack(), handle + DATA_OFF)?; - let m = read_u64(allocator.stack(), handle + M_OFF)?; + let [data, m] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; allocator.stack().set_batched([ (data, vec![0u8; m as usize]), (handle + N_OFF, 0u64.to_le_bytes().to_vec()), @@ -233,9 +229,7 @@ impl BStackCountingBloomFilter { add: bool, ) -> io::Result<()> { let handle = self.range.start(); - let data = read_u64(allocator.stack(), handle + DATA_OFF)?; - let m = read_u64(allocator.stack(), handle + M_OFF)?; - let k = read_u64(allocator.stack(), handle + K_OFF)?; + let [data, m, k] = read_fields::<3>(allocator.stack(), handle + DATA_OFF)?; let agg = Self::aggregate(Self::indices(m, k, bytemuck::bytes_of(key))); let cn = agg.len(); @@ -352,8 +346,7 @@ impl BStackBlock for BStackCountingBloomFilter { range: BStackRange, allocator: &A, ) -> io::Result<()> { - let data = read_u64(allocator.stack(), range.start() + DATA_OFF)?; - let m = read_u64(allocator.stack(), range.start() + M_OFF)?; + let [data, m] = read_fields::<2>(allocator.stack(), range.start() + DATA_OFF)?; if data != 0 { // SAFETY: the filter solely owns its counter block. unsafe { dealloc_range(allocator, BStackRange::new(data, m))? }; @@ -369,10 +362,7 @@ impl BStackBlock for BStackCountingBloomFilter { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let data = read_u64(allocator.stack(), handle + DATA_OFF)?; - let m = read_u64(allocator.stack(), handle + M_OFF)?; - let k = read_u64(allocator.stack(), handle + K_OFF)?; - let n = read_u64(allocator.stack(), handle + N_OFF)?; + let [data, m, k, n] = read_fields::<4>(allocator.stack(), handle + DATA_OFF)?; let new_data = if m != 0 { let mut bytes = vec![0u8; m as usize]; diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 6cc157b..8005dc7 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -27,7 +27,7 @@ use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; -use super::util::{Scratch, alloc_image, read_u64}; +use super::util::{Scratch, alloc_image, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -289,8 +289,7 @@ impl BStackBTreeSet { let handle = self.range.start(); let stack = allocator.stack(); - let root = read_u64(stack, handle + ROOT_OFF)?; - let len = read_u64(stack, handle + LEN_OFF)?; + let [root, len] = read_fields::<2>(stack, handle + ROOT_OFF)?; let mut build = Build { allocator, @@ -577,8 +576,7 @@ impl BStackBTreeSet { if !self.tree_contains(stack, key, &key_bytes)? { return Ok(false); } - let root = read_u64(stack, handle + ROOT_OFF)?; - let len = read_u64(stack, handle + LEN_OFF)?; + let [root, len] = read_fields::<2>(stack, handle + ROOT_OFF)?; let mut build = Build { allocator, @@ -775,9 +773,8 @@ impl BStackBlock for BStackBTreeSet { allocator: &A, ) -> io::Result<()> { let handle = range.start(); - let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; + let [root, _len, bloom_off] = read_fields::<3>(allocator.stack(), handle + ROOT_OFF)?; Self::drop_subtree(allocator.stack(), root, allocator)?; - let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; if bloom_off != 0 { // SAFETY: the set solely owns its embedded Bloom filter. let bloom = as BStackBlock>::from_range( @@ -796,9 +793,7 @@ impl BStackBlock for BStackBTreeSet { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; - let len = read_u64(allocator.stack(), handle + LEN_OFF)?; - let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + let [root, len, bloom_off] = read_fields::<3>(allocator.stack(), handle + ROOT_OFF)?; let new_root = Self::clone_subtree(allocator.stack(), root, allocator, plan)?; let bloom = as BStackBlock>::from_range(BStackRange::new( diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index 4c6439b..1877a29 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -39,7 +39,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, read_u64}; +use super::util::{alloc_image, atomic_update, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -101,14 +101,10 @@ impl BStackDeque { } /// Read the four `(head, len, cap, data)` metadata fields of the handle at - /// `handle`. + /// `handle` in a single I/O (the on-disk order is `data, cap, head, len`). fn read_meta(stack: &BStack, handle: u64) -> io::Result<(u64, u64, u64, u64)> { - Ok(( - read_u64(stack, handle + HEAD_OFF)?, - read_u64(stack, handle + LEN_OFF)?, - read_u64(stack, handle + CAP_OFF)?, - read_u64(stack, handle + DATA_OFF)?, - )) + let [data, cap, head, len] = read_fields::<4>(stack, handle + DATA_OFF)?; + Ok((head, len, cap, data)) } /// Allocate an empty deque (no ring is allocated until the first push). diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index bea7035..bd3697a 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -38,7 +38,7 @@ use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; use super::hash::fnv1a; -use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_u64}; +use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -227,8 +227,7 @@ impl BStackHashSet { let stride = Self::stride(); let ksz = Self::ksize(); loop { - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - let used = read_u64(allocator.stack(), handle + USED_OFF)?; + let [cap, _len, used] = read_fields::<3>(allocator.stack(), handle + CAP_OFF)?; if cap == 0 || (used + 1) * 4 > cap * 3 { self.grow(allocator)?; continue; @@ -324,8 +323,7 @@ impl BStackHashSet { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); - let table = read_u64(stack, handle + TABLE_OFF)?; - let cap = read_u64(stack, handle + CAP_OFF)?; + let [table, cap] = read_fields::<2>(stack, handle + TABLE_OFF)?; if cap == 0 { return Ok(false); } @@ -513,9 +511,8 @@ impl BStackBlock for BStackHashSet { allocator: &A, ) -> io::Result<()> { let handle = range.start(); - let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + let [table, cap, _len, _used, bloom_off] = + read_fields::<5>(allocator.stack(), handle + TABLE_OFF)?; if table != 0 { // SAFETY: the set solely owns its bucket block. unsafe { dealloc_range(allocator, BStackRange::new(table, cap * Self::stride()))? }; @@ -539,11 +536,8 @@ impl BStackBlock for BStackHashSet { ) -> io::Result { let handle = self.range.start(); let stride = Self::stride(); - let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - let len = read_u64(allocator.stack(), handle + LEN_OFF)?; - let used = read_u64(allocator.stack(), handle + USED_OFF)?; - let bloom_off = read_u64(allocator.stack(), handle + BLOOM_OFF)?; + let [table, cap, len, used, bloom_off] = + read_fields::<5>(allocator.stack(), handle + TABLE_OFF)?; let new_table = if cap != 0 { let mut image = vec![0u8; (cap * stride) as usize]; diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs index 41b0569..fc8cc5b 100644 --- a/bstack_raii/src/stdlib/heap.rs +++ b/bstack_raii/src/stdlib/heap.rs @@ -33,7 +33,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, read_u64}; +use super::util::{alloc_image, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -89,12 +89,10 @@ impl BStackBinaryHeap { get_u64(&slot[Self::ksize()..Self::ksize() + 8]) } + /// Read `(data, cap, len)` — the three contiguous handle fields — in one I/O. fn read_meta(stack: &BStack, handle: u64) -> io::Result<(u64, u64, u64)> { - Ok(( - read_u64(stack, handle + DATA_OFF)?, - read_u64(stack, handle + CAP_OFF)?, - read_u64(stack, handle + LEN_OFF)?, - )) + let [data, cap, len] = read_fields::<3>(stack, handle + DATA_OFF)?; + Ok((data, cap, len)) } /// Read the `stride` raw bytes of slot `i`. diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 61efa34..72aae5a 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -41,7 +41,7 @@ use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; -use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_u64}; +use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -207,8 +207,7 @@ impl BStackHashMap { loop { // Proactively keep the load factor under 3/4 (also clears tombstones). - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - let used = read_u64(allocator.stack(), handle + USED_OFF)?; + let [cap, _len, used] = read_fields::<3>(allocator.stack(), handle + CAP_OFF)?; if cap == 0 || (used + 1) * 4 > cap * 3 { self.grow(allocator)?; continue; @@ -328,29 +327,24 @@ impl BStackHashMap { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); - let table = read_u64(stack, handle + TABLE_OFF)?; - let cap = read_u64(stack, handle + CAP_OFF)?; + let [table, cap] = read_fields::<2>(stack, handle + TABLE_OFF)?; if cap == 0 { return Ok(None); } let key_bytes = bytemuck::bytes_of(key); let mask = cap - 1; let mut idx = fnv1a(key_bytes) & mask; - // Stack buffer for the probed key (no heap alloc for typical key sizes). + // One read per probed bucket (state + key + value in a single get_into). let mut scratch = Scratch::new(); for _ in 0..cap { - let bucket = table + idx * stride; - let state = read_u64(stack, bucket)?; + let buf = scratch.buf(stride as usize); + stack.get_into(table + idx * stride, buf)?; + let state = get_u64(&buf[0..8]); if state == EMPTY { return Ok(None); } - if state == OCCUPIED { - let kb = scratch.buf(ksz); - stack.get_into(bucket + 8, kb)?; - if kb == key_bytes { - let vref = read_u64(stack, bucket + 8 + ksz as u64)?; - return Ok(Some(Self::value_at(vref))); - } + if state == OCCUPIED && buf[8..8 + ksz] == *key_bytes { + return Ok(Some(Self::value_at(get_u64(&buf[8 + ksz..8 + ksz + 8])))); } idx = (idx + 1) & mask; } @@ -538,14 +532,19 @@ impl BStackBlock for BStackHashMap { allocator: &A, ) -> io::Result<()> { let stride = Self::stride(); - let ksz = Self::ksize() as u64; + let ksz = Self::ksize(); let handle = range.start(); - let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - for j in 0..cap { - let bucket = table + j * stride; - if read_u64(allocator.stack(), bucket)? == OCCUPIED { - let vref = read_u64(allocator.stack(), bucket + 8 + ksz)?; + let [table, cap] = read_fields::<2>(allocator.stack(), handle + TABLE_OFF)?; + if table == 0 { + return Ok(()); + } + // Read the whole bucket block once, then free values from memory. + let mut image = vec![0u8; (cap * stride) as usize]; + allocator.stack().get_into(table, &mut image)?; + for j in 0..cap as usize { + let lo = j * stride as usize; + if get_u64(&image[lo..lo + 8]) == OCCUPIED { + let vref = get_u64(&image[lo + 8 + ksz..lo + 16 + ksz]); if vref != 0 { // SAFETY: the map solely owns each value block. let owned = unsafe { BStackOwned::from_raw(Self::value_at(vref)) }; @@ -553,10 +552,8 @@ impl BStackBlock for BStackHashMap { } } } - if table != 0 { - // SAFETY: the map solely owns its bucket block. - unsafe { dealloc_range(allocator, BStackRange::new(table, cap * stride))? }; - } + // SAFETY: the map solely owns its bucket block. + unsafe { dealloc_range(allocator, BStackRange::new(table, cap * stride))? }; Ok(()) } @@ -572,10 +569,7 @@ impl BStackBlock for BStackHashMap { let stride = Self::stride(); let ksz = Self::ksize(); let handle = self.range.start(); - let table = read_u64(allocator.stack(), handle + TABLE_OFF)?; - let cap = read_u64(allocator.stack(), handle + CAP_OFF)?; - let len = read_u64(allocator.stack(), handle + LEN_OFF)?; - let used = read_u64(allocator.stack(), handle + USED_OFF)?; + let [table, cap, len, used] = read_fields::<4>(allocator.stack(), handle + TABLE_OFF)?; let (new_table, new_cap, new_used) = if cap == 0 { (0, 0, 0) diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index 3fed7b3..1a0c7c6 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -19,7 +19,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, read_u64}; +use super::util::{alloc_image, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -109,8 +109,8 @@ impl BStackString { /// Read the raw UTF-8 bytes. pub fn read_bytes(&self, stack: &BStack) -> io::Result> { - let data = read_u64(stack, self.range.start() + DATA_OFF)?; - let len = read_u64(stack, self.range.start() + LEN_OFF)? as usize; + let [data, len] = read_fields::<2>(stack, self.range.start() + DATA_OFF)?; + let len = len as usize; let mut buf = vec![0u8; len]; if len != 0 { stack.get_into(data, &mut buf)?; @@ -137,8 +137,7 @@ impl BStackString { let newlen = s.len() as u64; let newdata = Self::alloc_bytes(allocator, s.as_bytes())?; - let old_data = read_u64(stack, handle + DATA_OFF)?; - let old_len = read_u64(stack, handle + LEN_OFF)?; + let [old_data, old_len] = read_fields::<2>(stack, handle + DATA_OFF)?; // `data` and `len` are contiguous — swap both in one 16-byte write. let mut buf = [0u8; 16]; @@ -253,8 +252,7 @@ impl BStackBlock for BStackString { range: BStackRange, allocator: &A, ) -> io::Result<()> { - let data = read_u64(allocator.stack(), range.start() + DATA_OFF)?; - let len = read_u64(allocator.stack(), range.start() + LEN_OFF)?; + let [data, len] = read_fields::<2>(allocator.stack(), range.start() + DATA_OFF)?; if data != 0 { // SAFETY: the string solely owns its bytes block. unsafe { dealloc_range(allocator, BStackRange::new(data, len))? }; @@ -270,8 +268,7 @@ impl BStackBlock for BStackString { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let data = read_u64(allocator.stack(), handle + DATA_OFF)?; - let len = read_u64(allocator.stack(), handle + LEN_OFF)?; + let [data, len] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; let new_data = if len != 0 { let mut bytes = vec![0u8; len as usize]; diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 01754a1..c8e31be 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -44,7 +44,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{Scratch, alloc_image, read_u64}; +use super::util::{Scratch, alloc_image, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -331,8 +331,7 @@ impl BStackBTreeMap { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let val_ref = value.into_inner().range().start(); - let root = read_u64(stack, handle + ROOT_OFF)?; - let len = read_u64(stack, handle + LEN_OFF)?; + let [root, len] = read_fields::<2>(stack, handle + ROOT_OFF)?; let mut build = Build { allocator, @@ -674,12 +673,11 @@ impl BStackBTreeMap { ) -> io::Result>> { let handle = self.range.start(); let stack = allocator.stack(); - let root = read_u64(stack, handle + ROOT_OFF)?; + let [root, len] = read_fields::<2>(stack, handle + ROOT_OFF)?; // Absent-key fast path avoids a wasted path copy. if root == 0 || self.get(stack, key)?.is_none() { return Ok(None); } - let len = read_u64(stack, handle + LEN_OFF)?; let mut build = Build { allocator, @@ -917,8 +915,7 @@ impl BStackBlock for BStackBTreeMap { plan: &mut ClonePlan, ) -> io::Result { let handle = self.range.start(); - let root = read_u64(allocator.stack(), handle + ROOT_OFF)?; - let len = read_u64(allocator.stack(), handle + LEN_OFF)?; + let [root, len] = read_fields::<2>(allocator.stack(), handle + ROOT_OFF)?; let new_root = Self::clone_subtree(allocator.stack(), root, allocator, plan)?; let handle_dst = plan.alloc_raw(allocator, TREE_SIZE)?; diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 2ff5221..85160d0 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -18,6 +18,21 @@ pub(super) fn read_u64(stack: &BStack, off: u64) -> io::Result { Ok(u64::from_le_bytes(b)) } +/// Read `N` **contiguous** little-endian `u64` fields starting at `off` in a +/// *single* I/O call, returning them as an array. Use this instead of several +/// [`read_u64`] calls when the fields are adjacent (e.g. a handle's metadata) — +/// one `get_into` is one lock/seek/read, not `N`. +pub(super) fn read_fields(stack: &BStack, off: u64) -> io::Result<[u64; N]> { + debug_assert!(N <= 8, "read_fields: at most 8 u64 fields"); + let buf = &mut [0u8; 64][..N * 8]; + stack.get_into(off, buf)?; + let mut out = [0u64; N]; + for (dst, chunk) in out.iter_mut().zip(buf.chunks_exact(8)) { + *dst = get_u64(chunk); + } + Ok(out) +} + /// Inline capacity of a [`Scratch`] buffer. Sized to hold a B-tree node (or a /// map bucket / key) for any reasonably-small `Pod` key entirely on the stack; /// unusually large keys spill to the heap. A `BStackBTreeMap` node with minimum From 77609bcec15bfffdc86db70a0c60c0098196d765 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Thu, 6 Aug 2026 23:55:47 -0700 Subject: [PATCH 088/140] stdlib: Iterators --- bstack_raii/src/lib.rs | 5 +- bstack_raii/src/stdlib/btreeset.rs | 107 ++++++++++++++++++++++ bstack_raii/src/stdlib/deque.rs | 46 ++++++++++ bstack_raii/src/stdlib/hashset.rs | 49 ++++++++++ bstack_raii/src/stdlib/list.rs | 48 +++++++++- bstack_raii/src/stdlib/map.rs | 52 +++++++++++ bstack_raii/src/stdlib/mod.rs | 12 +-- bstack_raii/src/stdlib/tree.rs | 121 +++++++++++++++++++++++++ bstack_raii/src/stdlib/util.rs | 1 + bstack_raii/src/tests.rs | 138 +++++++++++++++++++++++++++++ 10 files changed, 568 insertions(+), 11 deletions(-) diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index e937ef4..6bd3d38 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -84,8 +84,9 @@ pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBox, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackHashMap, BStackHashSet, BStackLinkedList, BStackString, - BloomOnDisk, BoxOnDisk, DequeOnDisk, HashSetOnDisk, HeapOnDisk, ListOnDisk, MapOnDisk, - NodeOnDisk, StringOnDisk, TreeOnDisk, TreeSetOnDisk, + BTreeMapIter, BTreeSetIter, BloomOnDisk, BoxOnDisk, DequeIter, DequeOnDisk, HashMapIter, + HashSetIter, HashSetOnDisk, HeapOnDisk, ListIter, ListOnDisk, MapOnDisk, NodeOnDisk, + StringOnDisk, TreeOnDisk, TreeSetOnDisk, }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 8005dc7..b97eac4 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -691,6 +691,67 @@ impl BStackBTreeSet { Ok(()) } + /// A lazy in-order iterator over all keys, ascending. Reads nodes on demand; + /// yields `io::Result`. Do not mutate the set's structure while iterating. + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; + let frames = Self::descend_left(stack, root)?; + Ok(BTreeSetIter { + stack, + frames, + hi: None, + _marker: PhantomData, + }) + } + + /// A lazy in-order iterator over the keys with `lo <= key <= hi`, ascending. + pub fn range<'a>(&self, stack: &'a BStack, lo: K, hi: K) -> io::Result> { + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; + let frames = Self::seek(stack, root, &lo)?; + Ok(BTreeSetIter { + stack, + frames, + hi: Some(hi), + _marker: PhantomData, + }) + } + + /// Build the frame stack for the leftmost path from `root`. + fn descend_left(stack: &BStack, mut cur: u64) -> io::Result> { + let mut frames = Vec::new(); + while cur != 0 { + let n = Self::read_node(stack, cur)?; + let next = if n.leaf { 0 } else { n.children[0] }; + let leaf = n.leaf; + frames.push((n, 0)); + if leaf { + break; + } + cur = next; + } + Ok(frames) + } + + /// Build the frame stack positioned at the first key `>= lo`. + fn seek(stack: &BStack, mut cur: u64, lo: &K) -> io::Result> { + let mut frames = Vec::new(); + while cur != 0 { + let n = Self::read_node(stack, cur)?; + let (i, exact) = Self::search(&n, lo); + let descend = if n.leaf || exact { + None + } else { + Some(n.children[i]) + }; + frames.push((n, i)); + match descend { + Some(c) => cur = c, + None => break, + } + } + Ok(frames) + } + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the set was created. @@ -842,3 +903,49 @@ impl TryCloneIn for BStackBTreeSet { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// A lazy in-order iterator over a [`BStackBTreeSet`], yielding `io::Result` +/// in ascending order. Created by [`BStackBTreeSet::iter`] / +/// [`BStackBTreeSet::range`]. +pub struct BTreeSetIter<'a, K: Pod + Ord> { + stack: &'a BStack, + frames: Vec<(BNode, usize)>, + hi: Option, + _marker: PhantomData K>, +} + +impl<'a, K: Pod + Ord> Iterator for BTreeSetIter<'a, K> { + type Item = io::Result; + + fn next(&mut self) -> Option { + loop { + let (node, i) = self.frames.last()?; + let i = *i; + if i >= node.keys.len() { + self.frames.pop(); + continue; + } + let key = BStackBTreeSet::::read_key(&node.keys[i]); + let leaf = node.leaf; + let child = if leaf { 0 } else { node.children[i + 1] }; + + if let Some(ref hi) = self.hi + && key > *hi + { + self.frames.clear(); + return None; + } + self.frames.last_mut().unwrap().1 = i + 1; + if !leaf { + match BStackBTreeSet::::descend_left(self.stack, child) { + Ok(mut f) => self.frames.append(&mut f), + Err(e) => { + self.frames.clear(); + return Some(Err(e)); + } + } + } + return Some(Ok(key)); + } + } +} diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index 1877a29..44ad46f 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -459,6 +459,21 @@ impl BStackDeque { Ok(out) } + /// A lazy iterator over the elements, front to back, yielding `io::Result` + /// value handles. A read snapshot: do not mutate the deque while iterating. + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let (head, len, cap, data) = Self::read_meta(stack, self.range.start())?; + Ok(DequeIter { + stack, + data, + cap, + head, + len, + pos: 0, + _marker: PhantomData, + }) + } + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the deque was created. @@ -592,3 +607,34 @@ impl TryCloneIn for BStackDeque { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// A front-to-back iterator over a [`BStackDeque`], yielding `io::Result` +/// value handles. Created by [`BStackDeque::iter`]. +pub struct DequeIter<'a, T: BStackBlock> { + stack: &'a BStack, + data: u64, + cap: u64, + head: u64, + len: u64, + pos: u64, + _marker: PhantomData T>, +} + +impl<'a, T: BStackBlock> Iterator for DequeIter<'a, T> { + type Item = io::Result; + + fn next(&mut self) -> Option { + if self.pos >= self.len { + return None; + } + let slot = self.data + ((self.head + self.pos) % self.cap) * 8; + self.pos += 1; + match read_u64(self.stack, slot) { + Ok(vref) => Some(Ok(BStackDeque::::value_at(vref))), + Err(e) => { + self.pos = self.len; // stop after an error + Some(Err(e)) + } + } + } +} diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index bd3697a..b350d67 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -216,6 +216,22 @@ impl BStackHashSet { self.table_contains(stack, key_bytes, fnv1a(key_bytes)) } + /// A lazy iterator over all keys in **unspecified** order, yielding + /// `io::Result`. A read snapshot: do not mutate the set while iterating. + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let [table, cap] = read_fields::<2>(stack, self.range.start() + TABLE_OFF)?; + Ok(HashSetIter { + stack, + table, + cap, + stride: Self::stride(), + ksz: Self::ksize(), + idx: 0, + scratch: Scratch::new(), + _marker: PhantomData, + }) + } + /// Place `key` in the table if absent; returns whether it was newly added. fn table_insert( &self, @@ -597,3 +613,36 @@ impl TryCloneIn for BStackHashSet { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// An unordered iterator over a [`BStackHashSet`]'s keys, yielding +/// `io::Result`. Created by [`BStackHashSet::iter`]; scans the buckets. +pub struct HashSetIter<'a, K: Pod> { + stack: &'a BStack, + table: u64, + cap: u64, + stride: u64, + ksz: usize, + idx: u64, + scratch: Scratch, + _marker: PhantomData K>, +} + +impl<'a, K: Pod> Iterator for HashSetIter<'a, K> { + type Item = io::Result; + + fn next(&mut self) -> Option { + while self.idx < self.cap { + let i = self.idx; + self.idx += 1; + let buf = self.scratch.buf(self.stride as usize); + if let Err(e) = self.stack.get_into(self.table + i * self.stride, buf) { + self.idx = self.cap; + return Some(Err(e)); + } + if get_u64(&buf[0..8]) == OCCUPIED { + return Some(Ok(bytemuck::pod_read_unaligned::(&buf[8..8 + self.ksz]))); + } + } + None + } +} diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index 8ce64d1..4663ec9 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -36,7 +36,7 @@ use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, read_u64}; +use super::util::{alloc_image, atomic_update, read_fields, read_u64}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -391,12 +391,25 @@ impl BStackLinkedList { let mut out = Vec::new(); let mut cur = read_u64(stack, self.range.start() + HEAD_OFF)?; while cur != 0 { - out.push(Self::value_at(read_u64(stack, cur + NVAL_OFF)?)); - cur = read_u64(stack, cur + NNEXT_OFF)?; + // `next` (@24) and `value` (@32) are adjacent — one read per node. + let [next, value] = read_fields::<2>(stack, cur + NNEXT_OFF)?; + out.push(Self::value_at(value)); + cur = next; } Ok(out) } + /// A lazy iterator over the elements, front to back, yielding `io::Result` + /// value handles. A read snapshot: do not mutate the list while iterating. + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let head = read_u64(stack, self.range.start() + HEAD_OFF)?; + Ok(ListIter { + stack, + cur: head, + _marker: PhantomData, + }) + } + /// Attach an allocator to make an auto-freeing [`crate::AutoDrop`] guard. pub fn auto( self, @@ -543,3 +556,32 @@ impl TryCloneIn for BStackLinkedList { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// A front-to-back iterator over a [`BStackLinkedList`], yielding `io::Result` +/// value handles. Created by [`BStackLinkedList::iter`]; walks the `next` links. +pub struct ListIter<'a, T: BStackBlock> { + stack: &'a BStack, + cur: u64, + _marker: PhantomData T>, +} + +impl<'a, T: BStackBlock> Iterator for ListIter<'a, T> { + type Item = io::Result; + + fn next(&mut self) -> Option { + if self.cur == 0 { + return None; + } + // `next` (@24) and `value` (@32) are adjacent — one read per node. + match read_fields::<2>(self.stack, self.cur + NNEXT_OFF) { + Ok([next, value]) => { + self.cur = next; + Some(Ok(BStackLinkedList::::value_at(value))) + } + Err(e) => { + self.cur = 0; + Some(Err(e)) + } + } + } +} diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 72aae5a..cce2e8e 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -356,6 +356,23 @@ impl BStackHashMap { Ok(self.get(stack, key)?.is_some()) } + /// A lazy iterator over all `(key, value)` entries in **unspecified** order, + /// yielding `io::Result`. A read snapshot: do not mutate the map while + /// iterating (mutating a yielded value block is fine). + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let [table, cap] = read_fields::<2>(stack, self.range.start() + TABLE_OFF)?; + Ok(HashMapIter { + stack, + table, + cap, + stride: Self::stride(), + ksz: Self::ksize(), + idx: 0, + scratch: Scratch::new(), + _marker: PhantomData, + }) + } + /// Grow the table to at least double its capacity, rehashing every live entry /// (and dropping tombstones) atomically. A no-op (beyond a freed spare block) /// if another thread already grew it. @@ -635,3 +652,38 @@ impl TryCloneIn for BStackHashMap { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// An unordered iterator over a [`BStackHashMap`]'s live entries, yielding +/// `io::Result<(K, V)>`. Created by [`BStackHashMap::iter`]; scans the buckets. +pub struct HashMapIter<'a, K: Pod, V: BStackBlock> { + stack: &'a BStack, + table: u64, + cap: u64, + stride: u64, + ksz: usize, + idx: u64, + scratch: Scratch, + _marker: PhantomData (K, V)>, +} + +impl<'a, K: Pod, V: BStackBlock> Iterator for HashMapIter<'a, K, V> { + type Item = io::Result<(K, V)>; + + fn next(&mut self) -> Option { + while self.idx < self.cap { + let i = self.idx; + self.idx += 1; + let buf = self.scratch.buf(self.stride as usize); + if let Err(e) = self.stack.get_into(self.table + i * self.stride, buf) { + self.idx = self.cap; + return Some(Err(e)); + } + if get_u64(&buf[0..8]) == OCCUPIED { + let k = bytemuck::pod_read_unaligned::(&buf[8..8 + self.ksz]); + let vref = get_u64(&buf[8 + self.ksz..8 + self.ksz + 8]); + return Some(Ok((k, BStackHashMap::::value_at(vref)))); + } + } + None + } +} diff --git a/bstack_raii/src/stdlib/mod.rs b/bstack_raii/src/stdlib/mod.rs index 73b337b..e5cd186 100644 --- a/bstack_raii/src/stdlib/mod.rs +++ b/bstack_raii/src/stdlib/mod.rs @@ -41,12 +41,12 @@ mod util; pub use bloom::{BStackCountingBloomFilter, BloomOnDisk}; pub use boxed::{BStackBox, BoxOnDisk}; -pub use btreeset::{BStackBTreeSet, TreeSetOnDisk}; +pub use btreeset::{BStackBTreeSet, BTreeSetIter, TreeSetOnDisk}; pub use cow::BStackCow; -pub use deque::{BStackDeque, DequeOnDisk}; -pub use hashset::{BStackHashSet, HashSetOnDisk}; +pub use deque::{BStackDeque, DequeIter, DequeOnDisk}; +pub use hashset::{BStackHashSet, HashSetIter, HashSetOnDisk}; pub use heap::{BStackBinaryHeap, HeapOnDisk}; -pub use list::{BStackLinkedList, ListOnDisk, NodeOnDisk}; -pub use map::{BStackHashMap, MapOnDisk}; +pub use list::{BStackLinkedList, ListIter, ListOnDisk, NodeOnDisk}; +pub use map::{BStackHashMap, HashMapIter, MapOnDisk}; pub use string::{BStackString, StringOnDisk}; -pub use tree::{BStackBTreeMap, TreeOnDisk}; +pub use tree::{BStackBTreeMap, BTreeMapIter, TreeOnDisk}; diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index c8e31be..2c55310 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -798,6 +798,73 @@ impl BStackBTreeMap { Ok(()) } + /// A lazy in-order iterator over all `(key, value)` entries, ascending. Reads + /// nodes on demand (no full materialization); yields `io::Result` so a read + /// error surfaces per step. Do not mutate the tree's *structure* while + /// iterating (mutating a yielded value block is fine). + pub fn iter<'a>(&self, stack: &'a BStack) -> io::Result> { + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; + let frames = Self::descend_left(stack, root)?; + Ok(BTreeMapIter { + stack, + frames, + hi: None, + _marker: PhantomData, + }) + } + + /// A lazy in-order iterator over the entries with `lo <= key <= hi`, ascending. + pub fn range<'a>(&self, stack: &'a BStack, lo: K, hi: K) -> io::Result> { + let root = read_u64(stack, self.range.start() + ROOT_OFF)?; + let frames = Self::seek(stack, root, &lo)?; + Ok(BTreeMapIter { + stack, + frames, + hi: Some(hi), + _marker: PhantomData, + }) + } + + /// Build the frame stack for the leftmost path from `root` (positions an + /// in-order iterator at the smallest key). + fn descend_left(stack: &BStack, mut cur: u64) -> io::Result> { + let mut frames = Vec::new(); + while cur != 0 { + let n = Self::read_node(stack, cur)?; + let next = if n.leaf { 0 } else { n.children[0] }; + let leaf = n.leaf; + frames.push((n, 0)); + if leaf { + break; + } + cur = next; + } + Ok(frames) + } + + /// Build the frame stack positioned at the first key `>= lo`. + fn seek(stack: &BStack, mut cur: u64, lo: &K) -> io::Result> { + let mut frames = Vec::new(); + while cur != 0 { + let n = Self::read_node(stack, cur)?; + let (i, exact) = Self::search(&n, lo); + // Descend into child[i] only when it may hold keys `>= lo` — i.e. an + // internal node with no exact hit here (an exact hit means child[i] is + // entirely `< lo` and is skipped). + let descend = if n.leaf || exact { + None + } else { + Some(n.children[i]) + }; + frames.push((n, i)); + match descend { + Some(c) => cur = c, + None => break, + } + } + Ok(frames) + } + /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the tree was created. @@ -958,3 +1025,57 @@ impl TryCloneIn for BStackBTreeMap { Ok(unsafe { BStackOwned::from_raw(Self::from_range(dst)) }) } } + +/// A lazy in-order iterator over a [`BStackBTreeMap`], yielding +/// `io::Result<(K, V)>` in ascending key order. Created by +/// [`BStackBTreeMap::iter`] / [`BStackBTreeMap::range`]; borrows the `BStack` for +/// its lifetime and reads nodes on demand. +/// +/// Each frame `(node, i)` on the stack means "`key[i]` is next; the subtree of +/// `child[i]` has already been yielded" — the standard iterative in-order walk +/// generalized to a B-tree. +pub struct BTreeMapIter<'a, K: Pod + Ord, V: BStackBlock> { + stack: &'a BStack, + frames: Vec<(BNode, usize)>, + hi: Option, + _marker: PhantomData (K, V)>, +} + +impl<'a, K: Pod + Ord, V: BStackBlock> Iterator for BTreeMapIter<'a, K, V> { + type Item = io::Result<(K, V)>; + + fn next(&mut self) -> Option { + loop { + let (node, i) = self.frames.last()?; + let i = *i; + if i >= node.keys.len() { + self.frames.pop(); + continue; + } + let key = BStackBTreeMap::::read_key(&node.keys[i]); + let vref = node.vals[i]; + let leaf = node.leaf; + let child = if leaf { 0 } else { node.children[i + 1] }; + + if let Some(ref hi) = self.hi + && key > *hi + { + self.frames.clear(); + return None; + } + // Advance this frame past `key[i]`, then (if internal) descend the + // leftmost path of `child[i+1]` so `key[i+1]` comes after its subtree. + self.frames.last_mut().unwrap().1 = i + 1; + if !leaf { + match BStackBTreeMap::::descend_left(self.stack, child) { + Ok(mut f) => self.frames.append(&mut f), + Err(e) => { + self.frames.clear(); + return Some(Err(e)); + } + } + } + return Some(Ok((key, BStackBTreeMap::::value_at(vref)))); + } + } +} diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 85160d0..57a079a 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -27,6 +27,7 @@ pub(super) fn read_fields(stack: &BStack, off: u64) -> io::Resul let buf = &mut [0u8; 64][..N * 8]; stack.get_into(off, buf)?; let mut out = [0u64; N]; + #[allow(clippy::chunks_exact_to_as_chunks)] for (dst, chunk) in out.iter_mut().zip(buf.chunks_exact(8)) { *dst = get_u64(chunk); } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 4921373..cb54e87 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6000,3 +6000,141 @@ fn stdlib_heap_distinct_tags() { as BStackCast>::eightcc(), ); } + +// -------------------------------------------------------------------------- +// stdlib: iterators +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_deque_iter() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let dq = BStackDeque::::new(&alloc).unwrap(); + for v in 0..10u32 { + dq.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); + } + let mut got = Vec::new(); + for r in dq.iter(stack).unwrap() { + got.push(r.unwrap().val(stack).unwrap()); + } + assert_eq!(got, (0..10u32).collect::>()); + dq.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_list_iter() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let list = BStackLinkedList::::new(&alloc).unwrap(); + for v in 0..6u32 { + list.push_back(&alloc, MacroLeaf::new(&alloc, v).unwrap()) + .unwrap(); + } + let mut got = Vec::new(); + for r in list.iter(stack).unwrap() { + got.push(r.unwrap().val(stack).unwrap()); + } + assert_eq!(got, (0..6u32).collect::>()); + list.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_iter() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let map = BStackHashMap::::new(&alloc).unwrap(); + for k in 0..20u32 { + map.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap(); + } + let mut got = Vec::new(); + for r in map.iter(stack).unwrap() { + let (k, v) = r.unwrap(); + got.push((k, v.val(stack).unwrap())); + } + got.sort_unstable(); // unordered iteration + assert_eq!(got, (0..20u32).map(|k| (k, k * 10)).collect::>()); + map.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_hashset_iter() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let set = BStackHashSet::::new(&alloc).unwrap(); + for k in 0..20u32 { + set.insert(&alloc, k).unwrap(); + } + let mut got = Vec::new(); + for r in set.iter(stack).unwrap() { + got.push(r.unwrap()); + } + got.sort_unstable(); + assert_eq!(got, (0..20u32).collect::>()); + set.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_tree_iter_and_range() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + for i in 0..40u32 { + let k = (i * 13) % 40; // scrambled permutation + tree.insert(&alloc, k, MacroLeaf::new(&alloc, k * 10).unwrap()) + .unwrap(); + } + // Full iteration is sorted. + let mut got = Vec::new(); + for r in tree.iter(stack).unwrap() { + let (k, v) = r.unwrap(); + got.push((k, v.val(stack).unwrap())); + } + assert_eq!(got, (0..40u32).map(|k| (k, k * 10)).collect::>()); + + // range(10, 20) is the inclusive sub-slice, still sorted. + let mut ranged = Vec::new(); + for r in tree.range(stack, 10, 20).unwrap() { + ranged.push(r.unwrap().0); + } + assert_eq!(ranged, (10..=20u32).collect::>()); + + // A range whose lo falls between keys and hi past the end. + let mut r2 = Vec::new(); + for r in tree.range(stack, 37, 999).unwrap() { + r2.push(r.unwrap().0); + } + assert_eq!(r2, vec![37, 38, 39]); + + tree.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_btreeset_iter_and_range() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let set = BStackBTreeSet::::new(&alloc).unwrap(); + for i in 0..40u32 { + set.insert(&alloc, (i * 13) % 40).unwrap(); + } + let mut got = Vec::new(); + for r in set.iter(stack).unwrap() { + got.push(r.unwrap()); + } + assert_eq!(got, (0..40u32).collect::>()); + + let mut ranged = Vec::new(); + for r in set.range(stack, 15, 18).unwrap() { + ranged.push(r.unwrap()); + } + assert_eq!(ranged, vec![15, 16, 17, 18]); + + set.bstack_drop(&alloc).unwrap(); +} From ba1017b39be1331b0621bf8a6bba75a856cb9602 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 7 Aug 2026 04:44:11 -0700 Subject: [PATCH 089/140] stdlib: Advanced map entry API --- bstack_raii/src/stdlib/map.rs | 48 +++++++++++++++++ bstack_raii/src/stdlib/tree.rs | 42 +++++++++++++++ bstack_raii/src/tests.rs | 96 ++++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index cce2e8e..d3178c3 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -356,6 +356,54 @@ impl BStackHashMap { Ok(self.get(stack, key)?.is_some()) } + /// Get the value for `key`, inserting one produced by `f` if absent — the + /// fused entry operation. Returns `(value handle, was_newly_inserted)`. + /// + /// If `key` is already present this is a single probe: `f` is **not** called + /// and nothing is allocated. Existing values are never replaced (use + /// [`insert`](Self::insert) for that). The returned handle is mutable in + /// place, and the `bool` distinguishes a fresh insert from a hit (e.g. to + /// increment an existing counter). **Single-writer** — do not mutate the map + /// concurrently across this call. + pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> + where + A: BStackOwnedSliceAllocator, + F: FnOnce() -> io::Result>, + { + if let Some(v) = self.get(allocator.stack(), &key)? { + return Ok((v, false)); + } + let value = f()?; + let vref = value.handle().range().start(); + // Absent per the probe above, so `insert` returns no prior value; reclaim + // one defensively if a race produced it. + if let Some(old) = self.insert(allocator, key, value)? { + old.bstack_drop(allocator)?; + } + Ok((Self::value_at(vref), true)) + } + + /// Like [`get_or_insert_with`](Self::get_or_insert_with) but with an eager + /// `default`. If `key` is present, `default` is **freed** (its block is + /// dropped) — prefer the `_with` form to avoid allocating a value you may not + /// use. + pub fn get_or_insert( + &self, + allocator: &A, + key: K, + default: BStackOwned, + ) -> io::Result<(V, bool)> { + if let Some(v) = self.get(allocator.stack(), &key)? { + default.bstack_drop(allocator)?; + return Ok((v, false)); + } + let vref = default.handle().range().start(); + if let Some(old) = self.insert(allocator, key, default)? { + old.bstack_drop(allocator)?; + } + Ok((Self::value_at(vref), true)) + } + /// A lazy iterator over all `(key, value)` entries in **unspecified** order, /// yielding `io::Result`. A read snapshot: do not mutate the map while /// iterating (mutating a yielded value block is fine). diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 2c55310..e2cb270 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -463,6 +463,48 @@ impl BStackBTreeMap { Ok(self.get(stack, key)?.is_some()) } + /// Get the value for `key`, inserting one produced by `f` if absent — the + /// fused entry operation. Returns `(value handle, was_newly_inserted)`. + /// + /// If `key` is present this is a single descent: `f` is **not** called and + /// nothing is allocated. Existing values are never replaced (use + /// [`insert`](Self::insert)). The returned handle is mutable in place, and the + /// `bool` distinguishes a fresh insert from a hit. **Single-writer.** + pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> + where + A: BStackOwnedSliceAllocator, + F: FnOnce() -> io::Result>, + { + if let Some(v) = self.get(allocator.stack(), &key)? { + return Ok((v, false)); + } + let value = f()?; + let vref = value.handle().range().start(); + if let Some(old) = self.insert(allocator, key, value)? { + old.bstack_drop(allocator)?; + } + Ok((Self::value_at(vref), true)) + } + + /// Like [`get_or_insert_with`](Self::get_or_insert_with) but with an eager + /// `default`, which is **freed** if `key` is already present. + pub fn get_or_insert( + &self, + allocator: &A, + key: K, + default: BStackOwned, + ) -> io::Result<(V, bool)> { + if let Some(v) = self.get(allocator.stack(), &key)? { + default.bstack_drop(allocator)?; + return Ok((v, false)); + } + let vref = default.handle().range().start(); + if let Some(old) = self.insert(allocator, key, default)? { + old.bstack_drop(allocator)?; + } + Ok((Self::value_at(vref), true)) + } + /// The number of keys in the node at `off` (reads just the count field). fn child_nkeys(stack: &BStack, off: u64) -> io::Result { Ok(get_u64(&{ diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index cb54e87..19df100 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6138,3 +6138,99 @@ fn stdlib_btreeset_iter_and_range() { set.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// stdlib: entry API (get_or_insert_with / get_or_insert) +// -------------------------------------------------------------------------- + +#[test] +fn stdlib_map_entry() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let map = BStackHashMap::::new(&alloc).unwrap(); + + // Absent: inserts, f produced the value. + let (v, inserted) = map + .get_or_insert_with(&alloc, 7, || MacroLeaf::new(&alloc, 70)) + .unwrap(); + assert!(inserted); + assert_eq!(v.val(stack).unwrap(), 70); + + // Present: single probe, f NOT called, value unchanged. + map.insert(&alloc, 5, MacroLeaf::new(&alloc, 50).unwrap()) + .unwrap(); + let called = std::cell::Cell::new(false); + let (v, inserted) = map + .get_or_insert_with(&alloc, 5, || { + called.set(true); + MacroLeaf::new(&alloc, 999) + }) + .unwrap(); + assert!(!inserted); + assert!(!called.get(), "f must not run on a hit"); + assert_eq!(v.val(stack).unwrap(), 50); + + // Eager get_or_insert frees the unused default on a hit. + let (v, inserted) = map + .get_or_insert(&alloc, 5, MacroLeaf::new(&alloc, 111).unwrap()) + .unwrap(); + assert!(!inserted); + assert_eq!(v.val(stack).unwrap(), 50); + + map.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_map_entry_counter() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + // The headline entry pattern: a counter map with in-place value mutation. + let counts = BStackHashMap::>::new(&alloc).unwrap(); + for k in [1u32, 1, 2, 1, 2, 1] { + let (v, inserted) = counts + .get_or_insert_with(&alloc, k, || BStackBox::new(&alloc, 1u64)) + .unwrap(); + if !inserted { + let cur = v.get(stack).unwrap(); + v.set(&alloc, cur + 1).unwrap(); + } + } + assert_eq!( + counts.get(stack, &1).unwrap().unwrap().get(stack).unwrap(), + 4 + ); + assert_eq!( + counts.get(stack, &2).unwrap().unwrap().get(stack).unwrap(), + 2 + ); + counts.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn stdlib_tree_entry() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + + let (v, inserted) = tree + .get_or_insert_with(&alloc, 7, || MacroLeaf::new(&alloc, 70)) + .unwrap(); + assert!(inserted); + assert_eq!(v.val(stack).unwrap(), 70); + + let called = std::cell::Cell::new(false); + let (v, inserted) = tree + .get_or_insert_with(&alloc, 7, || { + called.set(true); + MacroLeaf::new(&alloc, 999) + }) + .unwrap(); + assert!(!inserted); + assert!(!called.get()); + assert_eq!(v.val(stack).unwrap(), 70); + + tree.bstack_drop(&alloc).unwrap(); +} From a52c43dd9b655b553b70f9d5909bd92983402455 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 8 Aug 2026 14:45:36 -0700 Subject: [PATCH 090/140] Extending WAL --- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/tests.rs | 57 +++++++++++++++- bstack_raii/src/wal.rs | 139 ++++++++++++++++++++++++++------------- 3 files changed, 150 insertions(+), 50 deletions(-) diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 6bd3d38..958801e 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -91,8 +91,8 @@ pub use stdlib::{ pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ - AllocReq, BStackWalAnchor, Reduced, WalEntry, WalHeader, WalLog, WalOp, WalStatus, finish, - finish_at, persist_at, reduce, + AllocReq, BStackWalAnchor, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, + WalStatus, finish, finish_at, persist_at, reduce, }; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 19df100..88f898e 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2465,13 +2465,68 @@ fn wal_finish_abandons_uncommitted() { log.append(WalEntry::dealloc(WalStatus::Pending, v1)); persist_at(&alloc, anchor, &log, WalStatus::Pending).unwrap(); - // Abandoned: nothing freed, anchor cleared. + // Abandoned: the old slice v1 must NOT be freed (it's still live). Reclaiming + // an abandoned txn frees its *allocs*, and this txn logged only a dealloc. assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); let mut buf = [0u8; 8]; alloc.stack().get_into(anchor, &mut buf).unwrap(); assert_eq!(u64::from_le_bytes(buf), 0); } +#[test] +fn wal_anchor_trait_reclaims_via_finish() { + use crate::BStackWalAnchor; + use crate::wal::{finish, persist_at}; + use crate::{WalEntry, WalLog, WalStatus}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); // FirstFitBStackAllocator: wal_anchor() == 8 + let orphan = alloc.alloc(64).unwrap().as_range(); + + // Persist an abandoned (Pending) txn into the allocator's own anchor slot. + let mut log = WalLog::with_capacity(1); + log.append(WalEntry::alloc(WalStatus::Pending, orphan)); + persist_at(&alloc, alloc.wal_anchor(), &log, WalStatus::Pending).unwrap(); + + // finish() uses the trait anchor and reclaims the orphan; the allocator is + // unharmed by our writes to its reserved slot (a fresh alloc reuses it). + assert_eq!(finish(&alloc).unwrap(), 1); + assert_eq!(alloc.alloc(64).unwrap().as_range().start(), orphan.start()); +} + +#[test] +fn wal_finish_reclaims_abandoned_allocs() { + use crate::wal::{finish_at, persist_at}; + use crate::{WalEntry, WalLog, WalStatus}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let anchor = alloc.alloc(8).unwrap().as_range().start(); + // Two blocks a crashed op allocated but never linked (orphans). + let a1 = alloc.alloc(64).unwrap().as_range(); + let a2 = alloc.alloc(64).unwrap().as_range(); + + // An UNCOMMITTED (Pending) transaction that had allocated a1/a2. + let mut log = WalLog::with_capacity(2); + log.append(WalEntry::alloc(WalStatus::Pending, a1)); + log.append(WalEntry::alloc(WalStatus::Pending, a2)); + persist_at(&alloc, anchor, &log, WalStatus::Pending).unwrap(); + + // Reclaiming the abandoned txn frees both orphans. + assert_eq!(finish_at(&alloc, anchor).unwrap(), 2); + // Reclaimed: a fresh 64-byte alloc reuses one of the freed slots. + let reused = alloc.alloc(64).unwrap().as_range(); + assert!(reused.start() == a1.start() || reused.start() == a2.start()); + + // A *committed* alloc-only txn keeps its allocs (frees nothing). + let anchor2 = alloc.alloc(8).unwrap().as_range().start(); + let keep = alloc.alloc(64).unwrap().as_range(); + let mut log2 = WalLog::with_capacity(1); + log2.append(WalEntry::alloc(WalStatus::Pending, keep)); + persist_at(&alloc, anchor2, &log2, WalStatus::Complete).unwrap(); + assert_eq!(finish_at(&alloc, anchor2).unwrap(), 0); +} + // -------------------------------------------------------------------------- // Inline fixed-size arrays [T; N] // -------------------------------------------------------------------------- diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 0ecbfd0..0b12ec3 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -30,17 +30,23 @@ //! * normal progress [`advance`](WalStatus::advance): `None → Pending → Complete`; //! * [`recover`](WalStatus::recover): `Pending → Abandon`, else identity. //! -//! An operation is thus `(R', Alloc|Dealloc) × Status`, and the WAL is the functor -//! `wal_append` mapping operations into disk state ([`WalLog`]). On disk an `Alloc` -//! stores its `R' = (id, len)` and a `Dealloc` stores its `S = (ptr, len)`. +//! An operation is thus `(slice, Alloc|Dealloc) × Status`, and the WAL is the +//! functor `wal_append` mapping operations into disk state ([`WalLog`]). On disk +//! **both** an `Alloc` and a `Dealloc` store their slice `S = (ptr, len)`; the +//! `op` marks the *recovery polarity* (which outcome orphans the slice). +//! ([`AllocReq`] / [`reduce`] are the pre-allocation planning form, `R' = (id, +//! len)`, used before an address exists.) //! -//! Recovery semantics (per operation): a `Pending` op was in flight at the crash, -//! so it is **abandoned and its slice leaked** — never re-run (which would -//! double-free) — while a `Complete` op stands. Each `Dealloc` self-brackets -//! (`write Pending → run → write Complete`), so its own status is the progress -//! marker; no separate cursor is needed. Leaks are accepted and minimised by the -//! reduction; recovery's job is consistency (no double-free, no dangling), not -//! reclamation. +//! Recovery semantics ([`finish`]), driven by the transaction-level +//! `txn_status`, **reclaims** rather than merely staying consistent: +//! +//! * **committed** → free each `Pending` `Dealloc` (the old blocks the op +//! unlinked) — roll forward; +//! * **abandoned** → free each `Pending` `Alloc` (the new blocks a crashed op +//! allocated but never linked) — reclaim the orphans. +//! +//! Each entry self-brackets (`persist Complete → free`), so a second crash mid- +//! completion never double-frees; no separate cursor is needed. use core::mem::size_of; use std::io; @@ -137,14 +143,16 @@ pub struct WalEntry { } impl WalEntry { - /// An `Alloc` entry recording a requirement `R' = (id, len)`. - pub fn alloc(status: WalStatus, req: AllocReq) -> Self { + /// An `Alloc` entry recording a freshly allocated slice `S = (ptr, len)`. + /// Recovery frees it iff the transaction is **abandoned** (the block is an + /// orphan of a crashed op); a committed transaction keeps it. + pub fn alloc(status: WalStatus, slice: BStackRange) -> Self { WalEntry { status: status as u8, op: WalOp::Alloc as u8, _pad: [0; 6], - word_a: req.id, - word_b: req.len, + word_a: slice.start(), + word_b: slice.len(), } } @@ -171,13 +179,10 @@ impl WalEntry { self.status = status as u8; } - /// The recorded `R'`, if this is an `Alloc` entry. - pub fn as_alloc(&self) -> Option { + /// The recorded slice `S`, if this is an `Alloc` entry (to be freed on abandon). + pub fn as_alloc(&self) -> Option { match self.op() { - WalOp::Alloc => Some(AllocReq { - id: self.word_a, - len: self.word_b, - }), + WalOp::Alloc => Some(BStackRange::new(self.word_a, self.word_b)), WalOp::Dealloc => None, } } @@ -345,6 +350,39 @@ pub unsafe trait BStackWalAnchor: BStackOwnedSliceAllocator { fn wal_anchor(&self) -> u64; } +/// Anchor offset for the bstack-provided freeing allocators: the second `u64` +/// word of the user-reserved region every one of them keeps at payload offset 0 +/// and never hands out (FirstFit reserves 16 B there, GhostTree 32 B, Slab and +/// CheckedSlab 24 B — all ≥ 16). Payload offset 0 is left as `bstack_raii`'s null +/// niche, so the anchor is the *next* word, `[8, 16)`. +pub const STD_WAL_ANCHOR: u64 = 8; + +// SAFETY: each of these allocators documents a user-reserved region at payload +// offset 0 (≥ 16 bytes) that it never allocates from and never writes to; the +// `[8, 16)` slot sits inside it and persists across open/close. `LinearBStack- +// Allocator` is intentionally excluded — its `dealloc` is a no-op, so there is +// nothing for the WAL to reclaim. +unsafe impl BStackWalAnchor for bstack::FirstFitBStackAllocator { + fn wal_anchor(&self) -> u64 { + STD_WAL_ANCHOR + } +} +unsafe impl BStackWalAnchor for bstack::GhostTreeBstackAllocator { + fn wal_anchor(&self) -> u64 { + STD_WAL_ANCHOR + } +} +unsafe impl BStackWalAnchor for bstack::SlabBStackAllocator { + fn wal_anchor(&self) -> u64 { + STD_WAL_ANCHOR + } +} +unsafe impl BStackWalAnchor for bstack::CheckedSlabBStackAllocator { + fn wal_anchor(&self) -> u64 { + STD_WAL_ANCHOR + } +} + /// Write `log` as a WAL block with transaction status `txn_status`, allocate the /// block, and point the anchor slot at it. Returns the block's range. pub fn persist_at( @@ -395,39 +433,46 @@ fn load_at( } /// **Complete** a crash-left transaction referenced by the anchor slot at -/// `anchor`, or abandon it. +/// `anchor` by reclaiming exactly the slices the transaction's outcome orphaned. /// -/// * **Committed** (`txn_status == Complete`): roll forward — for each still- -/// `Pending` `Dealloc`, persist its entry `Complete` **then** free the slice -/// (so a re-`finish` after another crash skips it — no double-free). -/// * **Uncommitted** (`txn_status == Pending`): abandon — free nothing (the old -/// slices stay; any new allocations leak, since an `Alloc` records only `R'`). +/// * **Committed** (`txn_status == Complete`): roll forward — free each still- +/// `Pending` `Dealloc` (the old blocks the committed op unlinked). +/// * **Uncommitted** (`txn_status == Pending`): abandon — free each still- +/// `Pending` `Alloc` (the new blocks the crashed op allocated but never linked). /// -/// Either way the WAL block is then cleared (anchor `:= 0`) and freed. Returns -/// the number of deallocations completed. This is what a caller runs after -/// `open` — a *completion*, not a leaky recovery. +/// Each freed entry is persisted `Complete` **before** its slice is freed, so a +/// second crash mid-completion never double-frees. Either way the WAL block is +/// then cleared (anchor `:= 0`) and freed. Returns the number of slices +/// reclaimed. This is what a caller runs once after `open` — a *completion*, not +/// a leaky recovery. pub fn finish_at(allocator: &A, anchor: u64) -> io::Result { let (wal_range, header, entries) = match load_at(allocator, anchor)? { Some(x) => x, None => return Ok(0), }; let stack = allocator.stack(); + let committed = header.txn_status() == WalStatus::Complete; + let base = wal_range.start() + size_of::() as u64; let mut completed = 0usize; - if header.txn_status() == WalStatus::Complete { - let base = wal_range.start() + size_of::() as u64; - for (i, e) in entries.iter().enumerate() { - // `as_dealloc` is `Some` only for a `Dealloc` entry. - if e.status() == WalStatus::Pending - && let Some(slice) = e.as_dealloc() - { - // Persist Complete for this entry (its status is byte 0), THEN - // free — so a second crash can't double-free it. - let entry_off = base + (i * size_of::()) as u64; - stack.set(entry_off, [WalStatus::Complete as u8])?; - unsafe { dealloc_range(allocator, slice)? }; - completed += 1; - } + for (i, e) in entries.iter().enumerate() { + if e.status() != WalStatus::Pending { + continue; + } + // Committed: the `Dealloc`s (old blocks) must go. Abandoned: the `Alloc`s + // (new orphans) must go. Everything else is kept. + let slice = if committed { + e.as_dealloc() + } else { + e.as_alloc() + }; + if let Some(slice) = slice { + // Persist Complete for this entry (its status is byte 0), THEN free — + // so a second crash can't double-free it. + let entry_off = base + (i * size_of::()) as u64; + stack.set(entry_off, [WalStatus::Complete as u8])?; + unsafe { dealloc_range(allocator, slice)? }; + completed += 1; } } @@ -461,10 +506,10 @@ mod tests { #[test] fn wal_entry_roundtrip() { - let a = WalEntry::alloc(WalStatus::Pending, AllocReq { id: 7, len: 256 }); + let a = WalEntry::alloc(WalStatus::Pending, BStackRange::new(0x1000, 256)); assert_eq!(a.op(), WalOp::Alloc); assert_eq!(a.status(), WalStatus::Pending); - assert_eq!(a.as_alloc(), Some(AllocReq { id: 7, len: 256 })); + assert_eq!(a.as_alloc(), Some(BStackRange::new(0x1000, 256))); assert_eq!(a.as_dealloc(), None); let d = WalEntry::dealloc(WalStatus::Complete, BStackRange::new(0x6CD4, 256)); @@ -479,7 +524,7 @@ mod tests { let mut log = WalLog::with_capacity(2); log.append(WalEntry::alloc( WalStatus::Pending, - AllocReq { id: 0, len: 64 }, + BStackRange::new(8192, 64), )); log.append(WalEntry::dealloc( WalStatus::Pending, @@ -488,7 +533,7 @@ mod tests { let bytes = log.as_bytes().to_vec(); let back = WalLog::entries_from_bytes(&bytes); assert_eq!(back.len(), 2); - assert_eq!(back[0].as_alloc(), Some(AllocReq { id: 0, len: 64 })); + assert_eq!(back[0].as_alloc(), Some(BStackRange::new(8192, 64))); assert_eq!(back[1].as_dealloc(), Some(BStackRange::new(4096, 64))); } From f521f460fdd59b7f579c566988b985cf2ebe5ad8 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 8 Aug 2026 20:45:22 -0700 Subject: [PATCH 091/140] Add WAL cloning wiring --- bstack_raii/Cargo.toml | 6 ++++ bstack_raii/src/clone.rs | 78 +++++++++++++++++++++++++++++++++++++--- bstack_raii/src/tests.rs | 56 +++++++++++++++++++++++++++++ bstack_raii/src/wal.rs | 27 ++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) diff --git a/bstack_raii/Cargo.toml b/bstack_raii/Cargo.toml index 9251d3e..4c57cdb 100644 --- a/bstack_raii/Cargo.toml +++ b/bstack_raii/Cargo.toml @@ -15,6 +15,12 @@ publish = false [workspace] members = ["derive"] +[features] +# Dev/test only: forwards bstack's fault-injection so crash-recovery tests can arm +# a fault at a chosen `BStack` op (e.g. the clone commit's `inplace_gen`). Off by +# default; enable with `--features fault-injection`. Also requires a debug build. +fault-injection = ["bstack/fault-injection"] + [dependencies] # RAII requires `alloc` + `set`; `atomic` is needed for the on-disk refcount RMW # (control blocks, strong/weak counters). diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 3582f62..29a3fc0 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -45,6 +45,9 @@ use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; use crate::vec::{BYTEVEC_HEADER, VecDesc}; +use crate::wal::{ + WalAnchorProbe, WalAnchorProbeFallback, WalEntry, WalLog, WalStatus, finish_at, persist_at, +}; /// Duplicate `self`, performing any fallible I/O the duplication requires, /// **without** needing an allocator. @@ -219,6 +222,29 @@ impl ClonePlan { } = self; let stack = allocator.stack(); + // If the allocator provides a WAL anchor, protect this clone's fresh + // allocations: log them `Pending` before the commit, so a crash mid-commit + // is reclaimed by `finish` on the next open. The transaction is flipped + // `Complete` *inside* the commit batch below, so "clone committed" and + // "WAL Complete" are the same atomic event. + let wal: Option<(u64, BStackRange)> = match allocator.wal_anchor_opt() { + Some(anchor) if !allocated.is_empty() => { + let mut log = WalLog::with_capacity(allocated.len()); + for &r in &allocated { + log.append(WalEntry::alloc(WalStatus::Pending, r)); + } + match persist_at(allocator, anchor, &log, WalStatus::Pending) { + Ok(range) => Some((anchor, range)), + Err(e) => { + // Couldn't stage the WAL — fall back to an immediate rollback. + Self::free_all(allocated, allocator); + return Err(e); + } + } + } + _ => None, + }; + // De-duplicate bump offsets into distinct `(counter, delta)`. bumps.sort_unstable(); let mut counters: Vec<(u64, u64)> = Vec::new(); @@ -237,6 +263,12 @@ impl ClonePlan { let mut newbufs: Vec<[u8; 8]> = vec![[0u8; 8]; n]; let mut overflow = false; + // The WAL commit marker (`WalHeader.txn_status`, the byte after the u64 + // magic). Written last, so it lands in the same atomic batch as the clone. + let flip = [WalStatus::Complete as u8]; + let flip_off = wal.map(|(_, r)| r.start() + 8); + let mut flipped = flip_off.is_none(); + let mut read_i = 0usize; let mut computed = false; let mut cwrite_i = 0usize; @@ -292,27 +324,63 @@ impl ClonePlan { let data: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; return Some(BStackGenOp::Write { offset: off, data }); } + // Phase 2c — flip the WAL transaction to `Complete`, last, so it commits + // atomically with the clone's writes (a no-op when there is no WAL). + if !flipped { + flipped = true; + // SAFETY: `flip` outlives this call and is only read here. + let data: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(&flip[..]) }; + return Some(BStackGenOp::Write { + offset: flip_off.unwrap(), + data, + }); + } None }); match result { Ok(()) if overflow => { - Self::free_all(allocated, allocator); + Self::reclaim(allocated, wal, allocator); Err(io::Error::new( io::ErrorKind::InvalidData, "refcount overflow while committing clone", )) } - Ok(()) => Ok(()), - // `inplace_gen` is atomic: on error nothing committed, so just free - // the plan's allocations (no bumps to undo — none were applied). + Ok(()) => { + // Committed — the WAL was flipped `Complete` in the same batch. + // Best-effort cleanup (a crash here is finished on the next open). + if let Some((anchor, wal_range)) = wal { + let _ = stack.set(anchor, 0u64.to_le_bytes()); + let _ = unsafe { dealloc_range(allocator, wal_range) }; + } + Ok(()) + } + // `inplace_gen` is atomic: on error nothing committed. Reclaim the + // plan's allocations (via the WAL if present, else directly). Err(e) => { - Self::free_all(allocated, allocator); + Self::reclaim(allocated, wal, allocator); Err(e) } } } + /// Reclaim a failed commit's allocations. With a WAL, `finish_at` abandons the + /// still-`Pending` transaction — freeing the logged alloc orphans *and* the WAL + /// block — matching exactly what `finish` would do after a real crash; without + /// one, free them directly. + fn reclaim( + allocated: Vec, + wal: Option<(u64, BStackRange)>, + allocator: &A, + ) { + match wal { + Some((anchor, _)) => { + let _ = finish_at(allocator, anchor); + } + None => Self::free_all(allocated, allocator), + } + } + fn free_all(allocated: Vec, allocator: &A) { for r in allocated.into_iter().rev() { // SAFETY: each range was returned by our own `alloc_raw` and never diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 88f898e..02153d0 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6289,3 +6289,59 @@ fn stdlib_tree_entry() { tree.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// WAL: clone commit is crash-atomic — a crash mid-commit reclaims the orphans +// (uses bstack's fault injection; requires --features fault-injection + debug) +// -------------------------------------------------------------------------- + +#[cfg(feature = "fault-injection")] +#[test] +fn wal_clone_reclaims_orphans_on_commit_fault() { + use bstack::fault::FaultPolicy; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + // Fail the first `inplace_gen` (the clone commit) exactly once; a real crash + // there would run no rollback, leaving the clone's fresh blocks orphaned. + struct FailFirstInplaceGen(AtomicBool); + impl FaultPolicy for FailFirstInplaceGen { + fn next_fault(&self, op: &'static str, _seq: u64) -> Option { + if op == "inplace_gen" && !self.0.swap(true, Ordering::SeqCst) { + Some(io::Error::other("injected clone-commit fault")) + } else { + None + } + } + } + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); // FirstFit is a BStackWalAnchor + let stack = alloc.stack(); + + // Source owns a child, so each deep clone allocates two blocks (+ a WAL block). + let leaf = MacroLeaf::new(&alloc, 7).unwrap(); + let src = MacroParent::new(&alloc, leaf, 1).unwrap(); + + // Repeatedly crash the clone commit. If the orphans (and WAL block) were + // leaked, the committed length would climb every iteration; WAL reclamation + // frees them back to the free list, so growth flattens once it's warm. + let mut prev: Option = None; + for i in 0..30 { + stack.set_fault_policy(Some(Arc::new(FailFirstInplaceGen(AtomicBool::new(false))))); + let r = src.try_clone_in(&alloc); + stack.set_fault_policy(None); + assert!(r.is_err(), "injected fault must fail the clone commit"); + let len = stack.len().unwrap(); + if i >= 3 { + assert_eq!(len, prev.unwrap(), "faulted clone leaked at iter {i}"); + } + prev = Some(len); + } + + // Source intact; a real (unfaulted) clone still succeeds, reusing the space. + let cl = src.try_clone_in(&alloc).unwrap(); + assert_eq!(cl.handle().child(stack).unwrap().val(stack).unwrap(), 7); + cl.bstack_drop(&alloc).unwrap(); + src.bstack_drop(&alloc).unwrap(); +} diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 0b12ec3..0ab477b 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -383,6 +383,33 @@ unsafe impl BStackWalAnchor for bstack::CheckedSlabBStackAllocator { } } +// -- Autoref-specialization anchor probe ----------------------------------- +// +// A compound op is generic over `A: BStackOwnedSliceAllocator`, but the WAL needs +// `A: BStackWalAnchor`. These two traits, resolved by autoref (the "dtolnay +// specialization" trick), let such an op ask `allocator.wal_anchor_opt()` and get +// `Some(anchor)` when `A` is anchored, `None` otherwise — opting into the WAL with +// no bound change and no break for plain allocators. Both must be in scope at the +// call site; the value-receiver impl wins (fewer autorefs) when it applies. + +pub(crate) trait WalAnchorProbe { + fn wal_anchor_opt(&self) -> Option; +} +impl WalAnchorProbe for A { + fn wal_anchor_opt(&self) -> Option { + Some(self.wal_anchor()) + } +} + +pub(crate) trait WalAnchorProbeFallback { + fn wal_anchor_opt(&self) -> Option; +} +impl WalAnchorProbeFallback for &A { + fn wal_anchor_opt(&self) -> Option { + None + } +} + /// Write `log` as a WAL block with transaction status `txn_status`, allocate the /// block, and point the anchor slot at it. Returns the block's range. pub fn persist_at( From ae86369f4c4f6a682b084ec90d6b61868b48099b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sat, 8 Aug 2026 21:38:15 -0700 Subject: [PATCH 092/140] Teardown --- bstack_raii/src/clone.rs | 57 ++++++++++++++++++---- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/owned.rs | 3 +- bstack_raii/src/teardown.rs | 94 ++++++++++++++++++++++++++++++++++++- bstack_raii/src/tests.rs | 74 ++++++++++++++++++++++++++++- bstack_raii/src/wal.rs | 33 +++---------- 6 files changed, 224 insertions(+), 41 deletions(-) diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 29a3fc0..d57a414 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -39,15 +39,13 @@ use std::io; use bstack::{BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; -use crate::block::BStackShared; +use crate::block::{BStackBlock, BStackShared}; use crate::layout; use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; use crate::vec::{BYTEVEC_HEADER, VecDesc}; -use crate::wal::{ - WalAnchorProbe, WalAnchorProbeFallback, WalEntry, WalLog, WalStatus, finish_at, persist_at, -}; +use crate::wal::{BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at, persist_at}; /// Duplicate `self`, performing any fallible I/O the duplication requires, /// **without** needing an allocator. @@ -215,6 +213,23 @@ impl ClonePlan { /// bump's pending write, so an overflow can be detected after the reads — /// before any write is emitted — and abort with nothing committed. pub fn commit(self, allocator: &A) -> io::Result<()> { + self.commit_inner(allocator, None) + } + + /// Like [`commit`](Self::commit) but crash-atomic against orphan leaks: the + /// plan's allocations are logged to the WAL and reclaimed by `finish` on the + /// next open if the process dies mid-commit. Requires an anchored allocator; + /// used by [`wal_clone_in`]. + pub fn commit_wal(self, allocator: &A) -> io::Result<()> { + let anchor = allocator.wal_anchor(); + self.commit_inner(allocator, Some(anchor)) + } + + fn commit_inner( + self, + allocator: &A, + anchor: Option, + ) -> io::Result<()> { let ClonePlan { allocated, writes, @@ -222,12 +237,12 @@ impl ClonePlan { } = self; let stack = allocator.stack(); - // If the allocator provides a WAL anchor, protect this clone's fresh - // allocations: log them `Pending` before the commit, so a crash mid-commit - // is reclaimed by `finish` on the next open. The transaction is flipped - // `Complete` *inside* the commit batch below, so "clone committed" and - // "WAL Complete" are the same atomic event. - let wal: Option<(u64, BStackRange)> = match allocator.wal_anchor_opt() { + // With a WAL anchor, protect this clone's fresh allocations: log them + // `Pending` before the commit, so a crash mid-commit is reclaimed by + // `finish` on the next open. The transaction is flipped `Complete` *inside* + // the commit batch below, so "clone committed" and "WAL Complete" are the + // same atomic event. + let wal: Option<(u64, BStackRange)> = match anchor { Some(anchor) if !allocated.is_empty() => { let mut log = WalLog::with_capacity(allocated.len()); for &r in &allocated { @@ -389,3 +404,25 @@ impl ClonePlan { } } } + +/// Deep-clone `src` into a fresh owned copy, WAL-protected: if the process crashes +/// mid-commit, the clone's orphaned allocations are reclaimed by +/// [`crate::wal::finish`] on the next open (whereas the plain +/// [`TryCloneIn::try_clone_in`] leaks them until then). Opt-in — requires an +/// anchored allocator ([`BStackWalAnchor`]). +pub fn wal_clone_in( + src: &T, + allocator: &A, +) -> io::Result> { + let mut plan = ClonePlan::new(); + let dst = match src.__bstack_clone_into(allocator, &mut plan) { + Ok(range) => range, + Err(e) => { + plan.rollback(allocator); + return Err(e); + } + }; + plan.commit_wal(allocator)?; + // SAFETY: `dst` is a fresh block owned by nobody else. + Ok(unsafe { BStackOwned::from_raw(T::from_range(dst)) }) +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 958801e..d1faced 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -72,7 +72,7 @@ pub use block::{ }; pub use bulk::{alloc_many, free_many}; pub use cast::{BStackCastAs, BStackCastInto}; -pub use clone::{ClonePlan, TryClone, TryCloneIn}; +pub use clone::{ClonePlan, TryClone, TryCloneIn, wal_clone_in}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; @@ -88,7 +88,7 @@ pub use stdlib::{ HashSetIter, HashSetOnDisk, HeapOnDisk, ListIter, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, TreeOnDisk, TreeSetOnDisk, }; -pub use teardown::{AutoDrop, BStackDrop, dealloc_range}; +pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_drop}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ AllocReq, BStackWalAnchor, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index 8df388e..ec276c2 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -69,7 +69,8 @@ impl Deref for BStackOwned { impl BStackDrop for BStackOwned { fn bstack_drop(self, allocator: &A) -> io::Result<()> { // Recursively free the owned block (and its children) via the inner - // handle's teardown. + // handle's teardown. For crash-atomic, leak-reclaiming teardown on an + // anchored allocator, use [`crate::wal_drop`] instead. self.0.bstack_drop(allocator) } } diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index d882a3a..a3dbadb 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -5,11 +5,91 @@ //! types in [`crate::handle`]. It takes `self` (a *without-allocator* handle) //! plus an explicit allocator, so it is generic over all handle-like types. +use core::cell::RefCell; use core::mem::ManuallyDrop; use core::ops::Deref; use std::io; -use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStackGenOp, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; + +use crate::wal::{BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at, persist_at}; + +thread_local! { + /// While a WAL-backed teardown is in progress, the collector that + /// [`dealloc_range`] funnels every subtree slice into *instead of* freeing it + /// eagerly. The root driver ([`wal_teardown`]) installs it; the generated + /// recursion and nested handle `bstack_drop`s see it transparently (they all + /// go through `dealloc_range`), so no allocator/sink parameter has to be + /// threaded through the whole teardown. + static TEARDOWN_SINK: RefCell>> = const { RefCell::new(None) }; +} + +/// Tear down `handle` (a whole owned subtree) as one crash-atomic batch of frees, +/// so a crash mid-teardown is completed — not leaked — by `finish` on the next +/// open. The WAL-backed, opt-in counterpart to [`BStackDrop::bstack_drop`]; +/// requires an anchored allocator ([`BStackWalAnchor`]). +/// +/// While the sink is installed, every [`dealloc_range`] in the (ordinary, +/// generic) teardown recursion *collects* its slice rather than freeing it; +/// afterwards the whole set commits as one `Dealloc` transaction and is executed +/// via [`finish_at`] (the same path crash recovery takes). Nested owned frees +/// (e.g. a collection freeing its values through `BStackOwned::bstack_drop`) see +/// the sink already set and just collect, so exactly one transaction wraps the +/// outermost teardown. +pub fn wal_drop(handle: T, allocator: &A) -> io::Result<()> { + // Nested (shouldn't happen at a public entry, but be safe): the outer driver + // owns the sink; frees already collect. + if TEARDOWN_SINK.with(|s| s.borrow().is_some()) { + return handle.bstack_drop(allocator); + } + let anchor = allocator.wal_anchor(); + TEARDOWN_SINK.with(|s| *s.borrow_mut() = Some(Vec::new())); + let result = handle.bstack_drop(allocator); + let slices = TEARDOWN_SINK + .with(|s| s.borrow_mut().take()) + .unwrap_or_default(); + wal_free_all(allocator, anchor, slices)?; + result +} + +/// Commit `slices` as one committed `Dealloc` transaction and execute the frees. +/// A crash mid-free leaves a `Complete` WAL that `finish` rolls forward on reopen. +fn wal_free_all( + allocator: &A, + anchor: u64, + slices: Vec, +) -> io::Result<()> { + if slices.is_empty() { + return Ok(()); + } + let mut log = WalLog::with_capacity(slices.len()); + for s in &slices { + log.append(WalEntry::dealloc(WalStatus::Pending, *s)); + } + // Stage the transaction `Pending`, then commit it by flipping `txn_status` to + // `Complete` in one atomic `inplace_gen` — the single commit point (a crash + // before it abandons; after it, `finish` rolls the frees forward). With the + // sink now cleared, `finish_at` executes the frees + reclaims the WAL block. + let wal_range = persist_at(allocator, anchor, &log, WalStatus::Pending)?; + let flip = [WalStatus::Complete as u8]; + let mut done = false; + allocator.stack().inplace_gen(|_feedback| { + if done { + None + } else { + done = true; + // SAFETY: `flip` outlives the call; the `txn_status` byte follows the + // u64 magic at offset 8 in `WalHeader`. + let data: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(&flip[..]) }; + Some(BStackGenOp::Write { + offset: wal_range.start() + 8, + data, + }) + } + })?; + finish_at(allocator, anchor)?; + Ok(()) +} /// Recursively free a block and all of its owned children. /// @@ -37,6 +117,18 @@ pub unsafe fn dealloc_range( allocator: &A, range: BStackRange, ) -> io::Result<()> { + // Inside a WAL-backed teardown, defer the free: collect the slice so the whole + // subtree commits (and frees) as one crash-atomic transaction. + let deferred = TEARDOWN_SINK.with(|s| match s.borrow_mut().as_mut() { + Some(sink) => { + sink.push(range); + true + } + None => false, + }); + if deferred { + return Ok(()); + } let owned: BStackOwnedSlice<'_, A> = unsafe { BStackOwnedSlice::from_raw_range(allocator, range) }; allocator.dealloc(owned).map_err(|e| e.source) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 02153d0..32753b1 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6329,7 +6329,7 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { let mut prev: Option = None; for i in 0..30 { stack.set_fault_policy(Some(Arc::new(FailFirstInplaceGen(AtomicBool::new(false))))); - let r = src.try_clone_in(&alloc); + let r = crate::wal_clone_in(src.handle(), &alloc); stack.set_fault_policy(None); assert!(r.is_err(), "injected fault must fail the clone commit"); let len = stack.len().unwrap(); @@ -6345,3 +6345,75 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { cl.bstack_drop(&alloc).unwrap(); src.bstack_drop(&alloc).unwrap(); } + +#[cfg(feature = "fault-injection")] +#[test] +fn wal_teardown_reclaims_on_free_fault() { + use bstack::fault::FaultPolicy; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + // The teardown WAL commits by flipping `txn_status` in one `inplace_gen`; the + // very next `set` is `finish_at`'s first entry-status write — just past the + // commit point, before any block is actually freed. Failing *that* set models + // a crash mid-teardown with the transaction already committed (so `finish` + // must roll every dealloc forward, with no half-freed block). + struct FailSetAfterCommit { + committed: AtomicBool, + fired: AtomicBool, + } + impl FaultPolicy for FailSetAfterCommit { + fn next_fault(&self, op: &'static str, _seq: u64) -> Option { + if op == "inplace_gen" { + self.committed.store(true, Ordering::SeqCst); + return None; + } + if op == "set" + && self.committed.load(Ordering::SeqCst) + && !self.fired.swap(true, Ordering::SeqCst) + { + return Some(io::Error::other("injected teardown free fault")); + } + None + } + } + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let build = |a: &_| { + let list = BStackLinkedList::::new(a).unwrap(); + for v in 0..5u32 { + list.push_back(a, MacroLeaf::new(a, v).unwrap()).unwrap(); + } + list + }; + + let list1 = build(&alloc); + let peak = stack.len().unwrap(); + + // Crash the teardown just after its WAL commits; nothing gets freed inline. + stack.set_fault_policy(Some(Arc::new(FailSetAfterCommit { + committed: AtomicBool::new(false), + fired: AtomicBool::new(false), + }))); + let r = crate::wal_drop(list1, &alloc); + stack.set_fault_policy(None); + assert!(r.is_err(), "the injected fault must interrupt the teardown"); + + // `finish` rolls the committed teardown forward, reclaiming the whole subtree. + assert!( + crate::wal::finish(&alloc).unwrap() > 0, + "finish should reclaim the committed teardown's slices" + ); + + // Reclaimed: rebuilding an identical tree reuses the freed space (no leak). + let list2 = build(&alloc); + let after = stack.len().unwrap(); + assert!( + after <= peak, + "teardown crash leaked: file grew {peak} -> {after}" + ); + list2.bstack_drop(&alloc).unwrap(); +} diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 0ab477b..d185a8c 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -383,32 +383,13 @@ unsafe impl BStackWalAnchor for bstack::CheckedSlabBStackAllocator { } } -// -- Autoref-specialization anchor probe ----------------------------------- -// -// A compound op is generic over `A: BStackOwnedSliceAllocator`, but the WAL needs -// `A: BStackWalAnchor`. These two traits, resolved by autoref (the "dtolnay -// specialization" trick), let such an op ask `allocator.wal_anchor_opt()` and get -// `Some(anchor)` when `A` is anchored, `None` otherwise — opting into the WAL with -// no bound change and no break for plain allocators. Both must be in scope at the -// call site; the value-receiver impl wins (fewer autorefs) when it applies. - -pub(crate) trait WalAnchorProbe { - fn wal_anchor_opt(&self) -> Option; -} -impl WalAnchorProbe for A { - fn wal_anchor_opt(&self) -> Option { - Some(self.wal_anchor()) - } -} - -pub(crate) trait WalAnchorProbeFallback { - fn wal_anchor_opt(&self) -> Option; -} -impl WalAnchorProbeFallback for &A { - fn wal_anchor_opt(&self) -> Option { - None - } -} +// Note: WAL reclamation is **opt-in**, not automatic. A generic op bounded on +// `BStackOwnedSliceAllocator` cannot detect at that call whether the concrete `A` +// also implements `BStackWalAnchor` — autoref "specialization" only resolves at a +// concrete call site, and stable Rust has no real specialization. So the WAL is +// exposed through explicit entry points ([`crate::wal_clone_in`], [`crate::wal_drop`]) +// that take a concrete `A: BStackWalAnchor` and call [`wal_anchor`](BStackWalAnchor::wal_anchor) +// directly; the plain generic `try_clone_in` / `bstack_drop` are unchanged. /// Write `log` as a WAL block with transaction status `txn_status`, allocate the /// block, and point the anchor slot at it. Returns the block's range. From 69352828860ef0f45a162d59e2a14ad899f42ec0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 00:54:18 -0700 Subject: [PATCH 093/140] WAL enhancement --- bstack_raii/src/wal.rs | 265 +++++++++++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 65 deletions(-) diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index d185a8c..d495c40 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -49,7 +49,9 @@ //! completion never double-frees; no separate cursor is needed. use core::mem::size_of; +use std::collections::HashMap; use std::io; +use std::sync::{Arc, Mutex, OnceLock}; use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; @@ -293,17 +295,32 @@ pub fn reduce(allocs: Vec, mut deallocs: Vec) -> Reduced // --------------------------------------------------------------------------- // On-disk WAL block + completion runtime. // -// A WAL block is `[WalHeader | WalEntry × count]`, allocated from the allocator -// and reached through a stable anchor slot (see [`BStackWalAnchor`]) that holds -// the block's offset (`0` = none). The header's `txn_status` is the -// transaction-level commit marker: `Complete` = committed (roll forward on the -// next open), `Pending` = uncommitted (abandon). +// The WAL block is **persistent and reused** (Vec-like), not allocated per +// transaction: `[WalHeader | WalEntry × capacity]`, reached through a stable +// anchor slot (see [`BStackWalAnchor`]) that holds the block's offset (`0` = not +// yet created — lazily allocated on first use). The header's `txn_status` doubles +// as the in-use flag: +// +// * `None` — idle: no transaction in flight (the block is free to reuse); +// * `Pending` — a transaction is staged but not committed (abandon on recover); +// * `Complete` — committed, deallocs may be unfinished (roll forward on recover). +// +// A transaction reuses the block in place (growing it — free old, alloc bigger — +// only when it needs more than `capacity` entries), sets its status back to +// `None` when done, and never frees the block. Concurrent transactions on the +// same file are serialized by an in-memory mutex ([`wal_lock_for`]); the on-disk +// `txn_status` is purely for crash recovery, not live mutual exclusion. // --------------------------------------------------------------------------- const WAL_MAGIC: u64 = 0x6273_7461_636b_5741; // "bstackWA" -/// On-disk header of a WAL block. `txn_status` is the transaction-level commit -/// marker (`Pending` = uncommitted, `Complete` = committed). +/// Minimum entry capacity of the persistent WAL block; larger transactions grow +/// it to the next power of two. +const WAL_MIN_CAP: u64 = 8; + +/// On-disk header of the persistent WAL block. `txn_status` is both the +/// transaction-level commit marker and the idle/in-use flag (`None` = idle); +/// `capacity` is the number of [`WalEntry`] slots the block was allocated for. #[derive(Clone, Copy, Debug, Pod, Zeroable)] #[repr(C)] pub struct WalHeader { @@ -311,6 +328,7 @@ pub struct WalHeader { txn_status: u8, _pad: [u8; 7], count: u64, + capacity: u64, } impl WalHeader { @@ -320,14 +338,16 @@ impl WalHeader { } impl WalLog { - /// The full WAL-block image `[WalHeader | entries]` at the given - /// transaction-level status, ready to write to an allocated block. - pub fn block_image(&self, txn_status: WalStatus) -> Vec { + /// The used prefix image `[WalHeader | entries]` at the given transaction + /// status, for a block allocated with `capacity` entry slots. Only the header + /// and the `count` live entries are written; the spare capacity is left as-is. + pub fn block_image(&self, txn_status: WalStatus, capacity: u64) -> Vec { let header = WalHeader { magic: WAL_MAGIC, txn_status: txn_status as u8, _pad: [0; 7], count: self.entries.len() as u64, + capacity, }; let mut img = Vec::with_capacity(size_of::() + self.entries.len() * size_of::()); @@ -337,17 +357,29 @@ impl WalLog { } } -/// A [`BStackOwnedSliceAllocator`] that can point `bstack_raii` at a stable -/// on-disk slot for its WAL block pointer. +/// The allocator capability the whole crate is bound on: a +/// [`BStackOwnedSliceAllocator`] that names a stable on-disk slot for the WAL +/// block pointer — or `None` to opt out of crash-reclamation. +/// +/// Making this the uniform bound is what lets `try_clone_in` / `bstack_drop` +/// reclaim orphaned allocations on the next open **automatically**, with no +/// separate opt-in call: they read [`wal_anchor`](Self::wal_anchor) directly. +/// `None` (the default) means "no reclamation" — the op behaves exactly as before. +/// Every bstack-provided allocator implements this; a custom allocator adds a +/// one-line `unsafe impl BStackWalAnchor for MyAlloc {}` (defaulting to `None`, +/// or returning `Some(slot)` if it reserves one). /// /// # Safety /// -/// The implementor asserts that `[wal_anchor(), wal_anchor() + 8)` is a stable, -/// persistent 8-byte region that the allocator **never** hands out via `alloc` +/// An implementor returning `Some(off)` asserts that `[off, off + 8)` is a +/// stable, persistent 8-byte region the allocator **never** hands out via `alloc` /// and **never** uses for its own metadata, and that survives across open/close. /// `bstack_raii` stores the current WAL block's offset there (`0` = none). +/// Returning `None` asserts nothing. pub unsafe trait BStackWalAnchor: BStackOwnedSliceAllocator { - fn wal_anchor(&self) -> u64; + fn wal_anchor(&self) -> Option { + None + } } /// Anchor offset for the bstack-provided freeing allocators: the second `u64` @@ -359,69 +391,141 @@ pub const STD_WAL_ANCHOR: u64 = 8; // SAFETY: each of these allocators documents a user-reserved region at payload // offset 0 (≥ 16 bytes) that it never allocates from and never writes to; the -// `[8, 16)` slot sits inside it and persists across open/close. `LinearBStack- -// Allocator` is intentionally excluded — its `dealloc` is a no-op, so there is -// nothing for the WAL to reclaim. +// `[8, 16)` slot sits inside it and persists across open/close. unsafe impl BStackWalAnchor for bstack::FirstFitBStackAllocator { - fn wal_anchor(&self) -> u64 { - STD_WAL_ANCHOR + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) } } unsafe impl BStackWalAnchor for bstack::GhostTreeBstackAllocator { - fn wal_anchor(&self) -> u64 { - STD_WAL_ANCHOR + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) } } unsafe impl BStackWalAnchor for bstack::SlabBStackAllocator { - fn wal_anchor(&self) -> u64 { - STD_WAL_ANCHOR + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) } } unsafe impl BStackWalAnchor for bstack::CheckedSlabBStackAllocator { - fn wal_anchor(&self) -> u64 { - STD_WAL_ANCHOR + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) } } +// `LinearBStackAllocator`'s `dealloc` is a no-op (nothing to reclaim), so it opts +// out via the default `None` — but it still needs the impl to satisfy the bound. +unsafe impl BStackWalAnchor for bstack::LinearBStackAllocator {} + +// --------------------------------------------------------------------------- +// In-memory serialization of WAL transactions. +// +// The persistent WAL block + anchor slot are single-writer per file: one +// transaction may be staged there at a time. Since automatic teardown/clone run +// concurrently, an in-memory mutex (keyed by the file's `BStack` identity) +// serializes the whole staging→commit→finish critical section. Different files +// use different locks and never contend. The lock is process-local, matching +// bstack's single-process write model; the on-disk `txn_status` handles the +// orthogonal job of crash recovery across a restart. +// --------------------------------------------------------------------------- + +/// Per-file WAL mutex registry, keyed by the address of the file's [`BStack`]. +static WAL_LOCKS: OnceLock>>>> = OnceLock::new(); + +/// The WAL mutex for `allocator`'s file (created on first use). Hold its guard +/// across a whole WAL transaction. +pub(crate) fn wal_lock_for(allocator: &A) -> Arc> { + let key = core::ptr::from_ref(allocator.stack()) as usize; + let reg = WAL_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = reg.lock().unwrap_or_else(|e| e.into_inner()); + map.entry(key) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} -// Note: WAL reclamation is **opt-in**, not automatic. A generic op bounded on -// `BStackOwnedSliceAllocator` cannot detect at that call whether the concrete `A` -// also implements `BStackWalAnchor` — autoref "specialization" only resolves at a -// concrete call site, and stable Rust has no real specialization. So the WAL is -// exposed through explicit entry points ([`crate::wal_clone_in`], [`crate::wal_drop`]) -// that take a concrete `A: BStackWalAnchor` and call [`wal_anchor`](BStackWalAnchor::wal_anchor) -// directly; the plain generic `try_clone_in` / `bstack_drop` are unchanged. +/// Read the anchor slot: the persistent WAL block's offset, or `None` if one has +/// not been created yet. +fn read_anchor(allocator: &A, anchor: u64) -> io::Result> { + let mut buf = [0u8; 8]; + allocator.stack().get_into(anchor, &mut buf)?; + let off = u64::from_le_bytes(buf); + Ok((off != 0).then_some(off)) +} -/// Write `log` as a WAL block with transaction status `txn_status`, allocate the -/// block, and point the anchor slot at it. Returns the block's range. +/// Ensure the persistent WAL block exists and holds at least `needed` entry +/// slots, returning `(block_offset, capacity)`. Lazily allocates it on first use; +/// grows it (free old, allocate a larger one — its contents are transient between +/// transactions) when a transaction needs more capacity. The header is +/// (re)initialized `None` (idle) whenever the block is created or grown. +fn wal_ensure_block( + allocator: &A, + anchor: u64, + needed: u64, +) -> io::Result<(u64, u64)> { + let stack = allocator.stack(); + let hsz = size_of::() as u64; + let esz = size_of::() as u64; + + if let Some(off) = read_anchor(allocator, anchor)? { + let mut hbuf = [0u8; size_of::()]; + stack.get_into(off, &mut hbuf)?; + let header: WalHeader = bytemuck::pod_read_unaligned(&hbuf); + if header.magic == WAL_MAGIC { + if header.capacity >= needed { + return Ok((off, header.capacity)); + } + // Too small: free the old block (its content is not needed across the + // grow) and fall through to allocate a bigger one. + let old = BStackRange::new(off, hsz + header.capacity * esz); + unsafe { dealloc_range(allocator, old)? }; + } + } + + let capacity = needed.max(WAL_MIN_CAP).next_power_of_two(); + let mut slice = allocator.alloc(hsz + capacity * esz)?; + let off = slice.as_range().start(); + let header = WalHeader { + magic: WAL_MAGIC, + txn_status: WalStatus::None as u8, + _pad: [0; 7], + count: 0, + capacity, + }; + if let Err(e) = slice.write_range(0, bytemuck::bytes_of(&header)) { + let _ = allocator.dealloc(slice); + return Err(e); + } + stack.set(anchor, off.to_le_bytes())?; + Ok((off, capacity)) +} + +/// Stage `log` into the persistent WAL block at transaction status `txn_status`, +/// (lazily) creating or growing the block as needed. Returns the block's range. +/// The caller must hold the file's WAL lock (see [`wal_lock_for`]). pub fn persist_at( allocator: &A, anchor: u64, log: &WalLog, txn_status: WalStatus, ) -> io::Result { - let image = log.block_image(txn_status); - let mut slice = allocator.alloc(image.len() as u64)?; - let range = slice.as_range(); - if let Err(e) = slice.write_range(0, &image) { - let _ = allocator.dealloc(slice); - return Err(e); - } - allocator.stack().set(anchor, range.start().to_le_bytes())?; - Ok(range) + let (off, capacity) = wal_ensure_block(allocator, anchor, log.entries().len() as u64)?; + let image = log.block_image(txn_status, capacity); + allocator.stack().set(off, &image)?; + let hsz = size_of::() as u64; + let esz = size_of::() as u64; + Ok(BStackRange::new(off, hsz + capacity * esz)) } -/// Read the WAL block referenced by the anchor slot, if any (and valid). +/// Read the staged transaction in the persistent WAL block, if any. Returns the +/// block range, header, and the `count` live entries; `None` if no block exists. fn load_at( allocator: &A, anchor: u64, ) -> io::Result)>> { let stack = allocator.stack(); - let mut buf = [0u8; 8]; - stack.get_into(anchor, &mut buf)?; - let wal_off = u64::from_le_bytes(buf); - if wal_off == 0 { - return Ok(None); - } + let wal_off = match read_anchor(allocator, anchor)? { + Some(off) => off, + None => return Ok(None), + }; let mut hbuf = [0u8; size_of::()]; stack.get_into(wal_off, &mut hbuf)?; let header: WalHeader = bytemuck::pod_read_unaligned(&hbuf); @@ -432,7 +536,7 @@ fn load_at( let mut ebuf = vec![0u8; ebytes]; stack.get_into(wal_off + size_of::() as u64, &mut ebuf)?; let entries = WalLog::entries_from_bytes(&ebuf); - let block_size = size_of::() as u64 + ebytes as u64; + let block_size = size_of::() as u64 + header.capacity * size_of::() as u64; Ok(Some(( BStackRange::new(wal_off, block_size), header, @@ -440,26 +544,43 @@ fn load_at( ))) } -/// **Complete** a crash-left transaction referenced by the anchor slot at -/// `anchor` by reclaiming exactly the slices the transaction's outcome orphaned. +/// Mark the persistent WAL block idle (`txn_status := None`) — a transaction is +/// complete and the block is free to reuse. The block itself is **not** freed. +pub(crate) fn wal_set_idle( + allocator: &A, + block_off: u64, +) -> io::Result<()> { + // `txn_status` is the byte right after the u64 magic (offset 8). + allocator.stack().set(block_off + 8, [WalStatus::None as u8]) +} + +/// **Complete** a staged transaction by reclaiming exactly the slices its outcome +/// orphaned, then marking the block idle. Assumes the file's WAL lock is already +/// held (used on the failure/recovery paths that run under the transaction lock). /// /// * **Committed** (`txn_status == Complete`): roll forward — free each still- /// `Pending` `Dealloc` (the old blocks the committed op unlinked). /// * **Uncommitted** (`txn_status == Pending`): abandon — free each still- -/// `Pending` `Alloc` (the new blocks the crashed op allocated but never linked). +/// `Pending` `Alloc` (the new blocks a crashed op allocated but never linked). /// /// Each freed entry is persisted `Complete` **before** its slice is freed, so a -/// second crash mid-completion never double-frees. Either way the WAL block is -/// then cleared (anchor `:= 0`) and freed. Returns the number of slices -/// reclaimed. This is what a caller runs once after `open` — a *completion*, not -/// a leaky recovery. -pub fn finish_at(allocator: &A, anchor: u64) -> io::Result { +/// second crash mid-completion never double-frees. The persistent block is then +/// marked idle (`None`) and kept for reuse. Returns the number of slices freed. +pub(crate) fn finish_at_locked( + allocator: &A, + anchor: u64, +) -> io::Result { let (wal_range, header, entries) = match load_at(allocator, anchor)? { Some(x) => x, None => return Ok(0), }; let stack = allocator.stack(); - let committed = header.txn_status() == WalStatus::Complete; + let txn = header.txn_status(); + if txn == WalStatus::None { + // Idle block, nothing staged. + return Ok(0); + } + let committed = txn == WalStatus::Complete; let base = wal_range.start() + size_of::() as u64; let mut completed = 0usize; @@ -484,15 +605,29 @@ pub fn finish_at(allocator: &A, anchor: u64) -> io } } - // Clear the anchor, then free the WAL block. - stack.set(anchor, 0u64.to_le_bytes())?; - unsafe { dealloc_range(allocator, wal_range)? }; + // Mark the persistent block idle (kept for reuse); do not free it. + wal_set_idle(allocator, wal_range.start())?; Ok(completed) } +/// **Complete** a crash-left transaction at `anchor`: reclaim the slices its +/// outcome orphaned and mark the persistent block idle. Returns the number of +/// slices reclaimed. This is what a caller runs once after `open` — a +/// *completion*, not a leaky recovery. Acquires the file's WAL lock. +pub fn finish_at(allocator: &A, anchor: u64) -> io::Result { + let lock = wal_lock_for(allocator); + let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + finish_at_locked(allocator, anchor) +} + /// Like [`finish_at`], using the allocator's own [`BStackWalAnchor`] slot. +/// An allocator that opts out of reclamation (`wal_anchor() == None`) has no WAL +/// to complete, so this is a no-op returning `0`. pub fn finish(allocator: &A) -> io::Result { - finish_at(allocator, allocator.wal_anchor()) + match allocator.wal_anchor() { + Some(anchor) => finish_at(allocator, anchor), + None => Ok(0), + } } #[cfg(test)] From 90d44737c6d1a1440e1e24dc96fb4a085ee24c29 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 00:55:05 -0700 Subject: [PATCH 094/140] Use WalAnchor --- bstack_raii/derive/src/block.rs | 76 +++++++++++++++--------------- bstack_raii/src/block.rs | 15 +++--- bstack_raii/src/stdlib/bloom.rs | 23 ++++----- bstack_raii/src/stdlib/boxed.rs | 13 ++--- bstack_raii/src/stdlib/btreeset.rs | 33 ++++++------- bstack_raii/src/stdlib/cow.rs | 9 ++-- bstack_raii/src/stdlib/deque.rs | 27 ++++++----- bstack_raii/src/stdlib/hashset.rs | 25 +++++----- bstack_raii/src/stdlib/heap.rs | 23 ++++----- bstack_raii/src/stdlib/list.rs | 21 +++++---- bstack_raii/src/stdlib/map.rs | 23 ++++----- bstack_raii/src/stdlib/string.rs | 25 +++++----- bstack_raii/src/stdlib/tree.rs | 35 +++++++------- bstack_raii/src/stdlib/util.rs | 7 +-- 14 files changed, 184 insertions(+), 171 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index dfd9edd..690de1e 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -458,7 +458,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ) }; accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -820,7 +820,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &acc_leaf, &acc_read); accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -1138,7 +1138,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let setter = format_ident!("set_{}", fname); setters.push(quote! { - #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #setter<'__s, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__s __A, index: usize, @@ -1160,7 +1160,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &leaf_ty, &acc_read); accessors.push(quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__u, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__u __A, ) -> ::std::io::Result<#acc_ret> { @@ -1759,14 +1759,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result Mode::Plain => quote!(), Mode::Rc => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; ::bstack_raii::StrongRef(data).bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, _allocator: &__A, ) -> ::std::io::Result<( @@ -1779,7 +1779,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }, Mode::RcWeak => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { @@ -1787,7 +1787,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::bstack_raii::StrongWeakRef::from_disk(data, allocator)? .bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<( @@ -1844,9 +1844,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // Implemented on the block type (local downstream) so the orphan rule // is satisfied; `bstack_move!` selects it from the argument's type. impl #impl_g ::bstack_raii::BStackMove for #name #ty_g #where_g { - type Fields<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator> = + type Fields<'__mv, __A: ::bstack_raii::BStackWalAnchor> = ( #(#mv_types,)* ); - fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn bstack_move<'__mv, __A: ::bstack_raii::BStackWalAnchor>( owned: ::bstack_raii::BStackOwned, __alloc: &'__mv __A, ) -> ::std::io::Result> { @@ -1920,7 +1920,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let clone_trait_methods = quote! { #[doc(hidden)] #[allow(unused_variables, unused_imports)] - fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -1932,7 +1932,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } #[doc(hidden)] #[allow(unused_variables)] - fn __bstack_clone_into<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn __bstack_clone_into<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -1943,7 +1943,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let clone_impl = if mode == Mode::Plain { quote! { impl #impl_g ::bstack_raii::TryCloneIn for #name #ty_g #where_g { - fn try_clone_in<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn try_clone_in<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { @@ -2071,7 +2071,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result /// `BStackBlock` default. #[doc(hidden)] #[allow(unused_imports)] - fn __bstack_drop_children<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn __bstack_drop_children<__A: ::bstack_raii::BStackWalAnchor>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { @@ -2089,7 +2089,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } impl #impl_g ::bstack_raii::BStackDrop for #name #ty_g #where_g { - fn bstack_drop<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn bstack_drop<__A: ::bstack_raii::BStackWalAnchor>( self, allocator: &__A, ) -> ::std::io::Result<()> { @@ -2350,7 +2350,7 @@ fn vec_accessor( let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2361,7 +2361,7 @@ fn vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { @@ -2538,7 +2538,7 @@ fn block_vec_accessor( let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2549,7 +2549,7 @@ fn block_vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { @@ -2779,7 +2779,7 @@ fn accessor( // Weak fields hold a control offset; the accessor attempts a live upgrade. if kind == Kind::Weak { return quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #fname<'__u, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__u __A, ) -> ::std::io::Result< @@ -3010,7 +3010,7 @@ fn weak_setter( ) -> TokenStream { let setter = format_ident!("set_{}", fname); quote! { - #vis fn #setter<'__s, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn #setter<'__s, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__s __A, weak: ::bstack_raii::BStackWeak<'__s, #fty, __A>, @@ -3084,7 +3084,7 @@ fn constructor( } }; quote! { - #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn new<'__ctor, __A: ::bstack_raii::BStackWalAnchor>( allocator: &'__ctor __A, #(#params)* ) -> ::std::io::Result<#ret> { @@ -3117,7 +3117,7 @@ fn constructor( ::core::mem::size_of::<::Control>() as u64 }; quote! { - #vis fn new<'__ctor, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn new<'__ctor, __A: ::bstack_raii::BStackWalAnchor>( allocator: &'__ctor __A, #(#params)* ) -> ::std::io::Result<::bstack_raii::BStackRc<'__ctor, Self, __A>> { @@ -5058,7 +5058,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = Vec::new(); if la { parts.push(lt.clone()); - parts.push(quote!(__A: ::bstack_raii::BStackOwnedSliceAllocator)); + parts.push(quote!(__A: ::bstack_raii::BStackWalAnchor)); } parts.extend(etp_decl.iter().cloned()); if parts.is_empty() { @@ -5134,7 +5134,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + #vis fn new<'__e, __A: ::bstack_raii::BStackWalAnchor>( allocator: &'__e __A, data: #data_ty, ) -> ::std::io::Result<#new_ret> { @@ -5166,7 +5166,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::Control>() as u64 }; quote! { - #vis fn new<'__e, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + #vis fn new<'__e, __A: ::bstack_raii::BStackWalAnchor>( allocator: &'__e __A, data: #data_ty, ) -> ::std::io::Result<::bstack_raii::BStackRc<'__e, Self, __A>> { @@ -5217,14 +5217,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote!(), Mode::Rc => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; ::bstack_raii::StrongRef(data).bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, _allocator: &__A, ) -> ::std::io::Result<( @@ -5237,7 +5237,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackOwnedSliceAllocator>( + fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5245,7 +5245,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<( @@ -5382,7 +5382,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn try_clone_in<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { @@ -5521,7 +5521,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -5535,7 +5535,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_clone_into<__A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -5549,7 +5549,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_drop_children<__A: ::bstack_raii::BStackWalAnchor>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5561,7 +5561,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn bstack_drop<__A: ::bstack_raii::BStackWalAnchor>( self, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5576,7 +5576,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + #vis fn read<'__e, __A: ::bstack_raii::BStackWalAnchor>( &self, allocator: &'__e __A, ) -> ::std::io::Result<#view_ty> { @@ -5614,8 +5614,8 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = #move_fields_ty; - fn bstack_move<'__mv, __A: ::bstack_raii::BStackOwnedSliceAllocator>( + type Fields<'__mv, __A: ::bstack_raii::BStackWalAnchor> = #move_fields_ty; + fn bstack_move<'__mv, __A: ::bstack_raii::BStackWalAnchor>( owned: ::bstack_raii::BStackOwned, __alloc: &'__mv __A, ) -> ::std::io::Result> { diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index a397e31..d7dcfb5 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -7,6 +7,7 @@ use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::Pod; use crate::clone::ClonePlan; @@ -56,7 +57,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// nothing. Exposed on the trait — rather than as a generated inherent method — /// so a generic parent can recurse into a type parameter. `#[doc(hidden)]`. #[doc(hidden)] - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -73,7 +74,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// rather than as a generated inherent method — so a generic parent can /// recurse into a type parameter's clone. `#[doc(hidden)]`: an impl detail. #[doc(hidden)] - fn __bstack_clone_children_inplace( + fn __bstack_clone_children_inplace( &self, allocator: &A, _plan: &mut ClonePlan, @@ -90,7 +91,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// caller). Overridden by the generated impl; the default suffices for a /// childless block. `#[doc(hidden)]`: an impl detail. #[doc(hidden)] - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -117,8 +118,8 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// allocator, since neither the owned handle nor the block type carries one. pub trait BStackMove: BStackBlock { /// The tuple of field handles produced, in field-declaration order. - type Fields<'a, A: BStackOwnedSliceAllocator>; - fn bstack_move<'a, A: BStackOwnedSliceAllocator>( + type Fields<'a, A: BStackWalAnchor>; + fn bstack_move<'a, A: BStackWalAnchor>( owned: BStackOwned, allocator: &'a A, ) -> io::Result>; @@ -157,7 +158,7 @@ pub trait BStackShared: BStackBlock { /// Drop one strong reference to a block of this type located at `data`, /// freeing it (and, for `(rc, weak)`, releasing the control block) when the /// strong count reaches zero. - fn drop_strong_ref( + fn drop_strong_ref( data: BStackRef, allocator: &A, ) -> io::Result<()>; @@ -166,7 +167,7 @@ pub trait BStackShared: BStackBlock { /// `data`: the data ref, plus the control-block range for `(rc, weak)` /// blocks (`None` for plain `(rc)`). Used by `bstack_move!` to rebuild a /// `BStackRc` for a `#[bstack_strong]` field. - fn strong_parts( + fn strong_parts( data: BStackRef, allocator: &A, ) -> io::Result<(BStackRef, Option)>; diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index 2927714..a656626 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -38,6 +38,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::hash::double_hash; @@ -109,7 +110,7 @@ impl BStackCountingBloomFilter { /// Allocate a filter with `m` counters and `k` hash functions (both forced to /// at least 1). Prefer [`with_capacity`](Self::with_capacity) to size these. - pub fn new( + pub fn new( allocator: &A, m: u64, k: u64, @@ -149,7 +150,7 @@ impl BStackCountingBloomFilter { /// Allocate a filter sized for `expected_items` at target false-positive rate /// `fp_rate`, using the standard optimal `m = -n·ln p / (ln 2)²` and /// `k = (m/n)·ln 2`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -181,7 +182,7 @@ impl BStackCountingBloomFilter { } /// Insert `key`, bumping each of its `k` counters (saturating at 255). - pub fn insert(&self, allocator: &A, key: &K) -> io::Result<()> { + pub fn insert(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, true) } @@ -189,7 +190,7 @@ impl BStackCountingBloomFilter { /// /// Only call this for a key that was actually inserted (see the module docs) — /// removing an absent key can introduce false negatives. - pub fn remove(&self, allocator: &A, key: &K) -> io::Result<()> { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, false) } @@ -211,7 +212,7 @@ impl BStackCountingBloomFilter { } /// Reset every counter and the item count to zero. - pub fn clear(&self, allocator: &A) -> io::Result<()> { + pub fn clear(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let [data, m] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; allocator.stack().set_batched([ @@ -222,7 +223,7 @@ impl BStackCountingBloomFilter { /// Atomically adjust the counters for `key` (and `n`) up or down, reading and /// writing every touched counter in one `inplace_gen` (external-lock-free). - fn adjust( + fn adjust( &self, allocator: &A, key: &K, @@ -313,7 +314,7 @@ impl BStackCountingBloomFilter { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the filter was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -342,7 +343,7 @@ impl BStackBlock for BStackCountingBloomFilter { } /// Free the counter block, **without** freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -356,7 +357,7 @@ impl BStackBlock for BStackCountingBloomFilter { /// Deep-clone: copy the counter block and stage the handle, in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -391,7 +392,7 @@ impl BStackBlock for BStackCountingBloomFilter { } impl BStackDrop for BStackCountingBloomFilter { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -399,7 +400,7 @@ impl BStackDrop for BStackCountingBloomFilter { } impl TryCloneIn for BStackCountingBloomFilter { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/boxed.rs b/bstack_raii/src/stdlib/boxed.rs index 69104b8..ece5eb3 100644 --- a/bstack_raii/src/stdlib/boxed.rs +++ b/bstack_raii/src/stdlib/boxed.rs @@ -21,6 +21,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackCast, BStackMove}; @@ -71,7 +72,7 @@ impl BStackBox { /// /// The header and payload are written as a single image, so the block is /// created with one write (and released without leaking on write failure). - pub fn new( + pub fn new( allocator: &A, value: T, ) -> io::Result> { @@ -107,7 +108,7 @@ impl BStackBox { } /// Overwrite the boxed value in place. - pub fn set(&self, allocator: &A, value: T) -> io::Result<()> { + pub fn set(&self, allocator: &A, value: T) -> io::Result<()> { allocator .stack() .set(self.range.start() + HEADER_SIZE, bytemuck::bytes_of(&value)) @@ -143,7 +144,7 @@ impl BStackBlock for BStackBox { } impl BStackDrop for BStackBox { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // Childless: just free the block. // SAFETY: sole ownership was asserted when this handle was created. unsafe { dealloc_range(allocator, self.range) } @@ -151,7 +152,7 @@ impl BStackDrop for BStackBox { } impl TryCloneIn for BStackBox { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { @@ -173,9 +174,9 @@ impl TryCloneIn for BStackBox { impl BStackMove for BStackBox { /// Moving a box out yields the plain value. - type Fields<'a, A: BStackOwnedSliceAllocator> = T; + type Fields<'a, A: BStackWalAnchor> = T; - fn bstack_move( + fn bstack_move( owned: BStackOwned, allocator: &A, ) -> io::Result { diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index b97eac4..c26099d 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -24,6 +24,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -80,7 +81,7 @@ struct Split { } /// Accumulates a path-copy insert's new nodes and the old path nodes to free. -struct Build<'a, A: BStackOwnedSliceAllocator> { +struct Build<'a, A: BStackWalAnchor> { allocator: &'a A, node_size: u64, ksize: usize, @@ -89,7 +90,7 @@ struct Build<'a, A: BStackOwnedSliceAllocator> { freed: Vec, } -impl<'a, A: BStackOwnedSliceAllocator> Build<'a, A> { +impl<'a, A: BStackWalAnchor> Build<'a, A> { fn emit(&mut self, nb: &BNode) -> io::Result { let mut b = vec![0u8; self.node_size as usize]; b[NKEYS_OFF..NKEYS_OFF + 8].copy_from_slice(&(nb.keys.len() as u64).to_le_bytes()); @@ -137,13 +138,13 @@ impl BStackBTreeSet { } /// Allocate an empty set with a default-sized Bloom filter. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) } /// Allocate an empty set whose Bloom filter is sized for `expected_items` at /// false-positive rate `fp_rate`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -247,7 +248,7 @@ impl BStackBTreeSet { /// Path-copy the subtree at `off`, inserting a **new** `key` (assumed absent). fn insert_rec( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, off: u64, key: &K, @@ -279,7 +280,7 @@ impl BStackBTreeSet { } /// Insert `key`; returns `true` if newly added, `false` if already present. - pub fn insert(&self, allocator: &A, key: K) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { // Exact check first, so the filter is only touched for genuinely new keys. let key_bytes = bytemuck::bytes_of(&key).to_vec(); if self.tree_contains(allocator.stack(), &key, &key_bytes)? { @@ -424,7 +425,7 @@ impl BStackBTreeSet { /// Path-copy delete of `key` from the subtree at `off`; returns the new /// subtree offset and whether the key was found. fn delete_off( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, off: u64, key: &K, @@ -438,7 +439,7 @@ impl BStackBTreeSet { /// Delete `key` from the in-memory node `nb`, rebalancing children to keep the /// B-tree invariant. Returns the modified node (not yet emitted) and found. fn delete_bnode( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, mut nb: BNode, key: &K, @@ -569,7 +570,7 @@ impl BStackBTreeSet { /// Remove `key`; returns `true` if it was present. Deletes from the tree /// first, then decrements the Bloom filter (see the module docs). - pub fn remove(&self, allocator: &A, key: &K) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let handle = self.range.start(); let stack = allocator.stack(); let key_bytes = bytemuck::bytes_of(key).to_vec(); @@ -753,12 +754,12 @@ impl BStackBTreeSet { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the set was created. unsafe { AutoDrop::from_raw(self, allocator) } } - fn drop_subtree( + fn drop_subtree( stack: &BStack, off: u64, allocator: &A, @@ -777,7 +778,7 @@ impl BStackBTreeSet { Ok(()) } - fn clone_subtree( + fn clone_subtree( stack: &BStack, off: u64, allocator: &A, @@ -829,7 +830,7 @@ impl BStackBlock for BStackBTreeSet { /// Recursively free every node and the embedded Bloom filter, **without** /// freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -848,7 +849,7 @@ impl BStackBlock for BStackBTreeSet { /// Deep-clone every node and the Bloom filter into `plan`, then stage the /// handle. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -878,7 +879,7 @@ impl BStackBlock for BStackBTreeSet { } impl BStackDrop for BStackBTreeSet { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -886,7 +887,7 @@ impl BStackDrop for BStackBTreeSet { } impl TryCloneIn for BStackBTreeSet { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs index d5c33ab..0fe0e90 100644 --- a/bstack_raii/src/stdlib/cow.rs +++ b/bstack_raii/src/stdlib/cow.rs @@ -19,6 +19,7 @@ use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use crate::block::BStackBlock; use crate::clone::TryCloneIn; @@ -100,7 +101,7 @@ impl BStackCow { /// * `Owned` — returned as-is; no I/O. /// * `Borrowed` — the referenced block is deep-cloned into a fresh /// independent [`BStackOwned`] allocated with `allocator`. - pub fn into_owned( + pub fn into_owned( self, allocator: &A, ) -> io::Result> @@ -122,7 +123,7 @@ impl BStackCow { /// through the returned handle (the block's setters + `allocator`) never /// touch the originally borrowed block. A no-op (beyond the ownership /// check) when already owned. - pub fn to_mut( + pub fn to_mut( &mut self, allocator: &A, ) -> io::Result<&mut BStackOwned> @@ -144,7 +145,7 @@ impl BStackCow { /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping /// the returned value runs this `Cow`'s teardown (a no-op when borrowed). - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: an `Owned` variant asserts sole ownership of a live block; a // `Borrowed` variant frees nothing, so the assertion is trivially met. unsafe { AutoDrop::from_raw(self, allocator) } @@ -154,7 +155,7 @@ impl BStackCow { impl BStackDrop for BStackCow { /// Free the block **only** when owned; a borrowed `Cow` has no claim on its /// target and frees nothing. - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { match self { BStackCow::Owned(o) => o.bstack_drop(allocator), BStackCow::Borrowed(_) => Ok(()), diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index 44ad46f..b9f831d 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -37,6 +37,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -108,13 +109,13 @@ impl BStackDeque { } /// Allocate an empty deque (no ring is allocated until the first push). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_image(allocator, 0, 0) } /// Allocate an empty deque with room for `cap` elements pre-reserved (so the /// first `cap` pushes never grow). `cap == 0` behaves like [`new`](Self::new). - pub fn with_capacity( + pub fn with_capacity( allocator: &A, cap: u64, ) -> io::Result> { @@ -134,7 +135,7 @@ impl BStackDeque { } } - fn with_image( + fn with_image( allocator: &A, data: u64, cap: u64, @@ -171,7 +172,7 @@ impl BStackDeque { /// Append a value to the back, taking ownership of its block. Grows the ring /// (once) if it is full, then commits the slot write + length bump atomically. - pub fn push_back( + pub fn push_back( &self, allocator: &A, value: BStackOwned, @@ -211,7 +212,7 @@ impl BStackDeque { } /// Prepend a value to the front, taking ownership of its block. - pub fn push_front( + pub fn push_front( &self, allocator: &A, value: BStackOwned, @@ -256,7 +257,7 @@ impl BStackDeque { /// empty. Atomic: the slot is read and the length decremented in one commit; /// the value block's ownership transfers to the caller (its ring slot is left /// stale and reused by a later push). - pub fn pop_back( + pub fn pop_back( &self, allocator: &A, ) -> io::Result>> { @@ -300,7 +301,7 @@ impl BStackDeque { /// Remove and return the first element (as an owned value block), or `None` /// if empty. - pub fn pop_front( + pub fn pop_front( &self, allocator: &A, ) -> io::Result>> { @@ -348,7 +349,7 @@ impl BStackDeque { /// Grow the ring to at least double its capacity, atomically snapshotting and /// re-basing the live elements. A no-op (beyond a wasted allocation, freed /// again) if another thread already made room. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; @@ -475,7 +476,7 @@ impl BStackDeque { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the deque was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -507,7 +508,7 @@ impl BStackBlock for BStackDeque { /// Recursively free every element block and the ring, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -531,7 +532,7 @@ impl BStackBlock for BStackDeque { /// own clone hook) and packed into a fresh, compacted ring (`head = 0`, /// `cap = len`); the handle block is staged — all in the parent plan's single /// atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -582,7 +583,7 @@ impl BStackBlock for BStackDeque { } impl BStackDrop for BStackDeque { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -590,7 +591,7 @@ impl BStackDrop for BStackDeque { } impl TryCloneIn for BStackDeque { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index b350d67..5553aa1 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -34,6 +34,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -128,13 +129,13 @@ impl BStackHashSet { } /// Allocate an empty set with a default-sized Bloom filter. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) } /// Allocate an empty set whose Bloom filter is sized for `expected_items` at /// false-positive rate `fp_rate`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -180,7 +181,7 @@ impl BStackHashSet { /// Insert `key`; returns `true` if it was newly added, `false` if already /// present. - pub fn insert(&self, allocator: &A, key: K) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let hash = fnv1a(&key_bytes); let bloom = self.bloom(allocator.stack())?; @@ -195,7 +196,7 @@ impl BStackHashSet { } /// Remove `key`; returns `true` if it was present. - pub fn remove(&self, allocator: &A, key: &K) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let key_bytes = bytemuck::bytes_of(key).to_vec(); let hash = fnv1a(&key_bytes); // Remove from the table first; only then decrement the filter (and only @@ -233,7 +234,7 @@ impl BStackHashSet { } /// Place `key` in the table if absent; returns whether it was newly added. - fn table_insert( + fn table_insert( &self, allocator: &A, key_bytes: &[u8], @@ -300,7 +301,7 @@ impl BStackHashSet { } /// Tombstone `key` in the table if present; returns whether it was. - fn table_remove( + fn table_remove( &self, allocator: &A, key_bytes: &[u8], @@ -364,7 +365,7 @@ impl BStackHashSet { /// Grow the table to at least double its capacity, rehashing every live key /// (and dropping tombstones) atomically. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); @@ -492,7 +493,7 @@ impl BStackHashSet { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the set was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -522,7 +523,7 @@ impl BStackBlock for BStackHashSet { /// Free the bucket block and the embedded Bloom filter, **without** freeing /// the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -545,7 +546,7 @@ impl BStackBlock for BStackHashSet { /// Deep-clone: copy the bucket block, deep-clone the Bloom filter, and stage /// the handle, in the parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -588,7 +589,7 @@ impl BStackBlock for BStackHashSet { } impl BStackDrop for BStackHashSet { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -596,7 +597,7 @@ impl BStackDrop for BStackHashSet { } impl TryCloneIn for BStackHashSet { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs index fc8cc5b..7be1310 100644 --- a/bstack_raii/src/stdlib/heap.rs +++ b/bstack_raii/src/stdlib/heap.rs @@ -31,6 +31,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -103,12 +104,12 @@ impl BStackBinaryHeap { } /// Allocate an empty heap (no array until the first push). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_image(allocator, 0, 0) } /// Allocate an empty heap with room for `cap` elements pre-reserved. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, cap: u64, ) -> io::Result> { @@ -128,7 +129,7 @@ impl BStackBinaryHeap { } } - fn with_image( + fn with_image( allocator: &A, data: u64, cap: u64, @@ -179,7 +180,7 @@ impl BStackBinaryHeap { /// Insert `key -> value`, taking ownership of the value block. /// /// Sifts the new element up and commits the whole path atomically. - pub fn push( + pub fn push( &self, allocator: &A, key: K, @@ -224,7 +225,7 @@ impl BStackBinaryHeap { /// Remove and return the minimum entry (its value block owned), or `None` if /// empty. Sifts the last element down and commits the whole path atomically. - pub fn pop( + pub fn pop( &self, allocator: &A, ) -> io::Result)>> { @@ -289,7 +290,7 @@ impl BStackBinaryHeap { /// Grow the array to at least double its capacity, copying the elements and /// atomically swapping the descriptor. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let (data, cap, len) = Self::read_meta(allocator.stack(), handle)?; @@ -316,7 +317,7 @@ impl BStackBinaryHeap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the heap was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -347,7 +348,7 @@ impl BStackBlock for BStackBinaryHeap { /// Recursively free every value block and the array, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -371,7 +372,7 @@ impl BStackBlock for BStackBinaryHeap { /// Deep-clone: pack the elements into a fresh, exactly-sized array with each /// value deep-cloned, and stage the handle, in the parent plan's atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -417,7 +418,7 @@ impl BStackBlock for BStackBinaryHeap { } impl BStackDrop for BStackBinaryHeap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -425,7 +426,7 @@ impl BStackDrop for BStackBinaryHeap { } impl TryCloneIn for BStackBinaryHeap { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index 4663ec9..1394152 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -34,6 +34,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -136,7 +137,7 @@ impl BStackLinkedList { } /// Allocate an empty list. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = ListOnDisk { header: BlockHeader { size: LIST_SIZE, @@ -167,7 +168,7 @@ impl BStackLinkedList { /// then the tail read, node-image write, tail/`prev.next` relink and length /// bump all commit as one crash-atomic [`atomic_update`]. A crash before the /// commit leaks the orphan node; it never tears the list. - pub fn push_back( + pub fn push_back( &self, allocator: &A, value: BStackOwned, @@ -211,7 +212,7 @@ impl BStackLinkedList { /// Prepend a value to the front, taking ownership of its block. Atomic and /// external-lock-free (see [`push_back`](Self::push_back)). - pub fn push_front( + pub fn push_front( &self, allocator: &A, value: BStackOwned, @@ -258,7 +259,7 @@ impl BStackLinkedList { /// decrement commit as one [`atomic_update`]. Only *after* the node is /// unlinked is its shell freed — a crash between leaks the shell, never a /// dangling link. - pub fn pop_back( + pub fn pop_back( &self, allocator: &A, ) -> io::Result>> { @@ -313,7 +314,7 @@ impl BStackLinkedList { /// Remove and return the first element (as an owned value block), or `None` /// if the list is empty. Atomic and external-lock-free (see /// [`pop_back`](Self::pop_back)). - pub fn pop_front( + pub fn pop_front( &self, allocator: &A, ) -> io::Result>> { @@ -411,7 +412,7 @@ impl BStackLinkedList { } /// Attach an allocator to make an auto-freeing [`crate::AutoDrop`] guard. - pub fn auto( + pub fn auto( self, allocator: &A, ) -> crate::teardown::AutoDrop<'_, Self, A> { @@ -447,7 +448,7 @@ impl BStackBlock for BStackLinkedList { /// Recursively free every value block and node, **without** freeing the list /// block itself (its embedding parent, or [`bstack_drop`](BStackDrop), does /// that). - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -471,7 +472,7 @@ impl BStackBlock for BStackLinkedList { /// Deep-clone the whole chain into `plan`: every value is deep-cloned (via /// `T`'s own clone hook), fresh nodes are allocated and wired, and the list /// block is staged — all as part of the parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -531,7 +532,7 @@ impl BStackBlock for BStackLinkedList { } impl BStackDrop for BStackLinkedList { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the list block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -539,7 +540,7 @@ impl BStackDrop for BStackLinkedList { } impl TryCloneIn for BStackLinkedList { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index d3178c3..8e5e797 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -38,6 +38,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; @@ -153,7 +154,7 @@ impl BStackHashMap { } /// Allocate an empty map (no bucket block until the first insert). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = MapOnDisk { header: BlockHeader { size: MAP_SIZE, @@ -185,7 +186,7 @@ impl BStackHashMap { /// Atomic and external-lock-free: grows the table first if the load factor /// would be exceeded, then probes and commits the bucket + metadata writes in /// one [`probe_commit`]. - pub fn insert( + pub fn insert( &self, allocator: &A, key: K, @@ -271,7 +272,7 @@ impl BStackHashMap { /// Remove `key`, returning its value (owned) if present, else `None`. The /// bucket becomes a tombstone; the value block's ownership transfers out. - pub fn remove( + pub fn remove( &self, allocator: &A, key: &K, @@ -367,7 +368,7 @@ impl BStackHashMap { /// concurrently across this call. pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> where - A: BStackOwnedSliceAllocator, + A: BStackWalAnchor, F: FnOnce() -> io::Result>, { if let Some(v) = self.get(allocator.stack(), &key)? { @@ -387,7 +388,7 @@ impl BStackHashMap { /// `default`. If `key` is present, `default` is **freed** (its block is /// dropped) — prefer the `_with` form to avoid allocating a value you may not /// use. - pub fn get_or_insert( + pub fn get_or_insert( &self, allocator: &A, key: K, @@ -424,7 +425,7 @@ impl BStackHashMap { /// Grow the table to at least double its capacity, rehashing every live entry /// (and dropping tombstones) atomically. A no-op (beyond a freed spare block) /// if another thread already grew it. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); @@ -559,7 +560,7 @@ impl BStackHashMap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the map was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -592,7 +593,7 @@ impl BStackBlock for BStackHashMap { /// Recursively free every value block and the bucket block, **without** /// freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -626,7 +627,7 @@ impl BStackBlock for BStackHashMap { /// every key's position), deep-clone each occupied value (via `V`'s clone /// hook) and swap in the clone's ref; stage the handle — all in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -675,7 +676,7 @@ impl BStackBlock for BStackHashMap { } impl BStackDrop for BStackHashMap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -683,7 +684,7 @@ impl BStackDrop for BStackHashMap { } impl TryCloneIn for BStackHashMap { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index 1a0c7c6..af25cca 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -17,6 +17,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -57,7 +58,7 @@ pub struct BStackString { impl BStackString { /// Allocate a bytes block holding `bytes` (or return `0` for an empty slice), /// releasing it without leaking on write failure. - fn alloc_bytes(allocator: &A, bytes: &[u8]) -> io::Result { + fn alloc_bytes(allocator: &A, bytes: &[u8]) -> io::Result { if bytes.is_empty() { return Ok(0); } @@ -70,7 +71,7 @@ impl BStackString { } /// Create a string from `s`. - pub fn new( + pub fn new( allocator: &A, s: &str, ) -> io::Result> { @@ -131,7 +132,7 @@ impl BStackString { /// `{data, len}` pair is updated in one atomic write (a crash before it leaves /// the old string intact; after it, the new). The old bytes block is then /// freed (leak-only on a crash in between). - pub fn set(&self, allocator: &A, s: &str) -> io::Result<()> { + pub fn set(&self, allocator: &A, s: &str) -> io::Result<()> { let handle = self.range.start(); let stack = allocator.stack(); let newlen = s.len() as u64; @@ -159,21 +160,21 @@ impl BStackString { } /// Append `s` to the string. - pub fn push_str(&self, allocator: &A, s: &str) -> io::Result<()> { + pub fn push_str(&self, allocator: &A, s: &str) -> io::Result<()> { let mut cur = self.to_string(allocator.stack())?; cur.push_str(s); self.set(allocator, &cur) } /// Append a single character. - pub fn push(&self, allocator: &A, ch: char) -> io::Result<()> { + pub fn push(&self, allocator: &A, ch: char) -> io::Result<()> { let mut buf = [0u8; 4]; self.push_str(allocator, ch.encode_utf8(&mut buf)) } /// Truncate to `new_len` **bytes**, which must be a UTF-8 char boundary and /// not exceed the current length; longer values leave the string unchanged. - pub fn truncate( + pub fn truncate( &self, allocator: &A, new_len: usize, @@ -193,7 +194,7 @@ impl BStackString { } /// Empty the string (frees its bytes block). - pub fn clear(&self, allocator: &A) -> io::Result<()> { + pub fn clear(&self, allocator: &A) -> io::Result<()> { self.set(allocator, "") } @@ -223,7 +224,7 @@ impl BStackString { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the string was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -248,7 +249,7 @@ impl BStackBlock for BStackString { } /// Free the bytes block, **without** freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -262,7 +263,7 @@ impl BStackBlock for BStackString { /// Deep-clone: copy the bytes into a fresh block and stage the handle, in the /// parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -295,7 +296,7 @@ impl BStackBlock for BStackString { } impl BStackDrop for BStackString { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -303,7 +304,7 @@ impl BStackDrop for BStackString { } impl TryCloneIn for BStackString { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index e2cb270..5f1237a 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -42,6 +42,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use super::util::{Scratch, alloc_image, read_fields, read_u64}; @@ -96,7 +97,7 @@ struct Split { /// Accumulates a path-copy insert's new-node writes and the old path nodes to /// free, so the whole insert commits as one [`BStack::set_batched`] batch. -struct Build<'a, A: BStackOwnedSliceAllocator> { +struct Build<'a, A: BStackWalAnchor> { allocator: &'a A, node_size: u64, ksize: usize, @@ -108,7 +109,7 @@ struct Build<'a, A: BStackOwnedSliceAllocator> { freed: Vec, } -impl<'a, A: BStackOwnedSliceAllocator> Build<'a, A> { +impl<'a, A: BStackWalAnchor> Build<'a, A> { /// Serialize `nb` and allocate a fresh block for it (an orphan until the /// commit links it), returning its offset. fn emit(&mut self, nb: &BNode) -> io::Result { @@ -170,7 +171,7 @@ impl BStackBTreeMap { } /// Allocate an empty tree. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = TreeOnDisk { header: BlockHeader { size: TREE_SIZE, @@ -268,7 +269,7 @@ impl BStackBTreeMap { /// Returns the new subtree offset, an optional lifted split, whether a new /// entry was added, and any replaced value. fn insert_rec( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, off: u64, key: &K, @@ -320,7 +321,7 @@ impl BStackBTreeMap { /// /// Path-copies the affected path and commits every new node plus the root /// swap as one crash-atomic batch. **Single-writer** (see the module docs). - pub fn insert( + pub fn insert( &self, allocator: &A, key: K, @@ -472,7 +473,7 @@ impl BStackBTreeMap { /// `bool` distinguishes a fresh insert from a hit. **Single-writer.** pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> where - A: BStackOwnedSliceAllocator, + A: BStackWalAnchor, F: FnOnce() -> io::Result>, { if let Some(v) = self.get(allocator.stack(), &key)? { @@ -488,7 +489,7 @@ impl BStackBTreeMap { /// Like [`get_or_insert_with`](Self::get_or_insert_with) but with an eager /// `default`, which is **freed** if `key` is already present. - pub fn get_or_insert( + pub fn get_or_insert( &self, allocator: &A, key: K, @@ -536,7 +537,7 @@ impl BStackBTreeMap { /// Path-copy delete of `key` from the subtree at `off`; returns the new /// subtree offset and the removed value (if the key was found). fn delete_off( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, off: u64, key: &K, @@ -551,7 +552,7 @@ impl BStackBTreeMap { /// for freeing), rebalancing children to keep the B-tree invariant. Returns /// the modified node (not yet emitted) and the removed value. fn delete_bnode( - build: &mut Build<'_, impl BStackOwnedSliceAllocator>, + build: &mut Build<'_, impl BStackWalAnchor>, stack: &BStack, mut nb: BNode, key: &K, @@ -708,7 +709,7 @@ impl BStackBTreeMap { /// Remove `key`, returning its value (owned) if present, else `None`. /// Path-copies the affected path (rebalancing as needed) and commits the new /// nodes plus the root update as one crash-atomic batch. **Single-writer.** - pub fn remove( + pub fn remove( &self, allocator: &A, key: &K, @@ -908,13 +909,13 @@ impl BStackBTreeMap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the tree was created. unsafe { AutoDrop::from_raw(self, allocator) } } /// Recursively free the subtree at `off` (values then nodes). - fn drop_subtree( + fn drop_subtree( stack: &BStack, off: u64, allocator: &A, @@ -942,7 +943,7 @@ impl BStackBTreeMap { /// Recursively deep-clone the subtree at `off` into `plan`, returning the new /// subtree offset. - fn clone_subtree( + fn clone_subtree( stack: &BStack, off: u64, allocator: &A, @@ -1007,7 +1008,7 @@ impl BStackBlock for BStackBTreeMap { /// Recursively free every value block and node, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -1018,7 +1019,7 @@ impl BStackBlock for BStackBTreeMap { /// Deep-clone the whole tree into `plan`: every node copied, every value /// deep-cloned via `V`'s clone hook, the handle staged — all in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -1042,7 +1043,7 @@ impl BStackBlock for BStackBTreeMap { } impl BStackDrop for BStackBTreeMap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -1050,7 +1051,7 @@ impl BStackDrop for BStackBTreeMap { } impl TryCloneIn for BStackBTreeMap { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result> { diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 57a079a..708d2da 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -8,6 +8,7 @@ use std::io; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use crate::layout::{HEADER_SIZE, get_u64}; @@ -79,7 +80,7 @@ impl Scratch { /// Allocate a block and write `bytes` as its whole image (one write; released /// without leaking on write failure). -pub(super) fn alloc_image( +pub(super) fn alloc_image( allocator: &A, bytes: &[u8], ) -> io::Result { @@ -117,7 +118,7 @@ pub(super) fn atomic_update( plan: W, ) -> io::Result<()> where - A: BStackOwnedSliceAllocator, + A: BStackWalAnchor, R2: FnOnce(&[u64]) -> Vec, W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, { @@ -228,7 +229,7 @@ pub(super) fn probe_commit( exhausted: E, ) -> io::Result<()> where - A: BStackOwnedSliceAllocator, + A: BStackWalAnchor, I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, E: FnOnce(&Meta) -> Vec<(u64, Vec)>, { From f58987af38fdf7fa6304fcd52d9072787f8637e4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 00:56:42 -0700 Subject: [PATCH 095/140] Use WAL Anchor for native types --- bstack_raii/src/cast.rs | 7 +-- bstack_raii/src/clone.rs | 94 +++++++++++++++++------------------- bstack_raii/src/construct.rs | 11 +++-- bstack_raii/src/handle.rs | 17 ++++--- bstack_raii/src/lib.rs | 4 +- bstack_raii/src/owned.rs | 21 ++++---- bstack_raii/src/shared.rs | 21 ++++---- bstack_raii/src/teardown.rs | 51 +++++++++++++------ bstack_raii/src/vec.rs | 25 +++++----- 9 files changed, 136 insertions(+), 115 deletions(-) diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs index 6f9d6b8..8ba7233 100644 --- a/bstack_raii/src/cast.rs +++ b/bstack_raii/src/cast.rs @@ -8,6 +8,7 @@ use std::io; use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; +use crate::wal::BStackWalAnchor; use crate::block::{BStackBlock, BStackCast}; use crate::layout::EightCC; @@ -17,7 +18,7 @@ use crate::teardown::AutoDrop; /// Byte offset of the `tag` within a [`crate::BlockHeader`] (`size: u64` first). const TAG_OFFSET: u64 = 8; -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> AutoDrop<'a, BStackOwned, A> { +impl<'a, T: BStackBlock, A: BStackWalAnchor> AutoDrop<'a, BStackOwned, A> { /// Upcast an auto-freeing owned handle to the untyped owned slice, discarding /// type info (infallible). /// @@ -34,14 +35,14 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> AutoDrop<'a, BStackOwned< /// Downcast an owned slice to a typed (bare) owned handle by checking the block /// tag. The result carries no allocator; free it with `owned.bstack_drop(alloc)` /// or wrap it via `owned.auto(alloc)`. -pub trait BStackCastInto<'a, A: BStackOwnedSliceAllocator>: Sized { +pub trait BStackCastInto<'a, A: BStackWalAnchor>: Sized { /// `Ok(Ok(owned))` on a tag match; `Ok(Err(self))` on mismatch (ownership is /// handed back so the caller can try another type); `Err` on an I/O failure /// reading the header. fn cast_into(self) -> io::Result, Self>>; } -impl<'a, A: BStackOwnedSliceAllocator> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { +impl<'a, A: BStackWalAnchor> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { fn cast_into(self) -> io::Result, Self>> { let mut tag = [0u8; 8]; self.read_range_into(TAG_OFFSET, &mut tag)?; diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index d57a414..dedb0c8 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -45,7 +45,10 @@ use crate::owned::BStackOwned; use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; use crate::vec::{BYTEVEC_HEADER, VecDesc}; -use crate::wal::{BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at, persist_at}; +use crate::wal::{ + BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, + wal_set_idle, +}; /// Duplicate `self`, performing any fallible I/O the duplication requires, /// **without** needing an allocator. @@ -84,7 +87,7 @@ pub trait TryClone: Sized { /// why (in particular, why a weak reference can only ever be cloned as another /// weak reference). pub trait TryCloneIn: BStackDrop + Sized { - fn try_clone_in( + fn try_clone_in( &self, allocator: &A, ) -> io::Result>; @@ -126,7 +129,7 @@ impl ClonePlan { /// for rollback. The caller supplies the block's bytes later via /// [`write`](Self::write). The allocator's owned-slice handle is not RAII, so /// letting it drop here does not free the block. - pub fn alloc_raw( + pub fn alloc_raw( &mut self, allocator: &A, size: u64, @@ -156,7 +159,7 @@ impl ClonePlan { /// our machinery and its bytes ride the same `inplace_gen` as everything else, /// instead of the vector runtime writing it eagerly. `cap == len` (a fresh /// clone carries no spare capacity, matching `BStackByteVec::from_slice`). - pub fn stage_bytevec( + pub fn stage_bytevec( &mut self, allocator: &A, data: &[u8], @@ -174,7 +177,7 @@ impl ClonePlan { /// Record that the strong count of a shared child at `data` must be bumped by /// one — the strong reference this clone's `#[bstack_strong]` field acquires. - pub fn bump_strong( + pub fn bump_strong( &mut self, data: BStackRef, allocator: &A, @@ -197,7 +200,7 @@ impl ClonePlan { /// Free everything allocated so far, in reverse order. The error path, taken /// when planning fails before [`commit`](Self::commit). - pub fn rollback(self, allocator: &A) { + pub fn rollback(self, allocator: &A) { Self::free_all(self.allocated, allocator); } @@ -212,20 +215,20 @@ impl ClonePlan { /// all writes**: because the counters are distinct, no read depends on another /// bump's pending write, so an overflow can be detected after the reads — /// before any write is emitted — and abort with nothing committed. - pub fn commit(self, allocator: &A) -> io::Result<()> { - self.commit_inner(allocator, None) - } - - /// Like [`commit`](Self::commit) but crash-atomic against orphan leaks: the - /// plan's allocations are logged to the WAL and reclaimed by `finish` on the - /// next open if the process dies mid-commit. Requires an anchored allocator; - /// used by [`wal_clone_in`]. - pub fn commit_wal(self, allocator: &A) -> io::Result<()> { + /// + /// **Crash reclamation is automatic**: when the allocator names a WAL anchor + /// ([`BStackWalAnchor::wal_anchor`] returns `Some`), the plan's fresh + /// allocations are logged `Pending` before the commit and reclaimed by + /// [`crate::wal::finish`] on the next open if the process dies mid-commit; the + /// transaction is flipped `Complete` *inside* the commit batch, so "clone + /// committed" and "WAL Complete" are the same atomic event. An allocator that + /// returns `None` behaves exactly as before (mid-commit crash ⇒ orphan leak). + pub fn commit(self, allocator: &A) -> io::Result<()> { let anchor = allocator.wal_anchor(); - self.commit_inner(allocator, Some(anchor)) + self.commit_inner(allocator, anchor) } - fn commit_inner( + fn commit_inner( self, allocator: &A, anchor: Option, @@ -237,6 +240,15 @@ impl ClonePlan { } = self; let stack = allocator.stack(); + // Serialize the WAL transaction against other WAL-backed ops on this file + // (the persistent block + anchor are single-writer). Held for the whole + // commit; `None` when this clone isn't WAL-backed. `_wal_lock` must outlive + // `_wal_guard`, so it is bound first. + let _wal_lock = anchor.map(|_| wal_lock_for(allocator)); + let _wal_guard = _wal_lock + .as_ref() + .map(|l| l.lock().unwrap_or_else(|e| e.into_inner())); + // With a WAL anchor, protect this clone's fresh allocations: log them // `Pending` before the commit, so a crash mid-commit is reclaimed by // `finish` on the next open. The transaction is flipped `Complete` *inside* @@ -362,11 +374,13 @@ impl ClonePlan { )) } Ok(()) => { - // Committed — the WAL was flipped `Complete` in the same batch. - // Best-effort cleanup (a crash here is finished on the next open). - if let Some((anchor, wal_range)) = wal { - let _ = stack.set(anchor, 0u64.to_le_bytes()); - let _ = unsafe { dealloc_range(allocator, wal_range) }; + // Committed — the WAL was flipped `Complete` in the same batch. A + // clone logs only `Alloc`s, which a committed txn keeps, so there is + // nothing to roll forward: just mark the persistent block idle for + // reuse (a crash before this is harmlessly finished on the next open, + // freeing nothing). The block itself is never freed. + if let Some((_anchor, wal_range)) = wal { + let _ = wal_set_idle(allocator, wal_range.start()); } Ok(()) } @@ -379,24 +393,25 @@ impl ClonePlan { } } - /// Reclaim a failed commit's allocations. With a WAL, `finish_at` abandons the - /// still-`Pending` transaction — freeing the logged alloc orphans *and* the WAL - /// block — matching exactly what `finish` would do after a real crash; without - /// one, free them directly. - fn reclaim( + /// Reclaim a failed commit's allocations. With a WAL, `finish_at_locked` + /// abandons the still-`Pending` transaction — freeing the logged alloc orphans + /// and marking the persistent block idle — matching exactly what `finish` would + /// do after a real crash; without one, free them directly. The WAL lock is + /// already held by the caller (`commit_inner`), so the *locked* variant is used. + fn reclaim( allocated: Vec, wal: Option<(u64, BStackRange)>, allocator: &A, ) { match wal { Some((anchor, _)) => { - let _ = finish_at(allocator, anchor); + let _ = finish_at_locked(allocator, anchor); } None => Self::free_all(allocated, allocator), } } - fn free_all(allocated: Vec, allocator: &A) { + fn free_all(allocated: Vec, allocator: &A) { for r in allocated.into_iter().rev() { // SAFETY: each range was returned by our own `alloc_raw` and never // handed to another owner, so freeing it here is sound. @@ -405,24 +420,3 @@ impl ClonePlan { } } -/// Deep-clone `src` into a fresh owned copy, WAL-protected: if the process crashes -/// mid-commit, the clone's orphaned allocations are reclaimed by -/// [`crate::wal::finish`] on the next open (whereas the plain -/// [`TryCloneIn::try_clone_in`] leaks them until then). Opt-in — requires an -/// anchored allocator ([`BStackWalAnchor`]). -pub fn wal_clone_in( - src: &T, - allocator: &A, -) -> io::Result> { - let mut plan = ClonePlan::new(); - let dst = match src.__bstack_clone_into(allocator, &mut plan) { - Ok(range) => range, - Err(e) => { - plan.rollback(allocator); - return Err(e); - } - }; - plan.commit_wal(allocator)?; - // SAFETY: `dst` is a fresh block owned by nobody else. - Ok(unsafe { BStackOwned::from_raw(T::from_range(dst)) }) -} diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 1753ffa..55edf1b 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -12,6 +12,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use crate::block::BStackWeakable; use crate::handle::WeakRef; @@ -32,7 +33,7 @@ fn read_u64_at(stack: &BStack, off: u64) -> io::Result { /// Returns the block's range. The bytes after the header are left as the /// allocator provided them; the caller fills in the payload. On a write failure /// the freshly allocated block is released so nothing leaks. -pub fn alloc_block( +pub fn alloc_block( allocator: &A, tag: EightCC, size: u64, @@ -50,7 +51,7 @@ pub fn alloc_block( /// /// Call once after [`alloc_block`] and after the payload is written. One is the /// count the single returned `BStackRc` accounts for. -pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { +pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { let off = data.start() + layout::RC_REFCOUNT_OFFSET; allocator.stack().set(off, 1u64.to_le_bytes()) } @@ -65,7 +66,7 @@ pub fn init_rc(allocator: &A, data: BStackRange) - /// /// On failure the control block is released; the caller still owns (and must /// release) the data block. -pub fn alloc_control( +pub fn alloc_control( allocator: &A, ctrl_tag: EightCC, data: BStackRange, @@ -120,7 +121,7 @@ pub fn build_control_payload(ctrl_tag: EightCC, data_start: u64, control_size: u /// resolving it at teardown is sound even after the target's data has been /// freed. `new_weak` is consumed and the weak count it holds becomes the field's; /// a previous non-null target has its weak count decremented. 0 means "unset". -pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( +pub fn set_weak_field<'w, T: BStackWeakable, A: BStackWalAnchor>( allocator: &A, field_off: u64, new_weak: BStackWeak<'w, T, A>, @@ -156,7 +157,7 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackOwnedSliceAllocator>( /// `field_off`) to a strong handle. Returns `None` if the field is unset (0) or /// the target's strong count has already reached zero. What a generated weak /// field accessor calls. -pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator>( +pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackWalAnchor>( allocator: &'a A, field_off: u64, ) -> io::Result>> { diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 91a0047..284a82e 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -17,6 +17,7 @@ use core::mem::size_of; use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use crate::block::{BStackBlock, BStackWeakable}; use crate::layout; @@ -51,7 +52,7 @@ pub struct WeakRef(pub BStackRef); /// Read a data block's `ctrl` back-pointer (a `u64` offset at /// [`layout::CTRL_BACKPTR_OFFSET`]) and resolve it to a typed control ref, /// recovering the control block's length from `size_of::()`. -fn read_ctrl_ref( +fn read_ctrl_ref( data_ref: BStackRef, allocator: &A, ) -> io::Result> { @@ -64,7 +65,7 @@ fn read_ctrl_ref( } impl BStackDrop for OwnedRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // An owned child is freed by running the block's own recursive teardown, // which frees its children (post-order) and then deallocs the block. T::from_range(self.0.into_range()).bstack_drop(allocator) @@ -72,7 +73,7 @@ impl BStackDrop for OwnedRef { } impl BStackDrop for StrongRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { let data_range = self.0.into_range(); let off = data_range.start() + layout::RC_REFCOUNT_OFFSET; // Decrement the inline refcount; only the last owner frees the block. @@ -86,7 +87,7 @@ impl BStackDrop for StrongRef { impl StrongWeakRef { /// Resolve the control ref from the data block's `ctrl` back-pointer with a /// single read, then pair it with the data ref. - pub fn from_disk( + pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { @@ -100,7 +101,7 @@ impl StrongWeakRef { /// recursive teardown), so it is shared by both [`StrongWeakRef::bstack_drop`] /// and [`crate::BStackRc`]'s `Drop` — the latter carries `T: BStackBlock` and so /// cannot construct a `StrongWeakRef` (which needs `BStackWeakable`) itself. -pub(crate) fn strong_release_ctrl( +pub(crate) fn strong_release_ctrl( allocator: &A, data_range: BStackRange, ctrl_range: BStackRange, @@ -122,14 +123,14 @@ pub(crate) fn strong_release_ctrl( } impl BStackDrop for StrongWeakRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { strong_release_ctrl::(allocator, self.0.into_range(), self.1.into_range()) } } impl WeakRef { /// Resolve the control ref from the data block's `ctrl` back-pointer. - pub fn from_disk( + pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { @@ -138,7 +139,7 @@ impl WeakRef { } impl BStackDrop for WeakRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { let ctrl_range = self.0.into_range(); let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; // Decrement ctrl.weak; free the control block when the last weak handle diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index d1faced..214d12c 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -72,7 +72,7 @@ pub use block::{ }; pub use bulk::{alloc_many, free_many}; pub use cast::{BStackCastAs, BStackCastInto}; -pub use clone::{ClonePlan, TryClone, TryCloneIn, wal_clone_in}; +pub use clone::{ClonePlan, TryClone, TryCloneIn}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; @@ -88,7 +88,7 @@ pub use stdlib::{ HashSetIter, HashSetOnDisk, HeapOnDisk, ListIter, ListOnDisk, MapOnDisk, NodeOnDisk, StringOnDisk, TreeOnDisk, TreeSetOnDisk, }; -pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_drop}; +pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_teardown}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ AllocReq, BStackWalAnchor, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index ec276c2..30ad963 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -14,10 +14,9 @@ use core::ops::Deref; use std::io; -use bstack::BStackOwnedSliceAllocator; - use crate::block::{BStackMove, BStackMoveExpr}; -use crate::teardown::{AutoDrop, BStackDrop}; +use crate::teardown::{AutoDrop, BStackDrop, wal_teardown}; +use crate::wal::BStackWalAnchor; /// A uniquely-owned handle to a block: an ownership marker over an inner /// [`BStackDrop`] handle whose teardown recursively frees the block on disk. @@ -52,7 +51,7 @@ impl BStackOwned { /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping /// the returned value runs this handle's recursive teardown. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: a `BStackOwned` asserts sole ownership of a live block at // construction, exactly the invariant `AutoDrop::from_raw` requires. unsafe { AutoDrop::from_raw(self, allocator) } @@ -67,11 +66,13 @@ impl Deref for BStackOwned { } impl BStackDrop for BStackOwned { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { - // Recursively free the owned block (and its children) via the inner - // handle's teardown. For crash-atomic, leak-reclaiming teardown on an - // anchored allocator, use [`crate::wal_drop`] instead. - self.0.bstack_drop(allocator) + fn bstack_drop(self, allocator: &A) -> io::Result<()> { + // Recursively free the owned block (and its children) as one crash-atomic, + // leak-reclaiming batch — automatically, whenever the allocator names a WAL + // anchor. `wal_teardown` collects the whole subtree's frees and commits + // them as one transaction (and is a plain teardown when there is no anchor, + // or when this runs nested inside an outer teardown that owns the sink). + wal_teardown(self.0, allocator) } } @@ -80,7 +81,7 @@ impl BStackDrop for BStackOwned { /// /// (A *bare* `BStackOwned` carries no allocator, so it is moved with the /// explicit two-argument form `bstack_move!(owned, allocator)` instead.) -impl<'a, X: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr +impl<'a, X: BStackMove, A: BStackWalAnchor> BStackMoveExpr for AutoDrop<'a, BStackOwned, A> { type Output = io::Result>; diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 4c9c8e5..d9da19d 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -9,6 +9,7 @@ use core::mem::size_of; use std::io; use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; @@ -33,7 +34,7 @@ pub(crate) struct StrongCore { } impl BStackDrop for StrongCore { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { match self.ctrl { None => StrongRef(self.data).bstack_drop(allocator), Some(ctrl) => strong_release_ctrl::(allocator, self.data.into_range(), ctrl), @@ -57,11 +58,11 @@ impl BStackDrop for StrongCore { /// **Invariant:** for a `T: BStackWeakable` block, `ctrl` is always `Some` — such /// blocks are only ever constructed through the control-block paths /// ([`BStackWeak::upgrade`], `bstack_move!`). `downgrade` relies on this. -pub struct BStackRc<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { +pub struct BStackRc<'a, T: BStackBlock, A: BStackWalAnchor> { inner: AutoDrop<'a, StrongCore, A>, } -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackRc<'a, T, A> { /// Reconstruct a shared handle from its raw parts. /// /// # Safety @@ -120,7 +121,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { /// handle to the **same** block — sharing, not copying (like `Rc::clone`). This /// is the clone semantics for a shared block; there is deliberately no /// deep-copy-to-owned (`TryCloneIn`) for one. -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackWalAnchor> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { refcount::fetch_add(self.allocator().stack(), self.strong_offset(), 1)?; // SAFETY: the fetch_add above established the strong count this clone @@ -129,7 +130,7 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> TryClone for BStackRc<'a, } } -impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackRc<'a, T, A> { /// Create a weak handle to the same block by incrementing `ctrl.weak`. /// /// Available only for `(rc, weak)` blocks (`T: BStackWeakable`), so a plain @@ -148,7 +149,7 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { } } -impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { +impl<'a, T: BStackMove, A: BStackWalAnchor> BStackRc<'a, T, A> { /// `Rc::try_unwrap` + destructure: if this handle is the **sole strong /// owner**, move every field out (freeing only the data shell) and return /// them; otherwise hand the handle back in `Err`. @@ -187,7 +188,7 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackRc<'a, T, A> { } } -impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackRc<'a, T, A> { +impl<'a, T: BStackMove, A: BStackWalAnchor> BStackMoveExpr for BStackRc<'a, T, A> { type Output = io::Result, Self>>; fn bstack_move(self) -> Self::Output { self.try_move() @@ -200,11 +201,11 @@ impl<'a, T: BStackMove, A: BStackOwnedSliceAllocator> BStackMoveExpr for BStackR /// control block alive (so [`upgrade`](BStackWeak::upgrade) can check liveness) /// but never pins the data block. Its drop core is a [`WeakRef`], whose /// [`BStackDrop`] decrements `ctrl.weak` and frees the control block at zero. -pub struct BStackWeak<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { +pub struct BStackWeak<'a, T: BStackWeakable, A: BStackWalAnchor> { inner: AutoDrop<'a, WeakRef, A>, } -impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeak<'a, T, A> { /// Reconstruct a weak handle from its raw control ref. /// /// # Safety @@ -261,7 +262,7 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeak<'a, T, A> { /// block, and a copy that observed anything else would not be observing what the /// original does. So a weak clone shares the observation (a count bump) rather /// than deep-copying — there is no `TryCloneIn` for a weak reference. -impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> TryClone for BStackWeak<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackWalAnchor> TryClone for BStackWeak<'a, T, A> { fn try_clone(&self) -> io::Result { let weak_off = self.ctrl().into_range().start() + layout::CTRL_WEAK_OFFSET; refcount::fetch_add(self.allocator().stack(), weak_off, 1)?; diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index a3dbadb..af64358 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -12,7 +12,9 @@ use std::io; use bstack::{BStackGenOp, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; -use crate::wal::{BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at, persist_at}; +use crate::wal::{ + BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, +}; thread_local! { /// While a WAL-backed teardown is in progress, the collector that @@ -26,8 +28,11 @@ thread_local! { /// Tear down `handle` (a whole owned subtree) as one crash-atomic batch of frees, /// so a crash mid-teardown is completed — not leaked — by `finish` on the next -/// open. The WAL-backed, opt-in counterpart to [`BStackDrop::bstack_drop`]; -/// requires an anchored allocator ([`BStackWalAnchor`]). +/// open. This is what [`BStackOwned::bstack_drop`](crate::BStackOwned) runs, so +/// **every owned teardown is automatically WAL-backed** when the allocator names +/// an anchor ([`BStackWalAnchor::wal_anchor`] returns `Some`); an allocator that +/// returns `None` falls straight through to a plain [`BStackDrop::bstack_drop`] +/// and behaves exactly as before (mid-teardown crash ⇒ orphan leak). /// /// While the sink is installed, every [`dealloc_range`] in the (ordinary, /// generic) teardown recursion *collects* its slice rather than freeing it; @@ -36,13 +41,20 @@ thread_local! { /// (e.g. a collection freeing its values through `BStackOwned::bstack_drop`) see /// the sink already set and just collect, so exactly one transaction wraps the /// outermost teardown. -pub fn wal_drop(handle: T, allocator: &A) -> io::Result<()> { - // Nested (shouldn't happen at a public entry, but be safe): the outer driver - // owns the sink; frees already collect. +pub fn wal_teardown( + handle: T, + allocator: &A, +) -> io::Result<()> { + // Nested teardown: an outer driver already owns the sink; frees already + // collect, so just recurse (exactly one transaction wraps the whole subtree). if TEARDOWN_SINK.with(|s| s.borrow().is_some()) { return handle.bstack_drop(allocator); } - let anchor = allocator.wal_anchor(); + // No anchor → the allocator opts out of reclamation: plain teardown. + let anchor = match allocator.wal_anchor() { + Some(a) => a, + None => return handle.bstack_drop(allocator), + }; TEARDOWN_SINK.with(|s| *s.borrow_mut() = Some(Vec::new())); let result = handle.bstack_drop(allocator); let slices = TEARDOWN_SINK @@ -54,7 +66,12 @@ pub fn wal_drop(handle: T, allocator: &A) -> /// Commit `slices` as one committed `Dealloc` transaction and execute the frees. /// A crash mid-free leaves a `Complete` WAL that `finish` rolls forward on reopen. -fn wal_free_all( +/// +/// The whole staging→commit→finish critical section runs under the file's WAL +/// lock, so concurrent teardowns on the same file serialize here (they collect +/// their subtrees independently first — that part stays concurrent) rather than +/// racing the single shared anchor slot. +fn wal_free_all( allocator: &A, anchor: u64, slices: Vec, @@ -62,6 +79,9 @@ fn wal_free_all( if slices.is_empty() { return Ok(()); } + let lock = wal_lock_for(allocator); + let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + let mut log = WalLog::with_capacity(slices.len()); for s in &slices { log.append(WalEntry::dealloc(WalStatus::Pending, *s)); @@ -69,7 +89,8 @@ fn wal_free_all( // Stage the transaction `Pending`, then commit it by flipping `txn_status` to // `Complete` in one atomic `inplace_gen` — the single commit point (a crash // before it abandons; after it, `finish` rolls the frees forward). With the - // sink now cleared, `finish_at` executes the frees + reclaims the WAL block. + // sink now cleared, `finish_at_locked` executes the frees and marks the + // persistent WAL block idle for reuse. let wal_range = persist_at(allocator, anchor, &log, WalStatus::Pending)?; let flip = [WalStatus::Complete as u8]; let mut done = false; @@ -87,7 +108,7 @@ fn wal_free_all( }) } })?; - finish_at(allocator, anchor)?; + finish_at_locked(allocator, anchor)?; Ok(()) } @@ -103,7 +124,7 @@ fn wal_free_all( /// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and /// `Error = io::Error` (so the layer speaks [`io::Result`]). pub trait BStackDrop: Sized { - fn bstack_drop(self, allocator: &A) -> io::Result<()>; + fn bstack_drop(self, allocator: &A) -> io::Result<()>; } /// Free a raw block range by reconstructing an owned slice and delegating to the @@ -147,12 +168,12 @@ pub unsafe fn dealloc_range( /// It is a newtype over `(ManuallyDrop, &'a A)`; `bstack_move!` and the raw /// accessors defuse it via [`into_raw_parts`](Self::into_raw_parts) so no /// parallel destruction path exists. -pub struct AutoDrop<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> { +pub struct AutoDrop<'a, T: BStackDrop, A: BStackWalAnchor> { inner: ManuallyDrop, allocator: &'a A, } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackWalAnchor> AutoDrop<'a, T, A> { /// Pair an inner handle with its allocator into an auto-dropping guard. /// /// # Safety @@ -188,14 +209,14 @@ impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> AutoDrop<'a, T, A> { } } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Deref for AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackWalAnchor> Deref for AutoDrop<'a, T, A> { type Target = T; fn deref(&self) -> &T { &self.inner } } -impl<'a, T: BStackDrop, A: BStackOwnedSliceAllocator> Drop for AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackWalAnchor> Drop for AutoDrop<'a, T, A> { fn drop(&mut self) { let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; // Errors are swallowed, matching the contract of Rust's `Drop`. diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 38a7b40..79d67f3 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -31,6 +31,7 @@ use core::mem::size_of; use std::io; use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use crate::wal::BStackWalAnchor; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; @@ -63,7 +64,7 @@ pub(crate) fn bytevec_image(len: u64, cap: u64, data: &[u8]) -> Vec { /// Build a fresh data block holding `offs` (an offset array), register it in /// `plan` for rollback, and return its descriptor. The shared back end of the /// block-element vector clones, whose elements are all `u64` offsets. -fn build_offset_desc( +fn build_offset_desc( allocator: &A, offs: &[u64], plan: &mut ClonePlan, @@ -110,7 +111,7 @@ fn write_vecdesc(stack: &BStack, loc: u64, desc: VecDesc) -> io::Result<()> { /// The handle carries the descriptor in memory (`data`), plus the inline field /// location to persist it to (`writeback`) when field-resident — `None` for a /// detached vector (from [`from_slice`](Self::from_slice) or `bstack_move!`). -pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { +pub struct BStackVec<'a, T, A: BStackWalAnchor> { /// The current data block range (the live descriptor). data: BStackRange, /// Where to persist descriptor changes on realloc (the inline field). `None` @@ -120,7 +121,7 @@ pub struct BStackVec<'a, T, A: BStackOwnedSliceAllocator> { _marker: PhantomData T>, } -impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { +impl<'a, T, A: BStackWalAnchor> BStackVec<'a, T, A> { /// Reconstruct a **field-resident** handle from its inline descriptor's /// absolute on-disk location (what a field accessor passes). Reads the /// current descriptor and remembers the location for write-back. @@ -201,7 +202,7 @@ impl<'a, T, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { } } -impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { +impl<'a, T: Pod, A: BStackWalAnchor> BStackVec<'a, T, A> { /// Create a **detached** vector holding `data`, allocating only the data /// block. It becomes persistent when written into a struct field. pub fn from_slice(allocator: &'a A, data: &[T]) -> io::Result { @@ -328,12 +329,12 @@ impl<'a, T: Pod, A: BStackOwnedSliceAllocator> BStackVec<'a, T, A> { /// Each element is a `u64` offset to a separately-allocated `#[bstack_block]` /// child this vector *owns*; dropping the vector recursively frees every child /// (post-order) plus the offset array. Backs `#[bstack_owned] Vec` fields. -pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { +pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackWalAnchor> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackBlockVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of data offsets to /// live `T` blocks this vector owns. @@ -461,12 +462,12 @@ impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackBlockVec<'a, T, A> /// Each element holds one strong reference; dropping the vector releases every /// one (freeing a child when its count hits zero) and frees the offset array. /// Backs `#[bstack_strong] Vec` fields. -pub struct BStackStrongVec<'a, T: BStackShared, A: BStackOwnedSliceAllocator> { +pub struct BStackStrongVec<'a, T: BStackShared, A: BStackWalAnchor> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A> { +impl<'a, T: BStackShared, A: BStackWalAnchor> BStackStrongVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of data offsets to /// live `T` blocks, each accounting for one strong reference this vector owns. @@ -593,12 +594,12 @@ impl<'a, T: BStackShared, A: BStackOwnedSliceAllocator> BStackStrongVec<'a, T, A /// Dropping the vector releases every weak count (freeing a control block when /// it reaches zero) and frees the offset array. Backs `#[bstack_weak] Vec` /// fields. -pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> { +pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackWalAnchor> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeakVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of control-block /// offsets, each accounting for one weak reference this vector owns. @@ -712,12 +713,12 @@ impl<'a, T: BStackWeakable, A: BStackOwnedSliceAllocator> BStackWeakVec<'a, T, A /// /// Elements carry no ownership: dropping the vector frees only the offset array, /// never the targets. Backs `#[bstack_ref] Vec` fields. -pub struct BStackRefVec<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> { +pub struct BStackRefVec<'a, T: BStackBlock, A: BStackWalAnchor> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackBlock, A: BStackOwnedSliceAllocator> BStackRefVec<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackRefVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of offsets to `T` /// blocks (which this vector does not own). From 359b8478b22edf19394f926e6590a479a0ede52c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 00:56:47 -0700 Subject: [PATCH 096/140] Enhance tests --- bstack_raii/src/tests.rs | 293 +++++++++++++++++++++------------------ 1 file changed, 159 insertions(+), 134 deletions(-) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 32753b1..245e937 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -17,8 +17,9 @@ use crate::{ AutoDrop, BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRc, - BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, TryClone, TryCloneIn, - alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, dealloc_range, + BStackRef, BStackShared, BStackString, BStackWalAnchor, BStackWeakable, EightCC, TryClone, + TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, + dealloc_range, }; // -------------------------------------------------------------------------- @@ -59,6 +60,29 @@ impl Drop for TempStack { } } +/// Assert a WAL-backed teardown reclaims a whole structure with **no leak** — +/// including any deeply-nested grandchildren, so it doubles as a recursion check +/// (a non-recursive teardown would leak the grandchild's block). +/// +/// `build` constructs the structure fresh each call. We build+tear down once to +/// warm and size the persistent WAL block (which stays allocated by design), then +/// measure the baseline, build+tear down the *identical* structure again, and +/// assert the stack returned exactly to that baseline. Comparing two like cycles +/// makes the constant WAL-block overhead cancel out, so only a real leak shows. +fn assert_teardown_reclaims( + alloc: &FirstFitBStackAllocator, + mut build: impl FnMut() -> T, +) { + build().bstack_drop(alloc).unwrap(); + let base = alloc.stack().len().unwrap(); + build().bstack_drop(alloc).unwrap(); + assert_eq!( + alloc.stack().len().unwrap(), + base, + "teardown leaked (non-recursive?)" + ); +} + // -------------------------------------------------------------------------- // A hand-written `#[bstack_block(rc, weak)]`-shaped type with no children // -------------------------------------------------------------------------- @@ -93,7 +117,7 @@ impl BStackCast for TestBlock { } impl BStackDrop for TestBlock { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // No owned children: just free the data block itself. unsafe { dealloc_range(allocator, self.0) } } @@ -325,26 +349,21 @@ fn macro_recursive_drop() { let leaf_size = size_of::<::OnDisk>() as u64; let parent_size = size_of::<::OnDisk>() as u64; - let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - let parent = alloc_block(&alloc, MacroParent::eightcc(), parent_size).unwrap(); - // Wire parent.child -> leaf (the first user field sits right after the header). - alloc - .stack() - .set( - parent.start() + layout::HEADER_SIZE, - leaf.start().to_le_bytes(), - ) - .unwrap(); - - // Own the parent; freeing it must recursively free the child, then itself. - let owned = unsafe { BStackOwned::from_raw(::from_range(parent)) }; - owned.bstack_drop(&alloc).unwrap(); - - // The child's slot (allocated first, so the lowest offset) is reclaimed — - // proof the generated `bstack_drop` recursed into the owned child. - let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - assert_eq!(reused.start(), leaf.start()); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + // Freeing the owned parent must recursively free the wired child, then itself — + // proven by the whole structure being reclaimed with no leak. + assert_teardown_reclaims(&alloc, || { + let leaf = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); + let parent = alloc_block(&alloc, MacroParent::eightcc(), parent_size).unwrap(); + // Wire parent.child -> leaf (the first user field sits right after the header). + alloc + .stack() + .set( + parent.start() + layout::HEADER_SIZE, + leaf.start().to_le_bytes(), + ) + .unwrap(); + unsafe { BStackOwned::from_raw(::from_range(parent)) } + }); } // -------------------------------------------------------------------------- @@ -506,9 +525,23 @@ fn macro_strong_child() { // Release the keep-alive: strong -> 0 frees the child data + control block. MacroStrongChild::drop_strong_ref(unsafe { BStackRef::from_range(child) }, &alloc).unwrap(); - let reused = alloc_block(&alloc, MacroStrongChild::eightcc(), child_data_size).unwrap(); - assert_eq!(reused.start(), child.start()); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + // The child's data slot is reclaimed. The parent teardown's persistent WAL + // block perturbs the free-list order, so the slot may not be handed back first; + // drain a few same-size allocations to confirm it reappears (all freed slots + // here are small, so none starves the batch). + let mut ranges = Vec::new(); + let mut hit = false; + for _ in 0..8 { + let r = alloc_block(&alloc, MacroStrongChild::eightcc(), child_data_size).unwrap(); + if r.start() == child.start() { + hit = true; + } + ranges.push(r); + } + for r in ranges { + unsafe { dealloc_range(&alloc, r).unwrap() }; + } + assert!(hit, "child data slot was not reclaimed on strong -> 0"); } // -------------------------------------------------------------------------- @@ -1209,7 +1242,6 @@ fn macro_owned_block_vec() { MacroLeaf::new(&alloc, 20).unwrap(), MacroLeaf::new(&alloc, 30).unwrap(), ]; - let first_off = kids[0].handle().range().start(); // lowest allocation let tree = Tree::new(&alloc, kids, 7).unwrap(); assert_eq!(tree.handle().label(stack).unwrap(), 7); @@ -1226,17 +1258,17 @@ fn macro_owned_block_vec() { assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20); assert!(v.get(3).unwrap().is_none()); - // Freeing the tree recursively frees every owned child, plus the offset - // array and descriptor. The lowest child slot returns as proof. + // Freeing the tree recursively frees every owned child, plus the offset array + // and descriptor — reclaimed with no leak. tree.bstack_drop(&alloc).unwrap(); - let reused = alloc_block( - &alloc, - MacroLeaf::eightcc(), - size_of::<::OnDisk>() as u64, - ) - .unwrap(); - assert_eq!(reused.start(), first_off); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + assert_teardown_reclaims(&alloc, || { + let kids = vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + MacroLeaf::new(&alloc, 30).unwrap(), + ]; + Tree::new(&alloc, kids, 7).unwrap() + }); } #[test] @@ -1646,10 +1678,8 @@ fn macro_enum_as_field() { let tmp = TempStack::new(); let alloc = tmp.allocator(); let stack = alloc.stack(); - let leaf_size = size_of::<::OnDisk>() as u64; let leaf = MacroLeaf::new(&alloc, 5).unwrap(); - let leaf_off = leaf.handle().range().start(); let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); let holder = EnumHolder::new(&alloc, node, 3).unwrap(); assert_eq!(holder.handle().tag(stack).unwrap(), 3); @@ -1661,11 +1691,14 @@ fn macro_enum_as_field() { _ => panic!("expected Child"), } - // Freeing the struct recursively frees the enum and its owned child. + // Freeing the struct recursively frees the enum and its owned child — + // reclaimed with no leak. holder.bstack_drop(&alloc).unwrap(); - let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - assert_eq!(reused.start(), leaf_off); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 5).unwrap(); + let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); + EnumHolder::new(&alloc, node, 3).unwrap() + }); } // -------------------------------------------------------------------------- @@ -2311,11 +2344,9 @@ fn macro_embed_struct_and_enum() { let tmp = TempStack::new(); let alloc = tmp.allocator(); let stack = alloc.stack(); - let leaf_size = size_of::<::OnDisk>() as u64; // Struct embed: parent -> embedded child -> the child's own owned leaf. let leaf = MacroLeaf::new(&alloc, 42).unwrap(); - let leaf_off = leaf.handle().range().start(); let child = EmbChild::new(&alloc, leaf, 7).unwrap(); let holder = EmbHolder::new(&alloc, child, 99).unwrap(); assert_eq!(holder.handle().tag(stack).unwrap(), 99); @@ -2323,12 +2354,14 @@ fn macro_embed_struct_and_enum() { assert_eq!(c.n(stack).unwrap(), 7); assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 42); - // Teardown frees the embedded child's owned leaf *in place*, then the holder; - // the leaf's slot (lowest) is reclaimed — proof the embed recursed. + // Teardown frees the embedded child's owned leaf *in place*, then the holder — + // reclaimed with no leak (proof the embed recursed). holder.bstack_drop(&alloc).unwrap(); - let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - assert_eq!(reused.start(), leaf_off); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let child = EmbChild::new(&alloc, leaf, 7).unwrap(); + EmbHolder::new(&alloc, child, 99).unwrap() + }); // bstack_move! re-homes the embedded child to a fresh standalone allocation. let leaf = MacroLeaf::new(&alloc, 5).unwrap(); @@ -2428,8 +2461,10 @@ fn wal_finish_rolls_forward_committed() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - // A stable anchor slot, plus two "old" slices the transaction was freeing. + // A stable anchor slot (zeroed = "no WAL block yet"), plus two "old" slices + // the transaction was freeing. let anchor = alloc.alloc(8).unwrap().as_range().start(); + alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); let v1 = alloc.alloc(64).unwrap().as_range(); let v2 = alloc.alloc(64).unwrap().as_range(); @@ -2442,10 +2477,9 @@ fn wal_finish_rolls_forward_committed() { // Completing it rolls both deallocs forward. assert_eq!(finish_at(&alloc, anchor).unwrap(), 2); - // Anchor cleared, and v1/v2 reclaimed (a fresh 64-byte alloc reuses a slot). - let mut buf = [0u8; 8]; - alloc.stack().get_into(anchor, &mut buf).unwrap(); - assert_eq!(u64::from_le_bytes(buf), 0); + // The persistent WAL block is now idle: re-completing finds nothing staged. + // v1/v2 were reclaimed (a fresh 64-byte alloc reuses a freed slot). + assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); let reused = alloc.alloc(64).unwrap().as_range(); assert!(reused.start() == v1.start() || reused.start() == v2.start()); } @@ -2458,6 +2492,7 @@ fn wal_finish_abandons_uncommitted() { let tmp = TempStack::new(); let alloc = tmp.allocator(); let anchor = alloc.alloc(8).unwrap().as_range().start(); + alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); let v1 = alloc.alloc(64).unwrap().as_range(); // An UNCOMMITTED transaction: its dealloc must NOT be performed. @@ -2468,9 +2503,8 @@ fn wal_finish_abandons_uncommitted() { // Abandoned: the old slice v1 must NOT be freed (it's still live). Reclaiming // an abandoned txn frees its *allocs*, and this txn logged only a dealloc. assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); - let mut buf = [0u8; 8]; - alloc.stack().get_into(anchor, &mut buf).unwrap(); - assert_eq!(u64::from_le_bytes(buf), 0); + // Idle after completion: re-running finds nothing staged. + assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); } #[test] @@ -2486,7 +2520,7 @@ fn wal_anchor_trait_reclaims_via_finish() { // Persist an abandoned (Pending) txn into the allocator's own anchor slot. let mut log = WalLog::with_capacity(1); log.append(WalEntry::alloc(WalStatus::Pending, orphan)); - persist_at(&alloc, alloc.wal_anchor(), &log, WalStatus::Pending).unwrap(); + persist_at(&alloc, alloc.wal_anchor().unwrap(), &log, WalStatus::Pending).unwrap(); // finish() uses the trait anchor and reclaims the orphan; the allocator is // unharmed by our writes to its reserved slot (a fresh alloc reuses it). @@ -2502,6 +2536,7 @@ fn wal_finish_reclaims_abandoned_allocs() { let tmp = TempStack::new(); let alloc = tmp.allocator(); let anchor = alloc.alloc(8).unwrap().as_range().start(); + alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); // Two blocks a crashed op allocated but never linked (orphans). let a1 = alloc.alloc(64).unwrap().as_range(); let a2 = alloc.alloc(64).unwrap().as_range(); @@ -2520,6 +2555,7 @@ fn wal_finish_reclaims_abandoned_allocs() { // A *committed* alloc-only txn keeps its allocs (frees nothing). let anchor2 = alloc.alloc(8).unwrap().as_range().start(); + alloc.stack().set(anchor2, 0u64.to_le_bytes()).unwrap(); let keep = alloc.alloc(64).unwrap().as_range(); let mut log2 = WalLog::with_capacity(1); log2.append(WalEntry::alloc(WalStatus::Pending, keep)); @@ -2560,12 +2596,10 @@ fn macro_owned_array() { let tmp = TempStack::new(); let alloc = tmp.allocator(); let stack = alloc.stack(); - let leaf_size = size_of::<::OnDisk>() as u64; let l0 = MacroLeaf::new(&alloc, 10).unwrap(); let l1 = MacroLeaf::new(&alloc, 20).unwrap(); let l2 = MacroLeaf::new(&alloc, 30).unwrap(); - let off0 = l0.handle().range().start(); let h = ArrHolder::new(&alloc, [l0, l1, l2], 7).unwrap(); assert_eq!(h.handle().tag(stack).unwrap(), 7); @@ -2574,11 +2608,14 @@ fn macro_owned_array() { assert_eq!(arr[1].val(stack).unwrap(), 20); assert_eq!(arr[2].val(stack).unwrap(), 30); - // Teardown frees all three inline children; the lowest slot (l0) is reclaimed. + // Teardown frees all three inline children — reclaimed with no leak. h.bstack_drop(&alloc).unwrap(); - let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - assert_eq!(reused.start(), off0); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + assert_teardown_reclaims(&alloc, || { + let l0 = MacroLeaf::new(&alloc, 10).unwrap(); + let l1 = MacroLeaf::new(&alloc, 20).unwrap(); + let l2 = MacroLeaf::new(&alloc, 30).unwrap(); + ArrHolder::new(&alloc, [l0, l1, l2], 7).unwrap() + }); } #[test] @@ -4008,7 +4045,6 @@ fn macro_generic_owned_box() { let stack = alloc.stack(); let leaf = MacroLeaf::new(&alloc, 42).unwrap(); - let leaf_off = leaf.handle().range().start(); let b = OwnedBox::::new(&alloc, leaf, 7).unwrap(); assert_eq!(b.handle().tag(stack).unwrap(), 7); assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); @@ -4025,12 +4061,12 @@ fn macro_generic_owned_box() { // Original child survives the clone's teardown. assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); - // Dropping the box frees its owned child; the slot is reclaimable. + // Dropping the box frees its owned child — reclaimed with no leak. b.bstack_drop(&alloc).unwrap(); - let leaf_size = size_of::<::OnDisk>() as u64; - let reused = alloc_block(&alloc, MacroLeaf::eightcc(), leaf_size).unwrap(); - assert_eq!(reused.start(), leaf_off); - unsafe { dealloc_range(&alloc, reused).unwrap() }; + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + OwnedBox::::new(&alloc, leaf, 7).unwrap() + }); } #[bstack_block] @@ -4639,19 +4675,15 @@ fn stdlib_list_drop_is_recursive() { let alloc = tmp.allocator(); // A value type that itself owns a child, to prove teardown recurses through - // the node's single value ref into the value's own children. - let leaf = MacroLeaf::new(&alloc, 10).unwrap(); - let leaf_start = leaf.handle().range().start(); - let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); - - let list = BStackLinkedList::::new(&alloc).unwrap(); - list.push_back(&alloc, parent).unwrap(); - list.bstack_drop(&alloc).unwrap(); - - // The leaf (a grandchild, freed only via full recursion) slot is reclaimed. - let reused = MacroLeaf::new(&alloc, 0).unwrap(); - assert_eq!(reused.handle().range().start(), leaf_start); - reused.bstack_drop(&alloc).unwrap(); + // the node's single value ref into the value's own children (a non-recursive + // teardown would leak the MacroLeaf grandchild). + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + let list = BStackLinkedList::::new(&alloc).unwrap(); + list.push_back(&alloc, parent).unwrap(); + list + }); } #[test] @@ -4859,18 +4891,14 @@ fn stdlib_deque_drop_is_recursive() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - let leaf = MacroLeaf::new(&alloc, 10).unwrap(); - let leaf_start = leaf.handle().range().start(); - let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); - - let dq = BStackDeque::::new(&alloc).unwrap(); - dq.push_back(&alloc, parent).unwrap(); - dq.bstack_drop(&alloc).unwrap(); - - // The leaf grandchild's slot is reclaimed — full recursion through the ring. - let reused = MacroLeaf::new(&alloc, 0).unwrap(); - assert_eq!(reused.handle().range().start(), leaf_start); - reused.bstack_drop(&alloc).unwrap(); + // Full recursion through the ring must free the MacroLeaf grandchild too. + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + let dq = BStackDeque::::new(&alloc).unwrap(); + dq.push_back(&alloc, parent).unwrap(); + dq + }); } #[test] @@ -5107,18 +5135,14 @@ fn stdlib_map_drop_is_recursive() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - let leaf = MacroLeaf::new(&alloc, 10).unwrap(); - let leaf_start = leaf.handle().range().start(); - let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); - - let map = BStackHashMap::::new(&alloc).unwrap(); - map.insert(&alloc, 42, parent).unwrap(); - map.bstack_drop(&alloc).unwrap(); - - // The leaf grandchild's slot is reclaimed — full recursion through a value. - let reused = MacroLeaf::new(&alloc, 0).unwrap(); - assert_eq!(reused.handle().range().start(), leaf_start); - reused.bstack_drop(&alloc).unwrap(); + // Full recursion through a stored value must free the MacroLeaf grandchild. + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + let map = BStackHashMap::::new(&alloc).unwrap(); + map.insert(&alloc, 42, parent).unwrap(); + map + }); } #[test] @@ -5280,18 +5304,14 @@ fn stdlib_tree_drop_is_recursive() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - let leaf = MacroLeaf::new(&alloc, 10).unwrap(); - let leaf_start = leaf.handle().range().start(); - let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); - - let tree = BStackBTreeMap::::new(&alloc).unwrap(); - tree.insert(&alloc, 42, parent).unwrap(); - tree.bstack_drop(&alloc).unwrap(); - - // The leaf grandchild's slot is reclaimed — full recursion through a value. - let reused = MacroLeaf::new(&alloc, 0).unwrap(); - assert_eq!(reused.handle().range().start(), leaf_start); - reused.bstack_drop(&alloc).unwrap(); + // Full recursion through a stored value must free the MacroLeaf grandchild. + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + let tree = BStackBTreeMap::::new(&alloc).unwrap(); + tree.insert(&alloc, 42, parent).unwrap(); + tree + }); } #[test] @@ -6004,18 +6024,14 @@ fn stdlib_heap_drop_is_recursive() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - let leaf = MacroLeaf::new(&alloc, 10).unwrap(); - let leaf_start = leaf.handle().range().start(); - let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); - - let heap = BStackBinaryHeap::::new(&alloc).unwrap(); - heap.push(&alloc, 5, parent).unwrap(); - heap.bstack_drop(&alloc).unwrap(); - - // The leaf grandchild's slot is reclaimed — full recursion through a value. - let reused = MacroLeaf::new(&alloc, 0).unwrap(); - assert_eq!(reused.handle().range().start(), leaf_start); - reused.bstack_drop(&alloc).unwrap(); + // Full recursion through a stored value must free the MacroLeaf grandchild. + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 10).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 1).unwrap(); + let heap = BStackBinaryHeap::::new(&alloc).unwrap(); + heap.push(&alloc, 5, parent).unwrap(); + heap + }); } #[test] @@ -6329,7 +6345,9 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { let mut prev: Option = None; for i in 0..30 { stack.set_fault_policy(Some(Arc::new(FailFirstInplaceGen(AtomicBool::new(false))))); - let r = crate::wal_clone_in(src.handle(), &alloc); + // Automatic WAL: `try_clone_in` on an anchored allocator (FirstFit) logs + // and reclaims its orphans with no separate opt-in call. + let r = src.try_clone_in(&alloc); stack.set_fault_policy(None); assert!(r.is_err(), "injected fault must fail the clone commit"); let len = stack.len().unwrap(); @@ -6390,6 +6408,10 @@ fn wal_teardown_reclaims_on_free_fault() { list }; + // Warm the persistent WAL block with one clean WAL-backed teardown, so `peak` + // already accounts for it (it is allocated once and reused, never per-txn). + build(&alloc).bstack_drop(&alloc).unwrap(); + let list1 = build(&alloc); let peak = stack.len().unwrap(); @@ -6398,7 +6420,9 @@ fn wal_teardown_reclaims_on_free_fault() { committed: AtomicBool::new(false), fired: AtomicBool::new(false), }))); - let r = crate::wal_drop(list1, &alloc); + // Automatic WAL: `bstack_drop` on the owned handle runs the WAL-backed + // teardown (via `BStackOwned::bstack_drop` → `wal_teardown`) with no opt-in. + let r = list1.bstack_drop(&alloc); stack.set_fault_policy(None); assert!(r.is_err(), "the injected fault must interrupt the teardown"); @@ -6417,3 +6441,4 @@ fn wal_teardown_reclaims_on_free_fault() { ); list2.bstack_drop(&alloc).unwrap(); } + From 9b94206e2b8647cbd1e8884c8d776ea6c851deed Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 00:57:10 -0700 Subject: [PATCH 097/140] fmt --- bstack_raii/src/block.rs | 7 ++----- bstack_raii/src/cast.rs | 2 +- bstack_raii/src/clone.rs | 6 +----- bstack_raii/src/construct.rs | 2 +- bstack_raii/src/handle.rs | 2 +- bstack_raii/src/owned.rs | 4 +--- bstack_raii/src/shared.rs | 2 +- bstack_raii/src/stdlib/bloom.rs | 20 ++++---------------- bstack_raii/src/stdlib/boxed.rs | 17 ++++------------- bstack_raii/src/stdlib/btreeset.rs | 13 +++---------- bstack_raii/src/stdlib/cow.rs | 12 +++--------- bstack_raii/src/stdlib/deque.rs | 7 ++----- bstack_raii/src/stdlib/hashset.rs | 7 ++----- bstack_raii/src/stdlib/heap.rs | 7 ++----- bstack_raii/src/stdlib/list.rs | 12 +++--------- bstack_raii/src/stdlib/map.rs | 7 ++----- bstack_raii/src/stdlib/string.rs | 18 ++++-------------- bstack_raii/src/stdlib/tree.rs | 13 +++---------- bstack_raii/src/stdlib/util.rs | 2 +- bstack_raii/src/teardown.rs | 5 +---- bstack_raii/src/tests.rs | 9 +++++++-- bstack_raii/src/vec.rs | 2 +- bstack_raii/src/wal.rs | 9 +++++++-- 23 files changed, 57 insertions(+), 128 deletions(-) diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index d7dcfb5..ce3ef34 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -6,8 +6,8 @@ use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use bytemuck::Pod; use crate::clone::ClonePlan; @@ -158,10 +158,7 @@ pub trait BStackShared: BStackBlock { /// Drop one strong reference to a block of this type located at `data`, /// freeing it (and, for `(rc, weak)`, releasing the control block) when the /// strong count reaches zero. - fn drop_strong_ref( - data: BStackRef, - allocator: &A, - ) -> io::Result<()>; + fn drop_strong_ref(data: BStackRef, allocator: &A) -> io::Result<()>; /// Resolve the raw parts of a strong handle to a child of this type at /// `data`: the data ref, plus the control-block range for `(rc, weak)` diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs index 8ba7233..dbb85a0 100644 --- a/bstack_raii/src/cast.rs +++ b/bstack_raii/src/cast.rs @@ -7,8 +7,8 @@ use std::io; -use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; use crate::wal::BStackWalAnchor; +use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; use crate::block::{BStackBlock, BStackCast}; use crate::layout::EightCC; diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index dedb0c8..d021be6 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -87,10 +87,7 @@ pub trait TryClone: Sized { /// why (in particular, why a weak reference can only ever be cloned as another /// weak reference). pub trait TryCloneIn: BStackDrop + Sized { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result>; + fn try_clone_in(&self, allocator: &A) -> io::Result>; } /// Accumulates a deep clone's allocations, payload writes, and refcount bumps so @@ -419,4 +416,3 @@ impl ClonePlan { } } } - diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 55edf1b..75ef6dc 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -11,8 +11,8 @@ use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::block::BStackWeakable; use crate::handle::WeakRef; diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 284a82e..737e141 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -16,8 +16,8 @@ use core::mem::size_of; use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackWeakable}; use crate::layout; diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index 30ad963..b13a322 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -81,9 +81,7 @@ impl BStackDrop for BStackOwned { /// /// (A *bare* `BStackOwned` carries no allocator, so it is moved with the /// explicit two-argument form `bstack_move!(owned, allocator)` instead.) -impl<'a, X: BStackMove, A: BStackWalAnchor> BStackMoveExpr - for AutoDrop<'a, BStackOwned, A> -{ +impl<'a, X: BStackMove, A: BStackWalAnchor> BStackMoveExpr for AutoDrop<'a, BStackOwned, A> { type Output = io::Result>; fn bstack_move(self) -> Self::Output { let (owned, allocator) = self.into_raw_parts(); diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index d9da19d..dba7075 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -8,8 +8,8 @@ use core::mem::size_of; use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index a656626..ea0a5cf 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -37,8 +37,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::double_hash; @@ -110,11 +110,7 @@ impl BStackCountingBloomFilter { /// Allocate a filter with `m` counters and `k` hash functions (both forced to /// at least 1). Prefer [`with_capacity`](Self::with_capacity) to size these. - pub fn new( - allocator: &A, - m: u64, - k: u64, - ) -> io::Result> { + pub fn new(allocator: &A, m: u64, k: u64) -> io::Result> { let m = m.max(1); let k = k.max(1); // Allocate and zero the counter block (an orphan until the handle links it). @@ -223,12 +219,7 @@ impl BStackCountingBloomFilter { /// Atomically adjust the counters for `key` (and `n`) up or down, reading and /// writing every touched counter in one `inplace_gen` (external-lock-free). - fn adjust( - &self, - allocator: &A, - key: &K, - add: bool, - ) -> io::Result<()> { + fn adjust(&self, allocator: &A, key: &K, add: bool) -> io::Result<()> { let handle = self.range.start(); let [data, m, k] = read_fields::<3>(allocator.stack(), handle + DATA_OFF)?; let agg = Self::aggregate(Self::indices(m, k, bytemuck::bytes_of(key))); @@ -400,10 +391,7 @@ impl BStackDrop for BStackCountingBloomFilter { } impl TryCloneIn for BStackCountingBloomFilter { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/boxed.rs b/bstack_raii/src/stdlib/boxed.rs index ece5eb3..6ffb554 100644 --- a/bstack_raii/src/stdlib/boxed.rs +++ b/bstack_raii/src/stdlib/boxed.rs @@ -20,8 +20,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackCast, BStackMove}; @@ -72,10 +72,7 @@ impl BStackBox { /// /// The header and payload are written as a single image, so the block is /// created with one write (and released without leaking on write failure). - pub fn new( - allocator: &A, - value: T, - ) -> io::Result> { + pub fn new(allocator: &A, value: T) -> io::Result> { let od = BoxOnDisk { header: BlockHeader { size: Self::SIZE, @@ -152,10 +149,7 @@ impl BStackDrop for BStackBox { } impl TryCloneIn for BStackBox { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { // Mirror the generated `try_clone_in`: build the plan (a byte copy, via // the childless `__bstack_clone_into` default), then commit atomically. let mut plan = ClonePlan::new(); @@ -176,10 +170,7 @@ impl BStackMove for BStackBox { /// Moving a box out yields the plain value. type Fields<'a, A: BStackWalAnchor> = T; - fn bstack_move( - owned: BStackOwned, - allocator: &A, - ) -> io::Result { + fn bstack_move(owned: BStackOwned, allocator: &A) -> io::Result { let me = owned.into_inner(); let value = me.get(allocator.stack())?; // Childless: free the shell after reading the value out. diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index c26099d..cd830eb 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -23,8 +23,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -759,11 +759,7 @@ impl BStackBTreeSet { unsafe { AutoDrop::from_raw(self, allocator) } } - fn drop_subtree( - stack: &BStack, - off: u64, - allocator: &A, - ) -> io::Result<()> { + fn drop_subtree(stack: &BStack, off: u64, allocator: &A) -> io::Result<()> { if off == 0 { return Ok(()); } @@ -887,10 +883,7 @@ impl BStackDrop for BStackBTreeSet { } impl TryCloneIn for BStackBTreeSet { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs index 0fe0e90..c98974c 100644 --- a/bstack_raii/src/stdlib/cow.rs +++ b/bstack_raii/src/stdlib/cow.rs @@ -18,8 +18,8 @@ use std::io; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStackOwnedSliceAllocator, BStackRange}; use crate::block::BStackBlock; use crate::clone::TryCloneIn; @@ -101,10 +101,7 @@ impl BStackCow { /// * `Owned` — returned as-is; no I/O. /// * `Borrowed` — the referenced block is deep-cloned into a fresh /// independent [`BStackOwned`] allocated with `allocator`. - pub fn into_owned( - self, - allocator: &A, - ) -> io::Result> + pub fn into_owned(self, allocator: &A) -> io::Result> where T: TryCloneIn, { @@ -123,10 +120,7 @@ impl BStackCow { /// through the returned handle (the block's setters + `allocator`) never /// touch the originally borrowed block. A no-op (beyond the ownership /// check) when already owned. - pub fn to_mut( - &mut self, - allocator: &A, - ) -> io::Result<&mut BStackOwned> + pub fn to_mut(&mut self, allocator: &A) -> io::Result<&mut BStackOwned> where T: TryCloneIn, { diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index b9f831d..f21150b 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -36,8 +36,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -591,10 +591,7 @@ impl BStackDrop for BStackDeque { } impl TryCloneIn for BStackDeque { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index 5553aa1..331e3f9 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -33,8 +33,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -597,10 +597,7 @@ impl BStackDrop for BStackHashSet { } impl TryCloneIn for BStackHashSet { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs index 7be1310..c85554b 100644 --- a/bstack_raii/src/stdlib/heap.rs +++ b/bstack_raii/src/stdlib/heap.rs @@ -30,8 +30,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -426,10 +426,7 @@ impl BStackDrop for BStackBinaryHeap { } impl TryCloneIn for BStackBinaryHeap { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index 1394152..9b0016b 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -33,8 +33,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -412,10 +412,7 @@ impl BStackLinkedList { } /// Attach an allocator to make an auto-freeing [`crate::AutoDrop`] guard. - pub fn auto( - self, - allocator: &A, - ) -> crate::teardown::AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> crate::teardown::AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the list was created. unsafe { crate::teardown::AutoDrop::from_raw(self, allocator) } } @@ -540,10 +537,7 @@ impl BStackDrop for BStackLinkedList { } impl TryCloneIn for BStackLinkedList { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 8e5e797..4eaf6ee 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -37,8 +37,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; @@ -684,10 +684,7 @@ impl BStackDrop for BStackHashMap { } impl TryCloneIn for BStackHashMap { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index af25cca..92623b0 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -16,8 +16,8 @@ use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -71,10 +71,7 @@ impl BStackString { } /// Create a string from `s`. - pub fn new( - allocator: &A, - s: &str, - ) -> io::Result> { + pub fn new(allocator: &A, s: &str) -> io::Result> { let len = s.len() as u64; let data = Self::alloc_bytes(allocator, s.as_bytes())?; let od = StringOnDisk { @@ -174,11 +171,7 @@ impl BStackString { /// Truncate to `new_len` **bytes**, which must be a UTF-8 char boundary and /// not exceed the current length; longer values leave the string unchanged. - pub fn truncate( - &self, - allocator: &A, - new_len: usize, - ) -> io::Result<()> { + pub fn truncate(&self, allocator: &A, new_len: usize) -> io::Result<()> { let mut cur = self.to_string(allocator.stack())?; if new_len >= cur.len() { return Ok(()); @@ -304,10 +297,7 @@ impl BStackDrop for BStackString { } impl TryCloneIn for BStackString { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 5f1237a..7f36ae2 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -41,8 +41,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{Scratch, alloc_image, read_fields, read_u64}; @@ -915,11 +915,7 @@ impl BStackBTreeMap { } /// Recursively free the subtree at `off` (values then nodes). - fn drop_subtree( - stack: &BStack, - off: u64, - allocator: &A, - ) -> io::Result<()> { + fn drop_subtree(stack: &BStack, off: u64, allocator: &A) -> io::Result<()> { if off == 0 { return Ok(()); } @@ -1051,10 +1047,7 @@ impl BStackDrop for BStackBTreeMap { } impl TryCloneIn for BStackBTreeMap { - fn try_clone_in( - &self, - allocator: &A, - ) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 708d2da..e606ab9 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -7,8 +7,8 @@ use std::io; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use crate::layout::{HEADER_SIZE, get_u64}; diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index af64358..9176820 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -41,10 +41,7 @@ thread_local! { /// (e.g. a collection freeing its values through `BStackOwned::bstack_drop`) see /// the sink already set and just collect, so exactly one transaction wraps the /// outermost teardown. -pub fn wal_teardown( - handle: T, - allocator: &A, -) -> io::Result<()> { +pub fn wal_teardown(handle: T, allocator: &A) -> io::Result<()> { // Nested teardown: an outer driver already owns the sink; frees already // collect, so just recurse (exactly one transaction wraps the whole subtree). if TEARDOWN_SINK.with(|s| s.borrow().is_some()) { diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 245e937..e9278e8 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2520,7 +2520,13 @@ fn wal_anchor_trait_reclaims_via_finish() { // Persist an abandoned (Pending) txn into the allocator's own anchor slot. let mut log = WalLog::with_capacity(1); log.append(WalEntry::alloc(WalStatus::Pending, orphan)); - persist_at(&alloc, alloc.wal_anchor().unwrap(), &log, WalStatus::Pending).unwrap(); + persist_at( + &alloc, + alloc.wal_anchor().unwrap(), + &log, + WalStatus::Pending, + ) + .unwrap(); // finish() uses the trait anchor and reclaims the orphan; the allocator is // unharmed by our writes to its reserved slot (a fresh alloc reuses it). @@ -6441,4 +6447,3 @@ fn wal_teardown_reclaims_on_free_fault() { ); list2.bstack_drop(&alloc).unwrap(); } - diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 79d67f3..33ef918 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -30,8 +30,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; use crate::wal::BStackWalAnchor; +use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index d495c40..65420b0 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -444,7 +444,10 @@ pub(crate) fn wal_lock_for(allocator: &A) -> Arc(allocator: &A, anchor: u64) -> io::Result> { +fn read_anchor( + allocator: &A, + anchor: u64, +) -> io::Result> { let mut buf = [0u8; 8]; allocator.stack().get_into(anchor, &mut buf)?; let off = u64::from_le_bytes(buf); @@ -551,7 +554,9 @@ pub(crate) fn wal_set_idle( block_off: u64, ) -> io::Result<()> { // `txn_status` is the byte right after the u64 magic (offset 8). - allocator.stack().set(block_off + 8, [WalStatus::None as u8]) + allocator + .stack() + .set(block_off + 8, [WalStatus::None as u8]) } /// **Complete** a staged transaction by reclaiming exactly the slices its outcome From 32ee8ea182f4b844f05c400fcf6e364ddaf0a821 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 01:17:03 -0700 Subject: [PATCH 098/140] Rename BStackWalAnchor to BStackRaiiAllocator --- bstack_raii/derive/src/block.rs | 76 +++++++++++++++--------------- bstack_raii/src/block.rs | 21 +++++---- bstack_raii/src/cast.rs | 10 ++-- bstack_raii/src/clone.rs | 26 +++++----- bstack_raii/src/construct.rs | 14 +++--- bstack_raii/src/handle.rs | 20 ++++---- bstack_raii/src/lib.rs | 63 ++++++++++++++++++------- bstack_raii/src/owned.rs | 8 ++-- bstack_raii/src/shared.rs | 24 +++++----- bstack_raii/src/stdlib/bloom.rs | 28 ++++++----- bstack_raii/src/stdlib/boxed.rs | 19 ++++---- bstack_raii/src/stdlib/btreeset.rs | 40 +++++++++------- bstack_raii/src/stdlib/cow.rs | 15 +++--- bstack_raii/src/stdlib/deque.rs | 30 ++++++------ bstack_raii/src/stdlib/hashset.rs | 28 +++++------ bstack_raii/src/stdlib/heap.rs | 26 +++++----- bstack_raii/src/stdlib/list.rs | 27 ++++++----- bstack_raii/src/stdlib/map.rs | 26 +++++----- bstack_raii/src/stdlib/string.rs | 32 +++++++------ bstack_raii/src/stdlib/tree.rs | 42 +++++++++-------- bstack_raii/src/stdlib/util.rs | 10 ++-- bstack_raii/src/teardown.rs | 24 +++++----- bstack_raii/src/tests.rs | 16 +++---- bstack_raii/src/vec.rs | 28 +++++------ bstack_raii/src/wal.rs | 46 +++++------------- 25 files changed, 368 insertions(+), 331 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 690de1e..89207c8 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -458,7 +458,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ) }; accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -820,7 +820,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &acc_leaf, &acc_read); accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -1138,7 +1138,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let setter = format_ident!("set_{}", fname); setters.push(quote! { - #vis fn #setter<'__s, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #setter<'__s, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__s __A, index: usize, @@ -1160,7 +1160,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &leaf_ty, &acc_read); accessors.push(quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__u __A, ) -> ::std::io::Result<#acc_ret> { @@ -1759,14 +1759,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result Mode::Plain => quote!(), Mode::Rc => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( + fn drop_strong_ref<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; ::bstack_raii::StrongRef(data).bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( + fn strong_parts<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, _allocator: &__A, ) -> ::std::io::Result<( @@ -1779,7 +1779,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }, Mode::RcWeak => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( + fn drop_strong_ref<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { @@ -1787,7 +1787,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ::bstack_raii::StrongWeakRef::from_disk(data, allocator)? .bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( + fn strong_parts<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<( @@ -1844,9 +1844,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // Implemented on the block type (local downstream) so the orphan rule // is satisfied; `bstack_move!` selects it from the argument's type. impl #impl_g ::bstack_raii::BStackMove for #name #ty_g #where_g { - type Fields<'__mv, __A: ::bstack_raii::BStackWalAnchor> = + type Fields<'__mv, __A: ::bstack_raii::BStackRaiiAllocator> = ( #(#mv_types,)* ); - fn bstack_move<'__mv, __A: ::bstack_raii::BStackWalAnchor>( + fn bstack_move<'__mv, __A: ::bstack_raii::BStackRaiiAllocator>( owned: ::bstack_raii::BStackOwned, __alloc: &'__mv __A, ) -> ::std::io::Result> { @@ -1920,7 +1920,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let clone_trait_methods = quote! { #[doc(hidden)] #[allow(unused_variables, unused_imports)] - fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackWalAnchor>( + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -1932,7 +1932,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } #[doc(hidden)] #[allow(unused_variables)] - fn __bstack_clone_into<__A: ::bstack_raii::BStackWalAnchor>( + fn __bstack_clone_into<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -1943,7 +1943,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let clone_impl = if mode == Mode::Plain { quote! { impl #impl_g ::bstack_raii::TryCloneIn for #name #ty_g #where_g { - fn try_clone_in<__A: ::bstack_raii::BStackWalAnchor>( + fn try_clone_in<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { @@ -2071,7 +2071,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result /// `BStackBlock` default. #[doc(hidden)] #[allow(unused_imports)] - fn __bstack_drop_children<__A: ::bstack_raii::BStackWalAnchor>( + fn __bstack_drop_children<__A: ::bstack_raii::BStackRaiiAllocator>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { @@ -2089,7 +2089,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } impl #impl_g ::bstack_raii::BStackDrop for #name #ty_g #where_g { - fn bstack_drop<__A: ::bstack_raii::BStackWalAnchor>( + fn bstack_drop<__A: ::bstack_raii::BStackRaiiAllocator>( self, allocator: &__A, ) -> ::std::io::Result<()> { @@ -2350,7 +2350,7 @@ fn vec_accessor( let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2361,7 +2361,7 @@ fn vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { @@ -2538,7 +2538,7 @@ fn block_vec_accessor( let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2549,7 +2549,7 @@ fn block_vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { @@ -2779,7 +2779,7 @@ fn accessor( // Weak fields hold a control offset; the accessor attempts a live upgrade. if kind == Kind::Weak { return quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #fname<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__u __A, ) -> ::std::io::Result< @@ -3010,7 +3010,7 @@ fn weak_setter( ) -> TokenStream { let setter = format_ident!("set_{}", fname); quote! { - #vis fn #setter<'__s, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn #setter<'__s, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__s __A, weak: ::bstack_raii::BStackWeak<'__s, #fty, __A>, @@ -3084,7 +3084,7 @@ fn constructor( } }; quote! { - #vis fn new<'__ctor, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn new<'__ctor, __A: ::bstack_raii::BStackRaiiAllocator>( allocator: &'__ctor __A, #(#params)* ) -> ::std::io::Result<#ret> { @@ -3117,7 +3117,7 @@ fn constructor( ::core::mem::size_of::<::Control>() as u64 }; quote! { - #vis fn new<'__ctor, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn new<'__ctor, __A: ::bstack_raii::BStackRaiiAllocator>( allocator: &'__ctor __A, #(#params)* ) -> ::std::io::Result<::bstack_raii::BStackRc<'__ctor, Self, __A>> { @@ -5058,7 +5058,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = Vec::new(); if la { parts.push(lt.clone()); - parts.push(quote!(__A: ::bstack_raii::BStackWalAnchor)); + parts.push(quote!(__A: ::bstack_raii::BStackRaiiAllocator)); } parts.extend(etp_decl.iter().cloned()); if parts.is_empty() { @@ -5134,7 +5134,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + #vis fn new<'__e, __A: ::bstack_raii::BStackRaiiAllocator>( allocator: &'__e __A, data: #data_ty, ) -> ::std::io::Result<#new_ret> { @@ -5166,7 +5166,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result::Control>() as u64 }; quote! { - #vis fn new<'__e, __A: ::bstack_raii::BStackWalAnchor>( + #vis fn new<'__e, __A: ::bstack_raii::BStackRaiiAllocator>( allocator: &'__e __A, data: #data_ty, ) -> ::std::io::Result<::bstack_raii::BStackRc<'__e, Self, __A>> { @@ -5217,14 +5217,14 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote!(), Mode::Rc => quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( + fn drop_strong_ref<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { use ::bstack_raii::BStackDrop as _; ::bstack_raii::StrongRef(data).bstack_drop(allocator) } - fn strong_parts<__A: ::bstack_raii::BStackWalAnchor>( + fn strong_parts<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, _allocator: &__A, ) -> ::std::io::Result<( @@ -5237,7 +5237,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result quote! { impl ::bstack_raii::BStackShared for #name { - fn drop_strong_ref<__A: ::bstack_raii::BStackWalAnchor>( + fn drop_strong_ref<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5245,7 +5245,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn strong_parts<__A: ::bstack_raii::BStackRaiiAllocator>( data: ::bstack_raii::BStackRef, allocator: &__A, ) -> ::std::io::Result<( @@ -5382,7 +5382,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn try_clone_in<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { @@ -5521,7 +5521,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_clone_children_inplace<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -5535,7 +5535,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_clone_into<__A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &__A, __plan: &mut ::bstack_raii::ClonePlan, @@ -5549,7 +5549,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn __bstack_drop_children<__A: ::bstack_raii::BStackRaiiAllocator>( __range: ::bstack_raii::BStackRange, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5561,7 +5561,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + fn bstack_drop<__A: ::bstack_raii::BStackRaiiAllocator>( self, allocator: &__A, ) -> ::std::io::Result<()> { @@ -5576,7 +5576,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result( + #vis fn read<'__e, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__e __A, ) -> ::std::io::Result<#view_ty> { @@ -5614,8 +5614,8 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result = #move_fields_ty; - fn bstack_move<'__mv, __A: ::bstack_raii::BStackWalAnchor>( + type Fields<'__mv, __A: ::bstack_raii::BStackRaiiAllocator> = #move_fields_ty; + fn bstack_move<'__mv, __A: ::bstack_raii::BStackRaiiAllocator>( owned: ::bstack_raii::BStackOwned, __alloc: &'__mv __A, ) -> ::std::io::Result> { diff --git a/bstack_raii/src/block.rs b/bstack_raii/src/block.rs index ce3ef34..d1c6356 100644 --- a/bstack_raii/src/block.rs +++ b/bstack_raii/src/block.rs @@ -6,8 +6,8 @@ use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::BStackRange; use bytemuck::Pod; use crate::clone::ClonePlan; @@ -57,7 +57,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// nothing. Exposed on the trait — rather than as a generated inherent method — /// so a generic parent can recurse into a type parameter. `#[doc(hidden)]`. #[doc(hidden)] - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -74,7 +74,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// rather than as a generated inherent method — so a generic parent can /// recurse into a type parameter's clone. `#[doc(hidden)]`: an impl detail. #[doc(hidden)] - fn __bstack_clone_children_inplace( + fn __bstack_clone_children_inplace( &self, allocator: &A, _plan: &mut ClonePlan, @@ -91,7 +91,7 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// caller). Overridden by the generated impl; the default suffices for a /// childless block. `#[doc(hidden)]`: an impl detail. #[doc(hidden)] - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -118,8 +118,8 @@ pub trait BStackBlock: BStackCast + BStackDrop + Sized { /// allocator, since neither the owned handle nor the block type carries one. pub trait BStackMove: BStackBlock { /// The tuple of field handles produced, in field-declaration order. - type Fields<'a, A: BStackWalAnchor>; - fn bstack_move<'a, A: BStackWalAnchor>( + type Fields<'a, A: BStackRaiiAllocator>; + fn bstack_move<'a, A: BStackRaiiAllocator>( owned: BStackOwned, allocator: &'a A, ) -> io::Result>; @@ -158,13 +158,16 @@ pub trait BStackShared: BStackBlock { /// Drop one strong reference to a block of this type located at `data`, /// freeing it (and, for `(rc, weak)`, releasing the control block) when the /// strong count reaches zero. - fn drop_strong_ref(data: BStackRef, allocator: &A) -> io::Result<()>; + fn drop_strong_ref( + data: BStackRef, + allocator: &A, + ) -> io::Result<()>; /// Resolve the raw parts of a strong handle to a child of this type at /// `data`: the data ref, plus the control-block range for `(rc, weak)` /// blocks (`None` for plain `(rc)`). Used by `bstack_move!` to rebuild a /// `BStackRc` for a `#[bstack_strong]` field. - fn strong_parts( + fn strong_parts( data: BStackRef, allocator: &A, ) -> io::Result<(BStackRef, Option)>; diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs index dbb85a0..48d1259 100644 --- a/bstack_raii/src/cast.rs +++ b/bstack_raii/src/cast.rs @@ -7,8 +7,8 @@ use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStackOwnedSlice, BStackOwnedSliceAllocator, BStackSlice}; +use crate::BStackRaiiAllocator; +use bstack::{BStackOwnedSlice, BStackSlice}; use crate::block::{BStackBlock, BStackCast}; use crate::layout::EightCC; @@ -18,7 +18,7 @@ use crate::teardown::AutoDrop; /// Byte offset of the `tag` within a [`crate::BlockHeader`] (`size: u64` first). const TAG_OFFSET: u64 = 8; -impl<'a, T: BStackBlock, A: BStackWalAnchor> AutoDrop<'a, BStackOwned, A> { +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> AutoDrop<'a, BStackOwned, A> { /// Upcast an auto-freeing owned handle to the untyped owned slice, discarding /// type info (infallible). /// @@ -35,14 +35,14 @@ impl<'a, T: BStackBlock, A: BStackWalAnchor> AutoDrop<'a, BStackOwned, A> { /// Downcast an owned slice to a typed (bare) owned handle by checking the block /// tag. The result carries no allocator; free it with `owned.bstack_drop(alloc)` /// or wrap it via `owned.auto(alloc)`. -pub trait BStackCastInto<'a, A: BStackWalAnchor>: Sized { +pub trait BStackCastInto<'a, A: BStackRaiiAllocator>: Sized { /// `Ok(Ok(owned))` on a tag match; `Ok(Err(self))` on mismatch (ownership is /// handed back so the caller can try another type); `Err` on an I/O failure /// reading the header. fn cast_into(self) -> io::Result, Self>>; } -impl<'a, A: BStackWalAnchor> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { +impl<'a, A: BStackRaiiAllocator> BStackCastInto<'a, A> for BStackOwnedSlice<'a, A> { fn cast_into(self) -> io::Result, Self>> { let mut tag = [0u8; 8]; self.read_range_into(TAG_OFFSET, &mut tag)?; diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index d021be6..0bfdcd9 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -37,8 +37,9 @@ use std::io; -use bstack::{BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStackGenOp, BStackRange}; +use crate::BStackRaiiAllocator; use crate::block::{BStackBlock, BStackShared}; use crate::layout; use crate::owned::BStackOwned; @@ -46,8 +47,7 @@ use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; use crate::vec::{BYTEVEC_HEADER, VecDesc}; use crate::wal::{ - BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, - wal_set_idle, + WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, wal_set_idle, }; /// Duplicate `self`, performing any fallible I/O the duplication requires, @@ -87,7 +87,7 @@ pub trait TryClone: Sized { /// why (in particular, why a weak reference can only ever be cloned as another /// weak reference). pub trait TryCloneIn: BStackDrop + Sized { - fn try_clone_in(&self, allocator: &A) -> io::Result>; + fn try_clone_in(&self, allocator: &A) -> io::Result>; } /// Accumulates a deep clone's allocations, payload writes, and refcount bumps so @@ -126,7 +126,7 @@ impl ClonePlan { /// for rollback. The caller supplies the block's bytes later via /// [`write`](Self::write). The allocator's owned-slice handle is not RAII, so /// letting it drop here does not free the block. - pub fn alloc_raw( + pub fn alloc_raw( &mut self, allocator: &A, size: u64, @@ -156,7 +156,7 @@ impl ClonePlan { /// our machinery and its bytes ride the same `inplace_gen` as everything else, /// instead of the vector runtime writing it eagerly. `cap == len` (a fresh /// clone carries no spare capacity, matching `BStackByteVec::from_slice`). - pub fn stage_bytevec( + pub fn stage_bytevec( &mut self, allocator: &A, data: &[u8], @@ -174,7 +174,7 @@ impl ClonePlan { /// Record that the strong count of a shared child at `data` must be bumped by /// one — the strong reference this clone's `#[bstack_strong]` field acquires. - pub fn bump_strong( + pub fn bump_strong( &mut self, data: BStackRef, allocator: &A, @@ -197,7 +197,7 @@ impl ClonePlan { /// Free everything allocated so far, in reverse order. The error path, taken /// when planning fails before [`commit`](Self::commit). - pub fn rollback(self, allocator: &A) { + pub fn rollback(self, allocator: &A) { Self::free_all(self.allocated, allocator); } @@ -214,18 +214,18 @@ impl ClonePlan { /// before any write is emitted — and abort with nothing committed. /// /// **Crash reclamation is automatic**: when the allocator names a WAL anchor - /// ([`BStackWalAnchor::wal_anchor`] returns `Some`), the plan's fresh + /// ([`BStackRaiiAllocator::wal_anchor`] returns `Some`), the plan's fresh /// allocations are logged `Pending` before the commit and reclaimed by /// [`crate::wal::finish`] on the next open if the process dies mid-commit; the /// transaction is flipped `Complete` *inside* the commit batch, so "clone /// committed" and "WAL Complete" are the same atomic event. An allocator that /// returns `None` behaves exactly as before (mid-commit crash ⇒ orphan leak). - pub fn commit(self, allocator: &A) -> io::Result<()> { + pub fn commit(self, allocator: &A) -> io::Result<()> { let anchor = allocator.wal_anchor(); self.commit_inner(allocator, anchor) } - fn commit_inner( + fn commit_inner( self, allocator: &A, anchor: Option, @@ -395,7 +395,7 @@ impl ClonePlan { /// and marking the persistent block idle — matching exactly what `finish` would /// do after a real crash; without one, free them directly. The WAL lock is /// already held by the caller (`commit_inner`), so the *locked* variant is used. - fn reclaim( + fn reclaim( allocated: Vec, wal: Option<(u64, BStackRange)>, allocator: &A, @@ -408,7 +408,7 @@ impl ClonePlan { } } - fn free_all(allocated: Vec, allocator: &A) { + fn free_all(allocated: Vec, allocator: &A) { for r in allocated.into_iter().rev() { // SAFETY: each range was returned by our own `alloc_raw` and never // handed to another owner, so freeing it here is sound. diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 75ef6dc..e3c8544 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -11,8 +11,8 @@ use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use crate::block::BStackWeakable; use crate::handle::WeakRef; @@ -33,7 +33,7 @@ fn read_u64_at(stack: &BStack, off: u64) -> io::Result { /// Returns the block's range. The bytes after the header are left as the /// allocator provided them; the caller fills in the payload. On a write failure /// the freshly allocated block is released so nothing leaks. -pub fn alloc_block( +pub fn alloc_block( allocator: &A, tag: EightCC, size: u64, @@ -51,7 +51,7 @@ pub fn alloc_block( /// /// Call once after [`alloc_block`] and after the payload is written. One is the /// count the single returned `BStackRc` accounts for. -pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { +pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { let off = data.start() + layout::RC_REFCOUNT_OFFSET; allocator.stack().set(off, 1u64.to_le_bytes()) } @@ -66,7 +66,7 @@ pub fn init_rc(allocator: &A, data: BStackRange) -> io::Resu /// /// On failure the control block is released; the caller still owns (and must /// release) the data block. -pub fn alloc_control( +pub fn alloc_control( allocator: &A, ctrl_tag: EightCC, data: BStackRange, @@ -121,7 +121,7 @@ pub fn build_control_payload(ctrl_tag: EightCC, data_start: u64, control_size: u /// resolving it at teardown is sound even after the target's data has been /// freed. `new_weak` is consumed and the weak count it holds becomes the field's; /// a previous non-null target has its weak count decremented. 0 means "unset". -pub fn set_weak_field<'w, T: BStackWeakable, A: BStackWalAnchor>( +pub fn set_weak_field<'w, T: BStackWeakable, A: BStackRaiiAllocator>( allocator: &A, field_off: u64, new_weak: BStackWeak<'w, T, A>, @@ -157,7 +157,7 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackWalAnchor>( /// `field_off`) to a strong handle. Returns `None` if the field is unset (0) or /// the target's strong count has already reached zero. What a generated weak /// field accessor calls. -pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackWalAnchor>( +pub fn upgrade_weak_field<'a, T: BStackWeakable, A: BStackRaiiAllocator>( allocator: &'a A, field_off: u64, ) -> io::Result>> { diff --git a/bstack_raii/src/handle.rs b/bstack_raii/src/handle.rs index 737e141..a4ba34a 100644 --- a/bstack_raii/src/handle.rs +++ b/bstack_raii/src/handle.rs @@ -16,8 +16,8 @@ use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::BStackRange; use crate::block::{BStackBlock, BStackWeakable}; use crate::layout; @@ -52,7 +52,7 @@ pub struct WeakRef(pub BStackRef); /// Read a data block's `ctrl` back-pointer (a `u64` offset at /// [`layout::CTRL_BACKPTR_OFFSET`]) and resolve it to a typed control ref, /// recovering the control block's length from `size_of::()`. -fn read_ctrl_ref( +fn read_ctrl_ref( data_ref: BStackRef, allocator: &A, ) -> io::Result> { @@ -65,7 +65,7 @@ fn read_ctrl_ref( } impl BStackDrop for OwnedRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // An owned child is freed by running the block's own recursive teardown, // which frees its children (post-order) and then deallocs the block. T::from_range(self.0.into_range()).bstack_drop(allocator) @@ -73,7 +73,7 @@ impl BStackDrop for OwnedRef { } impl BStackDrop for StrongRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { let data_range = self.0.into_range(); let off = data_range.start() + layout::RC_REFCOUNT_OFFSET; // Decrement the inline refcount; only the last owner frees the block. @@ -87,7 +87,7 @@ impl BStackDrop for StrongRef { impl StrongWeakRef { /// Resolve the control ref from the data block's `ctrl` back-pointer with a /// single read, then pair it with the data ref. - pub fn from_disk( + pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { @@ -101,7 +101,7 @@ impl StrongWeakRef { /// recursive teardown), so it is shared by both [`StrongWeakRef::bstack_drop`] /// and [`crate::BStackRc`]'s `Drop` — the latter carries `T: BStackBlock` and so /// cannot construct a `StrongWeakRef` (which needs `BStackWeakable`) itself. -pub(crate) fn strong_release_ctrl( +pub(crate) fn strong_release_ctrl( allocator: &A, data_range: BStackRange, ctrl_range: BStackRange, @@ -123,14 +123,14 @@ pub(crate) fn strong_release_ctrl( } impl BStackDrop for StrongWeakRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { strong_release_ctrl::(allocator, self.0.into_range(), self.1.into_range()) } } impl WeakRef { /// Resolve the control ref from the data block's `ctrl` back-pointer. - pub fn from_disk( + pub fn from_disk( data_ref: BStackRef, allocator: &A, ) -> io::Result { @@ -139,7 +139,7 @@ impl WeakRef { } impl BStackDrop for WeakRef { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { let ctrl_range = self.0.into_range(); let weak_off = ctrl_range.start() + layout::CTRL_WEAK_OFFSET; // Decrement ctrl.weak; free the control block when the last weak handle diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 214d12c..85bc4d8 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -91,8 +91,8 @@ pub use stdlib::{ pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_teardown}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ - AllocReq, BStackWalAnchor, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, - WalStatus, finish, finish_at, persist_at, reduce, + AllocReq, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, WalStatus, finish, + finish_at, persist_at, reduce, }; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that @@ -100,6 +100,49 @@ pub use wal::{ // crates need not depend on `bstack` or `bytemuck` directly. pub use bstack::{BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, BStackSlice}; pub use bytemuck::{Pod, Zeroable}; + +/// The allocator every `bstack_raii` operation is bound on: a +/// [`BStackOwnedSliceAllocator`] the layer can soundly build owning handles on top +/// of. It is the crate-wide allocator capability — constructors, `try_clone_in`, +/// `bstack_drop`, and every stdlib collection require it. +/// +/// Beyond its supertrait it carries two things the layer relies on: the **null +/// niche** at payload offset 0 (a hard safety requirement, see below) and an +/// **optional** WAL anchor slot. The anchor is what lets `try_clone_in` / +/// `bstack_drop` reclaim orphaned allocations on the next open **automatically** +/// (they read [`wal_anchor`](Self::wal_anchor) directly); `None` (the default) +/// means "no reclamation" — those ops behave exactly as before, minus the +/// crash-orphan cleanup. Every bstack-provided allocator implements this trait; a +/// custom allocator that upholds the null niche adds a one-line +/// `unsafe impl BStackRaiiAllocator for MyAlloc {}` (defaulting to `None`, or +/// returning `Some(slot)` if it reserves a stable slot). +/// +/// The WAL machinery it feeds lives in the `wal` module; the trait itself is the +/// crate's front-door allocator bound, hence its home here at the root. +/// +/// # Safety +/// +/// An implementor asserts **both** of the following: +/// +/// 1. **Null niche.** The allocator **never** hands out a live slice whose +/// `start()` is `0`. `bstack_raii` reserves payload offset 0 as its universal +/// null sentinel — a `0` offset means "none" everywhere in the layer (an absent +/// handle / [`Option`] niche, a dead weak reference, "no WAL block", …). An +/// allocator that could return offset 0 is **unsound** with this crate: a real +/// allocation would be indistinguishable from null. (Every bstack allocator +/// satisfies this: each keeps a reserved region at payload offset 0 that it +/// never allocates from.) +/// +/// 2. **WAL anchor (only when returning `Some(off)`).** `[off, off + 8)` is a +/// stable, persistent 8-byte region the allocator **never** hands out via +/// `alloc` and **never** uses for its own metadata, and that survives across +/// open/close. `bstack_raii` stores the current WAL block's offset there +/// (`0` = none). Returning `None` asserts nothing beyond (1). +pub unsafe trait BStackRaiiAllocator: BStackOwnedSliceAllocator { + fn wal_anchor(&self) -> Option { + None + } +} // Re-exported whole so generated code can call `::bstack_raii::bytemuck::bytes_of`. pub use bytemuck; @@ -194,14 +237,6 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// # fn main() {} /// ``` /// -/// A **non-`Pod`** field in a POD aggregate variant: -/// ```compile_fail -/// use bstack_raii::bstack_enum; -/// #[bstack_enum] -/// enum E { V(String) } -/// # fn main() {} -/// ``` -/// /// An ownership annotation targeting a **non-block** type: /// ```compile_fail /// use bstack_raii::bstack_enum; @@ -257,14 +292,6 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// # fn main() {} /// ``` /// -/// A **generic** struct: -/// ```compile_fail -/// use bstack_raii::bstack_block; -/// #[bstack_block] -/// struct X { f: T } -/// # fn main() {} -/// ``` -/// /// `weak` without `rc`: /// ```compile_fail /// use bstack_raii::bstack_block; diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index b13a322..ed5221c 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -14,9 +14,9 @@ use core::ops::Deref; use std::io; +use crate::BStackRaiiAllocator; use crate::block::{BStackMove, BStackMoveExpr}; use crate::teardown::{AutoDrop, BStackDrop, wal_teardown}; -use crate::wal::BStackWalAnchor; /// A uniquely-owned handle to a block: an ownership marker over an inner /// [`BStackDrop`] handle whose teardown recursively frees the block on disk. @@ -51,7 +51,7 @@ impl BStackOwned { /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping /// the returned value runs this handle's recursive teardown. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: a `BStackOwned` asserts sole ownership of a live block at // construction, exactly the invariant `AutoDrop::from_raw` requires. unsafe { AutoDrop::from_raw(self, allocator) } @@ -66,7 +66,7 @@ impl Deref for BStackOwned { } impl BStackDrop for BStackOwned { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // Recursively free the owned block (and its children) as one crash-atomic, // leak-reclaiming batch — automatically, whenever the allocator names a WAL // anchor. `wal_teardown` collects the whole subtree's frees and commits @@ -81,7 +81,7 @@ impl BStackDrop for BStackOwned { /// /// (A *bare* `BStackOwned` carries no allocator, so it is moved with the /// explicit two-argument form `bstack_move!(owned, allocator)` instead.) -impl<'a, X: BStackMove, A: BStackWalAnchor> BStackMoveExpr for AutoDrop<'a, BStackOwned, A> { +impl<'a, X: BStackMove, A: BStackRaiiAllocator> BStackMoveExpr for AutoDrop<'a, BStackOwned, A> { type Output = io::Result>; fn bstack_move(self) -> Self::Output { let (owned, allocator) = self.into_raw_parts(); diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index dba7075..6bf5f3a 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -8,8 +8,8 @@ use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::BStackRange; use crate::block::{BStackBlock, BStackMove, BStackMoveExpr, BStackWeakable}; use crate::clone::TryClone; @@ -34,7 +34,7 @@ pub(crate) struct StrongCore { } impl BStackDrop for StrongCore { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { match self.ctrl { None => StrongRef(self.data).bstack_drop(allocator), Some(ctrl) => strong_release_ctrl::(allocator, self.data.into_range(), ctrl), @@ -58,11 +58,11 @@ impl BStackDrop for StrongCore { /// **Invariant:** for a `T: BStackWeakable` block, `ctrl` is always `Some` — such /// blocks are only ever constructed through the control-block paths /// ([`BStackWeak::upgrade`], `bstack_move!`). `downgrade` relies on this. -pub struct BStackRc<'a, T: BStackBlock, A: BStackWalAnchor> { +pub struct BStackRc<'a, T: BStackBlock, A: BStackRaiiAllocator> { inner: AutoDrop<'a, StrongCore, A>, } -impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackRc<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { /// Reconstruct a shared handle from its raw parts. /// /// # Safety @@ -121,7 +121,7 @@ impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackRc<'a, T, A> { /// handle to the **same** block — sharing, not copying (like `Rc::clone`). This /// is the clone semantics for a shared block; there is deliberately no /// deep-copy-to-owned (`TryCloneIn`) for one. -impl<'a, T: BStackBlock, A: BStackWalAnchor> TryClone for BStackRc<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> TryClone for BStackRc<'a, T, A> { fn try_clone(&self) -> io::Result { refcount::fetch_add(self.allocator().stack(), self.strong_offset(), 1)?; // SAFETY: the fetch_add above established the strong count this clone @@ -130,7 +130,7 @@ impl<'a, T: BStackBlock, A: BStackWalAnchor> TryClone for BStackRc<'a, T, A> { } } -impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackRc<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> BStackRc<'a, T, A> { /// Create a weak handle to the same block by incrementing `ctrl.weak`. /// /// Available only for `(rc, weak)` blocks (`T: BStackWeakable`), so a plain @@ -149,7 +149,7 @@ impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackRc<'a, T, A> { } } -impl<'a, T: BStackMove, A: BStackWalAnchor> BStackRc<'a, T, A> { +impl<'a, T: BStackMove, A: BStackRaiiAllocator> BStackRc<'a, T, A> { /// `Rc::try_unwrap` + destructure: if this handle is the **sole strong /// owner**, move every field out (freeing only the data shell) and return /// them; otherwise hand the handle back in `Err`. @@ -188,7 +188,7 @@ impl<'a, T: BStackMove, A: BStackWalAnchor> BStackRc<'a, T, A> { } } -impl<'a, T: BStackMove, A: BStackWalAnchor> BStackMoveExpr for BStackRc<'a, T, A> { +impl<'a, T: BStackMove, A: BStackRaiiAllocator> BStackMoveExpr for BStackRc<'a, T, A> { type Output = io::Result, Self>>; fn bstack_move(self) -> Self::Output { self.try_move() @@ -201,11 +201,11 @@ impl<'a, T: BStackMove, A: BStackWalAnchor> BStackMoveExpr for BStackRc<'a, T, A /// control block alive (so [`upgrade`](BStackWeak::upgrade) can check liveness) /// but never pins the data block. Its drop core is a [`WeakRef`], whose /// [`BStackDrop`] decrements `ctrl.weak` and frees the control block at zero. -pub struct BStackWeak<'a, T: BStackWeakable, A: BStackWalAnchor> { +pub struct BStackWeak<'a, T: BStackWeakable, A: BStackRaiiAllocator> { inner: AutoDrop<'a, WeakRef, A>, } -impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeak<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> BStackWeak<'a, T, A> { /// Reconstruct a weak handle from its raw control ref. /// /// # Safety @@ -262,7 +262,7 @@ impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeak<'a, T, A> { /// block, and a copy that observed anything else would not be observing what the /// original does. So a weak clone shares the observation (a count bump) rather /// than deep-copying — there is no `TryCloneIn` for a weak reference. -impl<'a, T: BStackWeakable, A: BStackWalAnchor> TryClone for BStackWeak<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> TryClone for BStackWeak<'a, T, A> { fn try_clone(&self) -> io::Result { let weak_off = self.ctrl().into_range().start() + layout::CTRL_WEAK_OFFSET; refcount::fetch_add(self.allocator().stack(), weak_off, 1)?; diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index ea0a5cf..f4e7351 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -37,7 +37,7 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; +use crate::BStackRaiiAllocator; use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; @@ -110,7 +110,11 @@ impl BStackCountingBloomFilter { /// Allocate a filter with `m` counters and `k` hash functions (both forced to /// at least 1). Prefer [`with_capacity`](Self::with_capacity) to size these. - pub fn new(allocator: &A, m: u64, k: u64) -> io::Result> { + pub fn new( + allocator: &A, + m: u64, + k: u64, + ) -> io::Result> { let m = m.max(1); let k = k.max(1); // Allocate and zero the counter block (an orphan until the handle links it). @@ -146,7 +150,7 @@ impl BStackCountingBloomFilter { /// Allocate a filter sized for `expected_items` at target false-positive rate /// `fp_rate`, using the standard optimal `m = -n·ln p / (ln 2)²` and /// `k = (m/n)·ln 2`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -178,7 +182,7 @@ impl BStackCountingBloomFilter { } /// Insert `key`, bumping each of its `k` counters (saturating at 255). - pub fn insert(&self, allocator: &A, key: &K) -> io::Result<()> { + pub fn insert(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, true) } @@ -186,7 +190,7 @@ impl BStackCountingBloomFilter { /// /// Only call this for a key that was actually inserted (see the module docs) — /// removing an absent key can introduce false negatives. - pub fn remove(&self, allocator: &A, key: &K) -> io::Result<()> { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result<()> { self.adjust(allocator, key, false) } @@ -208,7 +212,7 @@ impl BStackCountingBloomFilter { } /// Reset every counter and the item count to zero. - pub fn clear(&self, allocator: &A) -> io::Result<()> { + pub fn clear(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let [data, m] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; allocator.stack().set_batched([ @@ -219,7 +223,7 @@ impl BStackCountingBloomFilter { /// Atomically adjust the counters for `key` (and `n`) up or down, reading and /// writing every touched counter in one `inplace_gen` (external-lock-free). - fn adjust(&self, allocator: &A, key: &K, add: bool) -> io::Result<()> { + fn adjust(&self, allocator: &A, key: &K, add: bool) -> io::Result<()> { let handle = self.range.start(); let [data, m, k] = read_fields::<3>(allocator.stack(), handle + DATA_OFF)?; let agg = Self::aggregate(Self::indices(m, k, bytemuck::bytes_of(key))); @@ -305,7 +309,7 @@ impl BStackCountingBloomFilter { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the filter was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -334,7 +338,7 @@ impl BStackBlock for BStackCountingBloomFilter { } /// Free the counter block, **without** freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -348,7 +352,7 @@ impl BStackBlock for BStackCountingBloomFilter { /// Deep-clone: copy the counter block and stage the handle, in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -383,7 +387,7 @@ impl BStackBlock for BStackCountingBloomFilter { } impl BStackDrop for BStackCountingBloomFilter { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -391,7 +395,7 @@ impl BStackDrop for BStackCountingBloomFilter { } impl TryCloneIn for BStackCountingBloomFilter { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/boxed.rs b/bstack_raii/src/stdlib/boxed.rs index 6ffb554..e8778f6 100644 --- a/bstack_raii/src/stdlib/boxed.rs +++ b/bstack_raii/src/stdlib/boxed.rs @@ -20,8 +20,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackCast, BStackMove}; @@ -72,7 +72,7 @@ impl BStackBox { /// /// The header and payload are written as a single image, so the block is /// created with one write (and released without leaking on write failure). - pub fn new(allocator: &A, value: T) -> io::Result> { + pub fn new(allocator: &A, value: T) -> io::Result> { let od = BoxOnDisk { header: BlockHeader { size: Self::SIZE, @@ -105,7 +105,7 @@ impl BStackBox { } /// Overwrite the boxed value in place. - pub fn set(&self, allocator: &A, value: T) -> io::Result<()> { + pub fn set(&self, allocator: &A, value: T) -> io::Result<()> { allocator .stack() .set(self.range.start() + HEADER_SIZE, bytemuck::bytes_of(&value)) @@ -141,7 +141,7 @@ impl BStackBlock for BStackBox { } impl BStackDrop for BStackBox { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // Childless: just free the block. // SAFETY: sole ownership was asserted when this handle was created. unsafe { dealloc_range(allocator, self.range) } @@ -149,7 +149,7 @@ impl BStackDrop for BStackBox { } impl TryCloneIn for BStackBox { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { // Mirror the generated `try_clone_in`: build the plan (a byte copy, via // the childless `__bstack_clone_into` default), then commit atomically. let mut plan = ClonePlan::new(); @@ -168,9 +168,12 @@ impl TryCloneIn for BStackBox { impl BStackMove for BStackBox { /// Moving a box out yields the plain value. - type Fields<'a, A: BStackWalAnchor> = T; + type Fields<'a, A: BStackRaiiAllocator> = T; - fn bstack_move(owned: BStackOwned, allocator: &A) -> io::Result { + fn bstack_move( + owned: BStackOwned, + allocator: &A, + ) -> io::Result { let me = owned.into_inner(); let value = me.get(allocator.stack())?; // Childless: free the shell after reading the value out. diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index cd830eb..948ddf2 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -23,8 +23,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -81,7 +81,7 @@ struct Split { } /// Accumulates a path-copy insert's new nodes and the old path nodes to free. -struct Build<'a, A: BStackWalAnchor> { +struct Build<'a, A: BStackRaiiAllocator> { allocator: &'a A, node_size: u64, ksize: usize, @@ -90,7 +90,7 @@ struct Build<'a, A: BStackWalAnchor> { freed: Vec, } -impl<'a, A: BStackWalAnchor> Build<'a, A> { +impl<'a, A: BStackRaiiAllocator> Build<'a, A> { fn emit(&mut self, nb: &BNode) -> io::Result { let mut b = vec![0u8; self.node_size as usize]; b[NKEYS_OFF..NKEYS_OFF + 8].copy_from_slice(&(nb.keys.len() as u64).to_le_bytes()); @@ -138,13 +138,13 @@ impl BStackBTreeSet { } /// Allocate an empty set with a default-sized Bloom filter. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) } /// Allocate an empty set whose Bloom filter is sized for `expected_items` at /// false-positive rate `fp_rate`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -248,7 +248,7 @@ impl BStackBTreeSet { /// Path-copy the subtree at `off`, inserting a **new** `key` (assumed absent). fn insert_rec( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, off: u64, key: &K, @@ -280,7 +280,7 @@ impl BStackBTreeSet { } /// Insert `key`; returns `true` if newly added, `false` if already present. - pub fn insert(&self, allocator: &A, key: K) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { // Exact check first, so the filter is only touched for genuinely new keys. let key_bytes = bytemuck::bytes_of(&key).to_vec(); if self.tree_contains(allocator.stack(), &key, &key_bytes)? { @@ -425,7 +425,7 @@ impl BStackBTreeSet { /// Path-copy delete of `key` from the subtree at `off`; returns the new /// subtree offset and whether the key was found. fn delete_off( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, off: u64, key: &K, @@ -439,7 +439,7 @@ impl BStackBTreeSet { /// Delete `key` from the in-memory node `nb`, rebalancing children to keep the /// B-tree invariant. Returns the modified node (not yet emitted) and found. fn delete_bnode( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, mut nb: BNode, key: &K, @@ -570,7 +570,7 @@ impl BStackBTreeSet { /// Remove `key`; returns `true` if it was present. Deletes from the tree /// first, then decrements the Bloom filter (see the module docs). - pub fn remove(&self, allocator: &A, key: &K) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let handle = self.range.start(); let stack = allocator.stack(); let key_bytes = bytemuck::bytes_of(key).to_vec(); @@ -754,12 +754,16 @@ impl BStackBTreeSet { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the set was created. unsafe { AutoDrop::from_raw(self, allocator) } } - fn drop_subtree(stack: &BStack, off: u64, allocator: &A) -> io::Result<()> { + fn drop_subtree( + stack: &BStack, + off: u64, + allocator: &A, + ) -> io::Result<()> { if off == 0 { return Ok(()); } @@ -774,7 +778,7 @@ impl BStackBTreeSet { Ok(()) } - fn clone_subtree( + fn clone_subtree( stack: &BStack, off: u64, allocator: &A, @@ -826,7 +830,7 @@ impl BStackBlock for BStackBTreeSet { /// Recursively free every node and the embedded Bloom filter, **without** /// freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -845,7 +849,7 @@ impl BStackBlock for BStackBTreeSet { /// Deep-clone every node and the Bloom filter into `plan`, then stage the /// handle. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -875,7 +879,7 @@ impl BStackBlock for BStackBTreeSet { } impl BStackDrop for BStackBTreeSet { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -883,7 +887,7 @@ impl BStackDrop for BStackBTreeSet { } impl TryCloneIn for BStackBTreeSet { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs index c98974c..8cb4966 100644 --- a/bstack_raii/src/stdlib/cow.rs +++ b/bstack_raii/src/stdlib/cow.rs @@ -18,8 +18,8 @@ use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::BStackRange; use crate::block::BStackBlock; use crate::clone::TryCloneIn; @@ -101,7 +101,7 @@ impl BStackCow { /// * `Owned` — returned as-is; no I/O. /// * `Borrowed` — the referenced block is deep-cloned into a fresh /// independent [`BStackOwned`] allocated with `allocator`. - pub fn into_owned(self, allocator: &A) -> io::Result> + pub fn into_owned(self, allocator: &A) -> io::Result> where T: TryCloneIn, { @@ -120,7 +120,10 @@ impl BStackCow { /// through the returned handle (the block's setters + `allocator`) never /// touch the originally borrowed block. A no-op (beyond the ownership /// check) when already owned. - pub fn to_mut(&mut self, allocator: &A) -> io::Result<&mut BStackOwned> + pub fn to_mut( + &mut self, + allocator: &A, + ) -> io::Result<&mut BStackOwned> where T: TryCloneIn, { @@ -139,7 +142,7 @@ impl BStackCow { /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard: dropping /// the returned value runs this `Cow`'s teardown (a no-op when borrowed). - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: an `Owned` variant asserts sole ownership of a live block; a // `Borrowed` variant frees nothing, so the assertion is trivially met. unsafe { AutoDrop::from_raw(self, allocator) } @@ -149,7 +152,7 @@ impl BStackCow { impl BStackDrop for BStackCow { /// Free the block **only** when owned; a borrowed `Cow` has no claim on its /// target and frees nothing. - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { match self { BStackCow::Owned(o) => o.bstack_drop(allocator), BStackCow::Borrowed(_) => Ok(()), diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index f21150b..d9bcd20 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -36,8 +36,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -109,13 +109,13 @@ impl BStackDeque { } /// Allocate an empty deque (no ring is allocated until the first push). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_image(allocator, 0, 0) } /// Allocate an empty deque with room for `cap` elements pre-reserved (so the /// first `cap` pushes never grow). `cap == 0` behaves like [`new`](Self::new). - pub fn with_capacity( + pub fn with_capacity( allocator: &A, cap: u64, ) -> io::Result> { @@ -135,7 +135,7 @@ impl BStackDeque { } } - fn with_image( + fn with_image( allocator: &A, data: u64, cap: u64, @@ -172,7 +172,7 @@ impl BStackDeque { /// Append a value to the back, taking ownership of its block. Grows the ring /// (once) if it is full, then commits the slot write + length bump atomically. - pub fn push_back( + pub fn push_back( &self, allocator: &A, value: BStackOwned, @@ -212,7 +212,7 @@ impl BStackDeque { } /// Prepend a value to the front, taking ownership of its block. - pub fn push_front( + pub fn push_front( &self, allocator: &A, value: BStackOwned, @@ -257,7 +257,7 @@ impl BStackDeque { /// empty. Atomic: the slot is read and the length decremented in one commit; /// the value block's ownership transfers to the caller (its ring slot is left /// stale and reused by a later push). - pub fn pop_back( + pub fn pop_back( &self, allocator: &A, ) -> io::Result>> { @@ -301,7 +301,7 @@ impl BStackDeque { /// Remove and return the first element (as an owned value block), or `None` /// if empty. - pub fn pop_front( + pub fn pop_front( &self, allocator: &A, ) -> io::Result>> { @@ -349,7 +349,7 @@ impl BStackDeque { /// Grow the ring to at least double its capacity, atomically snapshotting and /// re-basing the live elements. A no-op (beyond a wasted allocation, freed /// again) if another thread already made room. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; @@ -476,7 +476,7 @@ impl BStackDeque { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the deque was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -508,7 +508,7 @@ impl BStackBlock for BStackDeque { /// Recursively free every element block and the ring, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -532,7 +532,7 @@ impl BStackBlock for BStackDeque { /// own clone hook) and packed into a fresh, compacted ring (`head = 0`, /// `cap = len`); the handle block is staged — all in the parent plan's single /// atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -583,7 +583,7 @@ impl BStackBlock for BStackDeque { } impl BStackDrop for BStackDeque { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -591,7 +591,7 @@ impl BStackDrop for BStackDeque { } impl TryCloneIn for BStackDeque { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index 331e3f9..26a233d 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -33,8 +33,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackGenOp, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; @@ -129,13 +129,13 @@ impl BStackHashSet { } /// Allocate an empty set with a default-sized Bloom filter. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_capacity(allocator, DEFAULT_ITEMS, DEFAULT_FP) } /// Allocate an empty set whose Bloom filter is sized for `expected_items` at /// false-positive rate `fp_rate`. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, expected_items: u64, fp_rate: f64, @@ -181,7 +181,7 @@ impl BStackHashSet { /// Insert `key`; returns `true` if it was newly added, `false` if already /// present. - pub fn insert(&self, allocator: &A, key: K) -> io::Result { + pub fn insert(&self, allocator: &A, key: K) -> io::Result { let key_bytes = bytemuck::bytes_of(&key).to_vec(); let hash = fnv1a(&key_bytes); let bloom = self.bloom(allocator.stack())?; @@ -196,7 +196,7 @@ impl BStackHashSet { } /// Remove `key`; returns `true` if it was present. - pub fn remove(&self, allocator: &A, key: &K) -> io::Result { + pub fn remove(&self, allocator: &A, key: &K) -> io::Result { let key_bytes = bytemuck::bytes_of(key).to_vec(); let hash = fnv1a(&key_bytes); // Remove from the table first; only then decrement the filter (and only @@ -234,7 +234,7 @@ impl BStackHashSet { } /// Place `key` in the table if absent; returns whether it was newly added. - fn table_insert( + fn table_insert( &self, allocator: &A, key_bytes: &[u8], @@ -301,7 +301,7 @@ impl BStackHashSet { } /// Tombstone `key` in the table if present; returns whether it was. - fn table_remove( + fn table_remove( &self, allocator: &A, key_bytes: &[u8], @@ -365,7 +365,7 @@ impl BStackHashSet { /// Grow the table to at least double its capacity, rehashing every live key /// (and dropping tombstones) atomically. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); @@ -493,7 +493,7 @@ impl BStackHashSet { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the set was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -523,7 +523,7 @@ impl BStackBlock for BStackHashSet { /// Free the bucket block and the embedded Bloom filter, **without** freeing /// the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -546,7 +546,7 @@ impl BStackBlock for BStackHashSet { /// Deep-clone: copy the bucket block, deep-clone the Bloom filter, and stage /// the handle, in the parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -589,7 +589,7 @@ impl BStackBlock for BStackHashSet { } impl BStackDrop for BStackHashSet { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -597,7 +597,7 @@ impl BStackDrop for BStackHashSet { } impl TryCloneIn for BStackHashSet { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs index c85554b..3217c3f 100644 --- a/bstack_raii/src/stdlib/heap.rs +++ b/bstack_raii/src/stdlib/heap.rs @@ -30,8 +30,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -104,12 +104,12 @@ impl BStackBinaryHeap { } /// Allocate an empty heap (no array until the first push). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { Self::with_image(allocator, 0, 0) } /// Allocate an empty heap with room for `cap` elements pre-reserved. - pub fn with_capacity( + pub fn with_capacity( allocator: &A, cap: u64, ) -> io::Result> { @@ -129,7 +129,7 @@ impl BStackBinaryHeap { } } - fn with_image( + fn with_image( allocator: &A, data: u64, cap: u64, @@ -180,7 +180,7 @@ impl BStackBinaryHeap { /// Insert `key -> value`, taking ownership of the value block. /// /// Sifts the new element up and commits the whole path atomically. - pub fn push( + pub fn push( &self, allocator: &A, key: K, @@ -225,7 +225,7 @@ impl BStackBinaryHeap { /// Remove and return the minimum entry (its value block owned), or `None` if /// empty. Sifts the last element down and commits the whole path atomically. - pub fn pop( + pub fn pop( &self, allocator: &A, ) -> io::Result)>> { @@ -290,7 +290,7 @@ impl BStackBinaryHeap { /// Grow the array to at least double its capacity, copying the elements and /// atomically swapping the descriptor. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let (data, cap, len) = Self::read_meta(allocator.stack(), handle)?; @@ -317,7 +317,7 @@ impl BStackBinaryHeap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the heap was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -348,7 +348,7 @@ impl BStackBlock for BStackBinaryHeap { /// Recursively free every value block and the array, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -372,7 +372,7 @@ impl BStackBlock for BStackBinaryHeap { /// Deep-clone: pack the elements into a fresh, exactly-sized array with each /// value deep-cloned, and stage the handle, in the parent plan's atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -418,7 +418,7 @@ impl BStackBlock for BStackBinaryHeap { } impl BStackDrop for BStackBinaryHeap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -426,7 +426,7 @@ impl BStackDrop for BStackBinaryHeap { } impl TryCloneIn for BStackBinaryHeap { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index 9b0016b..46879a6 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -33,8 +33,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, atomic_update, read_fields, read_u64}; @@ -137,7 +137,7 @@ impl BStackLinkedList { } /// Allocate an empty list. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = ListOnDisk { header: BlockHeader { size: LIST_SIZE, @@ -168,7 +168,7 @@ impl BStackLinkedList { /// then the tail read, node-image write, tail/`prev.next` relink and length /// bump all commit as one crash-atomic [`atomic_update`]. A crash before the /// commit leaks the orphan node; it never tears the list. - pub fn push_back( + pub fn push_back( &self, allocator: &A, value: BStackOwned, @@ -212,7 +212,7 @@ impl BStackLinkedList { /// Prepend a value to the front, taking ownership of its block. Atomic and /// external-lock-free (see [`push_back`](Self::push_back)). - pub fn push_front( + pub fn push_front( &self, allocator: &A, value: BStackOwned, @@ -259,7 +259,7 @@ impl BStackLinkedList { /// decrement commit as one [`atomic_update`]. Only *after* the node is /// unlinked is its shell freed — a crash between leaks the shell, never a /// dangling link. - pub fn pop_back( + pub fn pop_back( &self, allocator: &A, ) -> io::Result>> { @@ -314,7 +314,7 @@ impl BStackLinkedList { /// Remove and return the first element (as an owned value block), or `None` /// if the list is empty. Atomic and external-lock-free (see /// [`pop_back`](Self::pop_back)). - pub fn pop_front( + pub fn pop_front( &self, allocator: &A, ) -> io::Result>> { @@ -412,7 +412,10 @@ impl BStackLinkedList { } /// Attach an allocator to make an auto-freeing [`crate::AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> crate::teardown::AutoDrop<'_, Self, A> { + pub fn auto( + self, + allocator: &A, + ) -> crate::teardown::AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the list was created. unsafe { crate::teardown::AutoDrop::from_raw(self, allocator) } } @@ -445,7 +448,7 @@ impl BStackBlock for BStackLinkedList { /// Recursively free every value block and node, **without** freeing the list /// block itself (its embedding parent, or [`bstack_drop`](BStackDrop), does /// that). - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -469,7 +472,7 @@ impl BStackBlock for BStackLinkedList { /// Deep-clone the whole chain into `plan`: every value is deep-cloned (via /// `T`'s own clone hook), fresh nodes are allocated and wired, and the list /// block is staged — all as part of the parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -529,7 +532,7 @@ impl BStackBlock for BStackLinkedList { } impl BStackDrop for BStackLinkedList { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the list block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -537,7 +540,7 @@ impl BStackDrop for BStackLinkedList { } impl TryCloneIn for BStackLinkedList { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 4eaf6ee..7960733 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -37,8 +37,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackGenOp, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; @@ -154,7 +154,7 @@ impl BStackHashMap { } /// Allocate an empty map (no bucket block until the first insert). - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = MapOnDisk { header: BlockHeader { size: MAP_SIZE, @@ -186,7 +186,7 @@ impl BStackHashMap { /// Atomic and external-lock-free: grows the table first if the load factor /// would be exceeded, then probes and commits the bucket + metadata writes in /// one [`probe_commit`]. - pub fn insert( + pub fn insert( &self, allocator: &A, key: K, @@ -272,7 +272,7 @@ impl BStackHashMap { /// Remove `key`, returning its value (owned) if present, else `None`. The /// bucket becomes a tombstone; the value block's ownership transfers out. - pub fn remove( + pub fn remove( &self, allocator: &A, key: &K, @@ -368,7 +368,7 @@ impl BStackHashMap { /// concurrently across this call. pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> where - A: BStackWalAnchor, + A: BStackRaiiAllocator, F: FnOnce() -> io::Result>, { if let Some(v) = self.get(allocator.stack(), &key)? { @@ -388,7 +388,7 @@ impl BStackHashMap { /// `default`. If `key` is present, `default` is **freed** (its block is /// dropped) — prefer the `_with` form to avoid allocating a value you may not /// use. - pub fn get_or_insert( + pub fn get_or_insert( &self, allocator: &A, key: K, @@ -425,7 +425,7 @@ impl BStackHashMap { /// Grow the table to at least double its capacity, rehashing every live entry /// (and dropping tombstones) atomically. A no-op (beyond a freed spare block) /// if another thread already grew it. - fn grow(&self, allocator: &A) -> io::Result<()> { + fn grow(&self, allocator: &A) -> io::Result<()> { let handle = self.range.start(); let stride = Self::stride(); let ksz = Self::ksize(); @@ -560,7 +560,7 @@ impl BStackHashMap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the map was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -593,7 +593,7 @@ impl BStackBlock for BStackHashMap { /// Recursively free every value block and the bucket block, **without** /// freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -627,7 +627,7 @@ impl BStackBlock for BStackHashMap { /// every key's position), deep-clone each occupied value (via `V`'s clone /// hook) and swap in the clone's ref; stage the handle — all in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -676,7 +676,7 @@ impl BStackBlock for BStackHashMap { } impl BStackDrop for BStackHashMap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -684,7 +684,7 @@ impl BStackDrop for BStackHashMap { } impl TryCloneIn for BStackHashMap { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs index 92623b0..0522ff0 100644 --- a/bstack_raii/src/stdlib/string.rs +++ b/bstack_raii/src/stdlib/string.rs @@ -16,8 +16,8 @@ use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{alloc_image, read_fields, read_u64}; @@ -58,7 +58,7 @@ pub struct BStackString { impl BStackString { /// Allocate a bytes block holding `bytes` (or return `0` for an empty slice), /// releasing it without leaking on write failure. - fn alloc_bytes(allocator: &A, bytes: &[u8]) -> io::Result { + fn alloc_bytes(allocator: &A, bytes: &[u8]) -> io::Result { if bytes.is_empty() { return Ok(0); } @@ -71,7 +71,7 @@ impl BStackString { } /// Create a string from `s`. - pub fn new(allocator: &A, s: &str) -> io::Result> { + pub fn new(allocator: &A, s: &str) -> io::Result> { let len = s.len() as u64; let data = Self::alloc_bytes(allocator, s.as_bytes())?; let od = StringOnDisk { @@ -129,7 +129,7 @@ impl BStackString { /// `{data, len}` pair is updated in one atomic write (a crash before it leaves /// the old string intact; after it, the new). The old bytes block is then /// freed (leak-only on a crash in between). - pub fn set(&self, allocator: &A, s: &str) -> io::Result<()> { + pub fn set(&self, allocator: &A, s: &str) -> io::Result<()> { let handle = self.range.start(); let stack = allocator.stack(); let newlen = s.len() as u64; @@ -157,21 +157,25 @@ impl BStackString { } /// Append `s` to the string. - pub fn push_str(&self, allocator: &A, s: &str) -> io::Result<()> { + pub fn push_str(&self, allocator: &A, s: &str) -> io::Result<()> { let mut cur = self.to_string(allocator.stack())?; cur.push_str(s); self.set(allocator, &cur) } /// Append a single character. - pub fn push(&self, allocator: &A, ch: char) -> io::Result<()> { + pub fn push(&self, allocator: &A, ch: char) -> io::Result<()> { let mut buf = [0u8; 4]; self.push_str(allocator, ch.encode_utf8(&mut buf)) } /// Truncate to `new_len` **bytes**, which must be a UTF-8 char boundary and /// not exceed the current length; longer values leave the string unchanged. - pub fn truncate(&self, allocator: &A, new_len: usize) -> io::Result<()> { + pub fn truncate( + &self, + allocator: &A, + new_len: usize, + ) -> io::Result<()> { let mut cur = self.to_string(allocator.stack())?; if new_len >= cur.len() { return Ok(()); @@ -187,7 +191,7 @@ impl BStackString { } /// Empty the string (frees its bytes block). - pub fn clear(&self, allocator: &A) -> io::Result<()> { + pub fn clear(&self, allocator: &A) -> io::Result<()> { self.set(allocator, "") } @@ -217,7 +221,7 @@ impl BStackString { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the string was created. unsafe { AutoDrop::from_raw(self, allocator) } } @@ -242,7 +246,7 @@ impl BStackBlock for BStackString { } /// Free the bytes block, **without** freeing the handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -256,7 +260,7 @@ impl BStackBlock for BStackString { /// Deep-clone: copy the bytes into a fresh block and stage the handle, in the /// parent plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -289,7 +293,7 @@ impl BStackBlock for BStackString { } impl BStackDrop for BStackString { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -297,7 +301,7 @@ impl BStackDrop for BStackString { } impl TryCloneIn for BStackString { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 7f36ae2..0ceb50a 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -41,8 +41,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::util::{Scratch, alloc_image, read_fields, read_u64}; @@ -97,7 +97,7 @@ struct Split { /// Accumulates a path-copy insert's new-node writes and the old path nodes to /// free, so the whole insert commits as one [`BStack::set_batched`] batch. -struct Build<'a, A: BStackWalAnchor> { +struct Build<'a, A: BStackRaiiAllocator> { allocator: &'a A, node_size: u64, ksize: usize, @@ -109,7 +109,7 @@ struct Build<'a, A: BStackWalAnchor> { freed: Vec, } -impl<'a, A: BStackWalAnchor> Build<'a, A> { +impl<'a, A: BStackRaiiAllocator> Build<'a, A> { /// Serialize `nb` and allocate a fresh block for it (an orphan until the /// commit links it), returning its offset. fn emit(&mut self, nb: &BNode) -> io::Result { @@ -171,7 +171,7 @@ impl BStackBTreeMap { } /// Allocate an empty tree. - pub fn new(allocator: &A) -> io::Result> { + pub fn new(allocator: &A) -> io::Result> { let od = TreeOnDisk { header: BlockHeader { size: TREE_SIZE, @@ -269,7 +269,7 @@ impl BStackBTreeMap { /// Returns the new subtree offset, an optional lifted split, whether a new /// entry was added, and any replaced value. fn insert_rec( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, off: u64, key: &K, @@ -321,7 +321,7 @@ impl BStackBTreeMap { /// /// Path-copies the affected path and commits every new node plus the root /// swap as one crash-atomic batch. **Single-writer** (see the module docs). - pub fn insert( + pub fn insert( &self, allocator: &A, key: K, @@ -473,7 +473,7 @@ impl BStackBTreeMap { /// `bool` distinguishes a fresh insert from a hit. **Single-writer.** pub fn get_or_insert_with(&self, allocator: &A, key: K, f: F) -> io::Result<(V, bool)> where - A: BStackWalAnchor, + A: BStackRaiiAllocator, F: FnOnce() -> io::Result>, { if let Some(v) = self.get(allocator.stack(), &key)? { @@ -489,7 +489,7 @@ impl BStackBTreeMap { /// Like [`get_or_insert_with`](Self::get_or_insert_with) but with an eager /// `default`, which is **freed** if `key` is already present. - pub fn get_or_insert( + pub fn get_or_insert( &self, allocator: &A, key: K, @@ -537,7 +537,7 @@ impl BStackBTreeMap { /// Path-copy delete of `key` from the subtree at `off`; returns the new /// subtree offset and the removed value (if the key was found). fn delete_off( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, off: u64, key: &K, @@ -552,7 +552,7 @@ impl BStackBTreeMap { /// for freeing), rebalancing children to keep the B-tree invariant. Returns /// the modified node (not yet emitted) and the removed value. fn delete_bnode( - build: &mut Build<'_, impl BStackWalAnchor>, + build: &mut Build<'_, impl BStackRaiiAllocator>, stack: &BStack, mut nb: BNode, key: &K, @@ -709,7 +709,7 @@ impl BStackBTreeMap { /// Remove `key`, returning its value (owned) if present, else `None`. /// Path-copies the affected path (rebalancing as needed) and commits the new /// nodes plus the root update as one crash-atomic batch. **Single-writer.** - pub fn remove( + pub fn remove( &self, allocator: &A, key: &K, @@ -909,13 +909,17 @@ impl BStackBTreeMap { } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. - pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { + pub fn auto(self, allocator: &A) -> AutoDrop<'_, Self, A> { // SAFETY: sole ownership was asserted when the tree was created. unsafe { AutoDrop::from_raw(self, allocator) } } /// Recursively free the subtree at `off` (values then nodes). - fn drop_subtree(stack: &BStack, off: u64, allocator: &A) -> io::Result<()> { + fn drop_subtree( + stack: &BStack, + off: u64, + allocator: &A, + ) -> io::Result<()> { if off == 0 { return Ok(()); } @@ -939,7 +943,7 @@ impl BStackBTreeMap { /// Recursively deep-clone the subtree at `off` into `plan`, returning the new /// subtree offset. - fn clone_subtree( + fn clone_subtree( stack: &BStack, off: u64, allocator: &A, @@ -1004,7 +1008,7 @@ impl BStackBlock for BStackBTreeMap { /// Recursively free every value block and node, **without** freeing the /// handle block itself. - fn __bstack_drop_children( + fn __bstack_drop_children( range: BStackRange, allocator: &A, ) -> io::Result<()> { @@ -1015,7 +1019,7 @@ impl BStackBlock for BStackBTreeMap { /// Deep-clone the whole tree into `plan`: every node copied, every value /// deep-cloned via `V`'s clone hook, the handle staged — all in the parent /// plan's single atomic commit. - fn __bstack_clone_into( + fn __bstack_clone_into( &self, allocator: &A, plan: &mut ClonePlan, @@ -1039,7 +1043,7 @@ impl BStackBlock for BStackBTreeMap { } impl BStackDrop for BStackBTreeMap { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { Self::__bstack_drop_children(self.range, allocator)?; // SAFETY: sole ownership of the handle block was asserted at construction. unsafe { dealloc_range(allocator, self.range) } @@ -1047,7 +1051,7 @@ impl BStackDrop for BStackBTreeMap { } impl TryCloneIn for BStackBTreeMap { - fn try_clone_in(&self, allocator: &A) -> io::Result> { + fn try_clone_in(&self, allocator: &A) -> io::Result> { let mut plan = ClonePlan::new(); let dst = match self.__bstack_clone_into(allocator, &mut plan) { Ok(range) => range, diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index e606ab9..4d8075e 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -7,8 +7,8 @@ use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackGenOp, BStackRange}; use crate::layout::{HEADER_SIZE, get_u64}; @@ -80,7 +80,7 @@ impl Scratch { /// Allocate a block and write `bytes` as its whole image (one write; released /// without leaking on write failure). -pub(super) fn alloc_image( +pub(super) fn alloc_image( allocator: &A, bytes: &[u8], ) -> io::Result { @@ -118,7 +118,7 @@ pub(super) fn atomic_update( plan: W, ) -> io::Result<()> where - A: BStackWalAnchor, + A: BStackRaiiAllocator, R2: FnOnce(&[u64]) -> Vec, W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, { @@ -229,7 +229,7 @@ pub(super) fn probe_commit( exhausted: E, ) -> io::Result<()> where - A: BStackWalAnchor, + A: BStackRaiiAllocator, I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, E: FnOnce(&Meta) -> Vec<(u64, Vec)>, { diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index 9176820..2b1e69c 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -12,9 +12,8 @@ use std::io; use bstack::{BStackGenOp, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; -use crate::wal::{ - BStackWalAnchor, WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, -}; +use crate::BStackRaiiAllocator; +use crate::wal::{WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for}; thread_local! { /// While a WAL-backed teardown is in progress, the collector that @@ -30,7 +29,7 @@ thread_local! { /// so a crash mid-teardown is completed — not leaked — by `finish` on the next /// open. This is what [`BStackOwned::bstack_drop`](crate::BStackOwned) runs, so /// **every owned teardown is automatically WAL-backed** when the allocator names -/// an anchor ([`BStackWalAnchor::wal_anchor`] returns `Some`); an allocator that +/// an anchor ([`BStackRaiiAllocator::wal_anchor`] returns `Some`); an allocator that /// returns `None` falls straight through to a plain [`BStackDrop::bstack_drop`] /// and behaves exactly as before (mid-teardown crash ⇒ orphan leak). /// @@ -41,7 +40,10 @@ thread_local! { /// (e.g. a collection freeing its values through `BStackOwned::bstack_drop`) see /// the sink already set and just collect, so exactly one transaction wraps the /// outermost teardown. -pub fn wal_teardown(handle: T, allocator: &A) -> io::Result<()> { +pub fn wal_teardown( + handle: T, + allocator: &A, +) -> io::Result<()> { // Nested teardown: an outer driver already owns the sink; frees already // collect, so just recurse (exactly one transaction wraps the whole subtree). if TEARDOWN_SINK.with(|s| s.borrow().is_some()) { @@ -68,7 +70,7 @@ pub fn wal_teardown(handle: T, allocator: &A) /// lock, so concurrent teardowns on the same file serialize here (they collect /// their subtrees independently first — that part stays concurrent) rather than /// racing the single shared anchor slot. -fn wal_free_all( +fn wal_free_all( allocator: &A, anchor: u64, slices: Vec, @@ -121,7 +123,7 @@ fn wal_free_all( /// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and /// `Error = io::Error` (so the layer speaks [`io::Result`]). pub trait BStackDrop: Sized { - fn bstack_drop(self, allocator: &A) -> io::Result<()>; + fn bstack_drop(self, allocator: &A) -> io::Result<()>; } /// Free a raw block range by reconstructing an owned slice and delegating to the @@ -165,12 +167,12 @@ pub unsafe fn dealloc_range( /// It is a newtype over `(ManuallyDrop, &'a A)`; `bstack_move!` and the raw /// accessors defuse it via [`into_raw_parts`](Self::into_raw_parts) so no /// parallel destruction path exists. -pub struct AutoDrop<'a, T: BStackDrop, A: BStackWalAnchor> { +pub struct AutoDrop<'a, T: BStackDrop, A: BStackRaiiAllocator> { inner: ManuallyDrop, allocator: &'a A, } -impl<'a, T: BStackDrop, A: BStackWalAnchor> AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackRaiiAllocator> AutoDrop<'a, T, A> { /// Pair an inner handle with its allocator into an auto-dropping guard. /// /// # Safety @@ -206,14 +208,14 @@ impl<'a, T: BStackDrop, A: BStackWalAnchor> AutoDrop<'a, T, A> { } } -impl<'a, T: BStackDrop, A: BStackWalAnchor> Deref for AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackRaiiAllocator> Deref for AutoDrop<'a, T, A> { type Target = T; fn deref(&self) -> &T { &self.inner } } -impl<'a, T: BStackDrop, A: BStackWalAnchor> Drop for AutoDrop<'a, T, A> { +impl<'a, T: BStackDrop, A: BStackRaiiAllocator> Drop for AutoDrop<'a, T, A> { fn drop(&mut self) { let inner = unsafe { ManuallyDrop::take(&mut self.inner) }; // Errors are swallowed, matching the contract of Rust's `Drop`. diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e9278e8..731b27b 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -9,17 +9,17 @@ use std::io; use std::sync::atomic::{AtomicU64, Ordering}; use bstack::{ - BStack, BStackAllocator, BStackOwnedSliceAllocator, BStackRange, FirstFitBStackAllocator, + BStack, BStackAllocator, BStackRange, FirstFitBStackAllocator, }; use crate::layout::{self, BlockHeader}; use crate::{ AutoDrop, BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBlock, BStackBlockVec, BStackBox, BStackCast, BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, - BStackDeque, BStackDrop, BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRc, - BStackRef, BStackShared, BStackString, BStackWalAnchor, BStackWeakable, EightCC, TryClone, - TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, bstack_move, - dealloc_range, + BStackDeque, BStackDrop, BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, + BStackRaiiAllocator, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, + TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, + bstack_move, dealloc_range, }; // -------------------------------------------------------------------------- @@ -117,7 +117,7 @@ impl BStackCast for TestBlock { } impl BStackDrop for TestBlock { - fn bstack_drop(self, allocator: &A) -> io::Result<()> { + fn bstack_drop(self, allocator: &A) -> io::Result<()> { // No owned children: just free the data block itself. unsafe { dealloc_range(allocator, self.0) } } @@ -2509,7 +2509,7 @@ fn wal_finish_abandons_uncommitted() { #[test] fn wal_anchor_trait_reclaims_via_finish() { - use crate::BStackWalAnchor; + use crate::BStackRaiiAllocator; use crate::wal::{finish, persist_at}; use crate::{WalEntry, WalLog, WalStatus}; @@ -6338,7 +6338,7 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { } let tmp = TempStack::new(); - let alloc = tmp.allocator(); // FirstFit is a BStackWalAnchor + let alloc = tmp.allocator(); // FirstFit is a BStackRaiiAllocator let stack = alloc.stack(); // Source owns a child, so each deep clone allocates two blocks (+ a WAL block). diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index 33ef918..a6b1fd2 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -30,8 +30,8 @@ use core::marker::PhantomData; use core::mem::size_of; use std::io; -use crate::wal::BStackWalAnchor; -use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; @@ -64,7 +64,7 @@ pub(crate) fn bytevec_image(len: u64, cap: u64, data: &[u8]) -> Vec { /// Build a fresh data block holding `offs` (an offset array), register it in /// `plan` for rollback, and return its descriptor. The shared back end of the /// block-element vector clones, whose elements are all `u64` offsets. -fn build_offset_desc( +fn build_offset_desc( allocator: &A, offs: &[u64], plan: &mut ClonePlan, @@ -111,7 +111,7 @@ fn write_vecdesc(stack: &BStack, loc: u64, desc: VecDesc) -> io::Result<()> { /// The handle carries the descriptor in memory (`data`), plus the inline field /// location to persist it to (`writeback`) when field-resident — `None` for a /// detached vector (from [`from_slice`](Self::from_slice) or `bstack_move!`). -pub struct BStackVec<'a, T, A: BStackWalAnchor> { +pub struct BStackVec<'a, T, A: BStackRaiiAllocator> { /// The current data block range (the live descriptor). data: BStackRange, /// Where to persist descriptor changes on realloc (the inline field). `None` @@ -121,7 +121,7 @@ pub struct BStackVec<'a, T, A: BStackWalAnchor> { _marker: PhantomData T>, } -impl<'a, T, A: BStackWalAnchor> BStackVec<'a, T, A> { +impl<'a, T, A: BStackRaiiAllocator> BStackVec<'a, T, A> { /// Reconstruct a **field-resident** handle from its inline descriptor's /// absolute on-disk location (what a field accessor passes). Reads the /// current descriptor and remembers the location for write-back. @@ -202,7 +202,7 @@ impl<'a, T, A: BStackWalAnchor> BStackVec<'a, T, A> { } } -impl<'a, T: Pod, A: BStackWalAnchor> BStackVec<'a, T, A> { +impl<'a, T: Pod, A: BStackRaiiAllocator> BStackVec<'a, T, A> { /// Create a **detached** vector holding `data`, allocating only the data /// block. It becomes persistent when written into a struct field. pub fn from_slice(allocator: &'a A, data: &[T]) -> io::Result { @@ -329,12 +329,12 @@ impl<'a, T: Pod, A: BStackWalAnchor> BStackVec<'a, T, A> { /// Each element is a `u64` offset to a separately-allocated `#[bstack_block]` /// child this vector *owns*; dropping the vector recursively frees every child /// (post-order) plus the offset array. Backs `#[bstack_owned] Vec` fields. -pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackWalAnchor> { +pub struct BStackBlockVec<'a, T: BStackBlock, A: BStackRaiiAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackBlockVec<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackBlockVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of data offsets to /// live `T` blocks this vector owns. @@ -462,12 +462,12 @@ impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackBlockVec<'a, T, A> { /// Each element holds one strong reference; dropping the vector releases every /// one (freeing a child when its count hits zero) and frees the offset array. /// Backs `#[bstack_strong] Vec` fields. -pub struct BStackStrongVec<'a, T: BStackShared, A: BStackWalAnchor> { +pub struct BStackStrongVec<'a, T: BStackShared, A: BStackRaiiAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackShared, A: BStackWalAnchor> BStackStrongVec<'a, T, A> { +impl<'a, T: BStackShared, A: BStackRaiiAllocator> BStackStrongVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of data offsets to /// live `T` blocks, each accounting for one strong reference this vector owns. @@ -594,12 +594,12 @@ impl<'a, T: BStackShared, A: BStackWalAnchor> BStackStrongVec<'a, T, A> { /// Dropping the vector releases every weak count (freeing a control block when /// it reaches zero) and frees the offset array. Backs `#[bstack_weak] Vec` /// fields. -pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackWalAnchor> { +pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackRaiiAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeakVec<'a, T, A> { +impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> BStackWeakVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of control-block /// offsets, each accounting for one weak reference this vector owns. @@ -713,12 +713,12 @@ impl<'a, T: BStackWeakable, A: BStackWalAnchor> BStackWeakVec<'a, T, A> { /// /// Elements carry no ownership: dropping the vector frees only the offset array, /// never the targets. Backs `#[bstack_ref] Vec` fields. -pub struct BStackRefVec<'a, T: BStackBlock, A: BStackWalAnchor> { +pub struct BStackRefVec<'a, T: BStackBlock, A: BStackRaiiAllocator> { offsets: BStackVec<'a, u64, A>, _marker: PhantomData T>, } -impl<'a, T: BStackBlock, A: BStackWalAnchor> BStackRefVec<'a, T, A> { +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRefVec<'a, T, A> { /// # Safety /// `loc` must be a live inline descriptor over an array of offsets to `T` /// blocks (which this vector does not own). diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 65420b0..4bf9b16 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -56,6 +56,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; +use crate::BStackRaiiAllocator; use crate::teardown::dealloc_range; /// `R'`: an allocation requirement carrying identity — a length whose address has @@ -297,7 +298,7 @@ pub fn reduce(allocs: Vec, mut deallocs: Vec) -> Reduced // // The WAL block is **persistent and reused** (Vec-like), not allocated per // transaction: `[WalHeader | WalEntry × capacity]`, reached through a stable -// anchor slot (see [`BStackWalAnchor`]) that holds the block's offset (`0` = not +// anchor slot (see [`BStackRaiiAllocator`]) that holds the block's offset (`0` = not // yet created — lazily allocated on first use). The header's `txn_status` doubles // as the in-use flag: // @@ -357,30 +358,9 @@ impl WalLog { } } -/// The allocator capability the whole crate is bound on: a -/// [`BStackOwnedSliceAllocator`] that names a stable on-disk slot for the WAL -/// block pointer — or `None` to opt out of crash-reclamation. -/// -/// Making this the uniform bound is what lets `try_clone_in` / `bstack_drop` -/// reclaim orphaned allocations on the next open **automatically**, with no -/// separate opt-in call: they read [`wal_anchor`](Self::wal_anchor) directly. -/// `None` (the default) means "no reclamation" — the op behaves exactly as before. -/// Every bstack-provided allocator implements this; a custom allocator adds a -/// one-line `unsafe impl BStackWalAnchor for MyAlloc {}` (defaulting to `None`, -/// or returning `Some(slot)` if it reserves one). -/// -/// # Safety -/// -/// An implementor returning `Some(off)` asserts that `[off, off + 8)` is a -/// stable, persistent 8-byte region the allocator **never** hands out via `alloc` -/// and **never** uses for its own metadata, and that survives across open/close. -/// `bstack_raii` stores the current WAL block's offset there (`0` = none). -/// Returning `None` asserts nothing. -pub unsafe trait BStackWalAnchor: BStackOwnedSliceAllocator { - fn wal_anchor(&self) -> Option { - None - } -} +// `BStackRaiiAllocator` (the crate-wide allocator bound) is defined at the crate +// root in `lib.rs`; the impls for bstack's own allocators live here, next to the +// anchor constant and the WAL machinery they feed. /// Anchor offset for the bstack-provided freeing allocators: the second `u64` /// word of the user-reserved region every one of them keeps at payload offset 0 @@ -392,29 +372,29 @@ pub const STD_WAL_ANCHOR: u64 = 8; // SAFETY: each of these allocators documents a user-reserved region at payload // offset 0 (≥ 16 bytes) that it never allocates from and never writes to; the // `[8, 16)` slot sits inside it and persists across open/close. -unsafe impl BStackWalAnchor for bstack::FirstFitBStackAllocator { +unsafe impl BStackRaiiAllocator for bstack::FirstFitBStackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } } -unsafe impl BStackWalAnchor for bstack::GhostTreeBstackAllocator { +unsafe impl BStackRaiiAllocator for bstack::GhostTreeBstackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } } -unsafe impl BStackWalAnchor for bstack::SlabBStackAllocator { +unsafe impl BStackRaiiAllocator for bstack::SlabBStackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } } -unsafe impl BStackWalAnchor for bstack::CheckedSlabBStackAllocator { +unsafe impl BStackRaiiAllocator for bstack::CheckedSlabBStackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } } // `LinearBStackAllocator`'s `dealloc` is a no-op (nothing to reclaim), so it opts // out via the default `None` — but it still needs the impl to satisfy the bound. -unsafe impl BStackWalAnchor for bstack::LinearBStackAllocator {} +unsafe impl BStackRaiiAllocator for bstack::LinearBStackAllocator {} // --------------------------------------------------------------------------- // In-memory serialization of WAL transactions. @@ -503,7 +483,7 @@ fn wal_ensure_block( /// Stage `log` into the persistent WAL block at transaction status `txn_status`, /// (lazily) creating or growing the block as needed. Returns the block's range. -/// The caller must hold the file's WAL lock (see [`wal_lock_for`]). +/// The caller must hold the file's WAL lock (the crate-internal `wal_lock_for`). pub fn persist_at( allocator: &A, anchor: u64, @@ -625,10 +605,10 @@ pub fn finish_at(allocator: &A, anchor: u64) -> io finish_at_locked(allocator, anchor) } -/// Like [`finish_at`], using the allocator's own [`BStackWalAnchor`] slot. +/// Like [`finish_at`], using the allocator's own [`BStackRaiiAllocator`] slot. /// An allocator that opts out of reclamation (`wal_anchor() == None`) has no WAL /// to complete, so this is a no-op returning `0`. -pub fn finish(allocator: &A) -> io::Result { +pub fn finish(allocator: &A) -> io::Result { match allocator.wal_anchor() { Some(anchor) => finish_at(allocator, anchor), None => Ok(0), From 0a915cce5d7907365a9911b8283cc27373bc3d0a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 02:07:26 -0700 Subject: [PATCH 099/140] Refactor wal.rs to remove BStackOwnedSliceAllocator dependency completely --- bstack_raii/src/bulk.rs | 9 ++-- bstack_raii/src/clone.rs | 20 ++++---- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/teardown.rs | 39 +++++++-------- bstack_raii/src/tests.rs | 61 +++++++++-------------- bstack_raii/src/wal.rs | 97 +++++++++++++++++-------------------- 6 files changed, 101 insertions(+), 127 deletions(-) diff --git a/bstack_raii/src/bulk.rs b/bstack_raii/src/bulk.rs index ee0986e..3716bc8 100644 --- a/bstack_raii/src/bulk.rs +++ b/bstack_raii/src/bulk.rs @@ -6,7 +6,7 @@ //! bstack provides atomic [`BStackBulkAllocator::alloc_bulk`] / //! [`dealloc_bulk`](bstack::BStackBulkAllocator::dealloc_bulk), but only some //! allocators implement it, and **all** of `bstack_raii` is generic over -//! `A: BStackOwnedSliceAllocator`. On stable Rust a generic function cannot +//! `A: BStackRaiiAllocator`. On stable Rust a generic function cannot //! dispatch on whether its concrete `A` *also* implements `BStackBulkAllocator`: //! trait-method selection happens once, at the generic definition site, where the //! extra bound is unprovable — so any "prefer bulk when available" shim (autoref @@ -23,14 +23,15 @@ use std::io; -use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStackAllocator, BStackRange}; +use crate::BStackRaiiAllocator; use crate::teardown::dealloc_range; /// Allocate one block per entry in `sizes`, in order. On any failure the blocks /// already allocated are freed (reverse order) before the error is returned, so a /// partial allocation never leaks within the call. -pub fn alloc_many( +pub fn alloc_many( allocator: &A, sizes: &[u64], ) -> io::Result> { @@ -52,7 +53,7 @@ pub fn alloc_many( /// Free every range in turn. Stops and propagates on the first error (the /// remaining ranges are left allocated for the caller to handle). -pub fn free_many( +pub fn free_many( allocator: &A, ranges: impl IntoIterator, ) -> io::Result<()> { diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 0bfdcd9..4f022d0 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -251,14 +251,14 @@ impl ClonePlan { // `finish` on the next open. The transaction is flipped `Complete` *inside* // the commit batch below, so "clone committed" and "WAL Complete" are the // same atomic event. - let wal: Option<(u64, BStackRange)> = match anchor { - Some(anchor) if !allocated.is_empty() => { + let wal: Option = match anchor { + Some(_) if !allocated.is_empty() => { let mut log = WalLog::with_capacity(allocated.len()); for &r in &allocated { log.append(WalEntry::alloc(WalStatus::Pending, r)); } - match persist_at(allocator, anchor, &log, WalStatus::Pending) { - Ok(range) => Some((anchor, range)), + match persist_at(allocator, &log, WalStatus::Pending) { + Ok(range) => Some(range), Err(e) => { // Couldn't stage the WAL — fall back to an immediate rollback. Self::free_all(allocated, allocator); @@ -290,7 +290,7 @@ impl ClonePlan { // The WAL commit marker (`WalHeader.txn_status`, the byte after the u64 // magic). Written last, so it lands in the same atomic batch as the clone. let flip = [WalStatus::Complete as u8]; - let flip_off = wal.map(|(_, r)| r.start() + 8); + let flip_off = wal.map(|r| r.start() + 8); let mut flipped = flip_off.is_none(); let mut read_i = 0usize; @@ -376,7 +376,7 @@ impl ClonePlan { // nothing to roll forward: just mark the persistent block idle for // reuse (a crash before this is harmlessly finished on the next open, // freeing nothing). The block itself is never freed. - if let Some((_anchor, wal_range)) = wal { + if let Some(wal_range) = wal { let _ = wal_set_idle(allocator, wal_range.start()); } Ok(()) @@ -397,12 +397,14 @@ impl ClonePlan { /// already held by the caller (`commit_inner`), so the *locked* variant is used. fn reclaim( allocated: Vec, - wal: Option<(u64, BStackRange)>, + wal: Option, allocator: &A, ) { match wal { - Some((anchor, _)) => { - let _ = finish_at_locked(allocator, anchor); + // A WAL block was staged: abandon its still-`Pending` transaction via + // the allocator's own anchor (same as a real crash's `finish`). + Some(_) => { + let _ = finish_at_locked(allocator); } None => Self::free_all(allocated, allocator), } diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 85bc4d8..8560622 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -92,7 +92,7 @@ pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_teardown}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; pub use wal::{ AllocReq, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, WalStatus, finish, - finish_at, persist_at, reduce, + persist_at, reduce, }; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index 2b1e69c..5e5ad3c 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -10,7 +10,7 @@ use core::mem::ManuallyDrop; use core::ops::Deref; use std::io; -use bstack::{BStackGenOp, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStackGenOp, BStackOwnedSlice, BStackRange}; use crate::BStackRaiiAllocator; use crate::wal::{WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for}; @@ -36,10 +36,10 @@ thread_local! { /// While the sink is installed, every [`dealloc_range`] in the (ordinary, /// generic) teardown recursion *collects* its slice rather than freeing it; /// afterwards the whole set commits as one `Dealloc` transaction and is executed -/// via [`finish_at`] (the same path crash recovery takes). Nested owned frees -/// (e.g. a collection freeing its values through `BStackOwned::bstack_drop`) see -/// the sink already set and just collect, so exactly one transaction wraps the -/// outermost teardown. +/// via [`finish`](crate::wal::finish)'s completion path (the same path crash +/// recovery takes). Nested owned frees (e.g. a collection freeing its values +/// through `BStackOwned::bstack_drop`) see the sink already set and just collect, +/// so exactly one transaction wraps the outermost teardown. pub fn wal_teardown( handle: T, allocator: &A, @@ -50,16 +50,15 @@ pub fn wal_teardown( return handle.bstack_drop(allocator); } // No anchor → the allocator opts out of reclamation: plain teardown. - let anchor = match allocator.wal_anchor() { - Some(a) => a, - None => return handle.bstack_drop(allocator), - }; + if allocator.wal_anchor().is_none() { + return handle.bstack_drop(allocator); + } TEARDOWN_SINK.with(|s| *s.borrow_mut() = Some(Vec::new())); let result = handle.bstack_drop(allocator); let slices = TEARDOWN_SINK .with(|s| s.borrow_mut().take()) .unwrap_or_default(); - wal_free_all(allocator, anchor, slices)?; + wal_free_all(allocator, slices)?; result } @@ -70,11 +69,7 @@ pub fn wal_teardown( /// lock, so concurrent teardowns on the same file serialize here (they collect /// their subtrees independently first — that part stays concurrent) rather than /// racing the single shared anchor slot. -fn wal_free_all( - allocator: &A, - anchor: u64, - slices: Vec, -) -> io::Result<()> { +fn wal_free_all(allocator: &A, slices: Vec) -> io::Result<()> { if slices.is_empty() { return Ok(()); } @@ -90,7 +85,7 @@ fn wal_free_all( // before it abandons; after it, `finish` rolls the frees forward). With the // sink now cleared, `finish_at_locked` executes the frees and marks the // persistent WAL block idle for reuse. - let wal_range = persist_at(allocator, anchor, &log, WalStatus::Pending)?; + let wal_range = persist_at(allocator, &log, WalStatus::Pending)?; let flip = [WalStatus::Complete as u8]; let mut done = false; allocator.stack().inplace_gen(|_feedback| { @@ -107,7 +102,7 @@ fn wal_free_all( }) } })?; - finish_at_locked(allocator, anchor)?; + finish_at_locked(allocator)?; Ok(()) } @@ -118,10 +113,10 @@ fn wal_free_all( /// `dealloc` — see [`dealloc_range`]. There is deliberately no `dealloc_range` /// method on the allocator trait itself. /// -/// The allocator is bound to [`BStackOwnedSliceAllocator`] rather than the bare -/// `BStackAllocator`: that supertrait pins `Allocated<'a> = BStackOwnedSlice<'a, -/// A>` (so a reconstructed owned slice is the accepted `dealloc` handle) and -/// `Error = io::Error` (so the layer speaks [`io::Result`]). +/// The allocator is bound to the crate-wide [`BStackRaiiAllocator`], whose +/// [`BStackOwnedSliceAllocator`] supertrait pins `Allocated<'a> = +/// BStackOwnedSlice<'a, A>` (so a reconstructed owned slice is the accepted +/// `dealloc` handle) and `Error = io::Error` (so the layer speaks [`io::Result`]). pub trait BStackDrop: Sized { fn bstack_drop(self, allocator: &A) -> io::Result<()>; } @@ -133,7 +128,7 @@ pub trait BStackDrop: Sized { /// # Safety /// `range` must be a live allocation owned by `allocator` that no other live /// handle will also free. -pub unsafe fn dealloc_range( +pub unsafe fn dealloc_range( allocator: &A, range: BStackRange, ) -> io::Result<()> { diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 731b27b..3be5a78 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -8,9 +8,7 @@ use core::mem::size_of; use std::io; use std::sync::atomic::{AtomicU64, Ordering}; -use bstack::{ - BStack, BStackAllocator, BStackRange, FirstFitBStackAllocator, -}; +use bstack::{BStack, BStackAllocator, BStackRange, FirstFitBStackAllocator}; use crate::layout::{self, BlockHeader}; use crate::{ @@ -2456,15 +2454,12 @@ fn macro_clone_embed() { #[test] fn wal_finish_rolls_forward_committed() { - use crate::wal::{finish_at, persist_at}; + use crate::wal::{finish, persist_at}; use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); - let alloc = tmp.allocator(); - // A stable anchor slot (zeroed = "no WAL block yet"), plus two "old" slices - // the transaction was freeing. - let anchor = alloc.alloc(8).unwrap().as_range().start(); - alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); + let alloc = tmp.allocator(); // FirstFit: wal_anchor() == Some(8), zeroed on a fresh file + // Two "old" slices the transaction was freeing. let v1 = alloc.alloc(64).unwrap().as_range(); let v2 = alloc.alloc(64).unwrap().as_range(); @@ -2472,63 +2467,54 @@ fn wal_finish_rolls_forward_committed() { let mut log = WalLog::with_capacity(2); log.append(WalEntry::dealloc(WalStatus::Pending, v1)); log.append(WalEntry::dealloc(WalStatus::Pending, v2)); - persist_at(&alloc, anchor, &log, WalStatus::Complete).unwrap(); + persist_at(&alloc, &log, WalStatus::Complete).unwrap(); // Completing it rolls both deallocs forward. - assert_eq!(finish_at(&alloc, anchor).unwrap(), 2); + assert_eq!(finish(&alloc).unwrap(), 2); // The persistent WAL block is now idle: re-completing finds nothing staged. // v1/v2 were reclaimed (a fresh 64-byte alloc reuses a freed slot). - assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); + assert_eq!(finish(&alloc).unwrap(), 0); let reused = alloc.alloc(64).unwrap().as_range(); assert!(reused.start() == v1.start() || reused.start() == v2.start()); } #[test] fn wal_finish_abandons_uncommitted() { - use crate::wal::{finish_at, persist_at}; + use crate::wal::{finish, persist_at}; use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); - let anchor = alloc.alloc(8).unwrap().as_range().start(); - alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); let v1 = alloc.alloc(64).unwrap().as_range(); // An UNCOMMITTED transaction: its dealloc must NOT be performed. let mut log = WalLog::with_capacity(1); log.append(WalEntry::dealloc(WalStatus::Pending, v1)); - persist_at(&alloc, anchor, &log, WalStatus::Pending).unwrap(); + persist_at(&alloc, &log, WalStatus::Pending).unwrap(); // Abandoned: the old slice v1 must NOT be freed (it's still live). Reclaiming // an abandoned txn frees its *allocs*, and this txn logged only a dealloc. - assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); + assert_eq!(finish(&alloc).unwrap(), 0); // Idle after completion: re-running finds nothing staged. - assert_eq!(finish_at(&alloc, anchor).unwrap(), 0); + assert_eq!(finish(&alloc).unwrap(), 0); } #[test] fn wal_anchor_trait_reclaims_via_finish() { - use crate::BStackRaiiAllocator; use crate::wal::{finish, persist_at}; use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); - let alloc = tmp.allocator(); // FirstFitBStackAllocator: wal_anchor() == 8 + let alloc = tmp.allocator(); // FirstFitBStackAllocator: wal_anchor() == Some(8) let orphan = alloc.alloc(64).unwrap().as_range(); // Persist an abandoned (Pending) txn into the allocator's own anchor slot. let mut log = WalLog::with_capacity(1); log.append(WalEntry::alloc(WalStatus::Pending, orphan)); - persist_at( - &alloc, - alloc.wal_anchor().unwrap(), - &log, - WalStatus::Pending, - ) - .unwrap(); + persist_at(&alloc, &log, WalStatus::Pending).unwrap(); - // finish() uses the trait anchor and reclaims the orphan; the allocator is + // finish() reclaims the orphan via the allocator's own anchor; the allocator is // unharmed by our writes to its reserved slot (a fresh alloc reuses it). assert_eq!(finish(&alloc).unwrap(), 1); assert_eq!(alloc.alloc(64).unwrap().as_range().start(), orphan.start()); @@ -2536,13 +2522,11 @@ fn wal_anchor_trait_reclaims_via_finish() { #[test] fn wal_finish_reclaims_abandoned_allocs() { - use crate::wal::{finish_at, persist_at}; + use crate::wal::{finish, persist_at}; use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); - let anchor = alloc.alloc(8).unwrap().as_range().start(); - alloc.stack().set(anchor, 0u64.to_le_bytes()).unwrap(); // Two blocks a crashed op allocated but never linked (orphans). let a1 = alloc.alloc(64).unwrap().as_range(); let a2 = alloc.alloc(64).unwrap().as_range(); @@ -2551,22 +2535,21 @@ fn wal_finish_reclaims_abandoned_allocs() { let mut log = WalLog::with_capacity(2); log.append(WalEntry::alloc(WalStatus::Pending, a1)); log.append(WalEntry::alloc(WalStatus::Pending, a2)); - persist_at(&alloc, anchor, &log, WalStatus::Pending).unwrap(); + persist_at(&alloc, &log, WalStatus::Pending).unwrap(); // Reclaiming the abandoned txn frees both orphans. - assert_eq!(finish_at(&alloc, anchor).unwrap(), 2); + assert_eq!(finish(&alloc).unwrap(), 2); // Reclaimed: a fresh 64-byte alloc reuses one of the freed slots. let reused = alloc.alloc(64).unwrap().as_range(); assert!(reused.start() == a1.start() || reused.start() == a2.start()); - // A *committed* alloc-only txn keeps its allocs (frees nothing). - let anchor2 = alloc.alloc(8).unwrap().as_range().start(); - alloc.stack().set(anchor2, 0u64.to_le_bytes()).unwrap(); + // A *committed* alloc-only txn keeps its allocs (frees nothing); the persistent + // WAL block is reused for it. let keep = alloc.alloc(64).unwrap().as_range(); let mut log2 = WalLog::with_capacity(1); log2.append(WalEntry::alloc(WalStatus::Pending, keep)); - persist_at(&alloc, anchor2, &log2, WalStatus::Complete).unwrap(); - assert_eq!(finish_at(&alloc, anchor2).unwrap(), 0); + persist_at(&alloc, &log2, WalStatus::Complete).unwrap(); + assert_eq!(finish(&alloc).unwrap(), 0); } // -------------------------------------------------------------------------- @@ -6378,7 +6361,7 @@ fn wal_teardown_reclaims_on_free_fault() { use std::sync::atomic::{AtomicBool, Ordering}; // The teardown WAL commits by flipping `txn_status` in one `inplace_gen`; the - // very next `set` is `finish_at`'s first entry-status write — just past the + // very next `set` is `finish_at_locked`'s first entry-status write — just past the // commit point, before any block is actually freed. Failing *that* set models // a crash mid-teardown with the transaction already committed (so `finish` // must roll every dealloc forward, with no half-freed block). diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 4bf9b16..d519fb1 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -413,7 +413,7 @@ static WAL_LOCKS: OnceLock>>>> = OnceLock::ne /// The WAL mutex for `allocator`'s file (created on first use). Hold its guard /// across a whole WAL transaction. -pub(crate) fn wal_lock_for(allocator: &A) -> Arc> { +pub(crate) fn wal_lock_for(allocator: &A) -> Arc> { let key = core::ptr::from_ref(allocator.stack()) as usize; let reg = WAL_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); let mut map = reg.lock().unwrap_or_else(|e| e.into_inner()); @@ -422,14 +422,16 @@ pub(crate) fn wal_lock_for(allocator: &A) -> Arc( - allocator: &A, - anchor: u64, -) -> io::Result> { +/// Read the anchor slot: the persistent WAL block's offset, or `None` if the +/// allocator opts out of reclamation ([`wal_anchor`](BStackRaiiAllocator::wal_anchor) +/// is `None`) or no block has been created yet. +fn read_anchor(allocator: &A) -> io::Result> { + let slot = match allocator.wal_anchor() { + Some(s) => s, + None => return Ok(None), + }; let mut buf = [0u8; 8]; - allocator.stack().get_into(anchor, &mut buf)?; + allocator.stack().get_into(slot, &mut buf)?; let off = u64::from_le_bytes(buf); Ok((off != 0).then_some(off)) } @@ -438,17 +440,17 @@ fn read_anchor( /// slots, returning `(block_offset, capacity)`. Lazily allocates it on first use; /// grows it (free old, allocate a larger one — its contents are transient between /// transactions) when a transaction needs more capacity. The header is -/// (re)initialized `None` (idle) whenever the block is created or grown. -fn wal_ensure_block( - allocator: &A, - anchor: u64, - needed: u64, -) -> io::Result<(u64, u64)> { +/// (re)initialized `None` (idle) whenever the block is created or grown. Errors if +/// the allocator names no anchor slot (callers gate on `wal_anchor().is_some()`). +fn wal_ensure_block(allocator: &A, needed: u64) -> io::Result<(u64, u64)> { + let slot = allocator + .wal_anchor() + .ok_or_else(|| io::Error::other("allocator names no WAL anchor slot"))?; let stack = allocator.stack(); let hsz = size_of::() as u64; let esz = size_of::() as u64; - if let Some(off) = read_anchor(allocator, anchor)? { + if let Some(off) = read_anchor(allocator)? { let mut hbuf = [0u8; size_of::()]; stack.get_into(off, &mut hbuf)?; let header: WalHeader = bytemuck::pod_read_unaligned(&hbuf); @@ -477,20 +479,21 @@ fn wal_ensure_block( let _ = allocator.dealloc(slice); return Err(e); } - stack.set(anchor, off.to_le_bytes())?; + stack.set(slot, off.to_le_bytes())?; Ok((off, capacity)) } -/// Stage `log` into the persistent WAL block at transaction status `txn_status`, -/// (lazily) creating or growing the block as needed. Returns the block's range. -/// The caller must hold the file's WAL lock (the crate-internal `wal_lock_for`). -pub fn persist_at( +/// Stage `log` into the allocator's persistent WAL block at transaction status +/// `txn_status`, (lazily) creating or growing the block as needed. Returns the +/// block's range. The anchor slot comes from the allocator itself +/// ([`wal_anchor`](BStackRaiiAllocator::wal_anchor)); the caller must hold the +/// file's WAL lock (the crate-internal `wal_lock_for`). +pub fn persist_at( allocator: &A, - anchor: u64, log: &WalLog, txn_status: WalStatus, ) -> io::Result { - let (off, capacity) = wal_ensure_block(allocator, anchor, log.entries().len() as u64)?; + let (off, capacity) = wal_ensure_block(allocator, log.entries().len() as u64)?; let image = log.block_image(txn_status, capacity); allocator.stack().set(off, &image)?; let hsz = size_of::() as u64; @@ -498,14 +501,14 @@ pub fn persist_at( Ok(BStackRange::new(off, hsz + capacity * esz)) } -/// Read the staged transaction in the persistent WAL block, if any. Returns the -/// block range, header, and the `count` live entries; `None` if no block exists. -fn load_at( +/// Read the staged transaction in the allocator's persistent WAL block, if any. +/// Returns the block range, header, and the `count` live entries; `None` if no +/// block exists (or the allocator opts out of reclamation). +fn load_at( allocator: &A, - anchor: u64, ) -> io::Result)>> { let stack = allocator.stack(); - let wal_off = match read_anchor(allocator, anchor)? { + let wal_off = match read_anchor(allocator)? { Some(off) => off, None => return Ok(None), }; @@ -529,7 +532,7 @@ fn load_at( /// Mark the persistent WAL block idle (`txn_status := None`) — a transaction is /// complete and the block is free to reuse. The block itself is **not** freed. -pub(crate) fn wal_set_idle( +pub(crate) fn wal_set_idle( allocator: &A, block_off: u64, ) -> io::Result<()> { @@ -539,9 +542,10 @@ pub(crate) fn wal_set_idle( .set(block_off + 8, [WalStatus::None as u8]) } -/// **Complete** a staged transaction by reclaiming exactly the slices its outcome -/// orphaned, then marking the block idle. Assumes the file's WAL lock is already -/// held (used on the failure/recovery paths that run under the transaction lock). +/// **Complete** the allocator's staged transaction by reclaiming exactly the +/// slices its outcome orphaned, then marking the block idle. Assumes the file's +/// WAL lock is already held (used on the failure/recovery paths that run under the +/// transaction lock). /// /// * **Committed** (`txn_status == Complete`): roll forward — free each still- /// `Pending` `Dealloc` (the old blocks the committed op unlinked). @@ -551,11 +555,8 @@ pub(crate) fn wal_set_idle( /// Each freed entry is persisted `Complete` **before** its slice is freed, so a /// second crash mid-completion never double-frees. The persistent block is then /// marked idle (`None`) and kept for reuse. Returns the number of slices freed. -pub(crate) fn finish_at_locked( - allocator: &A, - anchor: u64, -) -> io::Result { - let (wal_range, header, entries) = match load_at(allocator, anchor)? { +pub(crate) fn finish_at_locked(allocator: &A) -> io::Result { + let (wal_range, header, entries) = match load_at(allocator)? { Some(x) => x, None => return Ok(0), }; @@ -595,24 +596,16 @@ pub(crate) fn finish_at_locked( Ok(completed) } -/// **Complete** a crash-left transaction at `anchor`: reclaim the slices its -/// outcome orphaned and mark the persistent block idle. Returns the number of -/// slices reclaimed. This is what a caller runs once after `open` — a -/// *completion*, not a leaky recovery. Acquires the file's WAL lock. -pub fn finish_at(allocator: &A, anchor: u64) -> io::Result { +/// **Complete** a crash-left transaction in the allocator's WAL block: reclaim the +/// slices its outcome orphaned and mark the persistent block idle. Returns the +/// number of slices reclaimed. This is what a caller runs once after `open` — a +/// *completion*, not a leaky recovery. Acquires the file's WAL lock. An allocator +/// that opts out of reclamation ([`wal_anchor`](BStackRaiiAllocator::wal_anchor) +/// is `None`) has no WAL to complete, so this is a no-op returning `0`. +pub fn finish(allocator: &A) -> io::Result { let lock = wal_lock_for(allocator); let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); - finish_at_locked(allocator, anchor) -} - -/// Like [`finish_at`], using the allocator's own [`BStackRaiiAllocator`] slot. -/// An allocator that opts out of reclamation (`wal_anchor() == None`) has no WAL -/// to complete, so this is a no-op returning `0`. -pub fn finish(allocator: &A) -> io::Result { - match allocator.wal_anchor() { - Some(anchor) => finish_at(allocator, anchor), - None => Ok(0), - } + finish_at_locked(allocator) } #[cfg(test)] From 790a528f22f69d8913e3ed12cc332783465a5cd3 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 02:20:13 -0700 Subject: [PATCH 100/140] Update bstack --- bstack_raii/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bstack_raii/Cargo.lock b/bstack_raii/Cargo.lock index 2a27ba0..752d478 100644 --- a/bstack_raii/Cargo.lock +++ b/bstack_raii/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "bstack" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3d25282e16e90e033662b78e911d036da268c3b2a27e345d8feb677c80cd07" +checksum = "5099c95e4494ce92b330ddc52d626a07195d7731975e42c1761c7f746ec98758" dependencies = [ "libc", "windows-sys", From 7ff24422b5fe6557ca1cf290c50a46ae19a034f8 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 04:27:24 -0700 Subject: [PATCH 101/140] Add parking_lot --- bstack_raii/Cargo.lock | 66 ++++++++++++++++++++++++++++++++++++++++++ bstack_raii/Cargo.toml | 1 + 2 files changed, 67 insertions(+) diff --git a/bstack_raii/Cargo.lock b/bstack_raii/Cargo.lock index 752d478..a57832b 100644 --- a/bstack_raii/Cargo.lock +++ b/bstack_raii/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + [[package]] name = "bstack" version = "0.4.1" @@ -19,6 +25,7 @@ dependencies = [ "bstack", "bstack_raii_derive", "bytemuck", + "parking_lot", ] [[package]] @@ -50,12 +57,50 @@ dependencies = [ "syn", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -74,6 +119,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "syn" version = "2.0.118" diff --git a/bstack_raii/Cargo.toml b/bstack_raii/Cargo.toml index 4c57cdb..a02ad69 100644 --- a/bstack_raii/Cargo.toml +++ b/bstack_raii/Cargo.toml @@ -30,3 +30,4 @@ bstack_raii_derive = { path = "derive", version = "0.0.0" } # Gate for POD (plain-old-data) inline fields: any `bytemuck::Pod` type is safe # to store inline in an on-disk block and receives the blanket no-op BStackDrop. bytemuck = { version = "=1.25", features = ["derive"] } +parking_lot = "0.12.5" From dc64d349af91c8dc8875703f943603b9909f968a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 9 Aug 2026 05:10:52 -0700 Subject: [PATCH 102/140] foreign: Registery --- bstack_raii/src/lib.rs | 2 + bstack_raii/src/registry.rs | 547 ++++++++++++++++++++++++++++++++++++ bstack_raii/src/tests.rs | 70 +++++ 3 files changed, 619 insertions(+) create mode 100644 bstack_raii/src/registry.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 8560622..607bd12 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -58,6 +58,8 @@ mod layout; mod owned; mod refcount; mod reference; +/// Cross-file `Foreign` support: the process-wide path↔id file registry. +pub mod registry; mod shared; mod stdlib; mod teardown; diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs new file mode 100644 index 0000000..bbaf6ad --- /dev/null +++ b/bstack_raii/src/registry.rs @@ -0,0 +1,547 @@ +//! Process-wide registry of bstack files — the stateful foundation for +//! `Foreign` (cross-file pointers), built before `Foreign` itself. +//! +//! A `Foreign` is "a slice with a file identity attached": a [`FileId`] plus a +//! [`BStackRange`]. Paths are variable-length and awkward to embed on disk, so the +//! registry maps each file's **persistent path** to a small, **stable** numeric +//! [`FileId`] that a `Foreign` can store as a plain integer. Resolving that id +//! back to the file's live allocator (to read/write/allocate in it) happens here. +//! +//! ## Two layers +//! +//! * **Persistent** — a dedicated bstack file (its own [`FirstFitBStackAllocator`]) +//! holding the **append-only** path table. Ids are just indices into it, so a +//! `Foreign` written to disk with `FileId(5)` means the same path on every future +//! run. Paths are *never removed* (that would renumber ids and dangle stored +//! `Foreign`s); only appended. +//! * **In-memory** — mirrors the path↔id maps and adds `id -> live host`: the +//! *open* allocator for a file, type-erased behind [`ForeignHost`]. Guarded by a +//! [`parking_lot::RwLock`]. +//! +//! ## Why an `RwLock` (and why `parking_lot`) +//! +//! Resolving a foreign file to run an op on it is **hot** and concurrent (many +//! readers); *detaching* a live file is **cold** and must not race an in-flight op +//! (an exclusive writer). That is exactly a read-write lock — and since the read +//! side sits on the bstack io hot path, [`parking_lot::RwLock`] (cheaper, no +//! poisoning) is preferred over `std`. [`FileRegistry::with_host`] holds the read +//! lock for the whole duration of the caller's closure, so a concurrent +//! [`detach`](FileRegistry::detach) blocks until the op finishes — the "stop +//! token" that keeps a live file from vanishing mid-operation. +//! +//! ## Optional and zero-cost when unused +//! +//! The registry is a lazily-created global: a program that never registers a file +//! never instantiates it, so ordinary single-file ops pay nothing. It is brought +//! up explicitly with [`init`]. + +use core::fmt; +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use bstack::{BStack, BStackAllocator, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use parking_lot::RwLock; + +use crate::BStackRaiiAllocator; + +/// A small, stable identity for a registered bstack file: an index into the +/// registry's append-only path table. +/// +/// Backed by a `u32` (a sane program opens far fewer than `u16::MAX` files, so +/// this is generous headroom), but a `Foreign` pointer stores it **widened to a +/// `u64`** — for alignment next to a [`BStackRange`], and to leave room for future +/// RTTI. [`as_u64`](Self::as_u64) / [`from_u64`](Self::from_u64) bridge the two. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FileId(u32); + +impl FileId { + /// The raw `u32` index. + pub const fn get(self) -> u32 { + self.0 + } + + /// The id widened to the `u64` a `Foreign` pointer stores on disk. + pub const fn as_u64(self) -> u64 { + self.0 as u64 + } + + /// Reconstruct a `FileId` from its on-disk `u64` form, rejecting values that + /// do not fit the `u32` id space (corruption / a foreign id from a wider build). + pub const fn from_u64(v: u64) -> Option { + if v <= u32::MAX as u64 { + Some(FileId(v as u32)) + } else { + None + } + } +} + +/// A thread-shareable [`BStackRaiiAllocator`] — the bound a file's live host must +/// satisfy to be stored in (and resolved from) the registry across threads. +/// +/// Purely a convenience alias (`BStackRaiiAllocator + Send + Sync`, blanket-impl'd) +/// so call sites don't repeat the `+ Send + Sync` every time. It is **not** what +/// the registry stores: `BStackRaiiAllocator` is not object-safe (`BStackAllocator: +/// Sized`, plus the GAT `Allocated<'a>` and `alloc -> Self::Allocated<'_>`), so +/// there is no `dyn SyncBStackRaiiAllocator`. [`ForeignHost`] is its object-safe +/// projection, and what actually goes behind the `Arc`. +pub trait SyncBStackRaiiAllocator: BStackRaiiAllocator + Send + Sync {} +impl SyncBStackRaiiAllocator for A {} + +/// Error returned by [`ForeignHost::realloc`] and [`ForeignHost::dealloc`] when the +/// operation fails — the object-safe, range-based analogue of bstack's +/// `BStackAllocError`. +/// +/// A failed resize or free almost always leaves a valid allocation behind — the +/// original region untouched, or the new region fully committed. This type carries +/// that surviving region's range back to the caller so it can retry, fall back, or +/// explicitly [`dealloc`](ForeignHost::dealloc) it rather than leak it. Because a +/// bare [`BStackRange`] carries no ownership or `Drop`, *not* returning it here +/// would silently lose the region. +/// +/// Implements [`std::error::Error`] (delegating [`Display`](fmt::Display) to +/// [`source`](Self::source)), so `?` works in functions that return it. +pub struct ForeignAllocError { + /// The underlying I/O error that caused the operation to fail. + pub source: io::Error, + /// The recovered region's range, if it survived the failure. + /// + /// * `Some` — the allocation is intact and owned by the caller again (the + /// overwhelmingly common case: an untouched original or a fully committed new + /// region). + /// * `None` — the region was consumed or lost during the failed operation (a + /// multi-step path whose later step failed, or a crash mid-op); any bytes are + /// recoverable only through the file's crash-recovery / WAL. Treat `None` as + /// "not recoverable here," not as impossible. + pub handle: Option, +} + +impl ForeignAllocError { + /// Construct an error that hands the still-valid range back to the caller. + #[inline] + pub fn with_handle(source: io::Error, handle: BStackRange) -> Self { + Self { + source, + handle: Some(handle), + } + } + + /// Construct an error whose region was consumed or lost and cannot be returned. + #[inline] + pub fn lost(source: io::Error) -> Self { + Self { + source, + handle: None, + } + } +} + +impl fmt::Debug for ForeignAllocError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ForeignAllocError") + .field("source", &self.source) + .field("handle", &self.handle) + .finish() + } +} + +impl fmt::Display for ForeignAllocError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.source, f) + } +} + +impl std::error::Error for ForeignAllocError {} + +/// An **object-safe, range-based** view of a live file's allocator — the +/// type-erased handle a `Foreign` uses to reach *into another file*. +/// +/// This mirrors bstack's `BStackAllocator` surface (`stack` / `alloc` / `realloc` / +/// `dealloc`, plus `len` / `is_empty`), but is deliberately object-safe so the +/// registry can store `Arc` for files backed by different +/// allocator types: it drops the GAT `Allocated<'a>` handle and the associated +/// `Error` in favour of a plain [`BStackRange`] and [`io::Error`] — the very things +/// that make `BStackRaiiAllocator` itself non-object-safe (see +/// [`SyncBStackRaiiAllocator`]). Blanket-implemented for every +/// [`SyncBStackRaiiAllocator`], forwarding to the real allocator. +/// +/// Because a [`BStackRange`] carries no ownership (unlike a `BStackOwnedSlice`), +/// [`realloc`](Self::realloc) and [`dealloc`](Self::dealloc) are `unsafe`: the +/// caller asserts the range is a live allocation in this file that no other handle +/// will also resize or free. On the failure path they return a [`ForeignAllocError`] +/// carrying the surviving range, so a failed op never silently leaks. Raw reads and +/// writes go through [`stack`](Self::stack) (`get_into` / `set`). +/// +/// # Crash consistency +/// +/// Every method forwards to a single underlying allocator/stack call, so it +/// inherits that call's crash-consistency class (see the concrete allocator's docs). +pub trait ForeignHost: Send + Sync { + /// A shared reference to this file's underlying [`BStack`], for raw reads and + /// writes (`get_into` / `set`) at a resolved offset. + fn stack(&self) -> &BStack; + + /// Allocate `len` zero-initialised bytes, returning the region's range. The + /// region is durably synced before returning; `len = 0` is valid. + fn alloc(&self, len: u64) -> io::Result; + + /// Resize the region at `handle` to `new_len` bytes, returning the (possibly + /// moved) new range. + /// + /// # Safety + /// `handle` must be a live allocation in this file, solely owned by the caller. + /// + /// # Errors + /// Returns a [`ForeignAllocError`] on failure (including when the allocator does + /// not support reallocation). A failed resize leaves the original region intact, + /// so implementations return it in [`ForeignAllocError::handle`] (`Some`) + /// whenever it survives, reserving `None` for a genuinely lost region. + unsafe fn realloc( + &self, + handle: BStackRange, + new_len: u64, + ) -> Result; + + /// Release the region at `handle`. + /// + /// # Safety + /// `handle` must be a live allocation in this file, solely owned by the caller + /// and freed exactly once. + /// + /// # Errors + /// Returns a [`ForeignAllocError`] on failure. A failed free normally leaves the + /// region still allocated, so implementations return it in + /// [`ForeignAllocError::handle`] (`Some`) whenever it survives, reserving `None` + /// for a genuinely lost region (where handing it back would risk a double-free). + unsafe fn dealloc(&self, handle: BStackRange) -> Result<(), ForeignAllocError>; + + /// This file's WAL anchor slot, if it participates in crash reclamation + /// ([`BStackRaiiAllocator::wal_anchor`]). + fn wal_anchor(&self) -> Option; +} + +impl ForeignHost for A { + fn stack(&self) -> &BStack { + ::stack(self) + } + + fn alloc(&self, len: u64) -> io::Result { + Ok(::alloc(self, len)?.as_range()) + } + + unsafe fn realloc( + &self, + handle: BStackRange, + new_len: u64, + ) -> Result { + // SAFETY: caller's contract — a live, solely-owned allocation in this file. + let slice: BStackOwnedSlice<'_, A> = + unsafe { BStackOwnedSlice::from_raw_range(self, handle) }; + match ::realloc(self, slice, new_len) { + Ok(s) => Ok(s.as_range()), + Err(e) => Err(ForeignAllocError { + source: e.source, + handle: e.handle.map(|h| h.as_range()), + }), + } + } + + unsafe fn dealloc(&self, handle: BStackRange) -> Result<(), ForeignAllocError> { + // SAFETY: caller's contract — a live, solely-owned allocation in this file. + let slice: BStackOwnedSlice<'_, A> = + unsafe { BStackOwnedSlice::from_raw_range(self, handle) }; + match ::dealloc(self, slice) { + Ok(()) => Ok(()), + Err(e) => Err(ForeignAllocError { + source: e.source, + handle: e.handle.map(|h| h.as_range()), + }), + } + } + + fn wal_anchor(&self) -> Option { + ::wal_anchor(self) + } +} + +/// Persistent backing: an append-only log on the registry's own bstack file. +/// +/// No allocator needed — the path table is append-only, and a `BStack` *is* a +/// durable stack, so we just `push` one record per path and read them back from the +/// bottom. Each record is `[len: u64 | path bytes]`; the record's index (order of +/// pushing) is its `FileId`. Each `push` is crash-atomic (bstack contract), so a +/// crash leaves whole records only — a partial trailing record is impossible. +struct RegistryStore { + stack: BStack, +} + +impl RegistryStore { + /// Open (or create) the registry file and load its path table into memory. + fn open(path: &Path) -> io::Result<(Self, Vec)> { + let stack = BStack::open(path)?; + let paths = Self::load(&stack)?; + Ok((RegistryStore { stack }, paths)) + } + + /// Load the append-only path table from the registry's bstack file into memory. + fn load(stack: &BStack) -> io::Result> { + let total = stack.len()? as usize; + if total == 0 { + return Ok(Vec::new()); + } + let mut buf = vec![0u8; total]; + stack.get_into(0, &mut buf)?; + let mut paths = Vec::new(); + let mut cur = 0usize; + while cur + 8 <= buf.len() { + let len = u64::from_le_bytes(buf[cur..cur + 8].try_into().unwrap()) as usize; + cur += 8; + if cur + len > buf.len() { + // Truncated trailing record — shouldn't happen (push is atomic), but + // stop rather than misparse. + break; + } + paths.push(bytes_to_path(&buf[cur..cur + len])); + cur += len; + } + Ok(paths) + } + + /// Append one `[len | path]` record to the log (one atomic `push`). + fn append(&self, path: &Path) -> io::Result<()> { + let bytes = path_to_bytes(path); + let mut rec = Vec::with_capacity(8 + bytes.len()); + rec.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + rec.extend_from_slice(&bytes); + self.stack.push(rec)?; + Ok(()) + } +} + +/// The registry itself. +/// +/// See [`FileRegistry`] for the public interface. +struct RegistryInner<'h> { + /// `id -> path` (append-only; index is the `FileId`). + paths: Vec, + /// `path -> id`, for idempotent registration. + by_path: HashMap, + /// `id -> live host` (the open allocator), or `None` when the file is not + /// currently attached. In-memory only. + live: Vec>>, + store: RegistryStore, +} + +/// The file registry (see the [module docs](self)). +/// +/// The in-memory mirror of the persistent path table, plus the live host for each +/// file. Guarded by a [`parking_lot::RwLock`] for concurrent reads and exclusive writes. +/// +/// The `'h` lifetime is how long an attached host must live: a host need only +/// outlive *its attachment*, not the whole program, so a scoped `FileRegistry<'a>` +/// can hold hosts borrowing local data. The process-wide singleton behind [`init`] +/// is a `FileRegistry<'static>` (a `static` can hold nothing shorter), which is why +/// the free-function [`attach`] requires `'static` while [`FileRegistry::attach`] +/// does not. +pub struct FileRegistry<'h> { + inner: RwLock>, +} + +impl<'h> FileRegistry<'h> { + /// Open (or create) a registry backed by the file at `path`. The process-wide + /// [`init`] wraps this; a standalone instance is mainly useful for tests + /// (the global is a one-shot singleton). + pub(crate) fn open(path: &Path) -> io::Result { + let (store, paths) = RegistryStore::open(path)?; + let by_path = paths + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), FileId(i as u32))) + .collect(); + let live = (0..paths.len()).map(|_| None).collect(); + Ok(FileRegistry { + inner: RwLock::new(RegistryInner { + paths, + by_path, + live, + store, + }), + }) + } + + /// Assign (or look up) the stable [`FileId`] for `path`, persisting a new path + /// to the append-only table. Idempotent: an already-registered path returns its + /// existing id without touching disk. + pub fn register_path(&self, path: &Path) -> io::Result { + let mut g = self.inner.write(); + if let Some(&id) = g.by_path.get(path) { + return Ok(id); + } + let id = FileId(g.paths.len() as u32); + // Persist first (append the record); only mutate memory once the disk write + // succeeds, so a failed append leaves us consistent. + g.store.append(path)?; + g.paths.push(path.to_path_buf()); + g.by_path.insert(path.to_path_buf(), id); + g.live.push(None); + Ok(id) + } + + /// Register `path` (if needed) and mark it **live**, storing `host` as the open + /// allocator resolved by [`with_host`](Self::with_host). Returns the file's id. + /// + /// `host` need only live as long as `'h` (until this registry — or the host's + /// [`detach`](Self::detach) — drops it), not `'static`. + pub fn attach(&self, path: &Path, host: Arc) -> io::Result { + let id = self.register_path(path)?; + let mut g = self.inner.write(); + g.live[id.0 as usize] = Some(host); + Ok(id) + } + + /// Drop the live host for `id` (the file's *path* stays registered forever). + /// Takes the write lock, so it waits for any in-flight [`with_host`] op to + /// finish and cannot run *during* one. + pub fn detach(&self, id: FileId) { + let mut g = self.inner.write(); + if let Some(slot) = g.live.get_mut(id.0 as usize) { + *slot = None; + } + } + + /// Run `f` against `id`'s live host under a (recursive) read lock, so the file + /// cannot be [`detach`](Self::detach)ed while `f` runs. Returns `None` if `id` + /// is unknown or not currently live. + /// + /// Uses `read_recursive`, so a foreign op whose `f` itself resolves *another* + /// foreign file (nesting `with_host`) never deadlocks behind a queued writer: + /// readers are admitted even while a `detach` waits. The trade-off is that a + /// `detach` can be starved by a continuous stream of readers — acceptable, since + /// detaching is cold and a perpetually-in-use file cannot be safely detached + /// anyway. + pub fn with_host(&self, id: FileId, f: impl FnOnce(&dyn ForeignHost) -> R) -> Option { + let g = self.inner.read_recursive(); + let host = g.live.get(id.0 as usize)?.as_ref()?; + Some(f(&**host)) + } + + /// The path registered for `id`, if any. + pub fn path_of(&self, id: FileId) -> Option { + self.inner.read().paths.get(id.0 as usize).cloned() + } + + /// The id registered for `path`, if any. + pub fn id_of(&self, path: &Path) -> Option { + self.inner.read().by_path.get(path).copied() + } + + /// Whether `id` currently has a live host attached. + pub fn is_live(&self, id: FileId) -> bool { + self.inner + .read() + .live + .get(id.0 as usize) + .is_some_and(Option::is_some) + } +} + +/// The lazily-instantiated process-wide singleton + free-function front door. +/// A `static` holds nothing shorter than `'static`, so the global registry's hosts +/// are `'static` (see [`FileRegistry`] for the scoped, shorter-lived alternative). +static REGISTRY: OnceLock> = OnceLock::new(); + +/// Bring up the process-wide registry, backed by the bstack file at +/// `registry_path` (created if absent, its path table loaded if present). Call +/// once, before any [`attach`]/[`register_path`]. Errors if already initialized. +pub fn init(registry_path: impl AsRef) -> io::Result<()> { + let reg = FileRegistry::open(registry_path.as_ref())?; + REGISTRY.set(reg).map_err(|_| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "file registry already initialized", + ) + }) +} + +/// The initialized registry, or `None` if [`init`] has not run. `Foreign` +/// resolution uses this so an unregistered/opt-out program pays nothing. +pub fn get() -> Option<&'static FileRegistry<'static>> { + REGISTRY.get() +} + +fn require() -> io::Result<&'static FileRegistry<'static>> { + REGISTRY.get().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "file registry not initialized, init with `bstack_raii::registry::init` first", + ) + }) +} + +/// [`FileRegistry::register_path`] on the process-wide registry. +pub fn register_path(path: impl AsRef) -> io::Result { + require()?.register_path(path.as_ref()) +} + +/// [`FileRegistry::attach`] on the process-wide registry, taking an owned +/// allocator (any [`SyncBStackRaiiAllocator`]) as the file's live host. +/// +/// The `'static` bound is inherent to the *global* registry — a `static` cannot +/// hold a shorter-lived host. It is not a constraint of the machinery: bstack's own +/// allocators own their file and are `'static` anyway, and a host that borrows must +/// go through a scoped [`FileRegistry`] instance (whose `attach` accepts any `'h`). +pub fn attach(path: impl AsRef, allocator: A) -> io::Result +where + A: SyncBStackRaiiAllocator + 'static, +{ + require()?.attach(path.as_ref(), Arc::new(allocator)) +} + +/// [`FileRegistry::detach`] on the process-wide registry (no-op if uninitialized). +pub fn detach(id: FileId) { + if let Some(reg) = REGISTRY.get() { + reg.detach(id); + } +} + +/// [`FileRegistry::with_host`] on the process-wide registry (`None` if +/// uninitialized or `id` is not live). +pub fn with_host(id: FileId, f: impl FnOnce(&dyn ForeignHost) -> R) -> Option { + REGISTRY.get()?.with_host(id, f) +} + +/// The path registered for `id`, if any. +pub fn path_of(id: FileId) -> Option { + REGISTRY.get()?.path_of(id) +} + +/// The id registered for `path`, if any. +pub fn id_of(path: impl AsRef) -> Option { + REGISTRY.get()?.id_of(path.as_ref()) +} + +// Path <-> bytes (exact round-trip on unix; lossy elsewhere). + +#[cfg(unix)] +fn path_to_bytes(p: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + p.as_os_str().as_bytes().to_vec() +} + +#[cfg(unix)] +fn bytes_to_path(b: &[u8]) -> PathBuf { + use std::os::unix::ffi::OsStrExt; + std::ffi::OsStr::from_bytes(b).to_owned().into() +} + +#[cfg(not(unix))] +fn path_to_bytes(p: &Path) -> Vec { + p.to_string_lossy().into_owned().into_bytes() +} + +#[cfg(not(unix))] +fn bytes_to_path(b: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(b).into_owned()) +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 3be5a78..13fc564 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6430,3 +6430,73 @@ fn wal_teardown_reclaims_on_free_fault() { ); list2.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Cross-file registry: path<->id persistence + live-host resolution +// -------------------------------------------------------------------------- + +#[test] +fn registry_paths_persist_and_live_host_round_trips() { + use crate::registry::{FileId, FileRegistry}; + use std::sync::Arc; + + let reg_file = TempStack::new(); + let foreign = TempStack::new(); + // A path we only *register* (never open) — proves the table stores strings. + let ghost = std::env::temp_dir().join("bstack_raii_registry_ghost.bstack"); + + // --- "run 1": register paths, attach a live host, use it, detach --- + { + let reg = FileRegistry::open(®_file.path).unwrap(); + + let id_a = reg.register_path(&foreign.path).unwrap(); + assert_eq!(id_a, FileId::from_u64(0).unwrap()); + // Registration is idempotent (same path -> same id, no new slot). + assert_eq!(reg.register_path(&foreign.path).unwrap(), id_a); + // A distinct path gets the next id. + let id_g = reg.register_path(&ghost).unwrap(); + assert_eq!(id_g.get(), 1); + assert_eq!(reg.id_of(&foreign.path), Some(id_a)); + assert_eq!(reg.path_of(id_g).as_deref(), Some(ghost.as_path())); + + // Attach the foreign file's own allocator as its live host (same path -> + // same id), then read/write/alloc through the type-erased facade. + let host: Arc = Arc::new(foreign.allocator()); + let id = reg.attach(&foreign.path, host).unwrap(); + assert_eq!(id, id_a); + assert!(reg.is_live(id)); + + let block = reg + .with_host(id, |h| { + let r = h.alloc(64).unwrap(); + h.stack().set(r.start(), [1, 2, 3, 4, 5, 6, 7, 8]).unwrap(); + let mut buf = [0u8; 8]; + h.stack().get_into(r.start(), &mut buf).unwrap(); + assert_eq!(buf, [1, 2, 3, 4, 5, 6, 7, 8]); + r + }) + .expect("host is live"); + // Free it through the host (under the read lock). + reg.with_host(id, |h| unsafe { h.dealloc(block).unwrap() }) + .expect("host is live"); + + reg.detach(id); + assert!(!reg.is_live(id)); + assert!(reg.with_host(id, |_| ()).is_none()); + } + + // --- "run 2": reopen the same registry file; the path table persisted --- + { + let reg = FileRegistry::open(®_file.path).unwrap(); + assert_eq!(reg.id_of(&foreign.path).map(FileId::get), Some(0)); + assert_eq!(reg.id_of(&ghost).map(FileId::get), Some(1)); + assert_eq!( + reg.path_of(FileId::from_u64(0).unwrap()).as_deref(), + Some(foreign.path.as_path()) + ); + // The live layer is in-memory only: nothing is live after a reopen. + assert!(!reg.is_live(FileId::from_u64(0).unwrap())); + } + + let _ = std::fs::remove_file(&ghost); +} From 75f08800941bd23f26ebb7f096218a0335a2387c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 17:23:03 -0700 Subject: [PATCH 103/140] Reserve special points --- bstack_raii/src/registry.rs | 90 ++++++++++++++++++++++++++++++------- bstack_raii/src/tests.rs | 18 +++++--- 2 files changed, 86 insertions(+), 22 deletions(-) diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs index bbaf6ad..a4cf21b 100644 --- a/bstack_raii/src/registry.rs +++ b/bstack_raii/src/registry.rs @@ -46,18 +46,53 @@ use parking_lot::RwLock; use crate::BStackRaiiAllocator; -/// A small, stable identity for a registered bstack file: an index into the -/// registry's append-only path table. +/// A small, stable identity for a registered bstack file. /// /// Backed by a `u32` (a sane program opens far fewer than `u16::MAX` files, so /// this is generous headroom), but a `Foreign` pointer stores it **widened to a /// `u64`** — for alignment next to a [`BStackRange`], and to leave room for future /// RTTI. [`as_u64`](Self::as_u64) / [`from_u64`](Self::from_u64) bridge the two. +/// +/// # Id-space layout +/// +/// * **`0` = [`SELF`](Self::SELF)** — the *current* file. A `Foreign` with this id +/// points into whatever file it itself lives in, resolved directly against the +/// local allocator the caller already holds. Registry lookup (and its lock) is +/// never consulted for `SELF`. Never assigned to a registered path. +/// * **`1, 2, 3, …` (ascending) = ordinary registered files** — assigned in order +/// of registration; the id is `1 + ` the file's index in the append-only path +/// table. +/// * **`u32::MAX, u32::MAX - 1, …` (descending) = reserved "special" meanings** — +/// sentinels beyond a single concrete file, allocated from the top down so they +/// never collide with the ascending ordinary ids (`SELF` is the sole exception +/// at the bottom). Only `SELF` is defined so far; the descending region is +/// reserved for future use (see [`is_special`](Self::is_special)). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FileId(u32); impl FileId { - /// The raw `u32` index. + /// The self-referential id (`0`): a `Foreign` bearing it points into the + /// *current* file and is resolved against the local allocator **without** + /// touching the registry or its lock. Never assigned to a registered path. + pub const SELF: FileId = FileId(0); + + /// Whether this is [`SELF`](Self::SELF) (the current file). + pub const fn is_self(self) -> bool { + self.0 == 0 + } + + /// Whether this id is in the reserved descending "special" region (top of the + /// `u32` space). Ordinary registered files and `SELF` are **not** special. + /// The boundary is generous — far above any realistic file count. + pub const fn is_special(self) -> bool { + self.0 >= Self::SPECIAL_FLOOR + } + + /// Lowest id treated as a reserved special sentinel (special ids grow *down* + /// from `u32::MAX`). Chosen far above any plausible number of open files. + pub const SPECIAL_FLOOR: u32 = u32::MAX - 0xFFFF; + + /// The raw `u32` value. pub const fn get(self) -> u32 { self.0 } @@ -78,6 +113,17 @@ impl FileId { } } +/// Map a `FileId` to its index in the append-only path table, or `None` for +/// [`SELF`](FileId::SELF) and reserved special ids (neither of which is a concrete +/// registered file). Ordinary ids are 1-based, so the index is `id - 1`. +fn table_index(id: FileId) -> Option { + if id.0 >= 1 && !id.is_special() { + Some((id.0 - 1) as usize) + } else { + None + } +} + /// A thread-shareable [`BStackRaiiAllocator`] — the bound a file's live host must /// satisfy to be stored in (and resolved from) the registry across threads. /// @@ -358,7 +404,7 @@ impl<'h> FileRegistry<'h> { let by_path = paths .iter() .enumerate() - .map(|(i, p)| (p.clone(), FileId(i as u32))) + .map(|(i, p)| (p.clone(), FileId(i as u32 + 1))) // ids are 1-based (0 = SELF) .collect(); let live = (0..paths.len()).map(|_| None).collect(); Ok(FileRegistry { @@ -379,7 +425,14 @@ impl<'h> FileRegistry<'h> { if let Some(&id) = g.by_path.get(path) { return Ok(id); } - let id = FileId(g.paths.len() as u32); + let next = g.paths.len() as u32 + 1; // ids are 1-based; 0 is reserved for SELF + if next >= FileId::SPECIAL_FLOOR { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "file registry exhausted the ordinary id space", + )); + } + let id = FileId(next); // Persist first (append the record); only mutate memory once the disk write // succeeds, so a failed append leaves us consistent. g.store.append(path)?; @@ -397,7 +450,7 @@ impl<'h> FileRegistry<'h> { pub fn attach(&self, path: &Path, host: Arc) -> io::Result { let id = self.register_path(path)?; let mut g = self.inner.write(); - g.live[id.0 as usize] = Some(host); + g.live[(id.0 - 1) as usize] = Some(host); // register_path returns a 1-based id Ok(id) } @@ -405,8 +458,9 @@ impl<'h> FileRegistry<'h> { /// Takes the write lock, so it waits for any in-flight [`with_host`] op to /// finish and cannot run *during* one. pub fn detach(&self, id: FileId) { + let Some(idx) = table_index(id) else { return }; let mut g = self.inner.write(); - if let Some(slot) = g.live.get_mut(id.0 as usize) { + if let Some(slot) = g.live.get_mut(idx) { *slot = None; } } @@ -422,14 +476,18 @@ impl<'h> FileRegistry<'h> { /// detaching is cold and a perpetually-in-use file cannot be safely detached /// anyway. pub fn with_host(&self, id: FileId, f: impl FnOnce(&dyn ForeignHost) -> R) -> Option { + // `SELF` / special ids name no registry entry, so return without ever taking + // the lock — the caller resolves `SELF` against its own local allocator. + let idx = table_index(id)?; let g = self.inner.read_recursive(); - let host = g.live.get(id.0 as usize)?.as_ref()?; + let host = g.live.get(idx)?.as_ref()?; Some(f(&**host)) } - /// The path registered for `id`, if any. + /// The path registered for `id`, if any (`None` for `SELF` / special ids). pub fn path_of(&self, id: FileId) -> Option { - self.inner.read().paths.get(id.0 as usize).cloned() + let idx = table_index(id)?; + self.inner.read().paths.get(idx).cloned() } /// The id registered for `path`, if any. @@ -437,13 +495,13 @@ impl<'h> FileRegistry<'h> { self.inner.read().by_path.get(path).copied() } - /// Whether `id` currently has a live host attached. + /// Whether `id` currently has a live host attached (always `false` for `SELF` / + /// special ids). pub fn is_live(&self, id: FileId) -> bool { - self.inner - .read() - .live - .get(id.0 as usize) - .is_some_and(Option::is_some) + let Some(idx) = table_index(id) else { + return false; + }; + self.inner.read().live.get(idx).is_some_and(Option::is_some) } } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 13fc564..3abc311 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6450,14 +6450,20 @@ fn registry_paths_persist_and_live_host_round_trips() { let reg = FileRegistry::open(®_file.path).unwrap(); let id_a = reg.register_path(&foreign.path).unwrap(); - assert_eq!(id_a, FileId::from_u64(0).unwrap()); + // Ordinary ids are 1-based (0 is reserved for `SELF`). + assert_eq!(id_a, FileId::from_u64(1).unwrap()); + assert!(!id_a.is_self()); // Registration is idempotent (same path -> same id, no new slot). assert_eq!(reg.register_path(&foreign.path).unwrap(), id_a); // A distinct path gets the next id. let id_g = reg.register_path(&ghost).unwrap(); - assert_eq!(id_g.get(), 1); + assert_eq!(id_g.get(), 2); assert_eq!(reg.id_of(&foreign.path), Some(id_a)); assert_eq!(reg.path_of(id_g).as_deref(), Some(ghost.as_path())); + // `SELF` is never a registry entry and never takes the lock. + assert!(FileId::SELF.is_self()); + assert!(reg.path_of(FileId::SELF).is_none()); + assert!(reg.with_host(FileId::SELF, |_| ()).is_none()); // Attach the foreign file's own allocator as its live host (same path -> // same id), then read/write/alloc through the type-erased facade. @@ -6488,14 +6494,14 @@ fn registry_paths_persist_and_live_host_round_trips() { // --- "run 2": reopen the same registry file; the path table persisted --- { let reg = FileRegistry::open(®_file.path).unwrap(); - assert_eq!(reg.id_of(&foreign.path).map(FileId::get), Some(0)); - assert_eq!(reg.id_of(&ghost).map(FileId::get), Some(1)); + assert_eq!(reg.id_of(&foreign.path).map(FileId::get), Some(1)); + assert_eq!(reg.id_of(&ghost).map(FileId::get), Some(2)); assert_eq!( - reg.path_of(FileId::from_u64(0).unwrap()).as_deref(), + reg.path_of(FileId::from_u64(1).unwrap()).as_deref(), Some(foreign.path.as_path()) ); // The live layer is in-memory only: nothing is live after a reopen. - assert!(!reg.is_live(FileId::from_u64(0).unwrap())); + assert!(!reg.is_live(FileId::from_u64(1).unwrap())); } let _ = std::fs::remove_file(&ghost); From ee5354d5c7d80f7d81c1ce2d2b30dfc45f125a10 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 18:10:07 -0700 Subject: [PATCH 104/140] Rename accessor to get_ --- bstack_raii/README.md | 26 +- bstack_raii/derive/src/block.rs | 36 +- bstack_raii/examples/sessions.rs | 8 +- bstack_raii/src/owned.rs | 2 +- bstack_raii/src/shared.rs | 2 +- bstack_raii/src/stdlib/cow.rs | 2 +- bstack_raii/src/teardown.rs | 2 +- bstack_raii/src/tests.rs | 941 ++++++++++++++++++------------- 8 files changed, 602 insertions(+), 417 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 1d9edc5..2ff2876 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -82,8 +82,8 @@ fn main() -> io::Result<()> { let session = Session::new(&alloc, 0, config.try_clone()?)?; // strong = 2 // Read fields through generated accessors. - let cfg = session.handle().config(stack)?; // -> a Config handle - println!("v{} flags {:#b}", cfg.version(stack)?, cfg.flags(stack)?); + let cfg = session.handle().get_config(stack)?; // -> a Config handle + println!("v{} flags {:#b}", cfg.get_version(stack)?, cfg.get_flags(stack)?); drop(config); // strong = 1 — the session still owns it (Rc: auto-decrement) session.bstack_drop(&alloc)?; // strong = 0 — Config freed automatically by its refcount @@ -179,7 +179,7 @@ in an `AutoDrop` guard for RAII: ```rust let owned: BStackOwned = Node::new(&alloc, /* … */)?; -let value = owned.handle().tag(stack)?; // read a field (or `owned.tag(stack)?` via Deref) +let value = owned.handle().get_tag(stack)?; // read a field (or `owned.get_tag(stack)?` via Deref) owned.bstack_drop(&alloc)?; // free it now, explicitly … // … or: let _guard = owned.auto(&alloc); // RAII — freed when `_guard` drops @@ -255,7 +255,7 @@ enum Wrapper { It's still **exclusive ownership** (like `#[bstack_owned]`): `new` takes a `BStackOwned` — you build the child normally, and the parent folds its bytes in and frees the child's now-redundant shell (the child's *own* children -stay live). The accessor (`holder.handle().child()`) hands back a borrowed +stay live). The accessor (`holder.handle().get_child()`) hands back a borrowed `Child` handle into the inline region; teardown frees the embedded child's children in place; and `bstack_move!` re-homes the child to a fresh standalone `BStackOwned`. You can embed any block (`#[bstack_block]` / @@ -281,14 +281,14 @@ methods: child handles it takes ownership of (`#[bstack_owned]` → `BStackOwned`, `#[bstack_strong]` → `BStackRc`, `#[bstack_ref]` → `BStackRef`, POD by value; `#[bstack_weak]` fields are **not** parameters); -- **accessors** — `node.field(stack)` for each field; +- **accessors** — `node.get_field(stack)` for each field; - **`set_`** setters for `#[bstack_weak]` fields (see below); - recursive teardown, [casting](#casting-bstack_cast), and [moving](#moving-out-bstack_move). A **tuple struct** works too, as long as every field is `Pod`: its positional fields get synthetic names, so `struct Rgb(u8, u8, u8)` is constructed -`Rgb::new(&alloc, 10, 20, 30)`, read via `rgb.field0(stack)?` / `field1` / …, and +`Rgb::new(&alloc, 10, 20, 30)`, read via `rgb.get_field0(stack)?` / `get_field1` / …, and `bstack_move!` hands the fields back in order. A **unit struct** (`#[bstack_block] struct Marker;`) is a valid **header-only** block — just the 16-byte header, no payload. @@ -318,8 +318,8 @@ let a = WNode::new(&alloc, 1)?; let b = WNode::new(&alloc, 2)?; b.handle().set_back(&alloc, a.downgrade()?)?; // wire b.back -> a (weak) -if let Some(a2) = b.handle().back(&alloc)? { // accessor upgrades - println!("a still alive: {}", a2.handle().val(stack)?); +if let Some(a2) = b.handle().get_back(&alloc)? { // accessor upgrades + println!("a still alive: {}", a2.handle().get_val(stack)?); } ``` @@ -338,9 +338,9 @@ struct Record { } let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42)?; // &str / &[T] / value -let mut tags = rec.handle().tags(&alloc)?; // a BStackVec handle -tags.push(4)?; // grows; rewrites the inline descriptor -assert_eq!(rec.handle().tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); +let mut tags = rec.handle().get_tags(&alloc)?; // a BStackVec handle +tags.push(4)?; // grows; rewrites the inline descriptor +assert_eq!(rec.handle().get_tags(&alloc)?.to_vec()?, vec![1, 2, 3, 4]); ``` The accessor returns a [`BStackVec`] (`len` / `to_vec` / `push`); freeing the @@ -395,7 +395,7 @@ struct Board { } let b = Board::new(&alloc, [0; 9], [a, b, c], [r0, r1], [k0, k1])?; -let tiles: [Leaf; 3] = b.handle().tiles(stack)?; // an array of block views +let tiles: [Leaf; 3] = b.handle().get_tiles(stack)?; // an array of block views ``` A reference array stores `[u64; N]` inline (one offset per element). The @@ -473,7 +473,7 @@ enum Node { let node = Node::new(&alloc, NodeData::Child(leaf))?; // construct a variant match node.handle().read(&alloc)? { // read / match it - NodeView::Child(c) => assert_eq!(c.val(stack)?, 7), + NodeView::Child(c) => assert_eq!(c.get_val(stack)?, 7), _ => {} } node.bstack_drop(&alloc)?; // frees the owned child too diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 89207c8..35a119e 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -301,6 +301,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result for (fname, field) in &field_list { let kind = classify(field)?; + // The public accessor name (`get_`); `#fname` itself stays the + // on-disk field / struct-literal name throughout. + let getter = format_ident!("get_{}", fname); // Ergonomic: `&T` is coerced to owned `T` (and `&str` to `String`), with // a warning. `eff_ty` is the type after stripping a leading `&`. @@ -458,7 +461,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ) }; accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -820,7 +823,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &acc_leaf, &acc_read); accessors.push(quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<#acc_ret> { @@ -1024,7 +1027,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, "e!(#child), &acc_read); accessors.push(quote! { - #vis fn #fname(&self) -> #acc_ret { + #vis fn #getter(&self) -> #acc_ret { let __base = self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; let __step = ::core::mem::size_of::<#child_od>() as u64; @@ -1160,7 +1163,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &leaf_ty, &acc_read); accessors.push(quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__u __A, ) -> ::std::io::Result<#acc_ret> { @@ -1252,7 +1255,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }; let acc_body = nested_build(&dims, &leaf_view, &acc_read); accessors.push(quote! { - #vis fn #fname( + #vis fn #getter( &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#acc_ret> { @@ -1474,7 +1477,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result pod_types.extend(elems.iter().copied()); on_disk_fields.push(quote!(#fname: #wrapper,)); accessors.push(quote! { - #vis fn #fname( + #vis fn #getter( &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<#inner_ty> { @@ -1530,7 +1533,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // Accessor: a child handle at the embedded offset (pure offset math). accessors.push(quote! { - #vis fn #fname(&self) -> #child { + #vis fn #getter(&self) -> #child { <#child as ::bstack_raii::BStackBlock>::from_range( ::bstack_raii::BStackRange::new( self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64, @@ -2347,10 +2350,11 @@ fn vec_accessor( on_disk: &TokenStream, nullable: bool, ) -> TokenStream { + let getter = format_ident!("get_{}", fname); let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2361,7 +2365,7 @@ fn vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::BStackVec<'__v, #elem, __A>> { @@ -2535,10 +2539,11 @@ fn block_vec_accessor( vec_ty: TokenStream, nullable: bool, ) -> TokenStream { + let getter = format_ident!("get_{}", fname); let field = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); if nullable { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result< @@ -2549,7 +2554,7 @@ fn block_vec_accessor( } } else { quote! { - #vis fn #fname<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__v __A, ) -> ::std::io::Result<::bstack_raii::#vec_ty<'__v, #elem, __A>> { @@ -2776,10 +2781,11 @@ fn accessor( kind: Kind, nullable: bool, ) -> TokenStream { + let getter = format_ident!("get_{}", fname); // Weak fields hold a control offset; the accessor attempts a live upgrade. if kind == Kind::Weak { return quote! { - #vis fn #fname<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( + #vis fn #getter<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( &self, allocator: &'__u __A, ) -> ::std::io::Result< @@ -2797,7 +2803,7 @@ fn accessor( }; if kind == Kind::Pod { return quote! { - #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { + #vis fn #getter(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { #read ::std::result::Result::Ok(__od.#fname) } @@ -2812,7 +2818,7 @@ fn accessor( }; if nullable { quote! { - #vis fn #fname( + #vis fn #getter( &self, stack: &::bstack_raii::BStack, ) -> ::std::io::Result<::core::option::Option<#inner_ty>> { @@ -2826,7 +2832,7 @@ fn accessor( } } else { quote! { - #vis fn #fname(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { + #vis fn #getter(&self, stack: &::bstack_raii::BStack) -> ::std::io::Result<#inner_ty> { #read ::std::result::Result::Ok(#resolve) } diff --git a/bstack_raii/examples/sessions.rs b/bstack_raii/examples/sessions.rs index 7aa795a..73e1a74 100644 --- a/bstack_raii/examples/sessions.rs +++ b/bstack_raii/examples/sessions.rs @@ -56,11 +56,11 @@ fn shared_ownership_demo(path: &std::path::Path) -> io::Result<()> { drop(config); // the three sessions still keep it alive // Read the shared config through any session's generated accessor. - let cfg = sessions[0].handle().config(stack)?; + let cfg = sessions[0].handle().get_config(stack)?; println!( "shared config: version {}, flags {:#06b} (held by {} sessions)", - cfg.version(stack)?, - cfg.flags(stack)?, + cfg.get_version(stack)?, + cfg.get_flags(stack)?, sessions.len(), ); @@ -103,7 +103,7 @@ fn durability_demo(path: &std::path::Path) -> io::Result<()> { let cfg = ::from_range(saved); println!( "after reopen: config version {} (persisted across the close/reopen)", - cfg.version(alloc.stack())?, + cfg.get_version(alloc.stack())?, ); Ok(()) } diff --git a/bstack_raii/src/owned.rs b/bstack_raii/src/owned.rs index ed5221c..c3e1770 100644 --- a/bstack_raii/src/owned.rs +++ b/bstack_raii/src/owned.rs @@ -44,7 +44,7 @@ impl BStackOwned { } /// Borrow the inner handle, e.g. to call generated field accessors: - /// `owned.handle().field(stack)`. + /// `owned.handle().get_field(stack)`. pub fn handle(&self) -> &T { &self.0 } diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 6bf5f3a..e46f741 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -94,7 +94,7 @@ impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { } /// The underlying typed handle, e.g. to call generated field accessors: - /// `rc.handle().field(stack)`. Cheap: it just re-wraps the data ref and does + /// `rc.handle().get_field(stack)`. Cheap: it just re-wraps the data ref and does /// not touch the refcount. pub fn handle(&self) -> T { ::from_range(self.data().into_range()) diff --git a/bstack_raii/src/stdlib/cow.rs b/bstack_raii/src/stdlib/cow.rs index 8cb4966..3da9f81 100644 --- a/bstack_raii/src/stdlib/cow.rs +++ b/bstack_raii/src/stdlib/cow.rs @@ -90,7 +90,7 @@ impl BStackCow { /// Materialize a fresh, bare `T` handle over the current block for calling /// the block's generated field accessors — e.g. - /// `cow.handle().field(stack)`. Works for both variants; carries no + /// `cow.handle().get_field(stack)`. Works for both variants; carries no /// ownership (dropping it frees nothing). pub fn handle(&self) -> T { ::from_range(self.range()) diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index 5e5ad3c..05c7227 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -197,7 +197,7 @@ impl<'a, T: BStackDrop, A: BStackRaiiAllocator> AutoDrop<'a, T, A> { } /// Borrow the underlying handle, e.g. to call generated field accessors: - /// `owned.handle().field(stack)`. + /// `owned.handle().get_field(stack)`. pub fn handle(&self) -> &T { &self.inner } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 3abc311..15c7f0b 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -554,16 +554,16 @@ fn macro_new_and_accessors() { // Plain-block constructor: allocates and writes the whole payload. let leaf = MacroLeaf::new(&alloc, 42).unwrap(); - assert_eq!(leaf.handle().val(stack).unwrap(), 42); + assert_eq!(leaf.handle().get_val(stack).unwrap(), 42); // Owned child is consumed by the parent constructor (ownership transferred). let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); - assert_eq!(parent.handle().tag(stack).unwrap(), 7); + assert_eq!(parent.handle().get_tag(stack).unwrap(), 7); // Accessor resolves the owned-ref field to the child handle; reading its own // field proves the child pointer was wired correctly. - let child = parent.handle().child(stack).unwrap(); - assert_eq!(child.val(stack).unwrap(), 42); + let child = parent.handle().get_child(stack).unwrap(); + assert_eq!(child.get_val(stack).unwrap(), 42); // Freeing the parent recursively frees the child then itself; recursion // correctness is covered elsewhere. @@ -582,14 +582,22 @@ fn macro_clone_deep_owned() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); - let orig_child = parent.handle().child(stack).unwrap(); + let orig_child = parent.handle().get_child(stack).unwrap(); // Deep clone -> a fresh, independent BStackOwned copy. let clone = parent.try_clone_in(&alloc).unwrap(); // Same values read back through the clone. - assert_eq!(clone.handle().tag(stack).unwrap(), 7); - assert_eq!(clone.handle().child(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!(clone.handle().get_tag(stack).unwrap(), 7); + assert_eq!( + clone + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 42 + ); // Independent storage: both the clone's block and its owned child are new // allocations, distinct from the originals (proves the recursion + repoint). @@ -598,14 +606,19 @@ fn macro_clone_deep_owned() { parent.handle().range().start() ); assert_ne!( - clone.handle().child(stack).unwrap().range().start(), + clone.handle().get_child(stack).unwrap().range().start(), orig_child.range().start() ); // Freeing the clone frees only the clone's subtree; the original stays intact. clone.bstack_drop(&alloc).unwrap(); assert_eq!( - parent.handle().child(stack).unwrap().val(stack).unwrap(), + parent + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), 42 ); parent.bstack_drop(&alloc).unwrap(); @@ -641,7 +654,7 @@ fn macro_clone_bumps_shared_refcount() { clone.bstack_drop(&alloc).unwrap(); parent.bstack_drop(&alloc).unwrap(); assert_eq!(crate::refcount::load(stack, strong_off).unwrap(), 1); - assert_eq!(rc_keep.handle().val(stack).unwrap(), 5); + assert_eq!(rc_keep.handle().get_val(stack).unwrap(), 5); drop(rc_keep); } @@ -656,7 +669,14 @@ fn macro_new_rc_weak() { let rc = MacroShared::new(&alloc, leaf).unwrap(); // Traverse through the shared handle to the owned child and read it. - assert_eq!(rc.handle().child(stack).unwrap().val(stack).unwrap(), 99); + assert_eq!( + rc.handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 99 + ); // Full shared lifecycle on a constructor-built block. let rc2 = rc.try_clone().unwrap(); @@ -693,8 +713,8 @@ fn macro_weak_field_cycle() { b.handle().set_back(&alloc, a.downgrade().unwrap()).unwrap(); // Upgrade accessor resolves the live target. - let up = b.handle().back(&alloc).unwrap().expect("a is alive"); - assert_eq!(up.handle().val(alloc.stack()).unwrap(), 1); + let up = b.handle().get_back(&alloc).unwrap().expect("a is alive"); + assert_eq!(up.handle().get_val(alloc.stack()).unwrap(), 1); drop(up); // Drop the strong owner `a` first: its DATA block is freed, but its control @@ -703,7 +723,7 @@ fn macro_weak_field_cycle() { // The weak field can no longer upgrade — and reaching this did NOT read a's // freed data block, because the field stores a's control offset. - assert!(b.handle().back(&alloc).unwrap().is_none()); + assert!(b.handle().get_back(&alloc).unwrap().is_none()); // Dropping `b` releases b.back's weak on a's control block (freeing it), then // frees b. No use-after-free of a's data. @@ -743,7 +763,7 @@ fn macro_bstack_move() { // Ownership of the child transferred (same allocation), and it is still live // because bstack_move! frees only the parent shell. assert_eq!(child.handle().range().start(), leaf_off); - assert_eq!(child.handle().val(stack).unwrap(), 55); + assert_eq!(child.handle().get_val(stack).unwrap(), 55); // Freeing the moved-out child frees the leaf. With the parent shell already // freed, both slots coalesce and the lowest (leaf's) is reclaimed. @@ -790,7 +810,7 @@ fn macro_bstack_move_shared() { assert_eq!(n, 5); // The strong field came back as a live BStackRc. - assert_eq!(moved_s.handle().val(stack).unwrap(), 88); + assert_eq!(moved_s.handle().get_val(stack).unwrap(), 88); // The weak field came back as Some(weak) and still upgrades (target alive). let up = moved_w @@ -799,7 +819,7 @@ fn macro_bstack_move_shared() { .upgrade() .unwrap() .expect("wt alive"); - assert_eq!(up.handle().val(stack).unwrap(), 3); + assert_eq!(up.handle().get_val(stack).unwrap(), 3); drop(up); // Clean teardown across the moved-out handles. @@ -921,7 +941,7 @@ fn macro_cast() { sl.cast_as::() .unwrap() .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 9 ); @@ -942,7 +962,7 @@ fn macro_cast() { .unwrap() .ok() .unwrap(); - assert_eq!(owned.handle().val(stack).unwrap(), 9); + assert_eq!(owned.handle().get_val(stack).unwrap(), 9); owned.bstack_drop(&alloc).unwrap(); // frees the leaf } @@ -985,7 +1005,7 @@ fn macro_bstack_move_rc() { // Sole owner: the move succeeds and transfers the owned child out. let (moved_leaf, n) = bstack_move!(rc).unwrap().ok().expect("sole owner"); assert_eq!(n, 7); - assert_eq!(moved_leaf.handle().val(stack).unwrap(), 5); + assert_eq!(moved_leaf.handle().get_val(stack).unwrap(), 5); // Only the RcHolder shell was freed; the child is still live. Freeing it // reclaims the last block, so the lowest slot (the leaf's) comes back. @@ -1013,7 +1033,7 @@ fn macro_bstack_move_rc_weak() { // Sole *strong* owner: move succeeds even with a weak outstanding. let (moved_leaf, n) = bstack_move!(rc).unwrap().ok().expect("sole strong owner"); assert_eq!(n, 3); - assert_eq!(moved_leaf.handle().val(stack).unwrap(), 9); + assert_eq!(moved_leaf.handle().get_val(stack).unwrap(), 9); // The data block is gone, so the weak can no longer upgrade. assert!(weak.upgrade().unwrap().is_none()); @@ -1043,15 +1063,20 @@ fn macro_option_owned() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let leaf_off = leaf.handle().range().start(); let holder = OptHolder::new(&alloc, Some(leaf), 7).unwrap(); - assert_eq!(holder.handle().n(stack).unwrap(), 7); - let got = holder.handle().child(stack).unwrap(); - assert_eq!(got.unwrap().val(stack).unwrap(), 42); + assert_eq!(holder.handle().get_n(stack).unwrap(), 7); + let got = holder.handle().get_child(stack).unwrap(); + assert_eq!(got.unwrap().get_val(stack).unwrap(), 42); // bstack_move! yields Option>. let (moved_child, n) = bstack_move!(holder, &alloc).unwrap(); assert_eq!(n, 7); assert_eq!( - moved_child.as_ref().unwrap().handle().val(stack).unwrap(), + moved_child + .as_ref() + .unwrap() + .handle() + .get_val(stack) + .unwrap(), 42 ); moved_child.unwrap().bstack_drop(&alloc).unwrap(); // frees the leaf @@ -1068,8 +1093,8 @@ fn macro_option_owned() { // None: no child, accessor is None, teardown skips the null field cleanly. let empty = OptHolder::new(&alloc, None, 9).unwrap(); - assert_eq!(empty.handle().n(stack).unwrap(), 9); - assert!(empty.handle().child(stack).unwrap().is_none()); + assert_eq!(empty.handle().get_n(stack).unwrap(), 9); + assert!(empty.handle().get_child(stack).unwrap().is_none()); empty.bstack_drop(&alloc).unwrap(); } @@ -1135,24 +1160,24 @@ fn macro_vec_string_fields() { // Constructor takes `&str` for String and `&[T]` for Vec. let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42).unwrap(); - assert_eq!(rec.handle().id(stack).unwrap(), 42); + assert_eq!(rec.handle().get_id(stack).unwrap(), 42); // Accessors return BStackVec handles (take the allocator). assert_eq!( - rec.handle().name(&alloc).unwrap().to_vec().unwrap(), + rec.handle().get_name(&alloc).unwrap().to_vec().unwrap(), b"hello" ); assert_eq!( - rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3] ); // Mutate through the handle: the field points at the stable descriptor, so // growth (even if the data block moves) is visible on the next read. - let mut tags = rec.handle().tags(&alloc).unwrap(); + let mut tags = rec.handle().get_tags(&alloc).unwrap(); tags.push(4).unwrap(); assert_eq!( - rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3, 4], ); @@ -1162,11 +1187,11 @@ fn macro_vec_string_fields() { // Allocator is healthy: a fresh record round-trips. let rec2 = Record::new(&alloc, "again", &[9u32], 1).unwrap(); assert_eq!( - rec2.handle().name(&alloc).unwrap().to_vec().unwrap(), + rec2.handle().get_name(&alloc).unwrap().to_vec().unwrap(), b"again" ); assert_eq!( - rec2.handle().tags(&alloc).unwrap().to_vec().unwrap(), + rec2.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![9u32] ); rec2.bstack_drop(&alloc).unwrap(); @@ -1180,16 +1205,16 @@ fn macro_vec_field_push_growth_reclaims_old() { let alloc = tmp.allocator(); let rec = Record::new(&alloc, "hi", &[1u32, 2, 3], 0).unwrap(); - let old = rec.handle().tags(&alloc).unwrap().descriptor(); // cap == len == 12 B + let old = rec.handle().get_tags(&alloc).unwrap().descriptor(); // cap == len == 12 B // len 12 + elem 4 > cap 12 → field-resident growth → the reorder path. - let mut tags = rec.handle().tags(&alloc).unwrap(); + let mut tags = rec.handle().get_tags(&alloc).unwrap(); tags.push(4).unwrap(); - let new = rec.handle().tags(&alloc).unwrap().descriptor(); + let new = rec.handle().get_tags(&alloc).unwrap().descriptor(); assert_ne!(new.data_off, old.data_off); // moved to a fresh block assert_eq!( - rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3, 4] ); @@ -1241,19 +1266,19 @@ fn macro_owned_block_vec() { MacroLeaf::new(&alloc, 30).unwrap(), ]; let tree = Tree::new(&alloc, kids, 7).unwrap(); - assert_eq!(tree.handle().label(stack).unwrap(), 7); + assert_eq!(tree.handle().get_label(stack).unwrap(), 7); // Accessor resolves to a BStackBlockVec; read the children back. - let v = tree.handle().kids(&alloc).unwrap(); + let v = tree.handle().get_kids(&alloc).unwrap(); assert_eq!(v.len().unwrap(), 3); let vals: Vec = v .to_vec() .unwrap() .iter() - .map(|k| k.val(stack).unwrap()) + .map(|k| k.get_val(stack).unwrap()) .collect(); assert_eq!(vals, vec![10, 20, 30]); - assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20); + assert_eq!(v.get(1).unwrap().unwrap().get_val(stack).unwrap(), 20); assert!(v.get(3).unwrap().is_none()); // Freeing the tree recursively frees every owned child, plus the offset array @@ -1286,7 +1311,7 @@ fn macro_owned_block_vec_move() { let (kids_vec, label) = bstack_move!(tree, &alloc).unwrap(); assert_eq!(label, 9); assert_eq!(kids_vec.len().unwrap(), 2); - assert_eq!(kids_vec.get(0).unwrap().unwrap().val(stack).unwrap(), 1); + assert_eq!(kids_vec.get(0).unwrap().unwrap().get_val(stack).unwrap(), 1); // The moved-out vector is independently owned; free it (children + arrays). kids_vec.bstack_drop().unwrap(); @@ -1304,32 +1329,37 @@ fn macro_clone_pod_vec() { let stack = alloc.stack(); let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42).unwrap(); - let orig_name_off = rec.handle().name(&alloc).unwrap().descriptor().data_off; + let orig_name_off = rec.handle().get_name(&alloc).unwrap().descriptor().data_off; let clone = rec.try_clone_in(&alloc).unwrap(); - assert_eq!(clone.handle().id(stack).unwrap(), 42); + assert_eq!(clone.handle().get_id(stack).unwrap(), 42); assert_eq!( - clone.handle().name(&alloc).unwrap().to_vec().unwrap(), + clone.handle().get_name(&alloc).unwrap().to_vec().unwrap(), b"hello" ); assert_eq!( - clone.handle().tags(&alloc).unwrap().to_vec().unwrap(), + clone.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3] ); // The clone's data blocks are fresh allocations, distinct from the original's. - let clone_name_off = clone.handle().name(&alloc).unwrap().descriptor().data_off; + let clone_name_off = clone + .handle() + .get_name(&alloc) + .unwrap() + .descriptor() + .data_off; assert_ne!(clone_name_off, orig_name_off); // Growing the clone's vector leaves the original untouched (independent data). - let mut ct = clone.handle().tags(&alloc).unwrap(); + let mut ct = clone.handle().get_tags(&alloc).unwrap(); ct.push(99).unwrap(); assert_eq!( - clone.handle().tags(&alloc).unwrap().to_vec().unwrap(), + clone.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3, 99] ); assert_eq!( - rec.handle().tags(&alloc).unwrap().to_vec().unwrap(), + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), vec![1u32, 2, 3] ); @@ -1350,7 +1380,7 @@ fn macro_clone_owned_vec() { let tree = Tree::new(&alloc, kids, 7).unwrap(); let orig_first = tree .handle() - .kids(&alloc) + .get_kids(&alloc) .unwrap() .get(0) .unwrap() @@ -1359,13 +1389,13 @@ fn macro_clone_owned_vec() { .start(); let clone = tree.try_clone_in(&alloc).unwrap(); - let cv = clone.handle().kids(&alloc).unwrap(); + let cv = clone.handle().get_kids(&alloc).unwrap(); assert_eq!(cv.len().unwrap(), 2); let vals: Vec = cv .to_vec() .unwrap() .iter() - .map(|k| k.val(stack).unwrap()) + .map(|k| k.get_val(stack).unwrap()) .collect(); assert_eq!(vals, vec![10, 20]); @@ -1377,12 +1407,12 @@ fn macro_clone_owned_vec() { clone.bstack_drop(&alloc).unwrap(); assert_eq!( tree.handle() - .kids(&alloc) + .get_kids(&alloc) .unwrap() .get(0) .unwrap() .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 10 ); @@ -1416,7 +1446,7 @@ fn macro_clone_strong_vec() { list.bstack_drop(&alloc).unwrap(); assert_eq!(strong_of(stack, a_data), 1); assert_eq!(strong_of(stack, b_data), 1); - assert_eq!(a_keep.handle().val(stack).unwrap(), 100); + assert_eq!(a_keep.handle().get_val(stack).unwrap(), 100); drop(a_keep); drop(b_keep); } @@ -1458,16 +1488,16 @@ fn macro_strong_block_vec() { let list = StrongList::new(&alloc, vec![a, b], 3).unwrap(); assert_eq!(strong_of(stack, a_data), 2); // list + a_clone - let v = list.handle().items(&alloc).unwrap(); + let v = list.handle().get_items(&alloc).unwrap(); assert_eq!(v.len().unwrap(), 2); - assert_eq!(v.get(0).unwrap().unwrap().val(stack).unwrap(), 100); + assert_eq!(v.get(0).unwrap().unwrap().get_val(stack).unwrap(), 100); // Freeing the list releases every element's strong ref: `b` (sole owner) is // freed; `a` survives via `a_clone`. list.bstack_drop(&alloc).unwrap(); assert_eq!(strong_of(stack, a_data), 1); // a_clone only - assert_eq!(a_clone.handle().val(stack).unwrap(), 100); + assert_eq!(a_clone.handle().get_val(stack).unwrap(), 100); drop(a_clone); // a freed now } @@ -1495,18 +1525,18 @@ fn macro_weak_block_vec() { ) .unwrap(); - let v = list.handle().watchers(&alloc).unwrap(); + let v = list.handle().get_watchers(&alloc).unwrap(); assert_eq!(v.len().unwrap(), 2); // Upgrade element 0 while `a` is alive. let up = v.upgrade(0).unwrap().expect("a alive"); - assert_eq!(up.handle().val(stack).unwrap(), 1); + assert_eq!(up.handle().get_val(stack).unwrap(), 1); drop(up); // Drop `a`'s data block: element 0 can no longer upgrade (sound — the vector // stores control offsets, not freed data offsets). drop(a); - let v = list.handle().watchers(&alloc).unwrap(); + let v = list.handle().get_watchers(&alloc).unwrap(); assert!(v.upgrade(0).unwrap().is_none()); assert!(v.upgrade(1).unwrap().is_some()); // b still alive @@ -1537,14 +1567,14 @@ fn macro_ref_block_vec() { ]; let list = RefList::new(&alloc, refs, 9).unwrap(); - let v = list.handle().links(&alloc).unwrap(); + let v = list.handle().get_links(&alloc).unwrap(); assert_eq!(v.len().unwrap(), 2); - assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 8); + assert_eq!(v.get(1).unwrap().unwrap().get_val(stack).unwrap(), 8); // Freeing the list frees only the offset array + descriptor, not the targets. list.bstack_drop(&alloc).unwrap(); - assert_eq!(a.handle().val(stack).unwrap(), 7); // still alive - assert_eq!(b.handle().val(stack).unwrap(), 8); + assert_eq!(a.handle().get_val(stack).unwrap(), 7); // still alive + assert_eq!(b.handle().get_val(stack).unwrap(), 8); a.bstack_drop(&alloc).unwrap(); b.bstack_drop(&alloc).unwrap(); @@ -1569,10 +1599,10 @@ fn macro_option_vec() { // Some: constructor takes Option<&[T]> / Option<&str>; accessors resolve. let a = OptVec::new(&alloc, Some(&[1u32, 2, 3][..]), Some("hi"), 7).unwrap(); - assert_eq!(a.handle().id(stack).unwrap(), 7); + assert_eq!(a.handle().get_id(stack).unwrap(), 7); assert_eq!( a.handle() - .tags(&alloc) + .get_tags(&alloc) .unwrap() .expect("some") .to_vec() @@ -1581,7 +1611,7 @@ fn macro_option_vec() { ); assert_eq!( a.handle() - .name(&alloc) + .get_name(&alloc) .unwrap() .expect("some") .to_vec() @@ -1597,9 +1627,9 @@ fn macro_option_vec() { // None: `0` niche — accessors are None, teardown frees nothing extra. let b = OptVec::new(&alloc, None, None, 9).unwrap(); - assert_eq!(b.handle().id(stack).unwrap(), 9); - assert!(b.handle().tags(&alloc).unwrap().is_none()); - assert!(b.handle().name(&alloc).unwrap().is_none()); + assert_eq!(b.handle().get_id(stack).unwrap(), 9); + assert!(b.handle().get_tags(&alloc).unwrap().is_none()); + assert!(b.handle().get_name(&alloc).unwrap().is_none()); b.bstack_drop(&alloc).unwrap(); } @@ -1642,7 +1672,7 @@ fn macro_enum_basic() { let leaf_off = leaf.handle().range().start(); let e = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); match e.handle().read(&alloc).unwrap() { - NodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 7), + NodeView::Child(c) => assert_eq!(c.get_val(stack).unwrap(), 7), _ => panic!("expected Child"), } e.bstack_drop(&alloc).unwrap(); @@ -1655,11 +1685,11 @@ fn macro_enum_basic() { let link = unsafe { BStackRef::from_range(keep.handle().range()) }; let e = Node::new(&alloc, NodeData::Link(link)).unwrap(); match e.handle().read(&alloc).unwrap() { - NodeView::Link(l) => assert_eq!(l.val(stack).unwrap(), 9), + NodeView::Link(l) => assert_eq!(l.get_val(stack).unwrap(), 9), _ => panic!("expected Link"), } e.bstack_drop(&alloc).unwrap(); - assert_eq!(keep.handle().val(stack).unwrap(), 9); // still alive + assert_eq!(keep.handle().get_val(stack).unwrap(), 9); // still alive keep.bstack_drop(&alloc).unwrap(); } @@ -1680,12 +1710,12 @@ fn macro_enum_as_field() { let leaf = MacroLeaf::new(&alloc, 5).unwrap(); let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); let holder = EnumHolder::new(&alloc, node, 3).unwrap(); - assert_eq!(holder.handle().tag(stack).unwrap(), 3); + assert_eq!(holder.handle().get_tag(stack).unwrap(), 3); // Traverse struct -> enum -> owned child. - let node = holder.handle().node(stack).unwrap(); + let node = holder.handle().get_node(stack).unwrap(); match node.read(&alloc).unwrap() { - NodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 5), + NodeView::Child(c) => assert_eq!(c.get_val(stack).unwrap(), 5), _ => panic!("expected Child"), } @@ -1724,7 +1754,7 @@ fn macro_enum_rc() { let rc2 = rc.try_clone().unwrap(); // strong = 2 match rc.handle().read(&alloc).unwrap() { - RcNodeView::Child(c) => assert_eq!(c.val(stack).unwrap(), 4), + RcNodeView::Child(c) => assert_eq!(c.get_val(stack).unwrap(), 4), _ => panic!("expected Child"), } @@ -1754,7 +1784,7 @@ fn macro_enum_rc_weak() { let weak = rc.downgrade().unwrap(); match rc.handle().read(&alloc).unwrap() { - RcwNodeView::One(c) => assert_eq!(c.val(stack).unwrap(), 8), + RcwNodeView::One(c) => assert_eq!(c.get_val(stack).unwrap(), 8), _ => panic!("expected One"), } @@ -1798,18 +1828,18 @@ fn macro_enum_strong_weak_variants() { let keep = child.try_clone().unwrap(); // strong = 2 (observe after the enum drops) let cell = Cell::new(&alloc, CellData::Shared(child)).unwrap(); // consumes child's ref match cell.handle().read(&alloc).unwrap() { - CellView::Shared(c) => assert_eq!(c.val(stack).unwrap(), 11), + CellView::Shared(c) => assert_eq!(c.get_val(stack).unwrap(), 11), _ => panic!("expected Shared"), } cell.bstack_drop(&alloc).unwrap(); // releases the enum's strong ref (strong = 1) - assert_eq!(keep.handle().val(stack).unwrap(), 11); // still alive + assert_eq!(keep.handle().get_val(stack).unwrap(), 11); // still alive drop(keep); // strong = 0 — freed // Weak variant: consumes a BStackWeak; reading upgrades it. let owner = MacroStrongChild::new(&alloc, 22).unwrap(); // strong owner let cell = Cell::new(&alloc, CellData::Watch(owner.downgrade().unwrap())).unwrap(); match cell.handle().read(&alloc).unwrap() { - CellView::Watch(Some(up)) => assert_eq!(up.handle().val(stack).unwrap(), 22), + CellView::Watch(Some(up)) => assert_eq!(up.handle().get_val(stack).unwrap(), 22), _ => panic!("expected a live Watch"), } @@ -1859,7 +1889,7 @@ fn macro_clone_enum() { let c = e.try_clone_in(&alloc).unwrap(); let clone_child_off = match c.handle().read(&alloc).unwrap() { NodeView::Child(ch) => { - assert_eq!(ch.val(stack).unwrap(), 7); + assert_eq!(ch.get_val(stack).unwrap(), 7); ch.range().start() } _ => panic!("expected Child"), @@ -1867,7 +1897,7 @@ fn macro_clone_enum() { assert_ne!(clone_child_off, orig_child_off); // deep clone, not aliased c.bstack_drop(&alloc).unwrap(); match e.handle().read(&alloc).unwrap() { - NodeView::Child(ch) => assert_eq!(ch.val(stack).unwrap(), 7), // original intact + NodeView::Child(ch) => assert_eq!(ch.get_val(stack).unwrap(), 7), // original intact _ => panic!("expected Child"), } e.bstack_drop(&alloc).unwrap(); @@ -1879,14 +1909,14 @@ fn macro_clone_enum() { let c = e.try_clone_in(&alloc).unwrap(); match c.handle().read(&alloc).unwrap() { NodeView::Link(l) => { - assert_eq!(l.val(stack).unwrap(), 9); + assert_eq!(l.get_val(stack).unwrap(), 9); assert_eq!(l.range().start(), keep.handle().range().start()); // aliased } _ => panic!("expected Link"), } c.bstack_drop(&alloc).unwrap(); e.bstack_drop(&alloc).unwrap(); - assert_eq!(keep.handle().val(stack).unwrap(), 9); // target untouched + assert_eq!(keep.handle().get_val(stack).unwrap(), 9); // target untouched keep.bstack_drop(&alloc).unwrap(); } @@ -1909,7 +1939,7 @@ fn macro_clone_enum_shared() { clone.bstack_drop(&alloc).unwrap(); cell.bstack_drop(&alloc).unwrap(); assert_eq!(strong_of(stack, data), 1); - assert_eq!(keep.handle().val(stack).unwrap(), 11); + assert_eq!(keep.handle().get_val(stack).unwrap(), 11); drop(keep); } @@ -1928,7 +1958,7 @@ fn macro_enum_move() { let node = Node::new(&alloc, NodeData::Child(leaf)).unwrap(); match bstack_move!(node, &alloc).unwrap() { NodeData::Child(owned_leaf) => { - assert_eq!(owned_leaf.handle().val(stack).unwrap(), 5); // survived the move + assert_eq!(owned_leaf.handle().get_val(stack).unwrap(), 5); // survived the move owned_leaf.bstack_drop(&alloc).unwrap(); } _ => panic!("expected Child"), @@ -1956,7 +1986,7 @@ fn macro_enum_move() { } _ => panic!("expected Link"), } - assert_eq!(keep.handle().val(stack).unwrap(), 3); // untouched + assert_eq!(keep.handle().get_val(stack).unwrap(), 3); // untouched keep.bstack_drop(&alloc).unwrap(); } @@ -1972,12 +2002,12 @@ fn macro_enum_move_shared() { let cell = Cell::new(&alloc, CellData::Shared(child)).unwrap(); match bstack_move!(cell, &alloc).unwrap() { CellData::Shared(rc) => { - assert_eq!(rc.handle().val(stack).unwrap(), 11); + assert_eq!(rc.handle().get_val(stack).unwrap(), 11); drop(rc); // releases the moved-out strong ref } _ => panic!("expected Shared"), } - assert_eq!(keep.handle().val(stack).unwrap(), 11); // still alive + assert_eq!(keep.handle().get_val(stack).unwrap(), 11); // still alive drop(keep); // Weak variant: the BStackWeak is moved out (transferring the weak ref). @@ -1990,7 +2020,7 @@ fn macro_enum_move_shared() { .unwrap() .expect("alive") .handle() - .val(stack) + .get_val(stack) .unwrap(), 22 ); @@ -2059,7 +2089,7 @@ fn macro_enum_repr_aligned() { let leaf = MacroLeaf::new(&alloc, 3).unwrap(); let e = Aligned::new(&alloc, AlignedData::Y(leaf)).unwrap(); match e.handle().read(&alloc).unwrap() { - AlignedView::Y(c) => assert_eq!(c.val(stack).unwrap(), 3), + AlignedView::Y(c) => assert_eq!(c.get_val(stack).unwrap(), 3), _ => panic!("expected Y"), } e.bstack_drop(&alloc).unwrap(); @@ -2250,13 +2280,16 @@ fn macro_pod_option_and_tuple_fields() { ) .unwrap(); assert_eq!( - f.handle().maybe(stack).unwrap(), + f.handle().get_maybe(stack).unwrap(), core::num::NonZeroU32::new(7) ); - assert_eq!(f.handle().wrap(stack).unwrap(), core::num::Wrapping(42u32)); - assert_eq!(f.handle().pair(stack).unwrap(), (1u8, 2u8)); - assert_eq!(f.handle().mixed(stack).unwrap(), (300u16, -5i32)); - assert_eq!(f.handle().n(stack).unwrap(), 99); + assert_eq!( + f.handle().get_wrap(stack).unwrap(), + core::num::Wrapping(42u32) + ); + assert_eq!(f.handle().get_pair(stack).unwrap(), (1u8, 2u8)); + assert_eq!(f.handle().get_mixed(stack).unwrap(), (300u16, -5i32)); + assert_eq!(f.handle().get_n(stack).unwrap(), 99); // `bstack_move!` returns each tuple as ONE element (not flattened into // `(u8, u8, u16, i32, ..)`), so this exact type annotation must hold. @@ -2275,7 +2308,7 @@ fn macro_pod_option_and_tuple_fields() { // `Option` None round-trips too (the niche). let g = PodFeat::new(&alloc, None, core::num::Wrapping(0), (0, 0), (0, 0), 0).unwrap(); - assert!(g.handle().maybe(stack).unwrap().is_none()); + assert!(g.handle().get_maybe(stack).unwrap().is_none()); g.bstack_drop(&alloc).unwrap(); } @@ -2302,9 +2335,9 @@ fn macro_unit_and_tuple_structs() { // Tuple struct: positional constructor, `.field0` / `.field1` / … accessors. let c = Rgb::new(&alloc, 10, 20, 30).unwrap(); - assert_eq!(c.handle().field0(stack).unwrap(), 10); - assert_eq!(c.handle().field1(stack).unwrap(), 20); - assert_eq!(c.handle().field2(stack).unwrap(), 30); + assert_eq!(c.handle().get_field0(stack).unwrap(), 10); + assert_eq!(c.handle().get_field1(stack).unwrap(), 20); + assert_eq!(c.handle().get_field2(stack).unwrap(), 30); // bstack_move! yields the fields in order. let (r, g, b) = bstack_move!(c, &alloc).unwrap(); @@ -2347,10 +2380,10 @@ fn macro_embed_struct_and_enum() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let child = EmbChild::new(&alloc, leaf, 7).unwrap(); let holder = EmbHolder::new(&alloc, child, 99).unwrap(); - assert_eq!(holder.handle().tag(stack).unwrap(), 99); - let c = holder.handle().child(); // a handle into the inline region (no I/O) - assert_eq!(c.n(stack).unwrap(), 7); - assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!(holder.handle().get_tag(stack).unwrap(), 99); + let c = holder.handle().get_child(); // a handle into the inline region (no I/O) + assert_eq!(c.get_n(stack).unwrap(), 7); + assert_eq!(c.get_leaf(stack).unwrap().get_val(stack).unwrap(), 42); // Teardown frees the embedded child's owned leaf *in place*, then the holder — // reclaimed with no leak (proof the embed recursed). @@ -2367,7 +2400,15 @@ fn macro_embed_struct_and_enum() { let holder = EmbHolder::new(&alloc, child, 1).unwrap(); let (moved, tag) = bstack_move!(holder, &alloc).unwrap(); assert_eq!(tag, 1); - assert_eq!(moved.handle().leaf(stack).unwrap().val(stack).unwrap(), 5); + assert_eq!( + moved + .handle() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 5 + ); moved.bstack_drop(&alloc).unwrap(); // Enum embed: construct, read (a borrowed child handle), move out. @@ -2375,14 +2416,14 @@ fn macro_embed_struct_and_enum() { let child = EmbChild::new(&alloc, leaf, 9).unwrap(); let e = EmbEnum::new(&alloc, EmbEnumData::Wrap(child)).unwrap(); match e.handle().read(&alloc).unwrap() { - EmbEnumView::Wrap(c) => assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3), + EmbEnumView::Wrap(c) => assert_eq!(c.get_leaf(stack).unwrap().get_val(stack).unwrap(), 3), _ => panic!("expected Wrap"), } let moved = match bstack_move!(e, &alloc).unwrap() { EmbEnumData::Wrap(c) => c, _ => panic!("expected Wrap"), }; - assert_eq!(moved.handle().n(stack).unwrap(), 9); + assert_eq!(moved.handle().get_n(stack).unwrap(), 9); moved.bstack_drop(&alloc).unwrap(); } @@ -2396,17 +2437,23 @@ fn macro_clone_embed() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let child = EmbChild::new(&alloc, leaf, 7).unwrap(); let holder = EmbHolder::new(&alloc, child, 99).unwrap(); - let orig_leaf_off = holder.handle().child().leaf(stack).unwrap().range().start(); + let orig_leaf_off = holder + .handle() + .get_child() + .get_leaf(stack) + .unwrap() + .range() + .start(); let clone = holder.try_clone_in(&alloc).unwrap(); - assert_eq!(clone.handle().tag(stack).unwrap(), 99); - let cc = clone.handle().child(); - assert_eq!(cc.n(stack).unwrap(), 7); - assert_eq!(cc.leaf(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!(clone.handle().get_tag(stack).unwrap(), 99); + let cc = clone.handle().get_child(); + assert_eq!(cc.get_n(stack).unwrap(), 7); + assert_eq!(cc.get_leaf(stack).unwrap().get_val(stack).unwrap(), 42); // The embedded child's OWN owned leaf was deep-cloned into a fresh block // (the inline region was folded, not just byte-copied with an aliased offset). - let clone_leaf_off = cc.leaf(stack).unwrap().range().start(); + let clone_leaf_off = cc.get_leaf(stack).unwrap().range().start(); assert_ne!(clone_leaf_off, orig_leaf_off); // Freeing the clone frees only the clone's leaf; the original stays intact. @@ -2414,10 +2461,10 @@ fn macro_clone_embed() { assert_eq!( holder .handle() - .child() - .leaf(stack) + .get_child() + .get_leaf(stack) .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 42 ); @@ -2428,21 +2475,21 @@ fn macro_clone_embed() { let child = EmbChild::new(&alloc, leaf, 9).unwrap(); let e = EmbEnum::new(&alloc, EmbEnumData::Wrap(child)).unwrap(); let orig_off = match e.handle().read(&alloc).unwrap() { - EmbEnumView::Wrap(c) => c.leaf(stack).unwrap().range().start(), + EmbEnumView::Wrap(c) => c.get_leaf(stack).unwrap().range().start(), _ => panic!("expected Wrap"), }; let ce = e.try_clone_in(&alloc).unwrap(); let clone_off = match ce.handle().read(&alloc).unwrap() { EmbEnumView::Wrap(c) => { - assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3); - c.leaf(stack).unwrap().range().start() + assert_eq!(c.get_leaf(stack).unwrap().get_val(stack).unwrap(), 3); + c.get_leaf(stack).unwrap().range().start() } _ => panic!("expected Wrap"), }; assert_ne!(clone_off, orig_off); // deep-cloned, not aliased ce.bstack_drop(&alloc).unwrap(); match e.handle().read(&alloc).unwrap() { - EmbEnumView::Wrap(c) => assert_eq!(c.leaf(stack).unwrap().val(stack).unwrap(), 3), + EmbEnumView::Wrap(c) => assert_eq!(c.get_leaf(stack).unwrap().get_val(stack).unwrap(), 3), _ => panic!("expected Wrap"), } e.bstack_drop(&alloc).unwrap(); @@ -2568,8 +2615,8 @@ fn macro_pod_array() { let alloc = tmp.allocator(); let stack = alloc.stack(); let p = PodArr::new(&alloc, [1u16, 2, 3, 4], 9).unwrap(); - assert_eq!(p.handle().xs(stack).unwrap(), [1u16, 2, 3, 4]); - assert_eq!(p.handle().tag(stack).unwrap(), 9); + assert_eq!(p.handle().get_xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().get_tag(stack).unwrap(), 9); p.bstack_drop(&alloc).unwrap(); } @@ -2591,11 +2638,11 @@ fn macro_owned_array() { let l2 = MacroLeaf::new(&alloc, 30).unwrap(); let h = ArrHolder::new(&alloc, [l0, l1, l2], 7).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 7); - let arr = h.handle().leaves(stack).unwrap(); // [MacroLeaf; 3] - assert_eq!(arr[0].val(stack).unwrap(), 10); - assert_eq!(arr[1].val(stack).unwrap(), 20); - assert_eq!(arr[2].val(stack).unwrap(), 30); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); + let arr = h.handle().get_leaves(stack).unwrap(); // [MacroLeaf; 3] + assert_eq!(arr[0].get_val(stack).unwrap(), 10); + assert_eq!(arr[1].get_val(stack).unwrap(), 20); + assert_eq!(arr[2].get_val(stack).unwrap(), 30); // Teardown frees all three inline children — reclaimed with no leak. h.bstack_drop(&alloc).unwrap(); @@ -2625,14 +2672,19 @@ fn macro_owned_array_clone() { .unwrap(); let clone = h.try_clone_in(&alloc).unwrap(); - let carr = clone.handle().leaves(stack).unwrap(); - let oarr = h.handle().leaves(stack).unwrap(); - assert_eq!(carr[1].val(stack).unwrap(), 2); + let carr = clone.handle().get_leaves(stack).unwrap(); + let oarr = h.handle().get_leaves(stack).unwrap(); + assert_eq!(carr[1].get_val(stack).unwrap(), 2); // Deep-cloned: each clone element is a fresh block, distinct from the original. assert_ne!(carr[0].range().start(), oarr[0].range().start()); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().leaves(stack).unwrap()[2].val(stack).unwrap(), 3); + assert_eq!( + h.handle().get_leaves(stack).unwrap()[2] + .get_val(stack) + .unwrap(), + 3 + ); h.bstack_drop(&alloc).unwrap(); } @@ -2653,13 +2705,13 @@ fn macro_ref_array() { let r1 = unsafe { BStackRef::from_range(l1.handle().range()) }; let h = RefArrHolder::new(&alloc, [r0, r1]).unwrap(); - let arr = h.handle().refs(stack).unwrap(); - assert_eq!(arr[0].val(stack).unwrap(), 1); - assert_eq!(arr[1].val(stack).unwrap(), 2); + let arr = h.handle().get_refs(stack).unwrap(); + assert_eq!(arr[0].get_val(stack).unwrap(), 1); + assert_eq!(arr[1].get_val(stack).unwrap(), 2); // A ref array owns nothing: dropping the holder leaves the targets alive. h.bstack_drop(&alloc).unwrap(); - assert_eq!(l0.handle().val(stack).unwrap(), 1); + assert_eq!(l0.handle().get_val(stack).unwrap(), 1); l0.bstack_drop(&alloc).unwrap(); l1.bstack_drop(&alloc).unwrap(); } @@ -2681,8 +2733,8 @@ fn macro_strong_array() { let keep0 = c0.try_clone().unwrap(); let h = StrongArrHolder::new(&alloc, [c0, c1]).unwrap(); - let arr = h.handle().shared(stack).unwrap(); - assert_eq!(arr[0].val(stack).unwrap(), 5); + let arr = h.handle().get_shared(stack).unwrap(); + assert_eq!(arr[0].get_val(stack).unwrap(), 5); // Cloning the holder re-references each shared child: strong count +1. let d0 = arr[0].range().start(); @@ -2696,7 +2748,7 @@ fn macro_strong_array() { clone.bstack_drop(&alloc).unwrap(); h.bstack_drop(&alloc).unwrap(); assert_eq!(crate::refcount::load(stack, strong0).unwrap(), before - 1); - assert_eq!(keep0.handle().val(stack).unwrap(), 5); + assert_eq!(keep0.handle().get_val(stack).unwrap(), 5); drop(keep0); } @@ -2717,8 +2769,8 @@ fn macro_owned_array_move() { .unwrap(); let (leaves, tag) = bstack_move!(h, &alloc).unwrap(); assert_eq!(tag, 7); - assert_eq!(leaves[0].handle().val(stack).unwrap(), 10); - assert_eq!(leaves[2].handle().val(stack).unwrap(), 30); + assert_eq!(leaves[0].handle().get_val(stack).unwrap(), 10); + assert_eq!(leaves[2].handle().get_val(stack).unwrap(), 30); for l in leaves { l.bstack_drop(&alloc).unwrap(); } @@ -2740,7 +2792,7 @@ fn macro_weak_array() { // Weak arrays start null (not a ctor parameter). let h = WeakArrHolder::new(&alloc).unwrap(); - let arr = h.handle().weaks(&alloc).unwrap(); + let arr = h.handle().get_weaks(&alloc).unwrap(); assert!(arr[0].is_none() && arr[1].is_none()); // Wire each element via the per-index setter. @@ -2752,22 +2804,25 @@ fn macro_weak_array() { .unwrap(); // The accessor upgrades each live element. - let arr = h.handle().weaks(&alloc).unwrap(); - assert_eq!(arr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); - assert_eq!(arr[1].as_ref().unwrap().handle().val(stack).unwrap(), 6); + let arr = h.handle().get_weaks(&alloc).unwrap(); + assert_eq!(arr[0].as_ref().unwrap().handle().get_val(stack).unwrap(), 5); + assert_eq!(arr[1].as_ref().unwrap().handle().get_val(stack).unwrap(), 6); drop(arr); // Cloning aliases the same control blocks (weak counts bumped). let clone = h.try_clone_in(&alloc).unwrap(); - let carr = clone.handle().weaks(&alloc).unwrap(); - assert_eq!(carr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); + let carr = clone.handle().get_weaks(&alloc).unwrap(); + assert_eq!( + carr[0].as_ref().unwrap().handle().get_val(stack).unwrap(), + 5 + ); drop(carr); // Both holders' teardown releases the weak refs (no underflow); c0/c1 live. clone.bstack_drop(&alloc).unwrap(); h.bstack_drop(&alloc).unwrap(); - assert_eq!(c0.handle().val(stack).unwrap(), 5); - assert_eq!(c1.handle().val(stack).unwrap(), 6); + assert_eq!(c0.handle().get_val(stack).unwrap(), 5); + assert_eq!(c1.handle().get_val(stack).unwrap(), 6); drop(c0); drop(c1); } @@ -2791,15 +2846,15 @@ fn macro_owned_option_array() { let off0 = l0.handle().range().start(); let h = OptArrHolder::new(&alloc, [Some(l0), None, Some(l2)]).unwrap(); - let arr = h.handle().leaves(stack).unwrap(); // [Option; 3] - assert_eq!(arr[0].as_ref().unwrap().val(stack).unwrap(), 10); + let arr = h.handle().get_leaves(stack).unwrap(); // [Option; 3] + assert_eq!(arr[0].as_ref().unwrap().get_val(stack).unwrap(), 10); assert!(arr[1].is_none()); - assert_eq!(arr[2].as_ref().unwrap().val(stack).unwrap(), 30); + assert_eq!(arr[2].as_ref().unwrap().get_val(stack).unwrap(), 30); // Clone deep-copies the present elements, keeps the hole. let clone = h.try_clone_in(&alloc).unwrap(); - let carr = clone.handle().leaves(stack).unwrap(); - assert_eq!(carr[0].as_ref().unwrap().val(stack).unwrap(), 10); + let carr = clone.handle().get_leaves(stack).unwrap(); + assert_eq!(carr[0].as_ref().unwrap().get_val(stack).unwrap(), 10); assert!(carr[1].is_none()); assert_ne!( carr[0].as_ref().unwrap().range().start(), @@ -2809,7 +2864,10 @@ fn macro_owned_option_array() { // Move yields `[Option>; 3]`. clone.bstack_drop(&alloc).unwrap(); let (moved,) = bstack_move!(h, &alloc).unwrap(); - assert_eq!(moved[2].as_ref().unwrap().handle().val(stack).unwrap(), 30); + assert_eq!( + moved[2].as_ref().unwrap().handle().get_val(stack).unwrap(), + 30 + ); assert!(moved[1].is_none()); for o in moved.into_iter().flatten() { o.bstack_drop(&alloc).unwrap(); @@ -2836,12 +2894,12 @@ fn macro_ref_option_array() { // Element 1 is a null reference. let h = OptRefArrHolder::new(&alloc, [Some(r0), None]).unwrap(); - let arr = h.handle().refs(stack).unwrap(); // [Option; 2] - assert_eq!(arr[0].as_ref().unwrap().val(stack).unwrap(), 1); + let arr = h.handle().get_refs(stack).unwrap(); // [Option; 2] + assert_eq!(arr[0].as_ref().unwrap().get_val(stack).unwrap(), 1); assert!(arr[1].is_none()); h.bstack_drop(&alloc).unwrap(); // owns nothing - assert_eq!(l0.handle().val(stack).unwrap(), 1); + assert_eq!(l0.handle().get_val(stack).unwrap(), 1); l0.bstack_drop(&alloc).unwrap(); } @@ -2864,7 +2922,7 @@ fn macro_pod_option_array() { ], ) .unwrap(); - let arr = p.handle().xs(stack).unwrap(); // [Option; 3] + let arr = p.handle().get_xs(stack).unwrap(); // [Option; 3] assert_eq!(arr[0].map(|n| n.get()), Some(5)); assert!(arr[1].is_none()); assert_eq!(arr[2].map(|n| n.get()), Some(9)); @@ -2888,28 +2946,31 @@ fn macro_embed_array() { let k0 = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 10).unwrap(), 1).unwrap(); let k1 = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 20).unwrap(), 2).unwrap(); let h = EmbArrHolder::new(&alloc, [k0, k1], 99).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 99); + assert_eq!(h.handle().get_tag(stack).unwrap(), 99); // Accessor: `[EmbChild; 2]` handles into the inline slots (pure offset math). - let kids = h.handle().kids(); - assert_eq!(kids[0].n(stack).unwrap(), 1); - assert_eq!(kids[0].leaf(stack).unwrap().val(stack).unwrap(), 10); - assert_eq!(kids[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + let kids = h.handle().get_kids(); + assert_eq!(kids[0].get_n(stack).unwrap(), 1); + assert_eq!(kids[0].get_leaf(stack).unwrap().get_val(stack).unwrap(), 10); + assert_eq!(kids[1].get_leaf(stack).unwrap().get_val(stack).unwrap(), 20); // Clone folds each embedded child inline, deep-cloning its owned leaf. let clone = h.try_clone_in(&alloc).unwrap(); - let ckids = clone.handle().kids(); - assert_eq!(ckids[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + let ckids = clone.handle().get_kids(); + assert_eq!( + ckids[1].get_leaf(stack).unwrap().get_val(stack).unwrap(), + 20 + ); assert_ne!( - ckids[0].leaf(stack).unwrap().range().start(), - kids[0].leaf(stack).unwrap().range().start() + ckids[0].get_leaf(stack).unwrap().range().start(), + kids[0].get_leaf(stack).unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); assert_eq!( - h.handle().kids()[1] - .leaf(stack) + h.handle().get_kids()[1] + .get_leaf(stack) .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 20 ); @@ -2918,7 +2979,12 @@ fn macro_embed_array() { let (moved, tag) = bstack_move!(h, &alloc).unwrap(); assert_eq!(tag, 99); assert_eq!( - moved[0].handle().leaf(stack).unwrap().val(stack).unwrap(), + moved[0] + .handle() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), 10 ); for m in moved { @@ -2949,8 +3015,8 @@ fn macro_enum_owned_array() { .unwrap(); match e.handle().read(&alloc).unwrap() { ArrEnumView::Leaves(arr) => { - assert_eq!(arr[0].val(stack).unwrap(), 10); - assert_eq!(arr[1].val(stack).unwrap(), 20); + assert_eq!(arr[0].get_val(stack).unwrap(), 10); + assert_eq!(arr[1].get_val(stack).unwrap(), 20); } _ => panic!("expected Leaves"), } @@ -2958,7 +3024,7 @@ fn macro_enum_owned_array() { // Clone deep-copies each element. let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { - ArrEnumView::Leaves(arr) => assert_eq!(arr[0].val(stack).unwrap(), 10), + ArrEnumView::Leaves(arr) => assert_eq!(arr[0].get_val(stack).unwrap(), 10), _ => panic!("expected Leaves"), } clone.bstack_drop(&alloc).unwrap(); @@ -2966,7 +3032,7 @@ fn macro_enum_owned_array() { // Move yields `[BStackOwned; 2]`. match bstack_move!(e, &alloc).unwrap() { ArrEnumData::Leaves(arr) => { - assert_eq!(arr[1].handle().val(stack).unwrap(), 20); + assert_eq!(arr[1].handle().get_val(stack).unwrap(), 20); for l in arr { l.bstack_drop(&alloc).unwrap(); } @@ -2999,13 +3065,13 @@ fn macro_enum_ref_array() { .unwrap(); match e.handle().read(&alloc).unwrap() { RefArrEnumView::Refs(arr) => { - assert_eq!(arr[0].val(stack).unwrap(), 1); - assert_eq!(arr[1].val(stack).unwrap(), 2); + assert_eq!(arr[0].get_val(stack).unwrap(), 1); + assert_eq!(arr[1].get_val(stack).unwrap(), 2); } _ => panic!("expected Refs"), } e.bstack_drop(&alloc).unwrap(); // owns nothing - assert_eq!(l0.handle().val(stack).unwrap(), 1); + assert_eq!(l0.handle().get_val(stack).unwrap(), 1); l0.bstack_drop(&alloc).unwrap(); l1.bstack_drop(&alloc).unwrap(); } @@ -3027,14 +3093,14 @@ fn macro_enum_strong_array() { let keep0 = c0.try_clone().unwrap(); let e = StrongArrEnum::new(&alloc, StrongArrEnumData::Shared([c0, c1])).unwrap(); match e.handle().read(&alloc).unwrap() { - StrongArrEnumView::Shared(arr) => assert_eq!(arr[0].val(stack).unwrap(), 5), + StrongArrEnumView::Shared(arr) => assert_eq!(arr[0].get_val(stack).unwrap(), 5), _ => panic!("expected Shared"), } // Clone re-references each; teardown of both holders returns to keep0's ref. let clone = e.try_clone_in(&alloc).unwrap(); clone.bstack_drop(&alloc).unwrap(); e.bstack_drop(&alloc).unwrap(); - assert_eq!(keep0.handle().val(stack).unwrap(), 5); + assert_eq!(keep0.handle().get_val(stack).unwrap(), 5); drop(keep0); } @@ -3059,13 +3125,13 @@ fn macro_enum_weak_array() { .unwrap(); match e.handle().read(&alloc).unwrap() { WeakArrEnumView::Weaks(arr) => { - assert_eq!(arr[0].as_ref().unwrap().handle().val(stack).unwrap(), 5); - assert_eq!(arr[1].as_ref().unwrap().handle().val(stack).unwrap(), 6); + assert_eq!(arr[0].as_ref().unwrap().handle().get_val(stack).unwrap(), 5); + assert_eq!(arr[1].as_ref().unwrap().handle().get_val(stack).unwrap(), 6); } _ => panic!("expected Weaks"), } e.bstack_drop(&alloc).unwrap(); // releases the weak refs - assert_eq!(c0.handle().val(stack).unwrap(), 5); + assert_eq!(c0.handle().get_val(stack).unwrap(), 5); drop(c0); drop(c1); } @@ -3108,25 +3174,30 @@ fn macro_owned_nested_array() { let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); let h = OwnedGrid::new(&alloc, [[mk(1), mk(2)], [mk(3), mk(4)]], 7).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 7); - let g = h.handle().grid(stack).unwrap(); // [[MacroLeaf; 2]; 2] - assert_eq!(g[0][0].val(stack).unwrap(), 1); - assert_eq!(g[0][1].val(stack).unwrap(), 2); - assert_eq!(g[1][0].val(stack).unwrap(), 3); - assert_eq!(g[1][1].val(stack).unwrap(), 4); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); + let g = h.handle().get_grid(stack).unwrap(); // [[MacroLeaf; 2]; 2] + assert_eq!(g[0][0].get_val(stack).unwrap(), 1); + assert_eq!(g[0][1].get_val(stack).unwrap(), 2); + assert_eq!(g[1][0].get_val(stack).unwrap(), 3); + assert_eq!(g[1][1].get_val(stack).unwrap(), 4); // Deep clone: fresh blocks, same values. let clone = h.try_clone_in(&alloc).unwrap(); - let cg = clone.handle().grid(stack).unwrap(); - assert_eq!(cg[1][1].val(stack).unwrap(), 4); + let cg = clone.handle().get_grid(stack).unwrap(); + assert_eq!(cg[1][1].get_val(stack).unwrap(), 4); assert_ne!(cg[0][0].range().start(), g[0][0].range().start()); clone.bstack_drop(&alloc).unwrap(); - assert_eq!(h.handle().grid(stack).unwrap()[1][0].val(stack).unwrap(), 3); + assert_eq!( + h.handle().get_grid(stack).unwrap()[1][0] + .get_val(stack) + .unwrap(), + 3 + ); // Move: nested owning handles. let (moved, tag) = bstack_move!(h, &alloc).unwrap(); assert_eq!(tag, 7); - assert_eq!(moved[1][1].handle().val(stack).unwrap(), 4); + assert_eq!(moved[1][1].handle().get_val(stack).unwrap(), 4); for row in moved { for m in row { m.bstack_drop(&alloc).unwrap(); @@ -3150,16 +3221,16 @@ fn macro_ref_nested3_array() { let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; let h = RefCube::new(&alloc, [[[r(0), r(1)]], [[r(2), r(3)]]]).unwrap(); - let c = h.handle().cube(stack).unwrap(); // [[[MacroLeaf; 2]; 1]; 2] - assert_eq!(c[0][0][0].val(stack).unwrap(), 0); - assert_eq!(c[0][0][1].val(stack).unwrap(), 1); - assert_eq!(c[1][0][0].val(stack).unwrap(), 2); - assert_eq!(c[1][0][1].val(stack).unwrap(), 3); + let c = h.handle().get_cube(stack).unwrap(); // [[[MacroLeaf; 2]; 1]; 2] + assert_eq!(c[0][0][0].get_val(stack).unwrap(), 0); + assert_eq!(c[0][0][1].get_val(stack).unwrap(), 1); + assert_eq!(c[1][0][0].get_val(stack).unwrap(), 2); + assert_eq!(c[1][0][1].get_val(stack).unwrap(), 3); // A ref cube owns nothing: dropping leaves targets alive. h.bstack_drop(&alloc).unwrap(); for l in leaves { - assert!(l.handle().val(stack).unwrap() < 4); + assert!(l.handle().get_val(stack).unwrap() < 4); l.bstack_drop(&alloc).unwrap(); } } @@ -3179,18 +3250,21 @@ fn macro_embed_nested_array() { let k = |v| EmbChild::new(&alloc, MacroLeaf::new(&alloc, v).unwrap(), v).unwrap(); let h = EmbGrid::new(&alloc, [[k(10), k(20)]], 5).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 5); + assert_eq!(h.handle().get_tag(stack).unwrap(), 5); - let g = h.handle().kids(); // [[EmbChild; 2]; 1] - assert_eq!(g[0][0].leaf(stack).unwrap().val(stack).unwrap(), 10); - assert_eq!(g[0][1].leaf(stack).unwrap().val(stack).unwrap(), 20); + let g = h.handle().get_kids(); // [[EmbChild; 2]; 1] + assert_eq!(g[0][0].get_leaf(stack).unwrap().get_val(stack).unwrap(), 10); + assert_eq!(g[0][1].get_leaf(stack).unwrap().get_val(stack).unwrap(), 20); let clone = h.try_clone_in(&alloc).unwrap(); - let cg = clone.handle().kids(); - assert_eq!(cg[0][1].leaf(stack).unwrap().val(stack).unwrap(), 20); + let cg = clone.handle().get_kids(); + assert_eq!( + cg[0][1].get_leaf(stack).unwrap().get_val(stack).unwrap(), + 20 + ); assert_ne!( - cg[0][0].leaf(stack).unwrap().range().start(), - g[0][0].leaf(stack).unwrap().range().start() + cg[0][0].get_leaf(stack).unwrap().range().start(), + g[0][0].get_leaf(stack).unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); @@ -3199,9 +3273,9 @@ fn macro_embed_nested_array() { assert_eq!( moved[0][0] .handle() - .leaf(stack) + .get_leaf(stack) .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 10 ); @@ -3240,9 +3314,9 @@ fn macro_enum_owned_option_array() { .unwrap(); match e.handle().read(&alloc).unwrap() { OptArrEnumView::Slots(arr) => { - assert_eq!(arr[0].map(|h| h.val(stack).unwrap()), Some(10)); + assert_eq!(arr[0].map(|h| h.get_val(stack).unwrap()), Some(10)); assert!(arr[1].is_none()); - assert_eq!(arr[2].map(|h| h.val(stack).unwrap()), Some(30)); + assert_eq!(arr[2].map(|h| h.get_val(stack).unwrap()), Some(30)); } _ => panic!("expected Slots"), } @@ -3250,7 +3324,7 @@ fn macro_enum_owned_option_array() { let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { OptArrEnumView::Slots(arr) => { - assert_eq!(arr[2].map(|h| h.val(stack).unwrap()), Some(30)); + assert_eq!(arr[2].map(|h| h.get_val(stack).unwrap()), Some(30)); assert!(arr[1].is_none()); } _ => panic!("expected Slots"), @@ -3260,7 +3334,7 @@ fn macro_enum_owned_option_array() { match bstack_move!(e, &alloc).unwrap() { OptArrEnumData::Slots(arr) => { assert_eq!( - arr[0].as_ref().map(|h| h.handle().val(stack).unwrap()), + arr[0].as_ref().map(|h| h.handle().get_val(stack).unwrap()), Some(10) ); assert!(arr[1].is_none()); @@ -3290,8 +3364,8 @@ fn macro_enum_embed_array() { match e.handle().read(&alloc).unwrap() { EmbArrEnumView::Kids(arr) => { - assert_eq!(arr[0].leaf(stack).unwrap().val(stack).unwrap(), 10); - assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20); + assert_eq!(arr[0].get_leaf(stack).unwrap().get_val(stack).unwrap(), 10); + assert_eq!(arr[1].get_leaf(stack).unwrap().get_val(stack).unwrap(), 20); } _ => panic!("expected Kids"), } @@ -3299,7 +3373,7 @@ fn macro_enum_embed_array() { let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { EmbArrEnumView::Kids(arr) => { - assert_eq!(arr[1].leaf(stack).unwrap().val(stack).unwrap(), 20) + assert_eq!(arr[1].get_leaf(stack).unwrap().get_val(stack).unwrap(), 20) } _ => panic!("expected Kids"), } @@ -3307,7 +3381,15 @@ fn macro_enum_embed_array() { match bstack_move!(e, &alloc).unwrap() { EmbArrEnumData::Kids(arr) => { - assert_eq!(arr[0].handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + arr[0] + .handle() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 10 + ); for m in arr { m.bstack_drop(&alloc).unwrap(); } @@ -3337,8 +3419,8 @@ fn macro_enum_owned_nested_array() { .unwrap(); match e.handle().read(&alloc).unwrap() { NestArrEnumView::Grid(g) => { - assert_eq!(g[0][0].val(stack).unwrap(), 1); - assert_eq!(g[1][1].val(stack).unwrap(), 4); + assert_eq!(g[0][0].get_val(stack).unwrap(), 1); + assert_eq!(g[1][1].get_val(stack).unwrap(), 4); } _ => panic!("expected Grid"), } @@ -3348,7 +3430,7 @@ fn macro_enum_owned_nested_array() { match bstack_move!(e, &alloc).unwrap() { NestArrEnumData::Grid(g) => { - assert_eq!(g[1][0].handle().val(stack).unwrap(), 3); + assert_eq!(g[1][0].handle().get_val(stack).unwrap(), 3); for row in g { for m in row { m.bstack_drop(&alloc).unwrap(); @@ -3376,31 +3458,31 @@ fn macro_pod_vec_array() { let stack = alloc.stack(); let h = PodVecArr::new(&alloc, [&[1u32, 2][..], &[3, 4, 5][..]], 9).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 9); + assert_eq!(h.handle().get_tag(stack).unwrap(), 9); - let rows = h.handle().rows(&alloc).unwrap(); // [BStackVec; 2] + let rows = h.handle().get_rows(&alloc).unwrap(); // [BStackVec; 2] assert_eq!(rows[0].to_vec().unwrap(), vec![1u32, 2]); assert_eq!(rows[1].to_vec().unwrap(), vec![3u32, 4, 5]); // Each slot is an independent, growable vector. - let mut rows_mut = h.handle().rows(&alloc).unwrap(); + let mut rows_mut = h.handle().get_rows(&alloc).unwrap(); rows_mut[0].push(99).unwrap(); assert_eq!( - h.handle().rows(&alloc).unwrap()[0].to_vec().unwrap(), + h.handle().get_rows(&alloc).unwrap()[0].to_vec().unwrap(), vec![1u32, 2, 99] ); assert_eq!( - h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), + h.handle().get_rows(&alloc).unwrap()[1].to_vec().unwrap(), vec![3u32, 4, 5] ); // Clone deep-copies both data blocks. let clone = h.try_clone_in(&alloc).unwrap(); - let crows = clone.handle().rows(&alloc).unwrap(); + let crows = clone.handle().get_rows(&alloc).unwrap(); assert_eq!(crows[1].to_vec().unwrap(), vec![3u32, 4, 5]); clone.bstack_drop(&alloc).unwrap(); assert_eq!( - h.handle().rows(&alloc).unwrap()[1].to_vec().unwrap(), + h.handle().get_rows(&alloc).unwrap()[1].to_vec().unwrap(), vec![3u32, 4, 5] ); @@ -3429,15 +3511,15 @@ fn macro_ref_vec_array() { let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; let h = RefVecArr::new(&alloc, [vec![r(0), r(1)], vec![r(2), r(3)]]).unwrap(); - let ls = h.handle().lists(&alloc).unwrap(); // [BStackRefVec; 2] + let ls = h.handle().get_lists(&alloc).unwrap(); // [BStackRefVec; 2] assert_eq!(ls[0].len().unwrap(), 2); - assert_eq!(ls[0].get(1).unwrap().unwrap().val(stack).unwrap(), 1); - assert_eq!(ls[1].get(0).unwrap().unwrap().val(stack).unwrap(), 2); + assert_eq!(ls[0].get(1).unwrap().unwrap().get_val(stack).unwrap(), 1); + assert_eq!(ls[1].get(0).unwrap().unwrap().get_val(stack).unwrap(), 2); // Ref vecs own the offset arrays but not the targets. h.bstack_drop(&alloc).unwrap(); for l in leaves { - assert!(l.handle().val(stack).unwrap() < 4); + assert!(l.handle().get_val(stack).unwrap() < 4); l.bstack_drop(&alloc).unwrap(); } } @@ -3461,28 +3543,28 @@ fn macro_owned_vec_array() { ]; let g1 = vec![MacroLeaf::new(&alloc, 20).unwrap()]; let h = OwnedVecArr::new(&alloc, [g0, g1], 7).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 7); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); - let gs = h.handle().groups(&alloc).unwrap(); + let gs = h.handle().get_groups(&alloc).unwrap(); assert_eq!(gs[0].len().unwrap(), 2); - assert_eq!(gs[0].get(0).unwrap().unwrap().val(stack).unwrap(), 10); - assert_eq!(gs[1].get(0).unwrap().unwrap().val(stack).unwrap(), 20); + assert_eq!(gs[0].get(0).unwrap().unwrap().get_val(stack).unwrap(), 10); + assert_eq!(gs[1].get(0).unwrap().unwrap().get_val(stack).unwrap(), 20); // Deep clone: distinct child blocks. let clone = h.try_clone_in(&alloc).unwrap(); - let cgs = clone.handle().groups(&alloc).unwrap(); - assert_eq!(cgs[0].get(1).unwrap().unwrap().val(stack).unwrap(), 11); + let cgs = clone.handle().get_groups(&alloc).unwrap(); + assert_eq!(cgs[0].get(1).unwrap().unwrap().get_val(stack).unwrap(), 11); assert_ne!( cgs[0].get(0).unwrap().unwrap().range().start(), gs[0].get(0).unwrap().unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); assert_eq!( - h.handle().groups(&alloc).unwrap()[1] + h.handle().get_groups(&alloc).unwrap()[1] .get(0) .unwrap() .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 20 ); @@ -3501,7 +3583,7 @@ fn macro_option_vec_array() { let alloc = tmp.allocator(); let h = OptVecArr::new(&alloc, [Some(&[1u32, 2][..]), None, Some(&[9][..])]).unwrap(); - let s = h.handle().slots(&alloc).unwrap(); // [Option>; 3] + let s = h.handle().get_slots(&alloc).unwrap(); // [Option>; 3] assert_eq!(s[0].as_ref().unwrap().to_vec().unwrap(), vec![1u32, 2]); assert!(s[1].is_none()); assert_eq!(s[2].as_ref().unwrap().to_vec().unwrap(), vec![9u32]); @@ -3528,18 +3610,18 @@ fn macro_ref_vec_of_array() { let leaves: Vec<_> = (0..4).map(|v| MacroLeaf::new(&alloc, v).unwrap()).collect(); let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; let h = RefVecOfArr::new(&alloc, vec![[r(0), r(1)], [r(2), r(3)]], 7).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 7); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); - let rows = h.handle().rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> + let rows = h.handle().get_rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0].val(stack).unwrap(), 0); - assert_eq!(rows[0][1].val(stack).unwrap(), 1); - assert_eq!(rows[1][0].val(stack).unwrap(), 2); - assert_eq!(rows[1][1].val(stack).unwrap(), 3); + assert_eq!(rows[0][0].get_val(stack).unwrap(), 0); + assert_eq!(rows[0][1].get_val(stack).unwrap(), 1); + assert_eq!(rows[1][0].get_val(stack).unwrap(), 2); + assert_eq!(rows[1][1].get_val(stack).unwrap(), 3); // Clone aliases: same target offsets, but a fresh offset-array data block. let clone = h.try_clone_in(&alloc).unwrap(); - let crows = clone.handle().rows(&alloc).unwrap(); + let crows = clone.handle().get_rows(&alloc).unwrap(); assert_eq!( crows[1][0].range().start(), rows[1][0].range().start() // same target (aliased) @@ -3547,14 +3629,16 @@ fn macro_ref_vec_of_array() { clone.bstack_drop(&alloc).unwrap(); // Original + targets still alive after clone teardown. assert_eq!( - h.handle().rows(&alloc).unwrap()[0][1].val(stack).unwrap(), + h.handle().get_rows(&alloc).unwrap()[0][1] + .get_val(stack) + .unwrap(), 1 ); // Dropping the holder frees only the offset array, not the targets. h.bstack_drop(&alloc).unwrap(); for l in leaves { - assert!(l.handle().val(stack).unwrap() < 4); + assert!(l.handle().get_val(stack).unwrap() < 4); l.bstack_drop(&alloc).unwrap(); } } @@ -3574,23 +3658,25 @@ fn macro_owned_vec_of_array() { let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); let h = OwnedVecOfArr::new(&alloc, vec![[mk(1), mk(2)], [mk(3), mk(4)]], 7).unwrap(); - assert_eq!(h.handle().tag(stack).unwrap(), 7); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); - let rows = h.handle().rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> + let rows = h.handle().get_rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0].val(stack).unwrap(), 1); - assert_eq!(rows[0][1].val(stack).unwrap(), 2); - assert_eq!(rows[1][0].val(stack).unwrap(), 3); - assert_eq!(rows[1][1].val(stack).unwrap(), 4); + assert_eq!(rows[0][0].get_val(stack).unwrap(), 1); + assert_eq!(rows[0][1].get_val(stack).unwrap(), 2); + assert_eq!(rows[1][0].get_val(stack).unwrap(), 3); + assert_eq!(rows[1][1].get_val(stack).unwrap(), 4); // Deep clone: distinct child blocks. let clone = h.try_clone_in(&alloc).unwrap(); - let crows = clone.handle().rows(&alloc).unwrap(); - assert_eq!(crows[1][1].val(stack).unwrap(), 4); + let crows = clone.handle().get_rows(&alloc).unwrap(); + assert_eq!(crows[1][1].get_val(stack).unwrap(), 4); assert_ne!(crows[0][0].range().start(), rows[0][0].range().start()); clone.bstack_drop(&alloc).unwrap(); assert_eq!( - h.handle().rows(&alloc).unwrap()[1][0].val(stack).unwrap(), + h.handle().get_rows(&alloc).unwrap()[1][0] + .get_val(stack) + .unwrap(), 3 ); @@ -3617,9 +3703,9 @@ fn macro_strong_vec_of_array() { let h = StrongVecOfArr::new(&alloc, vec![[a, b]]).unwrap(); assert_eq!(strong_of(stack, a_data), 2); // h + a_keep - let g = h.handle().groups(&alloc).unwrap(); // Vec<[MacroStrongChild; 2]> - assert_eq!(g[0][0].val(stack).unwrap(), 10); - assert_eq!(g[0][1].val(stack).unwrap(), 20); + let g = h.handle().get_groups(&alloc).unwrap(); // Vec<[MacroStrongChild; 2]> + assert_eq!(g[0][0].get_val(stack).unwrap(), 10); + assert_eq!(g[0][1].get_val(stack).unwrap(), 20); // Clone bumps every element's strong count. let clone = h.try_clone_in(&alloc).unwrap(); @@ -3652,14 +3738,17 @@ fn macro_weak_vec_of_array() { ) .unwrap(); - let g = h.handle().groups(&alloc).unwrap(); // Vec<[Option; 2]> - assert_eq!(g[0][0].as_ref().unwrap().handle().val(stack).unwrap(), 1); + let g = h.handle().get_groups(&alloc).unwrap(); // Vec<[Option; 2]> + assert_eq!( + g[0][0].as_ref().unwrap().handle().get_val(stack).unwrap(), + 1 + ); assert!(g[0][1].as_ref().is_some()); drop(g); // release the upgraded strong refs so `a` can actually be freed // Drop `a`'s data: its slot no longer upgrades; `b` still does. drop(a); - let g = h.handle().groups(&alloc).unwrap(); + let g = h.handle().get_groups(&alloc).unwrap(); assert!(g[0][0].is_none()); assert!(g[0][1].is_some()); drop(g); @@ -3699,8 +3788,8 @@ fn macro_enum_owned_vec() { match e.handle().read(&alloc).unwrap() { OwnedVecEnumView::Items(v) => { assert_eq!(v.len().unwrap(), 2); - assert_eq!(v.get(0).unwrap().unwrap().val(stack).unwrap(), 10); - assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20); + assert_eq!(v.get(0).unwrap().unwrap().get_val(stack).unwrap(), 10); + assert_eq!(v.get(1).unwrap().unwrap().get_val(stack).unwrap(), 20); } _ => panic!("expected Items"), } @@ -3709,7 +3798,7 @@ fn macro_enum_owned_vec() { let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { OwnedVecEnumView::Items(v) => { - assert_eq!(v.get(1).unwrap().unwrap().val(stack).unwrap(), 20) + assert_eq!(v.get(1).unwrap().unwrap().get_val(stack).unwrap(), 20) } _ => panic!("expected Items"), } @@ -3718,7 +3807,7 @@ fn macro_enum_owned_vec() { // Move hands back the vector handle. match bstack_move!(e, &alloc).unwrap() { OwnedVecEnumData::Items(v) => { - assert_eq!(v.get(0).unwrap().unwrap().val(stack).unwrap(), 10); + assert_eq!(v.get(0).unwrap().unwrap().get_val(stack).unwrap(), 10); v.bstack_drop().unwrap(); } _ => panic!("expected Items"), @@ -3750,8 +3839,8 @@ fn macro_enum_ref_vec_of_array() { RefVecArrEnumView::Rows(v) => { // Vec<[MacroLeaf; 2]> assert_eq!(v.len(), 2); - assert_eq!(v[0][0].val(stack).unwrap(), 0); - assert_eq!(v[1][1].val(stack).unwrap(), 3); + assert_eq!(v[0][0].get_val(stack).unwrap(), 0); + assert_eq!(v[1][1].get_val(stack).unwrap(), 3); } _ => panic!("expected Rows"), } @@ -3759,7 +3848,7 @@ fn macro_enum_ref_vec_of_array() { // A ref vec of arrays owns nothing: teardown leaves targets alive. e.bstack_drop(&alloc).unwrap(); for l in leaves { - assert!(l.handle().val(stack).unwrap() < 4); + assert!(l.handle().get_val(stack).unwrap() < 4); l.bstack_drop(&alloc).unwrap(); } } @@ -3787,8 +3876,8 @@ fn macro_enum_owned_vec_of_array() { match e.handle().read(&alloc).unwrap() { OwnedVecArrEnumView::Grid(v) => { assert_eq!(v.len(), 2); - assert_eq!(v[0][0].val(stack).unwrap(), 1); - assert_eq!(v[1][1].val(stack).unwrap(), 4); + assert_eq!(v[0][0].get_val(stack).unwrap(), 1); + assert_eq!(v[1][1].get_val(stack).unwrap(), 4); } _ => panic!("expected Grid"), } @@ -3799,7 +3888,7 @@ fn macro_enum_owned_vec_of_array() { // Move rebuilds Vec<[BStackOwned; 2]> and frees the offset array. match bstack_move!(e, &alloc).unwrap() { OwnedVecArrEnumData::Grid(v) => { - assert_eq!(v[1][0].handle().val(stack).unwrap(), 3); + assert_eq!(v[1][0].handle().get_val(stack).unwrap(), 3); for row in v { for m in row { m.bstack_drop(&alloc).unwrap(); @@ -3909,21 +3998,24 @@ fn macro_generic_ref_box() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let r = unsafe { BStackRef::from_range(leaf.handle().range()) }; let b = RefBox::::new(&alloc, r, 7).unwrap(); - assert_eq!(b.handle().tag(stack).unwrap(), 7); - assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!(b.handle().get_tag(stack).unwrap(), 7); + assert_eq!( + b.handle().get_item(stack).unwrap().get_val(stack).unwrap(), + 42 + ); // Clone aliases the ref (same target block); the box itself is fresh. let clone = b.try_clone_in(&alloc).unwrap(); assert_eq!( - clone.handle().item(stack).unwrap().range().start(), - b.handle().item(stack).unwrap().range().start() + clone.handle().get_item(stack).unwrap().range().start(), + b.handle().get_item(stack).unwrap().range().start() ); assert_ne!(clone.handle().range().start(), b.handle().range().start()); clone.bstack_drop(&alloc).unwrap(); // The box references but does not own the leaf: dropping it leaves it alive. b.bstack_drop(&alloc).unwrap(); - assert_eq!(leaf.handle().val(stack).unwrap(), 42); + assert_eq!(leaf.handle().get_val(stack).unwrap(), 42); leaf.bstack_drop(&alloc).unwrap(); } @@ -3956,7 +4048,7 @@ fn macro_generic_move_cast() { let back = bstack_cast!(sl as RefBox) .unwrap() .expect("same tag"); - assert_eq!(back.item(stack).unwrap().val(stack).unwrap(), 9); + assert_eq!(back.get_item(stack).unwrap().get_val(stack).unwrap(), 9); // bstack_move!: hand out the ref + pod fields, freeing the box shell. let (item, tag) = bstack_move!(b, &alloc).unwrap(); @@ -3983,7 +4075,7 @@ fn macro_generic_strong_box() { let b = StrongBox::::new(&alloc, c, 5).unwrap(); assert_eq!(strong_of(stack, data), 2); // b + keep - assert_eq!(b.handle().tag(stack).unwrap(), 5); + assert_eq!(b.handle().get_tag(stack).unwrap(), 5); // Deep-cloning the box bumps the shared child's strong count. let clone = b.try_clone_in(&alloc).unwrap(); @@ -4011,12 +4103,12 @@ fn macro_generic_weak_box() { let b = WeakBox::::new(&alloc).unwrap(); b.handle().set_item(&alloc, c.downgrade().unwrap()).unwrap(); - let up = b.handle().item(&alloc).unwrap().expect("alive"); - assert_eq!(up.handle().val(stack).unwrap(), 7); + let up = b.handle().get_item(&alloc).unwrap().expect("alive"); + assert_eq!(up.handle().get_val(stack).unwrap(), 7); drop(up); drop(c); // sole strong owner gone → can't upgrade - assert!(b.handle().item(&alloc).unwrap().is_none()); + assert!(b.handle().get_item(&alloc).unwrap().is_none()); b.bstack_drop(&alloc).unwrap(); } @@ -4035,20 +4127,26 @@ fn macro_generic_owned_box() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let b = OwnedBox::::new(&alloc, leaf, 7).unwrap(); - assert_eq!(b.handle().tag(stack).unwrap(), 7); - assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!(b.handle().get_tag(stack).unwrap(), 7); + assert_eq!( + b.handle().get_item(stack).unwrap().get_val(stack).unwrap(), + 42 + ); // Deep clone: the owned child is a FRESH block (distinct offset), same value. let clone = b.try_clone_in(&alloc).unwrap(); - let citem = clone.handle().item(stack).unwrap(); - assert_eq!(citem.val(stack).unwrap(), 42); + let citem = clone.handle().get_item(stack).unwrap(); + assert_eq!(citem.get_val(stack).unwrap(), 42); assert_ne!( citem.range().start(), - b.handle().item(stack).unwrap().range().start() + b.handle().get_item(stack).unwrap().range().start() ); clone.bstack_drop(&alloc).unwrap(); // Original child survives the clone's teardown. - assert_eq!(b.handle().item(stack).unwrap().val(stack).unwrap(), 42); + assert_eq!( + b.handle().get_item(stack).unwrap().get_val(stack).unwrap(), + 42 + ); // Dropping the box frees its owned child — reclaimed with no leak. b.bstack_drop(&alloc).unwrap(); @@ -4097,8 +4195,8 @@ fn macro_generic_owns_enum() { // works only because the enum's clone hook is a `BStackBlock` trait method // (reachable through the generic `T` bound), not a generated inherent method. let clone = b.try_clone_in(&alloc).unwrap(); - match clone.handle().e(stack).unwrap().read(&alloc).unwrap() { - ArrEnumView::Leaves(a) => assert_eq!(a[1].val(stack).unwrap(), 2), + match clone.handle().get_e(stack).unwrap().read(&alloc).unwrap() { + ArrEnumView::Leaves(a) => assert_eq!(a[1].get_val(stack).unwrap(), 2), _ => panic!("expected Leaves"), } clone.bstack_drop(&alloc).unwrap(); @@ -4123,12 +4221,12 @@ fn macro_generic_pod_box() { let stack = alloc.stack(); let b = PodBoxG::::new(&alloc, 42u32, 7).unwrap(); - assert_eq!(b.handle().item(stack).unwrap(), 42); - assert_eq!(b.handle().tag(stack).unwrap(), 7); + assert_eq!(b.handle().get_item(stack).unwrap(), 42); + assert_eq!(b.handle().get_tag(stack).unwrap(), 7); // Clone byte-copies the POD value. let clone = b.try_clone_in(&alloc).unwrap(); - assert_eq!(clone.handle().item(stack).unwrap(), 42); + assert_eq!(clone.handle().get_item(stack).unwrap(), 42); clone.bstack_drop(&alloc).unwrap(); // Distinct type args → distinct on-disk layout → distinct tags. @@ -4158,10 +4256,15 @@ fn macro_generic_emb_box() { // EmbChild owns a MacroLeaf; embedding inlines the whole child on disk. let child = EmbChild::new(&alloc, MacroLeaf::new(&alloc, 10).unwrap(), 1).unwrap(); let b = EmbBoxG::::new(&alloc, child, 99).unwrap(); - assert_eq!(b.handle().tag(stack).unwrap(), 99); + assert_eq!(b.handle().get_tag(stack).unwrap(), 99); // Accessor: an EmbChild handle into the inline slot (pure offset math). assert_eq!( - b.handle().item().leaf(stack).unwrap().val(stack).unwrap(), + b.handle() + .get_item() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), 10 ); @@ -4171,23 +4274,42 @@ fn macro_generic_emb_box() { assert_eq!( clone .handle() - .item() - .leaf(stack) + .get_item() + .get_leaf(stack) .unwrap() - .val(stack) + .get_val(stack) .unwrap(), 10 ); assert_ne!( - clone.handle().item().leaf(stack).unwrap().range().start(), - b.handle().item().leaf(stack).unwrap().range().start() + clone + .handle() + .get_item() + .get_leaf(stack) + .unwrap() + .range() + .start(), + b.handle() + .get_item() + .get_leaf(stack) + .unwrap() + .range() + .start() ); clone.bstack_drop(&alloc).unwrap(); // Move re-homes the embedded child to a fresh standalone block. let (moved, tag) = bstack_move!(b, &alloc).unwrap(); assert_eq!(tag, 99); - assert_eq!(moved.handle().leaf(stack).unwrap().val(stack).unwrap(), 10); + assert_eq!( + moved + .handle() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 10 + ); moved.bstack_drop(&alloc).unwrap(); } @@ -4212,14 +4334,14 @@ fn macro_generic_enum_owned() { let leaf = MacroLeaf::new(&alloc, 42).unwrap(); let e = BoxEnumG::::new(&alloc, BoxEnumGData::Item(leaf)).unwrap(); match e.handle().read(&alloc).unwrap() { - BoxEnumGView::Item(l) => assert_eq!(l.val(stack).unwrap(), 42), + BoxEnumGView::Item(l) => assert_eq!(l.get_val(stack).unwrap(), 42), _ => panic!("expected Item"), } // Deep clone recurses into the owned child through T's BStackBlock hooks. let clone = e.try_clone_in(&alloc).unwrap(); match clone.handle().read(&alloc).unwrap() { - BoxEnumGView::Item(l) => assert_eq!(l.val(stack).unwrap(), 42), + BoxEnumGView::Item(l) => assert_eq!(l.get_val(stack).unwrap(), 42), _ => panic!("expected Item"), } clone.bstack_drop(&alloc).unwrap(); @@ -4233,7 +4355,7 @@ fn macro_generic_enum_owned() { // Move yields the owned child. match bstack_move!(e, &alloc).unwrap() { BoxEnumGData::Item(owned) => { - assert_eq!(owned.handle().val(stack).unwrap(), 42); + assert_eq!(owned.handle().get_val(stack).unwrap(), 42); owned.bstack_drop(&alloc).unwrap(); } _ => panic!("expected Item"), @@ -4302,10 +4424,10 @@ fn macro_generic_const_ref_array() { let leaves: Vec<_> = (0..3).map(|v| MacroLeaf::new(&alloc, v).unwrap()).collect(); let r = |i: usize| unsafe { BStackRef::from_range(leaves[i].handle().range()) }; let b = RefArrN::::new(&alloc, [r(0), r(1), r(2)], 9).unwrap(); - assert_eq!(b.handle().tag(stack).unwrap(), 9); - let arr = b.handle().arr(stack).unwrap(); // [MacroLeaf; 3] - assert_eq!(arr[0].val(stack).unwrap(), 0); - assert_eq!(arr[2].val(stack).unwrap(), 2); + assert_eq!(b.handle().get_tag(stack).unwrap(), 9); + let arr = b.handle().get_arr(stack).unwrap(); // [MacroLeaf; 3] + assert_eq!(arr[0].get_val(stack).unwrap(), 0); + assert_eq!(arr[2].get_val(stack).unwrap(), 2); // Distinct N → distinct on-disk layout → distinct tags. assert_ne!( @@ -4316,7 +4438,7 @@ fn macro_generic_const_ref_array() { // A ref array owns nothing: dropping leaves the targets alive. b.bstack_drop(&alloc).unwrap(); for l in leaves { - assert!(l.handle().val(stack).unwrap() < 3); + assert!(l.handle().get_val(stack).unwrap() < 3); l.bstack_drop(&alloc).unwrap(); } } @@ -4330,11 +4452,11 @@ fn macro_generic_const_owned_pod_array() { // Owned const array: deep-clone + teardown reuse the concrete paths. let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); let o = OwnArrN::::new(&alloc, [mk(10), mk(20)]).unwrap(); - let a = o.handle().arr(stack).unwrap(); - assert_eq!(a[1].val(stack).unwrap(), 20); + let a = o.handle().get_arr(stack).unwrap(); + assert_eq!(a[1].get_val(stack).unwrap(), 20); let clone = o.try_clone_in(&alloc).unwrap(); assert_ne!( - clone.handle().arr(stack).unwrap()[0].range().start(), + clone.handle().get_arr(stack).unwrap()[0].range().start(), a[0].range().start() ); clone.bstack_drop(&alloc).unwrap(); @@ -4342,8 +4464,8 @@ fn macro_generic_const_owned_pod_array() { // POD const array. let p = PodArrN::<4>::new(&alloc, [1u16, 2, 3, 4], 7).unwrap(); - assert_eq!(p.handle().xs(stack).unwrap(), [1u16, 2, 3, 4]); - assert_eq!(p.handle().tag(stack).unwrap(), 7); + assert_eq!(p.handle().get_xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().get_tag(stack).unwrap(), 7); p.bstack_drop(&alloc).unwrap(); } @@ -4365,17 +4487,17 @@ fn stdlib_cow_borrowed_into_owned_deep_copies() { assert!(cow.is_borrowed()); // Reads go through the borrowed block, at its address. - assert_eq!(cow.handle().val(stack).unwrap(), 7); + assert_eq!(cow.handle().get_val(stack).unwrap(), 7); assert_eq!(cow.range().start(), base_start); // into_owned deep-copies: a fresh block at a different address, same value. let owned = cow.into_owned(&alloc).unwrap(); assert_ne!(owned.handle().range().start(), base_start); - assert_eq!(owned.handle().val(stack).unwrap(), 7); + assert_eq!(owned.handle().get_val(stack).unwrap(), 7); owned.bstack_drop(&alloc).unwrap(); // The borrowed source is untouched. - assert_eq!(base.handle().val(stack).unwrap(), 7); + assert_eq!(base.handle().get_val(stack).unwrap(), 7); base.bstack_drop(&alloc).unwrap(); } @@ -4393,7 +4515,7 @@ fn stdlib_cow_owned_into_owned_is_free() { // Already owned: into_owned hands back the *same* block, no copy. let owned = cow.into_owned(&alloc).unwrap(); assert_eq!(owned.handle().range().start(), start); - assert_eq!(owned.handle().val(stack).unwrap(), 5); + assert_eq!(owned.handle().get_val(stack).unwrap(), 5); owned.bstack_drop(&alloc).unwrap(); } @@ -4412,7 +4534,7 @@ fn stdlib_cow_to_mut_copies_then_owns() { { let m = cow.to_mut(&alloc).unwrap(); assert_ne!(m.handle().range().start(), base_start); - assert_eq!(m.handle().val(stack).unwrap(), 9); + assert_eq!(m.handle().get_val(stack).unwrap(), 9); } assert!(cow.is_owned()); @@ -4423,7 +4545,7 @@ fn stdlib_cow_to_mut_copies_then_owns() { // Dropping the Cow frees only the copy; the borrowed source survives. cow.bstack_drop(&alloc).unwrap(); - assert_eq!(base.handle().val(stack).unwrap(), 9); + assert_eq!(base.handle().get_val(stack).unwrap(), 9); base.bstack_drop(&alloc).unwrap(); } @@ -4439,7 +4561,7 @@ fn stdlib_cow_borrowed_drop_frees_nothing() { // Dropping a borrowed Cow has no claim on the target. cow.bstack_drop(&alloc).unwrap(); - assert_eq!(base.handle().val(stack).unwrap(), 3); + assert_eq!(base.handle().get_val(stack).unwrap(), 3); base.bstack_drop(&alloc).unwrap(); } @@ -4545,19 +4667,24 @@ fn stdlib_box_composes_as_owned_field() { let inner = BStackBox::new(&alloc, 500u64).unwrap(); let holder = BoxHolder::new(&alloc, inner, 9).unwrap(); assert_eq!( - holder.handle().boxed(stack).unwrap().get(stack).unwrap(), + holder + .handle() + .get_boxed(stack) + .unwrap() + .get(stack) + .unwrap(), 500 ); - assert_eq!(holder.handle().tag(stack).unwrap(), 9); + assert_eq!(holder.handle().get_tag(stack).unwrap(), 9); // Deep-cloning the parent recurses into the child box (fresh child block). let clone = holder.try_clone_in(&alloc).unwrap(); assert_ne!( - clone.handle().boxed(stack).unwrap().range().start(), - holder.handle().boxed(stack).unwrap().range().start(), + clone.handle().get_boxed(stack).unwrap().range().start(), + holder.handle().get_boxed(stack).unwrap().range().start(), ); assert_eq!( - clone.handle().boxed(stack).unwrap().get(stack).unwrap(), + clone.handle().get_boxed(stack).unwrap().get(stack).unwrap(), 500 ); @@ -4595,7 +4722,7 @@ fn list_values(list: &BStackLinkedList, stack: &BStack) -> Vec { list.to_vec(stack) .unwrap() .iter() - .map(|h| h.val(stack).unwrap()) + .map(|h| h.get_val(stack).unwrap()) .collect() } @@ -4614,12 +4741,18 @@ fn stdlib_list_push_back_pop_front() { } assert_eq!(list.len(stack).unwrap(), 3); assert_eq!(list_values(&list, stack), vec![1, 2, 3]); - assert_eq!(list.front(stack).unwrap().unwrap().val(stack).unwrap(), 1); - assert_eq!(list.back(stack).unwrap().unwrap().val(stack).unwrap(), 3); + assert_eq!( + list.front(stack).unwrap().unwrap().get_val(stack).unwrap(), + 1 + ); + assert_eq!( + list.back(stack).unwrap().unwrap().get_val(stack).unwrap(), + 3 + ); // FIFO drain from the front. let a = list.pop_front(&alloc).unwrap().unwrap(); - assert_eq!(a.handle().val(stack).unwrap(), 1); + assert_eq!(a.handle().get_val(stack).unwrap(), 1); a.bstack_drop(&alloc).unwrap(); assert_eq!(list.len(stack).unwrap(), 2); assert_eq!(list_values(&list, stack), vec![2, 3]); @@ -4643,11 +4776,11 @@ fn stdlib_list_both_ends() { assert_eq!(list_values(&list, stack), vec![1, 2, 3]); let back = list.pop_back(&alloc).unwrap().unwrap(); - assert_eq!(back.handle().val(stack).unwrap(), 3); + assert_eq!(back.handle().get_val(stack).unwrap(), 3); back.bstack_drop(&alloc).unwrap(); let front = list.pop_front(&alloc).unwrap().unwrap(); - assert_eq!(front.handle().val(stack).unwrap(), 1); + assert_eq!(front.handle().get_val(stack).unwrap(), 1); front.bstack_drop(&alloc).unwrap(); assert_eq!(list_values(&list, stack), vec![2]); @@ -4746,7 +4879,7 @@ fn stdlib_list_concurrent_push_pop() { .to_vec(alloc.stack()) .unwrap() .iter() - .map(|h| h.val(alloc.stack()).unwrap()) + .map(|h| h.get_val(alloc.stack()).unwrap()) .collect(); seen.sort_unstable(); assert_eq!(seen.len() as u64, total); @@ -4782,7 +4915,7 @@ fn deque_values(dq: &BStackDeque, stack: &BStack) -> Vec { dq.to_vec(stack) .unwrap() .iter() - .map(|h| h.val(stack).unwrap()) + .map(|h| h.get_val(stack).unwrap()) .collect() } @@ -4803,13 +4936,13 @@ fn stdlib_deque_push_back_grows() { assert_eq!(dq.len(stack).unwrap(), 10); assert!(dq.capacity(stack).unwrap() >= 10); assert_eq!(deque_values(&dq, stack), (0..10).collect::>()); - assert_eq!(dq.front(stack).unwrap().unwrap().val(stack).unwrap(), 0); - assert_eq!(dq.back(stack).unwrap().unwrap().val(stack).unwrap(), 9); + assert_eq!(dq.front(stack).unwrap().unwrap().get_val(stack).unwrap(), 0); + assert_eq!(dq.back(stack).unwrap().unwrap().get_val(stack).unwrap(), 9); // FIFO drain from the front. for v in 0..10u32 { let x = dq.pop_front(&alloc).unwrap().unwrap(); - assert_eq!(x.handle().val(stack).unwrap(), v); + assert_eq!(x.handle().get_val(stack).unwrap(), v); x.bstack_drop(&alloc).unwrap(); } assert!(dq.is_empty(stack).unwrap()); @@ -4864,11 +4997,11 @@ fn stdlib_deque_both_ends() { assert_eq!(deque_values(&dq, stack), vec![1, 2, 3]); let back = dq.pop_back(&alloc).unwrap().unwrap(); - assert_eq!(back.handle().val(stack).unwrap(), 3); + assert_eq!(back.handle().get_val(stack).unwrap(), 3); back.bstack_drop(&alloc).unwrap(); let front = dq.pop_front(&alloc).unwrap().unwrap(); - assert_eq!(front.handle().val(stack).unwrap(), 1); + assert_eq!(front.handle().get_val(stack).unwrap(), 1); front.bstack_drop(&alloc).unwrap(); assert_eq!(deque_values(&dq, stack), vec![2]); @@ -5003,11 +5136,11 @@ fn stdlib_map_insert_get_remove() { ); assert_eq!(map.len(stack).unwrap(), 2); assert_eq!( - map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), + map.get(stack, &7).unwrap().unwrap().get_val(stack).unwrap(), 700 ); assert_eq!( - map.get(stack, &9).unwrap().unwrap().val(stack).unwrap(), + map.get(stack, &9).unwrap().unwrap().get_val(stack).unwrap(), 900 ); assert!(map.contains_key(stack, &7).unwrap()); @@ -5018,17 +5151,17 @@ fn stdlib_map_insert_get_remove() { .insert(&alloc, 7, MacroLeaf::new(&alloc, 701).unwrap()) .unwrap() .unwrap(); - assert_eq!(old.handle().val(stack).unwrap(), 700); + assert_eq!(old.handle().get_val(stack).unwrap(), 700); old.bstack_drop(&alloc).unwrap(); assert_eq!(map.len(stack).unwrap(), 2); assert_eq!( - map.get(stack, &7).unwrap().unwrap().val(stack).unwrap(), + map.get(stack, &7).unwrap().unwrap().get_val(stack).unwrap(), 701 ); // Remove returns the value (owned); the key is then absent. let removed = map.remove(&alloc, &9).unwrap().unwrap(); - assert_eq!(removed.handle().val(stack).unwrap(), 900); + assert_eq!(removed.handle().get_val(stack).unwrap(), 900); removed.bstack_drop(&alloc).unwrap(); assert!(map.get(stack, &9).unwrap().is_none()); assert!(map.remove(&alloc, &9).unwrap().is_none()); @@ -5056,7 +5189,7 @@ fn stdlib_map_grows_and_keeps_all() { // Every key survives the rehashes with its value. for k in 0..100u32 { assert_eq!( - map.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + map.get(stack, &k).unwrap().unwrap().get_val(stack).unwrap(), k * 10 ); } @@ -5075,7 +5208,7 @@ fn stdlib_map_grows_and_keeps_all() { if k % 2 == 0 { assert!(got.is_none()); } else { - assert_eq!(got.unwrap().val(stack).unwrap(), k * 10); + assert_eq!(got.unwrap().get_val(stack).unwrap(), k * 10); } } @@ -5096,8 +5229,14 @@ fn stdlib_map_pod_struct_key() { .unwrap(); map.insert(&alloc, b, MacroLeaf::new(&alloc, 22).unwrap()) .unwrap(); - assert_eq!(map.get(stack, &a).unwrap().unwrap().val(stack).unwrap(), 11); - assert_eq!(map.get(stack, &b).unwrap().unwrap().val(stack).unwrap(), 22); + assert_eq!( + map.get(stack, &a).unwrap().unwrap().get_val(stack).unwrap(), + 11 + ); + assert_eq!( + map.get(stack, &b).unwrap().unwrap().get_val(stack).unwrap(), + 22 + ); assert!( map.get(stack, &Point3 { x: 9, y: 9, z: 9 }) .unwrap() @@ -5149,7 +5288,12 @@ fn stdlib_map_deep_clone_is_independent() { let clone = map.try_clone_in(&alloc).unwrap(); for k in 0..8u32 { assert_eq!( - clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + clone + .get(stack, &k) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), k + 100 ); } @@ -5168,7 +5312,7 @@ fn stdlib_map_deep_clone_is_independent() { .unwrap(); assert!(clone.get(stack, &3).unwrap().is_none()); assert_eq!( - map.get(stack, &3).unwrap().unwrap().val(stack).unwrap(), + map.get(stack, &3).unwrap().unwrap().get_val(stack).unwrap(), 103 ); @@ -5184,7 +5328,7 @@ fn tree_pairs(tree: &BStackBTreeMap, stack: &BStack) -> Vec<(u32 tree.to_vec(stack) .unwrap() .iter() - .map(|(k, v)| (*k, v.val(stack).unwrap())) + .map(|(k, v)| (*k, v.get_val(stack).unwrap())) .collect() } @@ -5213,7 +5357,11 @@ fn stdlib_tree_insert_get_ordered() { // Every key present with its value. for k in 0..50u32 { assert_eq!( - tree.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + tree.get(stack, &k) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), k * 10 ); } @@ -5231,11 +5379,15 @@ fn stdlib_tree_insert_get_ordered() { .insert(&alloc, 25, MacroLeaf::new(&alloc, 9999).unwrap()) .unwrap() .unwrap(); - assert_eq!(old.handle().val(stack).unwrap(), 250); + assert_eq!(old.handle().get_val(stack).unwrap(), 250); old.bstack_drop(&alloc).unwrap(); assert_eq!(tree.len(stack).unwrap(), 50); assert_eq!( - tree.get(stack, &25).unwrap().unwrap().val(stack).unwrap(), + tree.get(stack, &25) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), 9999 ); @@ -5268,7 +5420,7 @@ fn stdlib_tree_large_key_spills() { tree.get(stack, &BigKey(k)) .unwrap() .unwrap() - .val(stack) + .get_val(stack) .unwrap(), i as u32 ); @@ -5318,7 +5470,12 @@ fn stdlib_tree_deep_clone_is_independent() { let clone = tree.try_clone_in(&alloc).unwrap(); for k in 0..30u32 { assert_eq!( - clone.get(stack, &k).unwrap().unwrap().val(stack).unwrap(), + clone + .get(stack, &k) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), k + 100 ); } @@ -5336,11 +5493,20 @@ fn stdlib_tree_deep_clone_is_independent() { .unwrap(); // (`clone` and `tree` share no nodes: the clone deep-copied every node.) assert_eq!( - clone.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), + clone + .get(stack, &10) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), 110 ); assert_eq!( - tree.get(stack, &10).unwrap().unwrap().val(stack).unwrap(), + tree.get(stack, &10) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), 7 ); @@ -5371,7 +5537,7 @@ fn stdlib_tree_concurrent_readers() { tree.get(alloc.stack(), &k) .unwrap() .unwrap() - .val(alloc.stack()) + .get_val(alloc.stack()) .unwrap(), k * 2 ); @@ -5415,7 +5581,7 @@ fn stdlib_map_concurrent_insert() { map.get(alloc.stack(), &k) .unwrap() .unwrap() - .val(alloc.stack()) + .get_val(alloc.stack()) .unwrap(), k ); @@ -5662,7 +5828,9 @@ fn stdlib_bloom_guards_a_map() { if !bloom.contains(stack, &k).unwrap() { return None; } - map.get(stack, &k).unwrap().map(|v| v.val(stack).unwrap()) + map.get(stack, &k) + .unwrap() + .map(|v| v.get_val(stack).unwrap()) }; for k in 0..40u32 { @@ -5833,7 +6001,7 @@ fn stdlib_tree_remove_rebalances() { // Remove every even key (heavy borrow/merge across a multi-level tree). for k in (0..200u32).step_by(2) { let v = tree.remove(&alloc, &k).unwrap().unwrap(); - assert_eq!(v.handle().val(stack).unwrap(), k * 10); + assert_eq!(v.handle().get_val(stack).unwrap(), k * 10); v.bstack_drop(&alloc).unwrap(); } assert_eq!(tree.len(stack).unwrap(), 100); @@ -5844,7 +6012,7 @@ fn stdlib_tree_remove_rebalances() { if k % 2 == 0 { assert!(g.is_none()); } else { - assert_eq!(g.unwrap().val(stack).unwrap(), k * 10); + assert_eq!(g.unwrap().get_val(stack).unwrap(), k * 10); } } let keys: Vec = tree @@ -5871,7 +6039,11 @@ fn stdlib_tree_remove_rebalances() { tree.insert(&alloc, 42, MacroLeaf::new(&alloc, 420).unwrap()) .unwrap(); assert_eq!( - tree.get(stack, &42).unwrap().unwrap().val(stack).unwrap(), + tree.get(stack, &42) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), 420 ); @@ -5977,7 +6149,7 @@ fn stdlib_heap_pop_ascending() { for expected in 0..50u32 { let (k, v) = heap.pop(&alloc).unwrap().unwrap(); assert_eq!(k, expected); - assert_eq!(v.handle().val(stack).unwrap(), expected * 10); + assert_eq!(v.handle().get_val(stack).unwrap(), expected * 10); v.bstack_drop(&alloc).unwrap(); } assert!(heap.is_empty(stack).unwrap()); @@ -6077,7 +6249,7 @@ fn stdlib_deque_iter() { } let mut got = Vec::new(); for r in dq.iter(stack).unwrap() { - got.push(r.unwrap().val(stack).unwrap()); + got.push(r.unwrap().get_val(stack).unwrap()); } assert_eq!(got, (0..10u32).collect::>()); dq.bstack_drop(&alloc).unwrap(); @@ -6095,7 +6267,7 @@ fn stdlib_list_iter() { } let mut got = Vec::new(); for r in list.iter(stack).unwrap() { - got.push(r.unwrap().val(stack).unwrap()); + got.push(r.unwrap().get_val(stack).unwrap()); } assert_eq!(got, (0..6u32).collect::>()); list.bstack_drop(&alloc).unwrap(); @@ -6114,7 +6286,7 @@ fn stdlib_map_iter() { let mut got = Vec::new(); for r in map.iter(stack).unwrap() { let (k, v) = r.unwrap(); - got.push((k, v.val(stack).unwrap())); + got.push((k, v.get_val(stack).unwrap())); } got.sort_unstable(); // unordered iteration assert_eq!(got, (0..20u32).map(|k| (k, k * 10)).collect::>()); @@ -6154,7 +6326,7 @@ fn stdlib_tree_iter_and_range() { let mut got = Vec::new(); for r in tree.iter(stack).unwrap() { let (k, v) = r.unwrap(); - got.push((k, v.val(stack).unwrap())); + got.push((k, v.get_val(stack).unwrap())); } assert_eq!(got, (0..40u32).map(|k| (k, k * 10)).collect::>()); @@ -6215,7 +6387,7 @@ fn stdlib_map_entry() { .get_or_insert_with(&alloc, 7, || MacroLeaf::new(&alloc, 70)) .unwrap(); assert!(inserted); - assert_eq!(v.val(stack).unwrap(), 70); + assert_eq!(v.get_val(stack).unwrap(), 70); // Present: single probe, f NOT called, value unchanged. map.insert(&alloc, 5, MacroLeaf::new(&alloc, 50).unwrap()) @@ -6229,14 +6401,14 @@ fn stdlib_map_entry() { .unwrap(); assert!(!inserted); assert!(!called.get(), "f must not run on a hit"); - assert_eq!(v.val(stack).unwrap(), 50); + assert_eq!(v.get_val(stack).unwrap(), 50); // Eager get_or_insert frees the unused default on a hit. let (v, inserted) = map .get_or_insert(&alloc, 5, MacroLeaf::new(&alloc, 111).unwrap()) .unwrap(); assert!(!inserted); - assert_eq!(v.val(stack).unwrap(), 50); + assert_eq!(v.get_val(stack).unwrap(), 50); map.bstack_drop(&alloc).unwrap(); } @@ -6279,7 +6451,7 @@ fn stdlib_tree_entry() { .get_or_insert_with(&alloc, 7, || MacroLeaf::new(&alloc, 70)) .unwrap(); assert!(inserted); - assert_eq!(v.val(stack).unwrap(), 70); + assert_eq!(v.get_val(stack).unwrap(), 70); let called = std::cell::Cell::new(false); let (v, inserted) = tree @@ -6290,7 +6462,7 @@ fn stdlib_tree_entry() { .unwrap(); assert!(!inserted); assert!(!called.get()); - assert_eq!(v.val(stack).unwrap(), 70); + assert_eq!(v.get_val(stack).unwrap(), 70); tree.bstack_drop(&alloc).unwrap(); } @@ -6348,7 +6520,14 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { // Source intact; a real (unfaulted) clone still succeeds, reusing the space. let cl = src.try_clone_in(&alloc).unwrap(); - assert_eq!(cl.handle().child(stack).unwrap().val(stack).unwrap(), 7); + assert_eq!( + cl.handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 7 + ); cl.bstack_drop(&alloc).unwrap(); src.bstack_drop(&alloc).unwrap(); } From 9df6fcd31b6475e8702bab8842e2bf4b68609840 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 18:28:57 -0700 Subject: [PATCH 105/140] Add raw accessor and ref/pod setters --- bstack_raii/derive/src/block.rs | 138 +++++++++++++++++++++++++++++++- bstack_raii/src/tests.rs | 91 +++++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 35a119e..0570f92 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1639,8 +1639,32 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result clone_stmts.push(cs); } - // Accessor. + // Accessor: the `get_` reader, the unsafe `raw__slice` place, + // and — for `#[bstack_mut]` POD / `#[bstack_ref]` fields — a `set_`. accessors.push(accessor(vis, fname, inner_ty, &on_disk_ty, kind, nullable)); + accessors.push(raw_slice_accessor(vis, fname, inner_ty, &on_disk_ty, kind)); + if is_bstack_mut(&field.attrs) { + match kind { + Kind::Pod | Kind::Ref => { + accessors.push(set_accessor( + vis, + fname, + inner_ty, + &on_disk_ty, + kind, + nullable, + )); + } + // Weak fields already have a `set_` (the weak setter). + Kind::Weak => {} + Kind::Owned | Kind::Strong | Kind::Embed => { + return Err(Error::new_spanned( + field, + "#[bstack_mut] is currently only supported on POD and #[bstack_ref] fields", + )); + } + } + } // Constructor. Weak fields are not parameters — they start null and are // wired afterwards via the generated `set_`. @@ -2840,6 +2864,109 @@ fn accessor( } } +/// The unsafe raw-place accessor `raw__slice`: a [`BStackSlice`] over the +/// field's **inline** storage within the record — the value's own bytes for a POD +/// field, or the `u64` offset slot for a pointer field +/// (`#[bstack_owned/strong/weak/ref]`; `#[embed]` yields the inline child's bytes). +/// +/// It bypasses the typed [`accessor`]/setter, so writing through the returned slice +/// can violate the field's invariants (a bogus/aliased offset for a pointer field, +/// an un-freed owned target); it is therefore `unsafe`. Reads are always valid. +fn raw_slice_accessor( + vis: &syn::Visibility, + fname: &Ident, + inner_ty: &Type, + on_disk: &TokenStream, + kind: Kind, +) -> TokenStream { + let raw = format_ident!("raw_{}_slice", fname); + // The field's inline byte length: a POD value, an `#[embed]` child's on-disk + // form, or a single `u64` offset slot for the pointer kinds. + let len = match kind { + Kind::Pod => quote!(::core::mem::size_of::<#inner_ty>() as u64), + Kind::Embed => { + quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64) + } + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => quote!(8u64), + }; + quote! { + /// Raw [`BStackSlice`] over this field's inline storage. + /// + /// # Safety + /// Bypasses the field's typed invariants: writing bytes through the returned + /// slice can corrupt a pointer field (a bogus or aliased offset) or leak an + /// owned target. Reads are always valid. + #vis unsafe fn #raw<'__s>( + &self, + stack: &'__s ::bstack_raii::BStack, + ) -> ::bstack_raii::BStackSlice<'__s> { + let __off = self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64; + // SAFETY: `[__off, __off + #len)` is exactly this field's inline region + // within the record, which is a live allocation of the backing stack. + unsafe { + ::bstack_raii::BStackSlice::from_raw_range( + stack, + ::bstack_raii::BStackRange::new(__off, #len), + ) + } + } + } +} + +/// The `set_` mutator, generated only for `#[bstack_mut]` POD and +/// `#[bstack_ref]` fields (both a single atomic `set` of the field's inline +/// storage — a POD field owns no children, and a ref does not own its target, so +/// neither frees anything). Ownership-bearing kinds (`owned`/`strong`/`embed`) are +/// rejected upstream, since their setter must free/refcount the old target. +fn set_accessor( + vis: &syn::Visibility, + fname: &Ident, + inner_ty: &Type, + on_disk: &TokenStream, + kind: Kind, + nullable: bool, +) -> TokenStream { + let setter = format_ident!("set_{}", fname); + let off = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); + match kind { + Kind::Pod => quote! { + /// Overwrite this POD field, as one crash-atomic `set`. + #vis fn #setter( + &self, + stack: &::bstack_raii::BStack, + value: #inner_ty, + ) -> ::std::io::Result<()> { + stack.set(#off, ::bstack_raii::bytemuck::bytes_of(&value)) + } + }, + Kind::Ref if nullable => quote! { + /// Repoint this `#[bstack_ref]` field (`None` writes the `0` null niche). + /// The ref borrows — it does not own — its target, so nothing is freed. + #vis fn #setter( + &self, + stack: &::bstack_raii::BStack, + value: ::core::option::Option<::bstack_raii::BStackRef<#inner_ty>>, + ) -> ::std::io::Result<()> { + let __ptr = value.map_or(0u64, |__r| __r.into_range().start()); + stack.set(#off, __ptr.to_le_bytes()) + } + }, + Kind::Ref => quote! { + /// Repoint this `#[bstack_ref]` field. The ref borrows — it does not own + /// — its target, so nothing is freed. + #vis fn #setter( + &self, + stack: &::bstack_raii::BStack, + value: ::bstack_raii::BStackRef<#inner_ty>, + ) -> ::std::io::Result<()> { + stack.set(#off, value.into_range().start().to_le_bytes()) + } + }, + // Only POD / ref reach here (the caller filters). + _ => quote!(), + } +} + /// Generate `(param, prep, init)` for one constructor field. Not called for /// `#[bstack_weak]` fields. `nullable` fields take an `Option` (None => 0). fn ctor_field( @@ -3575,6 +3702,15 @@ fn classify(field: &syn::Field) -> syn::Result { classify_attrs(&field.attrs) } +/// Whether a field is annotated `#[bstack_mut]`, opting it into a generated +/// `set_` (currently honoured for POD and `#[bstack_ref]` fields). +fn is_bstack_mut(attrs: &[syn::Attribute]) -> bool { + attrs + .iter() + .filter_map(|a| a.path().get_ident()) + .any(|id| id == "bstack_mut") +} + // =========================================================================== // #[bstack_enum] — a tagged union block // =========================================================================== diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 15c7f0b..bf59182 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6685,3 +6685,94 @@ fn registry_paths_persist_and_live_host_round_trips() { let _ = std::fs::remove_file(&ghost); } + +// -------------------------------------------------------------------------- +// #[bstack_mut]: generated set_ + raw__slice (POD and ref) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct MutPod { + #[bstack_mut] + n: u64, + tag: u32, // not mutable — no set_tag generated +} + +#[bstack_block] +struct MutRef { + #[bstack_mut] + #[bstack_ref] + target: MacroLeaf, +} + +#[test] +fn macro_bstack_mut_pod_set_and_raw_slice() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let b = MutPod::new(&alloc, 10, 7).unwrap(); + assert_eq!(b.handle().get_n(stack).unwrap(), 10); + assert_eq!(b.handle().get_tag(stack).unwrap(), 7); + + // Generated setter: one atomic overwrite. + b.handle().set_n(stack, 42).unwrap(); + assert_eq!(b.handle().get_n(stack).unwrap(), 42); + // `tag` (no #[bstack_mut]) is untouched — and there is no `set_tag` to call. + assert_eq!(b.handle().get_tag(stack).unwrap(), 7); + + // Raw place: read the field's inline bytes back. + let slice = unsafe { b.handle().raw_n_slice(stack) }; + assert_eq!(slice.len(), 8); + let bytes = slice.read().unwrap(); + assert_eq!(u64::from_le_bytes(bytes[..8].try_into().unwrap()), 42); + + // Raw place: a write through it is observed by the typed getter. + let mut w = unsafe { b.handle().raw_n_slice(stack) }; + w.write(99u64.to_le_bytes()).unwrap(); + assert_eq!(b.handle().get_n(stack).unwrap(), 99); + + b.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_bstack_mut_ref_repoints() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroLeaf::new(&alloc, 111).unwrap(); + let c = MacroLeaf::new(&alloc, 222).unwrap(); + + let holder = MutRef::new(&alloc, unsafe { BStackRef::from_range(a.handle().range()) }).unwrap(); + assert_eq!( + holder + .handle() + .get_target(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 111 + ); + + // Generated ref setter: repoint to `c` (a ref owns nothing, so nothing frees). + holder + .handle() + .set_target(stack, unsafe { BStackRef::from_range(c.handle().range()) }) + .unwrap(); + assert_eq!( + holder + .handle() + .get_target(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 222 + ); + + // Both targets are still independently live (the ref borrowed them). + holder.bstack_drop(&alloc).unwrap(); + assert_eq!(a.handle().get_val(stack).unwrap(), 111); + assert_eq!(c.handle().get_val(stack).unwrap(), 222); + a.bstack_drop(&alloc).unwrap(); + c.bstack_drop(&alloc).unwrap(); +} From a2914ba4d0cb752d850966d67b9976a6766e9efb Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 18:58:29 -0700 Subject: [PATCH 106/140] Add replace accessors --- bstack_raii/derive/src/block.rs | 222 +++++++++++++++++++++++++++++++- bstack_raii/src/tests.rs | 124 ++++++++++++++++++ 2 files changed, 342 insertions(+), 4 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 0570f92..9ed2893 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -1640,12 +1640,14 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } // Accessor: the `get_` reader, the unsafe `raw__slice` place, - // and — for `#[bstack_mut]` POD / `#[bstack_ref]` fields — a `set_`. + // and — for `#[bstack_mut]` fields — a `set_` (POD/ref) and/or + // `replace_` (owned/strong/ref). accessors.push(accessor(vis, fname, inner_ty, &on_disk_ty, kind, nullable)); accessors.push(raw_slice_accessor(vis, fname, inner_ty, &on_disk_ty, kind)); if is_bstack_mut(&field.attrs) { match kind { - Kind::Pod | Kind::Ref => { + // POD: overwrite in place. + Kind::Pod => { accessors.push(set_accessor( vis, fname, @@ -1655,12 +1657,44 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result nullable, )); } + // Ref is the only kind with BOTH: `set_` (overwrite; a ref owns + // nothing) and `replace_` (swap, handing the old ref back). + Kind::Ref => { + accessors.push(set_accessor( + vis, + fname, + inner_ty, + &on_disk_ty, + kind, + nullable, + )); + accessors.push(replace_accessor( + vis, + fname, + inner_ty, + &on_disk_ty, + kind, + nullable, + )); + } + // Owned / strong: only `replace_` — a plain `set_` would strand the + // old owned block / strong count; `replace_` moves it out instead. + Kind::Owned | Kind::Strong => { + accessors.push(replace_accessor( + vis, + fname, + inner_ty, + &on_disk_ty, + kind, + nullable, + )); + } // Weak fields already have a `set_` (the weak setter). Kind::Weak => {} - Kind::Owned | Kind::Strong | Kind::Embed => { + Kind::Embed => { return Err(Error::new_spanned( field, - "#[bstack_mut] is currently only supported on POD and #[bstack_ref] fields", + "#[bstack_mut] is not yet supported on #[embed] fields", )); } } @@ -2967,6 +3001,186 @@ fn set_accessor( } } +/// The `replace_` mutator for ownership-bearing fields: install `value` and +/// **move the previous value out** to the caller (`mem::replace` semantics), so the +/// old target is neither leaked nor silently freed — the caller owns it and decides +/// its fate. The swap itself is one crash-atomic `set` of the field's `u64` offset +/// slot; the old value is then reconstructed from the offset it held (exactly as +/// `bstack_move!` does). Generated for `#[bstack_mut]` +/// `#[bstack_owned]`/`#[bstack_strong]`/`#[bstack_ref]` fields (a ref *also* gets +/// `set_`; owned/strong get only `replace_`, since their old value +/// must not be dropped on the floor). +fn replace_accessor( + vis: &syn::Visibility, + fname: &Ident, + inner_ty: &Type, + on_disk: &TokenStream, + kind: Kind, + nullable: bool, +) -> TokenStream { + let name = format_ident!("replace_{}", fname); + let off = quote!(self.0.start() + ::core::mem::offset_of!(#on_disk, #fname) as u64); + let size_od = + quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); + + match kind { + // Owned / ref reconstruct the old handle from just the offset — no allocator + // needed — so they take `&BStack` (like their getter/setter). + Kind::Owned => { + let handle_ty = quote!(::bstack_raii::BStackOwned<#inner_ty>); + let new_off = quote!({ + let __h = __value.into_inner(); + ::bstack_raii::BStackBlock::range(&__h).start() + }); + let recon = quote!(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#inner_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__old_off, #size_od), + ), + ) + }); + replace_stack_method(vis, &name, &handle_ty, &off, &new_off, &recon, nullable) + } + Kind::Ref => { + let handle_ty = quote!(::bstack_raii::BStackRef<#inner_ty>); + let new_off = quote!(__value.into_range().start()); + let recon = quote!(unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range( + ::bstack_raii::BStackRange::new(__old_off, #size_od), + ) + }); + replace_stack_method(vis, &name, &handle_ty, &off, &new_off, &recon, nullable) + } + // Strong reconstructs a `BStackRc` (and the new value's count is transferred + // via `into_raw`), which needs the allocator — so it takes `&A`. + Kind::Strong => { + let handle_ty = quote!(::bstack_raii::BStackRc<'__r, #inner_ty, __A>); + let new_off = quote!({ + let (__d, _) = __value.into_raw(); + __d.into_range().start() + }); + // Reconstruct the old strong ref from its data offset (transfers the + // existing count out; dropping the returned `BStackRc` decrements). + let recon = quote! { + { + let __data = unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range( + ::bstack_raii::BStackRange::new(__old_off, #size_od), + ) + }; + let (__d, __c) = + <#inner_ty as ::bstack_raii::BStackShared>::strong_parts(__data, allocator)?; + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, allocator) } + } + }; + if nullable { + quote! { + /// Install `value` and move the previous value out (`None` = the + /// `0` null niche). + #vis fn #name<'__r, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__r __A, + value: ::core::option::Option<#handle_ty>, + ) -> ::std::io::Result<::core::option::Option<#handle_ty>> { + let __off = #off; + let __new: u64 = match value { + ::core::option::Option::Some(__value) => #new_off, + ::core::option::Option::None => 0u64, + }; + let __stack = allocator.stack(); + let mut __b = [0u8; 8]; + __stack.get_into(__off, &mut __b)?; + let __old_off = u64::from_le_bytes(__b); + __stack.set(__off, __new.to_le_bytes())?; + if __old_off == 0 { + ::std::result::Result::Ok(::core::option::Option::None) + } else { + ::std::result::Result::Ok(::core::option::Option::Some(#recon)) + } + } + } + } else { + quote! { + /// Install `value` and move the previous value out to the caller. + #vis fn #name<'__r, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__r __A, + value: #handle_ty, + ) -> ::std::io::Result<#handle_ty> { + let __off = #off; + let __new: u64 = { let __value = value; #new_off }; + let __stack = allocator.stack(); + let mut __b = [0u8; 8]; + __stack.get_into(__off, &mut __b)?; + let __old_off = u64::from_le_bytes(__b); + __stack.set(__off, __new.to_le_bytes())?; + ::std::result::Result::Ok(#recon) + } + } + } + } + // pod/weak/embed do not get `replace_`. + _ => quote!(), + } +} + +/// The `&BStack`-based body shared by owned/ref `replace_`: read the old +/// offset, commit the new one with a single atomic `set`, then reconstruct and +/// return the old handle. `new_off`/`recon` reference `__value` / `__old_off`. +fn replace_stack_method( + vis: &syn::Visibility, + name: &Ident, + handle_ty: &TokenStream, + off: &TokenStream, + new_off: &TokenStream, + recon: &TokenStream, + nullable: bool, +) -> TokenStream { + if nullable { + quote! { + /// Install `value` and move the previous value out (`None` = the `0` + /// null niche). + #vis fn #name( + &self, + stack: &::bstack_raii::BStack, + value: ::core::option::Option<#handle_ty>, + ) -> ::std::io::Result<::core::option::Option<#handle_ty>> { + let __off = #off; + let __new: u64 = match value { + ::core::option::Option::Some(__value) => #new_off, + ::core::option::Option::None => 0u64, + }; + let mut __b = [0u8; 8]; + stack.get_into(__off, &mut __b)?; + let __old_off = u64::from_le_bytes(__b); + stack.set(__off, __new.to_le_bytes())?; + if __old_off == 0 { + ::std::result::Result::Ok(::core::option::Option::None) + } else { + ::std::result::Result::Ok(::core::option::Option::Some(#recon)) + } + } + } + } else { + quote! { + /// Install `value` and move the previous value out to the caller. + #vis fn #name( + &self, + stack: &::bstack_raii::BStack, + value: #handle_ty, + ) -> ::std::io::Result<#handle_ty> { + let __off = #off; + let __new: u64 = { let __value = value; #new_off }; + let mut __b = [0u8; 8]; + stack.get_into(__off, &mut __b)?; + let __old_off = u64::from_le_bytes(__b); + stack.set(__off, __new.to_le_bytes())?; + ::std::result::Result::Ok(#recon) + } + } + } +} + /// Generate `(param, prep, init)` for one constructor field. Not called for /// `#[bstack_weak]` fields. `nullable` fields take an `Option` (None => 0). fn ctor_field( diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index bf59182..e5e5742 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6776,3 +6776,127 @@ fn macro_bstack_mut_ref_repoints() { a.bstack_drop(&alloc).unwrap(); c.bstack_drop(&alloc).unwrap(); } + +#[bstack_block] +struct MutOwned { + #[bstack_mut] + #[bstack_owned] + child: MacroLeaf, +} + +#[bstack_block] +struct MutStrong { + #[bstack_mut] + #[bstack_strong] + s: MacroStrongChild, +} + +#[test] +fn macro_bstack_mut_ref_replace_returns_old() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroLeaf::new(&alloc, 111).unwrap(); + let c = MacroLeaf::new(&alloc, 222).unwrap(); + + let holder = MutRef::new(&alloc, unsafe { BStackRef::from_range(a.handle().range()) }).unwrap(); + + // `replace_` installs `c` and hands the old ref (→ a) back. + let old = holder + .handle() + .replace_target(stack, unsafe { BStackRef::from_range(c.handle().range()) }) + .unwrap(); + assert_eq!( + holder + .handle() + .get_target(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 222 + ); + let old_leaf = ::from_range(old.into_range()); + assert_eq!(old_leaf.get_val(stack).unwrap(), 111); + + holder.bstack_drop(&alloc).unwrap(); + a.bstack_drop(&alloc).unwrap(); + c.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_bstack_mut_owned_replace_moves_old_out() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroLeaf::new(&alloc, 1).unwrap(); + let holder = MutOwned::new(&alloc, a).unwrap(); + assert_eq!( + holder + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 1 + ); + + // Install `b`, move the old child (`a`) out — it is NOT freed. + let b = MacroLeaf::new(&alloc, 2).unwrap(); + let old = holder.handle().replace_child(stack, b).unwrap(); + assert_eq!( + holder + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 2 + ); + assert_eq!(old.handle().get_val(stack).unwrap(), 1); // moved-out old is still live + + // The caller owns the old value and frees it explicitly. + old.bstack_drop(&alloc).unwrap(); + // Tearing down the holder frees the current child (`b`) + the shell. + holder.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_bstack_mut_strong_replace_moves_count_out() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 1).unwrap(); // BStackRc, strong = 1 + let holder = MutStrong::new(&alloc, a).unwrap(); // count transferred into the field + assert_eq!( + holder + .handle() + .get_s(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 1 + ); + + // Install `b`; the old strong ref (`a`, still count 1) is handed back as a + // `BStackRc` — the field's count moves out rather than being decremented here. + let b = MacroStrongChild::new(&alloc, 2).unwrap(); + let old_a = holder.handle().replace_s(&alloc, b).unwrap(); + assert_eq!( + holder + .handle() + .get_s(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 2 + ); + assert_eq!(old_a.handle().get_val(stack).unwrap(), 1); + + // Dropping the returned rc decrements `a` (1 -> 0) and frees it. + drop(old_a); + // Tearing down the holder decrements `b` (1 -> 0), freeing it + the shell. + holder.bstack_drop(&alloc).unwrap(); +} From 6be7ba83be0495f8930f56d22d09d86e9b426c81 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 22:36:59 -0700 Subject: [PATCH 107/140] Basic foreign group --- bstack_raii/derive/src/block.rs | 133 +++++++++++++++++++++++++ bstack_raii/src/foreign.rs | 171 ++++++++++++++++++++++++++++++++ bstack_raii/src/lib.rs | 31 ++++++ bstack_raii/src/tests.rs | 114 +++++++++++++++++++++ 4 files changed, 449 insertions(+) create mode 100644 bstack_raii/src/foreign.rs diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 9ed2893..b449005 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -329,6 +329,121 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // offending construct. Valid mixes (`Option>>`, …) pass. check_container_nesting(eff_ty)?; + // `Foreign`: a cross-file wide pointer, stored inline as a 16-byte + // `ForeignPtr` `(file_id, offset)` and resolved through the registry (length + // recovered from `size_of::()`, so it is not stored). The field's + // annotation (`#[bstack_owned/strong/weak/ref]`, or none) selects the + // target's ownership *in its own file*. `#[embed]` is meaningless for a + // pointer and rejected. Nullable via `Option>` (`offset == 0` + // niche). Cross-file teardown (free/decrement/release the target on the + // other side) and deep clone are DEFERRED — the field is byte-copied on + // clone (an alias) and freed by nobody on teardown, whatever the annotation; + // the annotation is recorded for the eventual per-kind dispatch. + if let Some(ftarget) = foreign_inner(opt_inner) { + // A `Foreign` points at a *block* in another file, so it must carry an + // ownership annotation naming the target's kind — never bare (POD is an + // inline value, not a pointer) and never `#[embed]` (can't inline a + // cross-file pointer). The annotation's dispatch (teardown/clone) is + // deferred, but the kind is required now. + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + &field.ty, + "`Foreign` needs an ownership annotation naming the target's \ + kind in its own file (`#[bstack_owned/strong/weak/ref]`); a bare \ + `Foreign` (POD) is not allowed — a foreign pointer targets a block", + )); + } + Kind::Embed => { + return Err(Error::new_spanned( + &field.ty, + "`Foreign` is a pointer and cannot be `#[embed]`ed", + )); + } + } + // `Foreign>` is almost always a mistake: a nullable foreign + // *pointer* is `Option>` (the pointer is absent), not a pointer + // to a nullable field. + if option_inner(ftarget).is_some() { + return Err(Error::new_spanned( + &field.ty, + "use `Option>` for a nullable foreign pointer, not \ + `Foreign>` (a foreign pointer targets a block, not a nullable field)", + )); + } + // The target must be a bstack block (ref-able) — reject `Foreign`. + let assert_fn = format_ident!("__bstack_foreign_target_{}", fname); + wrapper_defs.push(quote! { + #[doc(hidden)] + const _: fn() = { + fn #assert_fn<__T: ::bstack_raii::BStackBlock>() {} + #assert_fn::<#ftarget> + }; + }); + on_disk_fields.push(quote!(#fname: ::bstack_raii::ForeignPtr,)); + let field_ty = quote!(::bstack_raii::Foreign<#ftarget>); + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + + if nullable { + // Niche: a stored `offset == 0` is `None` (no target sits at 0). + accessors.push(quote! { + #vis fn #getter( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<::core::option::Option<#field_ty>> { + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; + let __p = __od.#fname; + ::std::result::Result::Ok(if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some(::bstack_raii::Foreign::from_ptr(__p)) + }) + } + }); + ctor_params.push(quote!(#fname: ::core::option::Option<#field_ty>,)); + ctor_preps.push(quote! { + let #fname: ::bstack_raii::ForeignPtr = match #fname { + ::core::option::Option::Some(__f) => __f.ptr(), + ::core::option::Option::None => ::bstack_raii::ForeignPtr::new(0, 0), + }; + }); + ctor_inits.push(quote!(#fname: #fname,)); + mv_types.push(quote!(::core::option::Option<#field_ty>)); + mv_recon.push(quote! { + if #cap.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some(::bstack_raii::Foreign::from_ptr(#cap)) + } + }); + } else { + accessors.push(quote! { + #vis fn #getter( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<#field_ty> { + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; + ::std::result::Result::Ok(::bstack_raii::Foreign::from_ptr(__od.#fname)) + } + }); + ctor_params.push(quote!(#fname: #field_ty,)); + ctor_preps.push(quote!(let #fname: ::bstack_raii::ForeignPtr = #fname.ptr();)); + ctor_inits.push(quote!(#fname: #fname,)); + mv_types.push(quote!(#field_ty)); + mv_recon.push(quote!(::bstack_raii::Foreign::from_ptr(#cap))); + } + + // Clone / teardown: none yet (deferred). No `clone_stmts` / `drop_stmts` + // entry — the ForeignPtr is byte-copied verbatim (alias) and not freed. + continue; + } + // `Vec` / `String` (and `&str` → `String`): an inline descriptor on // disk, a `BStackVec` at runtime. A nullable vec uses the `data_off == 0` // niche. Handled here. @@ -2689,6 +2804,24 @@ fn option_inner(ty: &Type) -> Option<&Type> { } } +/// Peek `Foreign` → `T`: a cross-file wide-pointer field. +fn foreign_inner(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { + return None; + }; + let seg = tp.path.segments.last()?; + if seg.ident != "Foreign" { + return None; + } + let PathArguments::AngleBracketed(ab) = &seg.arguments else { + return None; + }; + match ab.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + // --------------------------------------------------------------------------- // Nested fixed-size arrays `[[.. [T; N0]; N1]..; Nk]` of block references. // diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs new file mode 100644 index 0000000..d9c362e --- /dev/null +++ b/bstack_raii/src/foreign.rs @@ -0,0 +1,171 @@ +//! [`Foreign`]: a cross-file pointer — "a slice with a file identity attached". +//! +//! An in-file reference stores just a `u64` offset (length recovered from +//! `size_of::()`). A `Foreign` widens that with the target file's +//! identity: on disk it is a [`ForeignPtr`] `{ file_id: u64, offset: u64 }` (16 +//! bytes, `Pod`) — length is still recovered from the type, so it is *not* stored. +//! Dereferencing resolves `file_id` through the process-wide +//! [registry](crate::registry) to the file's live allocator, then reads/writes at +//! `offset` in that file. +//! +//! As a `#[bstack_block]` field, a `Foreign` carries the same ownership +//! annotations as an in-file field — `#[bstack_owned/strong/weak/ref]` (or none) — +//! but applied to the target `T` **in its own file**: an owning foreign pointer +//! frees / decrements / releases the target *on the other side* at teardown, and a +//! deep clone copies it across files. Those cross-file **teardown** and **deep +//! clone** dispatches are still deferred; today the field is byte-copied on clone +//! (an alias) and freed by nobody on teardown, regardless of annotation. The +//! annotation is recorded so the eventual dispatch is per-kind. Construction, +//! nullability (`Option>`, `offset == 0` niche), and resolution are +//! implemented here. + +use core::marker::PhantomData; + +use bstack::{BStack, BStackAllocator, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use crate::block::BStackBlock; +use crate::registry::{self, FileId, FileRegistry}; + +/// The on-disk form of a [`Foreign`] pointer: a file identity plus an offset in +/// that file. 16 bytes, `Pod`. The target's length is **not** stored — it is +/// recovered from `size_of::()`, exactly like an in-file `#[bstack_ref]`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Pod, Zeroable)] +#[repr(C)] +pub struct ForeignPtr { + /// The target file's [`FileId`] as a `u64` (`0` = [`FileId::SELF`]). + file_id: u64, + /// The target's offset within that file. + offset: u64, +} + +impl ForeignPtr { + /// A wide pointer from a raw `(file_id, offset)`. + pub const fn new(file_id: u64, offset: u64) -> Self { + Self { file_id, offset } + } + /// The raw file-id word. + pub const fn file_id(self) -> u64 { + self.file_id + } + /// The target offset. + pub const fn offset(self) -> u64 { + self.offset + } +} + +/// A typed cross-file pointer to a `T`: a [`FileId`] + an offset. Resolved through +/// the process-wide [registry](crate::registry) (or a specific [`FileRegistry`]). +/// +/// `Copy` regardless of `T` (it holds only a [`ForeignPtr`]). See the [module +/// docs](self) for what is deferred (teardown / deep clone). +pub struct Foreign { + ptr: ForeignPtr, + _marker: PhantomData T>, +} + +impl Clone for Foreign { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Foreign {} + +impl Foreign { + /// A foreign pointer to `offset` within the file identified by `file`. + pub const fn new(file: FileId, offset: u64) -> Self { + Self { + ptr: ForeignPtr::new(file.as_u64(), offset), + _marker: PhantomData, + } + } + + /// Reconstruct from the stored on-disk [`ForeignPtr`]. + pub const fn from_ptr(ptr: ForeignPtr) -> Self { + Self { + ptr, + _marker: PhantomData, + } + } + + /// The on-disk wide pointer, for storing into a field. + pub const fn ptr(self) -> ForeignPtr { + self.ptr + } + + /// The target's offset within its file. + pub const fn offset(self) -> u64 { + self.ptr.offset + } + + /// Whether this points into the *current* file ([`FileId::SELF`]). + pub const fn is_self(self) -> bool { + self.ptr.file_id == 0 + } + + /// The file this points into. A raw file-id that does not fit the [`FileId`] + /// space (corruption / a wider build) maps to [`FileId::SELF`]; use + /// [`with`](Self::with)/[`with_in`](Self::with_in), which reject a bad id + /// outright, when that distinction matters. + pub fn file_id(self) -> FileId { + FileId::from_u64(self.ptr.file_id).unwrap_or(FileId::SELF) + } +} + +impl Foreign { + /// A foreign pointer to the block `target` currently occupies, in file `file`. + pub fn at(file: FileId, target: &T) -> Self { + Self::new(file, target.range().start()) + } + + /// The target's range in its file (`offset` + `size_of::()`). + pub fn range(self) -> BStackRange { + BStackRange::new( + self.ptr.offset, + core::mem::size_of::() as u64, + ) + } + + /// Resolve the pointer and run `f` with a `T` handle at the target plus the + /// [`BStack`] of the file it lives in. + /// + /// There is exactly one registry — the process-wide one ([`crate::registry`]) — + /// so resolution never takes a registry argument: a `Foreign` (e.g. one moved + /// out via `bstack_move!`) is always resolvable on its own. [`SELF`](FileId::SELF) + /// resolves against `local` directly (no registry, no lock); a foreign id + /// resolves via the global registry, yielding `None` if it is uninitialized, or + /// the target file is unknown / not currently attached / the id is malformed. + pub fn with(self, local: &A, f: impl FnOnce(T, &BStack) -> R) -> Option + where + A: BStackAllocator, + { + let t = T::from_range(self.range()); + if self.ptr.file_id == 0 { + Some(f(t, local.stack())) + } else { + let id = FileId::from_u64(self.ptr.file_id)?; + registry::with_host(id, |host| f(t, host.stack())) + } + } + + /// Like [`with`](Self::with) but against an explicit `registry` — crate-internal, + /// for tests (the global is a one-shot `OnceLock`, awkward to exercise in unit + /// tests). Production code uses [`with`](Self::with) against the sole registry. + pub(crate) fn with_in( + self, + registry: &FileRegistry, + local: &A, + f: impl FnOnce(T, &BStack) -> R, + ) -> Option + where + A: BStackAllocator, + { + let t = T::from_range(self.range()); + if self.ptr.file_id == 0 { + Some(f(t, local.stack())) + } else { + let id = FileId::from_u64(self.ptr.file_id)?; + registry.with_host(id, |host| f(t, host.stack())) + } + } +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 607bd12..3d72e01 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -53,6 +53,7 @@ mod bulk; mod cast; mod clone; mod construct; +mod foreign; mod handle; mod layout; mod owned; @@ -75,6 +76,7 @@ pub use block::{ pub use bulk::{alloc_many, free_many}; pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::{ClonePlan, TryClone, TryCloneIn}; +pub use foreign::{Foreign, ForeignPtr}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; @@ -355,5 +357,34 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// } /// # fn main() {} /// ``` +/// +/// --- +/// +/// A **bare** `Foreign` field (no ownership annotation): a foreign pointer must +/// name its target's kind, like an in-file reference: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { link: Foreign } +/// # fn main() {} +/// ``` +/// +/// `Foreign` where `T` is **not a bstack block** (`Foreign`): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: bstack_raii::Foreign } +/// # fn main() {} +/// ``` +/// +/// `Foreign>` — a nullable foreign pointer is `Option>`: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign> } +/// # fn main() {} +/// ``` #[doc(hidden)] pub mod __macro_compile_fail_tests {} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e5e5742..e9bfb40 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6900,3 +6900,117 @@ fn macro_bstack_mut_strong_replace_moves_count_out() { // Tearing down the holder decrements `b` (1 -> 0), freeing it + the shell. holder.bstack_drop(&alloc).unwrap(); } + +// -------------------------------------------------------------------------- +// Foreign: cross-file wide pointer resolved through the registry +// -------------------------------------------------------------------------- + +#[test] +fn foreign_resolves_across_files_and_self() { + use crate::Foreign; + use crate::registry::{FileId, FileRegistry}; + use std::sync::Arc; + + let reg_file = TempStack::new(); + let foreign_file = TempStack::new(); + let local_file = TempStack::new(); + + let reg = FileRegistry::open(®_file.path).unwrap(); + let local = local_file.allocator(); + + // Place a MacroLeaf in the *foreign* file and remember its offset, then hand + // that file's allocator to the registry as the live host. + let foreign_alloc = foreign_file.allocator(); + let leaf = MacroLeaf::new(&foreign_alloc, 77).unwrap(); + let off = leaf.handle().range().start(); + let id = reg + .attach(&foreign_file.path, Arc::new(foreign_alloc)) + .unwrap(); + + // A Foreign pointing at that leaf resolves + reads through the registry. + let fp = Foreign::::new(id, off); + assert_eq!( + fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), + Some(77) + ); + + // Detaching the host makes resolution fail (None), not panic. + reg.detach(id); + assert_eq!( + fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), + None + ); + + // SELF resolves against `local` directly — no registry entry needed. + let lleaf = MacroLeaf::new(&local, 9).unwrap(); + let selfp = Foreign::::new(FileId::SELF, lleaf.handle().range().start()); + assert!(selfp.is_self()); + assert_eq!( + selfp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), + Some(9) + ); + lleaf.bstack_drop(&local).unwrap(); +} + +#[bstack_block] +struct ForeignHolder { + tag: u32, + // A cross-file link to an owned block on the other side. `Foreign` is parsed as + // a token (like `Option`), so the field type needs no `use` of `Foreign` here. + #[bstack_owned] + owned_link: Foreign, + // Nullable cross-file link (`offset == 0` niche); a *ref* target on the far side. + #[bstack_ref] + maybe: Option>, +} + +#[test] +fn macro_foreign_field() { + use crate::Foreign; + use crate::registry::FileRegistry; + use std::sync::Arc; + + let reg_file = TempStack::new(); + let foreign_file = TempStack::new(); + let local_file = TempStack::new(); + + let reg = FileRegistry::open(®_file.path).unwrap(); + let local = local_file.allocator(); + let stack = local.stack(); + + // Target lives in the foreign file; attach that file as its live host. + let foreign_alloc = foreign_file.allocator(); + let leaf = MacroLeaf::new(&foreign_alloc, 88).unwrap(); + let off = leaf.handle().range().start(); + let id = reg + .attach(&foreign_file.path, Arc::new(foreign_alloc)) + .unwrap(); + + // POD field + an owned cross-file link + a null optional link. + let h = ForeignHolder::new(&local, 5, Foreign::::new(id, off), None).unwrap(); + assert_eq!(h.handle().get_tag(stack).unwrap(), 5); + assert_eq!( + h.handle() + .get_owned_link(stack) + .unwrap() + .with_in(®, &local, |t, fs| t.get_val(fs).unwrap()), + Some(88) + ); + assert!(h.handle().get_maybe(stack).unwrap().is_none()); // the `None` niche + h.bstack_drop(&local).unwrap(); + + // A present optional link resolves like any other Foreign. + let h2 = ForeignHolder::new( + &local, + 5, + Foreign::::new(id, off), + Some(Foreign::::new(id, off)), + ) + .unwrap(); + let m = h2.handle().get_maybe(stack).unwrap().expect("Some link"); + assert_eq!( + m.with_in(®, &local, |t, fs| t.get_val(fs).unwrap()), + Some(88) + ); + h2.bstack_drop(&local).unwrap(); +} From 61fccbacdb4ff88c46a5081a35ac0e1010cb3355 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 22:49:12 -0700 Subject: [PATCH 108/140] Register host pointer locations --- bstack_raii/src/registry.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs index a4cf21b..571eccf 100644 --- a/bstack_raii/src/registry.rs +++ b/bstack_raii/src/registry.rs @@ -377,6 +377,11 @@ struct RegistryInner<'h> { /// `id -> live host` (the open allocator), or `None` when the file is not /// currently attached. In-memory only. live: Vec>>, + /// Reverse map `host BStack address -> id`, for turning a live handle back into + /// its [`FileId`] (`bstack_cast!(slice as Foreign)`). In-memory only; + /// populated on [`attach`](FileRegistry::attach), pruned on + /// [`detach`](FileRegistry::detach). + by_stack: HashMap, store: RegistryStore, } @@ -412,6 +417,7 @@ impl<'h> FileRegistry<'h> { paths, by_path, live, + by_stack: HashMap::new(), store, }), }) @@ -449,8 +455,10 @@ impl<'h> FileRegistry<'h> { /// [`detach`](Self::detach) — drops it), not `'static`. pub fn attach(&self, path: &Path, host: Arc) -> io::Result { let id = self.register_path(path)?; + let stack_key = core::ptr::from_ref(host.stack()) as usize; let mut g = self.inner.write(); g.live[(id.0 - 1) as usize] = Some(host); // register_path returns a 1-based id + g.by_stack.insert(stack_key, id); Ok(id) } @@ -460,8 +468,14 @@ impl<'h> FileRegistry<'h> { pub fn detach(&self, id: FileId) { let Some(idx) = table_index(id) else { return }; let mut g = self.inner.write(); - if let Some(slot) = g.live.get_mut(idx) { - *slot = None; + // Take the host out (this is the detach) and drop its reverse-map entry. + let stack_key = g + .live + .get_mut(idx) + .and_then(|slot| slot.take()) + .map(|host| core::ptr::from_ref(host.stack()) as usize); + if let Some(k) = stack_key { + g.by_stack.remove(&k); } } @@ -503,6 +517,14 @@ impl<'h> FileRegistry<'h> { }; self.inner.read().live.get(idx).is_some_and(Option::is_some) } + + /// The [`FileId`] of the currently-attached file whose backing stack is `stack`, + /// if any — the reverse of [`with_host`](Self::with_host). Lets a live handle be + /// turned back into a `Foreign` (`bstack_cast!(slice as Foreign)`). + pub fn id_of_host(&self, stack: &BStack) -> Option { + let key = core::ptr::from_ref(stack) as usize; + self.inner.read().by_stack.get(&key).copied() + } } /// The lazily-instantiated process-wide singleton + free-function front door. @@ -575,6 +597,11 @@ pub fn path_of(id: FileId) -> Option { REGISTRY.get()?.path_of(id) } +/// [`FileRegistry::id_of_host`] on the process-wide registry. +pub fn id_of_host(stack: &BStack) -> Option { + REGISTRY.get()?.id_of_host(stack) +} + /// The id registered for `path`, if any. pub fn id_of(path: impl AsRef) -> Option { REGISTRY.get()?.id_of(path.as_ref()) From 7a542bb03c4f49ff2e43836a46366fb4b994ec50 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 22:49:58 -0700 Subject: [PATCH 109/140] Foreign to local and local to foreign --- bstack_raii/derive/src/cast.rs | 9 +++++++ bstack_raii/src/foreign.rs | 32 +++++++++++++++++++---- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/bstack_raii/derive/src/cast.rs b/bstack_raii/derive/src/cast.rs index c58bea2..9e59eb2 100644 --- a/bstack_raii/derive/src/cast.rs +++ b/bstack_raii/derive/src/cast.rs @@ -46,6 +46,15 @@ pub fn expand(input: TokenStream) -> syn::Result { let inner = first_type_arg(seg)?; quote!(::bstack_raii::BStackCastInto::cast_into::<#inner>(#expr)) } + // normal → foreign: a `BStackSlice` into some registered file → `Foreign` + // naming it (`None` if the file isn't attached). No I/O. + "Foreign" => { + let inner = first_type_arg(seg)?; + quote!(::bstack_raii::Foreign::<#inner>::from_local(&#expr)) + } + // foreign → normal: a `Foreign` → its offset-only `BStackRef` in the + // target file (`None` unless that file is `SELF`/attached). No I/O. + "BStackRef" => quote!((#expr).as_local_ref()), // A concrete block type: borrowed downcast off a `BStackSlice`. _ => quote!(::bstack_raii::BStackCastAs::cast_as::<#ty>(&#expr)), }; diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index d9c362e..7968b7c 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -21,10 +21,11 @@ use core::marker::PhantomData; -use bstack::{BStack, BStackAllocator, BStackRange}; +use bstack::{BStack, BStackAllocator, BStackRange, BStackSlice}; use bytemuck::{Pod, Zeroable}; use crate::block::BStackBlock; +use crate::reference::BStackRef; use crate::registry::{self, FileId, FileRegistry}; /// The on-disk form of a [`Foreign`] pointer: a file identity plus an offset in @@ -120,10 +121,7 @@ impl Foreign { /// The target's range in its file (`offset` + `size_of::()`). pub fn range(self) -> BStackRange { - BStackRange::new( - self.ptr.offset, - core::mem::size_of::() as u64, - ) + BStackRange::new(self.ptr.offset, core::mem::size_of::() as u64) } /// Resolve the pointer and run `f` with a `T` handle at the target plus the @@ -148,6 +146,30 @@ impl Foreign { } } + /// **normal → foreign** (`bstack_cast!(slice as Foreign)`): name the block a + /// `BStackSlice` points at as a `Foreign`, resolving the slice's file to its + /// [`FileId`] via the registry's reverse map. `None` if that file is not + /// currently attached (so it has no id to name). Does no I/O. + pub fn from_local(slice: &BStackSlice<'_>) -> Option { + let id = registry::id_of_host(slice.stack())?; + Some(Self::new(id, slice.start())) + } + + /// **foreign → normal** (`bstack_cast!(foreign as BStackRef)`): the offset-only + /// [`BStackRef`] to the target, valid **in the target's own file**. `Some` iff + /// that file is [`SELF`](FileId::SELF) or currently attached (so the ref is + /// resolvable); `None` otherwise. Does no I/O — pair the ref with the target + /// file's stack (e.g. via [`with`](Self::with)) to read it. + pub fn as_local_ref(self) -> Option> { + let resolvable = self.ptr.file_id == 0 + || FileId::from_u64(self.ptr.file_id) + .and_then(|id| registry::get().map(|r| r.is_live(id))) + .unwrap_or(false); + // SAFETY: `range()` is this pointer's target region; the returned ref is a + // plain offset handle (no aliasing/liveness claim beyond the caller's). + resolvable.then(|| unsafe { BStackRef::from_range(self.range()) }) + } + /// Like [`with`](Self::with) but against an explicit `registry` — crate-internal, /// for tests (the global is a one-shot `OnceLock`, awkward to exercise in unit /// tests). Production code uses [`with`](Self::with) against the sole registry. diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 3d72e01..c982d38 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -76,10 +76,10 @@ pub use block::{ pub use bulk::{alloc_many, free_many}; pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::{ClonePlan, TryClone, TryCloneIn}; -pub use foreign::{Foreign, ForeignPtr}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; +pub use foreign::{Foreign, ForeignPtr}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC, get_u64}; pub use owned::BStackOwned; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index e9bfb40..3e033a9 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7014,3 +7014,51 @@ fn macro_foreign_field() { ); h2.bstack_drop(&local).unwrap(); } + +#[test] +fn foreign_reverse_map_and_bstack_cast() { + use crate::registry::{FileId, FileRegistry}; + use crate::{BStackRef, Foreign}; + use bstack::BStackSlice; + use std::sync::Arc; + + let reg_file = TempStack::new(); + let foreign_file = TempStack::new(); + let local_file = TempStack::new(); + + let reg = FileRegistry::open(®_file.path).unwrap(); + + let foreign_alloc = foreign_file.allocator(); + let leaf = MacroLeaf::new(&foreign_alloc, 3).unwrap(); + let off = leaf.handle().range().start(); + let id = reg + .attach(&foreign_file.path, Arc::new(foreign_alloc)) + .unwrap(); + + // Reverse map: a live host's stack resolves back to its FileId. + assert_eq!( + reg.with_host(id, |host| reg.id_of_host(host.stack())), + Some(Some(id)) + ); + + // foreign -> normal (`bstack_cast!(foreign as BStackRef)`): a SELF pointer is + // always resolvable-in-place; a foreign id is None here (the GLOBAL registry + // that `as_local_ref` consults is uninitialized in tests). + let selfp = Foreign::::new(FileId::SELF, off); + let r: Option> = bstack_cast!(selfp as BStackRef); + assert!(r.is_some()); + assert!(Foreign::::new(id, off).as_local_ref().is_none()); + + // normal -> foreign (`bstack_cast!(slice as Foreign)`): needs the GLOBAL + // registry (uninitialized in tests) → None, but the macro arm type-checks. + let la = local_file.allocator(); + let s = la.alloc(16).unwrap().as_range(); + let slice = unsafe { BStackSlice::from_raw_range(la.stack(), s) }; + let f: Option> = bstack_cast!(slice as Foreign); + assert!(f.is_none()); + + // Detach prunes the reverse-map entry. + reg.detach(id); + assert!(!reg.is_live(id)); + assert_eq!(reg.id_of_host(la.stack()), None); +} From f118c76dfc1cb1ae594d4abc86e1980bd06da9a1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 10 Aug 2026 23:48:06 -0700 Subject: [PATCH 110/140] Widen wal to support cross-file --- bstack_raii/src/tests.rs | 56 ++++++++++ bstack_raii/src/wal.rs | 218 ++++++++++++++++++++++++++++++++++----- 2 files changed, 249 insertions(+), 25 deletions(-) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 3e033a9..0f77ba2 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2599,6 +2599,62 @@ fn wal_finish_reclaims_abandoned_allocs() { assert_eq!(finish(&alloc).unwrap(), 0); } +#[test] +fn wal_finish_reclaims_foreign_orphan_via_registry() { + // Option-1 cross-file reclamation: the WAL lives on the op's HOME file, but a + // recorded slice can name a FOREIGN file (`file_id != 0`). Recovery resolves that + // id through the process-wide registry and frees the orphan on the other side. + // This is the only test that uses the global registry — `finish`'s recovery path + // (`free_recorded`) resolves foreign frees through it, exactly as real teardown / + // clone will. + use crate::registry; + use crate::wal::{finish, persist_at}; + use crate::{WalEntry, WalLog, WalStatus}; + + // The op's home file (where the WAL is staged) and a separate foreign file. + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let foreign_alloc = foreign.allocator(); + // An orphan a crashed cross-file op left behind in the foreign file. + let orphan = foreign_alloc.alloc(64).unwrap().as_range(); + + // Bring up the global registry and attach the foreign file, learning its id. + // Tolerant of a prior init (only this test touches the singleton). + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let fid = registry::attach(&foreign.path, foreign_alloc).unwrap(); + assert!(!fid.is_self()); + + // A COMMITTED cross-file free staged in the HOME file's WAL: a `Dealloc` tagged + // with the foreign file's id (the option-1 shape). + let mut log = WalLog::with_capacity(1); + log.append(WalEntry::dealloc_in(WalStatus::Pending, fid, orphan)); + persist_at(&home_alloc, &log, WalStatus::Complete).unwrap(); + + // Completing the home WAL rolls the foreign free forward *in the foreign file*. + assert_eq!(finish(&home_alloc).unwrap(), 1); + + // Reclaim confirmed on the foreign side: a fresh 64-byte alloc reuses the slot. + let reused = registry::with_host(fid, |host| host.alloc(64).unwrap().start()).unwrap(); + assert_eq!(reused, orphan.start()); + + // An unresolvable foreign entry (file detached) degrades to a leak, not an error. + let orphan2 = registry::with_host(fid, |host| host.alloc(64).unwrap().start()).unwrap(); + registry::detach(fid); + let mut log2 = WalLog::with_capacity(1); + log2.append(WalEntry::dealloc_in( + WalStatus::Pending, + fid, + BStackRange::new(orphan2, 64), + )); + persist_at(&home_alloc, &log2, WalStatus::Complete).unwrap(); + // The detached file can't be freed here — `finish` completes the entry (leaking + // it) and returns success, counting it as handled rather than erroring. + assert_eq!(finish(&home_alloc).unwrap(), 1); +} + // -------------------------------------------------------------------------- // Inline fixed-size arrays [T; N] // -------------------------------------------------------------------------- diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index d519fb1..d1c741f 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -33,7 +33,11 @@ //! An operation is thus `(slice, Alloc|Dealloc) × Status`, and the WAL is the //! functor `wal_append` mapping operations into disk state ([`WalLog`]). On disk //! **both** an `Alloc` and a `Dealloc` store their slice `S = (ptr, len)`; the -//! `op` marks the *recovery polarity* (which outcome orphans the slice). +//! `op` marks the *recovery polarity* (which outcome orphans the slice). Each slice +//! also carries a **file identity** (`file_id`): `0` = the WAL's own file +//! ([`FileId::SELF`](crate::registry::FileId::SELF), the common case), non-zero = a +//! foreign file whose orphan is reclaimed through the [registry](crate::registry) on +//! recovery — the on-disk half of the cross-file (`Foreign`) atomicity story. //! ([`AllocReq`] / [`reduce`] are the pre-allocation planning form, `R' = (id, //! len)`, used before an address exists.) //! @@ -57,15 +61,29 @@ use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::BStackRaiiAllocator; +use crate::registry::{self, FileId, ForeignHost}; use crate::teardown::dealloc_range; /// `R'`: an allocation requirement carrying identity — a length whose address has /// been "forgotten", plus an `id` that keeps equal-length requirements distinct. /// The `id` is a wrapping autoincrement (see [`WalLog::fresh_id`]). +/// +/// `file_id` names the file the allocation targets — `0` = the local file +/// ([`FileId::SELF`], the common case), non-zero = a foreign file. [`reduce`] only +/// repurposes a freed slice for a requirement in the **same** file, so a foreign +/// requirement never reuses local storage (or vice versa). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AllocReq { pub id: u64, pub len: u64, + /// Target file: `0` = local ([`FileId::SELF`]), non-zero = a foreign [`FileId`] + /// (a `FileId` *is* a `u32`). Split from `_obj_id` to mirror [`WalEntry`]'s + /// on-disk `(file_id, _obj_id)` word, so the planning form and the log form share + /// one file-identity shape. + pub file_id: u32, + /// Reserved companion to `file_id` (a future intra-file object id / RTTI); + /// currently always `0` and unused, mirroring [`WalEntry`]. + pub _obj_id: u32, } /// The status lifecycle of a WAL operation: the ordered set @@ -128,17 +146,32 @@ impl WalOp { } } -/// One on-disk WAL entry: `(status, op, payload)`. +/// One on-disk WAL entry: `(status, op, file_id, payload)`. /// /// `status` and `op` are **separate** fields — never packed into one byte. The two /// payload words are `R' = (id, len)` for an [`Alloc`](WalOp::Alloc) and -/// `S = (ptr, len)` for a [`Dealloc`](WalOp::Dealloc). 24 bytes, 8-aligned, `Pod`. +/// `S = (ptr, len)` for a [`Dealloc`](WalOp::Dealloc). `file_id` names the file the +/// slice lives in — `0` = the WAL's own file ([`FileId::SELF`], the common case), +/// non-zero = a foreign [`FileId`] reclaimed through the [registry](crate::registry) +/// on recovery. 32 bytes, 8-aligned, `Pod`. #[derive(Clone, Copy, Debug, Pod, Zeroable)] #[repr(C)] pub struct WalEntry { status: u8, op: u8, _pad: [u8; 6], + /// The file the recorded slice lives in: `0` = this file ([`FileId::SELF`]), + /// non-zero = a foreign [`FileId`] resolved through the registry on recovery. + /// + /// A `u32` (a [`FileId`] *is* a `u32`) paired with the reserved `_obj_id` word + /// below, so the two together fill the same 8 bytes the field used to be a single + /// `u64`. On little-endian `file_id` keeps its offset (it was the `u64`'s low + /// word), so the on-disk image is unchanged. + file_id: u32, + /// Reserved for a future intra-file object id (e.g. RTTI / sub-object + /// addressing). Currently always `0` and unread — split out now so the split is + /// a no-op on disk rather than a later breaking widening. + _obj_id: u32, /// `Alloc`: requirement `id`. `Dealloc`: slice `ptr` (start offset). word_a: u64, /// `Alloc` / `Dealloc`: `len`. @@ -146,25 +179,44 @@ pub struct WalEntry { } impl WalEntry { - /// An `Alloc` entry recording a freshly allocated slice `S = (ptr, len)`. - /// Recovery frees it iff the transaction is **abandoned** (the block is an - /// orphan of a crashed op); a committed transaction keeps it. + /// An `Alloc` entry recording a freshly allocated **local** slice `S = (ptr, len)` + /// (`file_id 0`, [`FileId::SELF`]). Recovery frees it iff the transaction is + /// **abandoned** (the block is an orphan of a crashed op); a committed + /// transaction keeps it. See [`alloc_in`](Self::alloc_in) for a foreign slice. pub fn alloc(status: WalStatus, slice: BStackRange) -> Self { + Self::alloc_in(status, FileId::SELF, slice) + } + + /// An `Alloc` entry for a slice in file `file` (foreign-aware). `file = ` + /// [`FileId::SELF`] is the WAL's own file; any other id names a foreign file + /// whose orphan is reclaimed through the [registry](crate::registry) on recovery. + pub fn alloc_in(status: WalStatus, file: FileId, slice: BStackRange) -> Self { WalEntry { status: status as u8, op: WalOp::Alloc as u8, _pad: [0; 6], + file_id: file.get(), + _obj_id: 0, word_a: slice.start(), word_b: slice.len(), } } - /// A `Dealloc` entry recording a concrete slice `S = (ptr, len)`. + /// A `Dealloc` entry recording a concrete **local** slice `S = (ptr, len)` + /// (`file_id 0`, [`FileId::SELF`]). See [`dealloc_in`](Self::dealloc_in) for a + /// slice in a foreign file. pub fn dealloc(status: WalStatus, slice: BStackRange) -> Self { + Self::dealloc_in(status, FileId::SELF, slice) + } + + /// A `Dealloc` entry for a slice in file `file` (foreign-aware). + pub fn dealloc_in(status: WalStatus, file: FileId, slice: BStackRange) -> Self { WalEntry { status: status as u8, op: WalOp::Dealloc as u8, _pad: [0; 6], + file_id: file.get(), + _obj_id: 0, word_a: slice.start(), word_b: slice.len(), } @@ -178,6 +230,14 @@ impl WalEntry { WalOp::from_u8(self.op) } + /// The file the recorded slice lives in: `0` = this file ([`FileId::SELF`]), + /// non-zero = a foreign [`FileId`] reclaimed through the registry on recovery. + /// Widened to `u64` for the recovery path (`FileId::from_u64`); the stored field + /// is a `u32`. + pub fn file_id(&self) -> u64 { + self.file_id as u64 + } + pub fn set_status(&mut self, status: WalStatus) { self.status = status as u8; } @@ -265,23 +325,30 @@ impl WalLog { #[derive(Debug, Default)] pub struct Reduced { /// `(requirement, repurposed slice)` — no physical alloc *or* dealloc needed. + /// The slice lives in `requirement.file_id` (reuse is always same-file). pub reused: Vec<(AllocReq, BStackRange)>, /// Requirements still needing a physical `Alloc`. pub allocs: Vec, - /// Slices still needing a physical `Dealloc`. - pub deallocs: Vec, + /// Slices still needing a physical `Dealloc`, each tagged with the file it lives + /// in (`0` = local [`FileId::SELF`], non-zero = a foreign [`FileId`], a `u32`). + pub deallocs: Vec<(u32, BStackRange)>, } /// The groupoid reduction: cancel each allocation requirement against a to-be-freed -/// slice of **equal length**, handing that slice's storage straight to the new -/// allocation (`ForgetAddress ∘ Dealloc ∘ Alloc ∘ Choice = id`). Only the unpaired -/// remainder becomes physical work. -pub fn reduce(allocs: Vec, mut deallocs: Vec) -> Reduced { +/// slice **of equal length in the same file**, handing that slice's storage straight +/// to the new allocation (`ForgetAddress ∘ Dealloc ∘ Alloc ∘ Choice = id`). A slice +/// in one file can never satisfy a requirement in another (a cross-file `Foreign` +/// alloc and a local free do not cancel), so the `file_id`s must match. Only the +/// unpaired remainder becomes physical work. +pub fn reduce(allocs: Vec, mut deallocs: Vec<(u32, BStackRange)>) -> Reduced { let mut reused = Vec::new(); let mut rem_allocs = Vec::new(); for req in allocs { - if let Some(pos) = deallocs.iter().position(|d| d.len() == req.len) { - reused.push((req, deallocs.remove(pos))); + if let Some(pos) = deallocs + .iter() + .position(|(fid, d)| *fid == req.file_id && d.len() == req.len) + { + reused.push((req, deallocs.remove(pos).1)); } else { rem_allocs.push(req); } @@ -542,6 +609,35 @@ pub(crate) fn wal_set_idle( .set(block_off + 8, [WalStatus::None as u8]) } +/// Free one WAL-recorded slice during recovery, in whichever file it lives in. +/// +/// * `file_id == 0` ([`FileId::SELF`]) — the WAL's own file: free through the local +/// `allocator` (the overwhelmingly common path). +/// * `file_id != 0` — a foreign file: resolve it through the [registry](crate::registry) +/// and free via its live [`ForeignHost`]. If that file is **not currently attached** +/// (or the registry is not up), the orphan cannot be reclaimed here — it is *left to +/// leak*, which the crate's atomicity contract explicitly permits (a cross-file +/// orphan whose file is unavailable at recovery degrades to a leak, exactly as if +/// the WAL did not cover it). A malformed id is likewise ignored (leak, not error). +/// +/// Errors only propagate a genuine I/O failure from an *attempted* free. +fn free_recorded( + allocator: &A, + file_id: u64, + slice: BStackRange, +) -> io::Result<()> { + if file_id == 0 { + return unsafe { dealloc_range(allocator, slice) }; + } + let Some(id) = FileId::from_u64(file_id) else { + return Ok(()); // malformed id: cannot resolve → leak (permitted) + }; + match registry::with_host(id, |host| unsafe { host.dealloc(slice) }) { + Some(res) => res.map_err(|e| e.source), + None => Ok(()), // file not attached / registry down → leak (permitted) + } +} + /// **Complete** the allocator's staged transaction by reclaiming exactly the /// slices its outcome orphaned, then marking the block idle. Assumes the file's /// WAL lock is already held (used on the failure/recovery paths that run under the @@ -586,7 +682,7 @@ pub(crate) fn finish_at_locked(allocator: &A) -> io::Res // so a second crash can't double-free it. let entry_off = base + (i * size_of::()) as u64; stack.set(entry_off, [WalStatus::Complete as u8])?; - unsafe { dealloc_range(allocator, slice)? }; + free_recorded(allocator, e.file_id(), slice)?; completed += 1; } } @@ -632,30 +728,51 @@ mod tests { assert_eq!(a.status(), WalStatus::Pending); assert_eq!(a.as_alloc(), Some(BStackRange::new(0x1000, 256))); assert_eq!(a.as_dealloc(), None); + assert_eq!(a.file_id(), 0); // local convenience ctor ⇒ SELF let d = WalEntry::dealloc(WalStatus::Complete, BStackRange::new(0x6CD4, 256)); assert_eq!(d.op(), WalOp::Dealloc); assert_eq!(d.as_dealloc(), Some(BStackRange::new(0x6CD4, 256))); assert_eq!(d.as_alloc(), None); + assert_eq!(d.file_id(), 0); + + // Foreign-aware ctors carry the file id. + let fa = WalEntry::alloc_in( + WalStatus::Pending, + FileId::from_u64(7).unwrap(), + BStackRange::new(0x20, 48), + ); + assert_eq!(fa.file_id(), 7); + assert_eq!(fa.as_alloc(), Some(BStackRange::new(0x20, 48))); + let fd = WalEntry::dealloc_in( + WalStatus::Pending, + FileId::from_u64(3).unwrap(), + BStackRange::new(0x40, 16), + ); + assert_eq!(fd.file_id(), 3); + assert_eq!(fd.as_dealloc(), Some(BStackRange::new(0x40, 16))); } #[test] - fn wal_entry_is_24_bytes_and_pod_roundtrips() { - assert_eq!(size_of::(), 24); + fn wal_entry_is_32_bytes_and_pod_roundtrips() { + assert_eq!(size_of::(), 32); let mut log = WalLog::with_capacity(2); log.append(WalEntry::alloc( WalStatus::Pending, BStackRange::new(8192, 64), )); - log.append(WalEntry::dealloc( + log.append(WalEntry::dealloc_in( WalStatus::Pending, + FileId::from_u64(9).unwrap(), BStackRange::new(4096, 64), )); let bytes = log.as_bytes().to_vec(); let back = WalLog::entries_from_bytes(&bytes); assert_eq!(back.len(), 2); assert_eq!(back[0].as_alloc(), Some(BStackRange::new(8192, 64))); + assert_eq!(back[0].file_id(), 0); assert_eq!(back[1].as_dealloc(), Some(BStackRange::new(4096, 64))); + assert_eq!(back[1].file_id(), 9); // file id survives the on-disk round trip } #[test] @@ -671,20 +788,71 @@ mod tests { #[test] fn reduce_cancels_equal_length_pairs() { // (Alloc 256, Alloc 600, Dealloc 256) → reuse the 256 slice, Alloc 600 left. - let allocs = vec![AllocReq { id: 0, len: 256 }, AllocReq { id: 1, len: 600 }]; - let deallocs = vec![BStackRange::new(0x1FF0, 256)]; + let allocs = vec![ + AllocReq { + id: 0, + len: 256, + file_id: 0, + _obj_id: 0, + }, + AllocReq { + id: 1, + len: 600, + file_id: 0, + _obj_id: 0, + }, + ]; + let deallocs = vec![(0, BStackRange::new(0x1FF0, 256))]; let r = reduce(allocs, deallocs); assert_eq!(r.reused.len(), 1); - assert_eq!(r.reused[0].0, AllocReq { id: 0, len: 256 }); + assert_eq!( + r.reused[0].0, + AllocReq { + id: 0, + len: 256, + file_id: 0, + _obj_id: 0, + } + ); assert_eq!(r.reused[0].1, BStackRange::new(0x1FF0, 256)); - assert_eq!(r.allocs, vec![AllocReq { id: 1, len: 600 }]); + assert_eq!( + r.allocs, + vec![AllocReq { + id: 1, + len: 600, + file_id: 0, + _obj_id: 0, + }] + ); assert!(r.deallocs.is_empty()); } #[test] fn reduce_leaves_unpaired_on_both_sides() { - let allocs = vec![AllocReq { id: 0, len: 100 }]; - let deallocs = vec![BStackRange::new(8, 200)]; + let allocs = vec![AllocReq { + id: 0, + len: 100, + file_id: 0, + _obj_id: 0, + }]; + let deallocs = vec![(0, BStackRange::new(8, 200))]; + let r = reduce(allocs, deallocs); + assert!(r.reused.is_empty()); + assert_eq!(r.allocs.len(), 1); + assert_eq!(r.deallocs.len(), 1); + } + + #[test] + fn reduce_does_not_cancel_across_files() { + // Same length, different file ⇒ no reuse (a foreign alloc can't repurpose a + // local free, and vice versa): both sides remain as physical work. + let allocs = vec![AllocReq { + id: 0, + len: 128, + file_id: 4, + _obj_id: 0, + }]; + let deallocs = vec![(0, BStackRange::new(0x100, 128))]; let r = reduce(allocs, deallocs); assert!(r.reused.is_empty()); assert_eq!(r.allocs.len(), 1); From 9f0d2b1e29d99fd7f8dfab1962ef00ae78eb6db6 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 03:20:12 -0700 Subject: [PATCH 111/140] Foreign teardown --- bstack_raii/derive/src/block.rs | 52 ++++++++++++- bstack_raii/src/foreign.rs | 74 +++++++++++++++++- bstack_raii/src/lib.rs | 18 ++++- bstack_raii/src/registry.rs | 130 +++++++++++++++++++++++++++++++- bstack_raii/src/teardown.rs | 26 +++++-- bstack_raii/src/tests.rs | 120 ++++++++++++++++++++++++++++- 6 files changed, 408 insertions(+), 12 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index b449005..cb6c40e 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -439,8 +439,56 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result mv_recon.push(quote!(::bstack_raii::Foreign::from_ptr(#cap))); } - // Clone / teardown: none yet (deferred). No `clone_stmts` / `drop_stmts` - // entry — the ForeignPtr is byte-copied verbatim (alias) and not freed. + // Teardown: an owning foreign pointer frees / decrements / releases its + // target *in the target's own file*. The kind picks a helper; all run the + // ordinary generic teardown against whichever allocator addresses the + // target — the local `allocator` for a `SELF` pointer, or a + // `ForeignHostAllocator` (over the live host) for a cross-file one. Frees + // are tagged (via `wal_file_id`) with the target's file so the home WAL + // reclaims them there. `#[bstack_ref]` owns nothing → no teardown. + let foreign_drop_helper = match kind { + Kind::Owned => Some(quote!(::bstack_raii::foreign_drop_owned)), + Kind::Strong => Some(quote!(::bstack_raii::foreign_drop_strong)), + Kind::Weak => Some(quote!(::bstack_raii::foreign_drop_weak)), + // Ref: non-owning. Pod / Embed: already rejected above. + Kind::Ref | Kind::Pod | Kind::Embed => None, + }; + if let Some(helper) = foreign_drop_helper { + drop_stmts.push(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __on_disk.#fname; + // A `0` offset is the null / unset niche (nullable field, or a + // never-set pointer) — nothing to free. + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + // `SELF`: the target is in this same file. + unsafe { #helper::<#ftarget, _>(allocator, __off)?; } + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + // Foreign: adapt the live host to an allocator and run + // the same teardown against the other file. If that + // file isn't currently attached, the target is + // unreachable and leaks (permitted). + if let ::core::option::Option::Some(__host) = + ::bstack_raii::registry::host_arc(__id) + { + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { #helper::<#ftarget, _>(&__adapter, __off)?; } + } + } + // A malformed id (does not fit the `FileId` space) is + // unreachable → leak (skip), not an error. + } + } + }); + } + + // Deep clone across files is still DEFERRED — the `ForeignPtr` is + // byte-copied verbatim (an alias) on clone, regardless of annotation. continue; } diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index 7968b7c..734efc6 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -20,13 +20,17 @@ //! implemented here. use core::marker::PhantomData; +use std::io; use bstack::{BStack, BStackAllocator, BStackRange, BStackSlice}; use bytemuck::{Pod, Zeroable}; -use crate::block::BStackBlock; +use crate::BStackRaiiAllocator; +use crate::block::{BStackBlock, BStackShared, BStackWeakable}; +use crate::handle::{OwnedRef, WeakRef}; use crate::reference::BStackRef; use crate::registry::{self, FileId, FileRegistry}; +use crate::teardown::BStackDrop; /// The on-disk form of a [`Foreign`] pointer: a file identity plus an offset in /// that file. 16 bytes, `Pod`. The target's length is **not** stored — it is @@ -191,3 +195,71 @@ impl Foreign { } } } + +// --------------------------------------------------------------------------- +// Cross-file teardown helpers. +// +// These run a `Foreign` field's per-kind teardown in *whichever file the target +// lives in*, selected entirely by the `alloc` handed in: the local allocator for a +// `SELF` target, or a +// [`ForeignHostAllocator`](crate::registry::ForeignHostAllocator) (an allocator view +// of the foreign host) for a cross-file one. Because the whole teardown machinery +// (`OwnedRef` / `BStackShared::drop_strong_ref` / `WeakRef`, and the recursive +// `__bstack_drop_children` they call) is generic over `A: BStackRaiiAllocator`, the +// same code frees the target in its own file with no duplication — reads/writes go +// through `alloc.stack()`, and frees are tagged with `alloc.wal_file_id()` so the +// home WAL reclaims them in the right file. The generated `Foreign` field teardown +// picks the helper by the field's annotation. `#[bstack_ref]` has no helper (a +// foreign ref owns nothing). +// --------------------------------------------------------------------------- + +/// Tear down an `#[bstack_owned] Foreign` target: free the block at `offset` +/// (and, recursively, its own children) in the file `alloc` addresses. +/// +/// # Safety +/// `offset` names a live `T` block, in the file `alloc` addresses, exclusively owned +/// by this foreign pointer (freed exactly once). +pub unsafe fn foreign_drop_owned( + alloc: &A, + offset: u64, +) -> io::Result<()> { + let range = BStackRange::new(offset, core::mem::size_of::() as u64); + // SAFETY: `range` is the caller-asserted live block of `T`. + let child = unsafe { BStackRef::::from_range(range) }; + OwnedRef(child).bstack_drop(alloc) +} + +/// Tear down an `#[bstack_strong] Foreign` target: decrement the strong count at +/// `offset` (the target's *data* block) and, at zero, free it in the file `alloc` +/// addresses. `T` must be a shared block. +/// +/// # Safety +/// `offset` names a live shared `T` data block in the file `alloc` addresses, holding +/// one strong reference on behalf of this foreign pointer. +pub unsafe fn foreign_drop_strong( + alloc: &A, + offset: u64, +) -> io::Result<()> { + let range = BStackRange::new(offset, core::mem::size_of::() as u64); + // SAFETY: `range` is the caller-asserted live data block of a shared `T`. + let data = unsafe { BStackRef::::from_range(range) }; + ::drop_strong_ref(data, alloc) +} + +/// Tear down a `#[bstack_weak] Foreign` target: decrement the weak count in the +/// *control* block at `ctrl_offset` and, at zero, free the control block in the file +/// `alloc` addresses. The data block is never touched. `T` must be weakable, and the +/// foreign pointer stores the **control** offset (as an in-file weak field does). +/// +/// # Safety +/// `ctrl_offset` names a live `T::Control` block in the file `alloc` addresses, +/// holding one weak reference on behalf of this foreign pointer. +pub unsafe fn foreign_drop_weak( + alloc: &A, + ctrl_offset: u64, +) -> io::Result<()> { + let range = BStackRange::new(ctrl_offset, core::mem::size_of::() as u64); + // SAFETY: `range` is the caller-asserted live control block of a weakable `T`. + let ctrl = unsafe { BStackRef::::from_range(range) }; + WeakRef::(ctrl).bstack_drop(alloc) +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index c982d38..8168639 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -79,11 +79,14 @@ pub use clone::{ClonePlan, TryClone, TryCloneIn}; pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; -pub use foreign::{Foreign, ForeignPtr}; +pub use foreign::{ + Foreign, ForeignPtr, foreign_drop_owned, foreign_drop_strong, foreign_drop_weak, +}; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC, get_u64}; pub use owned::BStackOwned; pub use reference::BStackRef; +pub use registry::ForeignHostAllocator; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBox, BStackCountingBloomFilter, @@ -146,6 +149,19 @@ pub unsafe trait BStackRaiiAllocator: BStackOwnedSliceAllocator { fn wal_anchor(&self) -> Option { None } + + /// The [`FileId`](crate::registry::FileId) whose file this allocator's frees + /// belong to, used to **tag WAL teardown entries**. A normal file-owning + /// allocator represents *its own* file, so it returns [`FileId::SELF`](crate::registry::FileId::SELF) + /// (`0`) — its WAL entries mean "this file". The cross-file teardown adapter + /// [`ForeignHostAllocator`](crate::registry::ForeignHostAllocator) overrides this + /// with the foreign file's id, so a free collected while tearing down a foreign + /// subtree is recorded against — and, on recovery, reclaimed in — *that* file + /// (see [`crate::wal`]'s `free_recorded`). Callers other than the teardown WAL + /// have no reason to read this. + fn wal_file_id(&self) -> crate::registry::FileId { + crate::registry::FileId::SELF + } } // Re-exported whole so generated code can call `::bstack_raii::bytemuck::bytes_of`. pub use bytemuck; diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs index 571eccf..da213b6 100644 --- a/bstack_raii/src/registry.rs +++ b/bstack_raii/src/registry.rs @@ -41,7 +41,10 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use bstack::{BStack, BStackAllocator, BStackOwnedSlice, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{ + BStack, BStackAllocError, BStackAllocator, BStackOwnedSlice, BStackOwnedSliceAllocator, + BStackRange, +}; use parking_lot::RwLock; use crate::BStackRaiiAllocator; @@ -312,6 +315,114 @@ impl ForeignHost for A { } } +/// An **allocator adapter over a live foreign host** — the bridge that lets the +/// crate's entirely generic teardown / clone machinery (`OwnedRef`, `StrongRef`, +/// `WeakRef`, `dealloc_range`, `__bstack_drop_children`, …), all written against +/// `A: BStackRaiiAllocator`, run **against another file** without duplicating any of +/// it. A `Foreign` field's teardown resolves the target file's host through the +/// [registry](self), wraps it here, and runs the same `T` teardown it would run +/// locally — every read/write lands in the foreign file (via [`stack`](BStackAllocator::stack)), +/// and every free is [tagged](BStackRaiiAllocator::wal_file_id) with the foreign +/// [`FileId`] so the home file's WAL reclaims it *there*. +/// +/// It owns an `Arc` (not a borrow) precisely so it can be +/// `'static`, which [`BStackRaiiAllocator`]'s `'static` supertrait +/// ([`BStackOwnedSliceAllocator`]) demands — and so the host stays alive for the +/// whole teardown even if it is concurrently [`detach`](FileRegistry::detach)ed. +/// +/// [`into_stack`](BStackAllocator::into_stack) is unsupported (the adapter does not +/// own its `BStack` — the host does); it panics if ever called. The teardown path +/// only ever uses `stack` / `dealloc` / the refcount primitives, never `into_stack`. +pub struct ForeignHostAllocator { + host: Arc, + file_id: FileId, +} + +impl ForeignHostAllocator { + /// Adapt a live foreign `host` (as an owned `Arc`, e.g. from + /// [`host_arc`](FileRegistry::host_arc)) into an allocator whose frees are tagged + /// with `file_id`. + pub fn new(host: Arc, file_id: FileId) -> Self { + Self { host, file_id } + } +} + +impl BStackAllocator for ForeignHostAllocator { + type Error = io::Error; + type Allocated<'a> = BStackOwnedSlice<'a, Self>; + + fn stack(&self) -> &BStack { + self.host.stack() + } + + fn into_stack(self) -> BStack { + unreachable!( + "ForeignHostAllocator is a cross-file adapter over a shared host and cannot \ + be consumed into its BStack" + ) + } + + fn alloc(&self, len: u64) -> io::Result> { + let r = self.host.alloc(len)?; + // SAFETY: `r` is a fresh live allocation in the host's file; we wrap it as the + // owned handle bound to this adapter, which forwards frees to the same host. + Ok(unsafe { BStackOwnedSlice::from_raw_range(self, r) }) + } + + fn realloc<'a>( + &'a self, + handle: BStackOwnedSlice<'a, Self>, + new_len: u64, + ) -> Result, BStackAllocError<'a, Self>> { + let r = handle.as_range(); + // SAFETY: `handle` is a live allocation owned here; `r` names the same region. + match unsafe { self.host.realloc(r, new_len) } { + Ok(nr) => Ok(unsafe { BStackOwnedSlice::from_raw_range(self, nr) }), + Err(e) => Err(foreign_to_alloc_error(self, e)), + } + } + + fn dealloc<'a>( + &'a self, + handle: BStackOwnedSlice<'a, Self>, + ) -> Result<(), BStackAllocError<'a, Self>> { + let r = handle.as_range(); + // SAFETY: `handle` is a live allocation owned here; `r` names the same region. + match unsafe { self.host.dealloc(r) } { + Ok(()) => Ok(()), + Err(e) => Err(foreign_to_alloc_error(self, e)), + } + } +} + +// SAFETY: (1) null niche — the adapter forwards to a real host allocator, which +// upholds it. (2) `wal_anchor` mirrors the host's; `wal_file_id` names the foreign +// file so its frees are reclaimed there. +unsafe impl BStackRaiiAllocator for ForeignHostAllocator { + fn wal_anchor(&self) -> Option { + self.host.wal_anchor() + } + fn wal_file_id(&self) -> FileId { + self.file_id + } +} + +/// Convert a host-level [`ForeignAllocError`] (range-based) into the allocator-level +/// [`BStackAllocError`] the `BStackAllocator` trait speaks, re-wrapping any surviving +/// range as an owned handle bound to `alloc`. +fn foreign_to_alloc_error( + alloc: &ForeignHostAllocator, + e: ForeignAllocError, +) -> BStackAllocError<'_, ForeignHostAllocator> { + match e.handle { + // SAFETY: `h` is the region the failed op left intact in the host's file. + Some(h) => BStackAllocError::with_handle(e.source, unsafe { + BStackOwnedSlice::from_raw_range(alloc, h) + }), + None => BStackAllocError::lost(e.source), + } +} + /// Persistent backing: an append-only log on the registry's own bstack file. /// /// No allocator needed — the path table is append-only, and a `BStack` *is* a @@ -498,6 +609,17 @@ impl<'h> FileRegistry<'h> { Some(f(&**host)) } + /// Clone out `id`'s live host as an owned [`Arc`], or `None` if `id` is unknown / + /// not currently live. Unlike [`with_host`](Self::with_host) (which lends a + /// `&dyn ForeignHost` only for the span of a closure), this hands back an owned + /// handle that keeps the host alive independently of the registry — the basis for + /// the `'static` [`ForeignHostAllocator`], which needs to outlive the lock and + /// survive a concurrent [`detach`](Self::detach) mid-teardown. + pub fn host_arc(&self, id: FileId) -> Option> { + let idx = table_index(id)?; + self.inner.read_recursive().live.get(idx)?.clone() + } + /// The path registered for `id`, if any (`None` for `SELF` / special ids). pub fn path_of(&self, id: FileId) -> Option { let idx = table_index(id)?; @@ -602,6 +724,12 @@ pub fn id_of_host(stack: &BStack) -> Option { REGISTRY.get()?.id_of_host(stack) } +/// [`FileRegistry::host_arc`] on the process-wide registry — the owned-`Arc` host +/// lookup that cross-file teardown/clone use to build a [`ForeignHostAllocator`]. +pub fn host_arc(id: FileId) -> Option> { + REGISTRY.get()?.host_arc(id) +} + /// The id registered for `path`, if any. pub fn id_of(path: impl AsRef) -> Option { REGISTRY.get()?.id_of(path.as_ref()) diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index 05c7227..7580eb5 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -13,6 +13,7 @@ use std::io; use bstack::{BStackGenOp, BStackOwnedSlice, BStackRange}; use crate::BStackRaiiAllocator; +use crate::registry::FileId; use crate::wal::{WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for}; thread_local! { @@ -22,7 +23,14 @@ thread_local! { /// recursion and nested handle `bstack_drop`s see it transparently (they all /// go through `dealloc_range`), so no allocator/sink parameter has to be /// threaded through the whole teardown. - static TEARDOWN_SINK: RefCell>> = const { RefCell::new(None) }; + /// + /// Each entry is `(file_id, range)`: the [`FileId`] the slice lives in — the + /// tearing allocator's [`wal_file_id`](BStackRaiiAllocator::wal_file_id), which is + /// [`SELF`](FileId::SELF) for the home file and the foreign id when a + /// `Foreign` subtree is being torn down through a `ForeignHostAllocator`. The + /// WAL commits each entry against its own file so recovery reclaims it there. + static TEARDOWN_SINK: RefCell>> = + const { RefCell::new(None) }; } /// Tear down `handle` (a whole owned subtree) as one crash-atomic batch of frees, @@ -69,7 +77,10 @@ pub fn wal_teardown( /// lock, so concurrent teardowns on the same file serialize here (they collect /// their subtrees independently first — that part stays concurrent) rather than /// racing the single shared anchor slot. -fn wal_free_all(allocator: &A, slices: Vec) -> io::Result<()> { +fn wal_free_all( + allocator: &A, + slices: Vec<(FileId, BStackRange)>, +) -> io::Result<()> { if slices.is_empty() { return Ok(()); } @@ -77,8 +88,10 @@ fn wal_free_all(allocator: &A, slices: Vec) let _guard = lock.lock().unwrap_or_else(|e| e.into_inner()); let mut log = WalLog::with_capacity(slices.len()); - for s in &slices { - log.append(WalEntry::dealloc(WalStatus::Pending, *s)); + for (fid, s) in &slices { + // `fid == SELF` ⇒ a local free (this file); a foreign id ⇒ reclaimed in that + // file through the registry on `finish` (see `wal::free_recorded`). + log.append(WalEntry::dealloc_in(WalStatus::Pending, *fid, *s)); } // Stage the transaction `Pending`, then commit it by flipping `txn_status` to // `Complete` in one atomic `inplace_gen` — the single commit point (a crash @@ -136,7 +149,10 @@ pub unsafe fn dealloc_range( // subtree commits (and frees) as one crash-atomic transaction. let deferred = TEARDOWN_SINK.with(|s| match s.borrow_mut().as_mut() { Some(sink) => { - sink.push(range); + // Tag the slice with the file it lives in: the tearing allocator's + // `wal_file_id` — `SELF` for the home file, the foreign id for a + // `ForeignHostAllocator` tearing down a cross-file subtree. + sink.push((allocator.wal_file_id(), range)); true } None => false, diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 0f77ba2..f2118ec 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7053,7 +7053,6 @@ fn macro_foreign_field() { Some(88) ); assert!(h.handle().get_maybe(stack).unwrap().is_none()); // the `None` niche - h.bstack_drop(&local).unwrap(); // A present optional link resolves like any other Foreign. let h2 = ForeignHolder::new( @@ -7068,7 +7067,124 @@ fn macro_foreign_field() { m.with_in(®, &local, |t, fs| t.get_val(fs).unwrap()), Some(88) ); - h2.bstack_drop(&local).unwrap(); + + // NB: this test exercises construction / accessors / nullability against a + // *scoped* registry, and deliberately reuses one target `off` across `h` and + // `h2`. It does NOT tear the holders down: cross-file teardown resolves the + // *global* registry (the owning `Foreign` would free the shared target — twice), + // which is covered by the dedicated `macro_foreign_*_teardown_*` tests. The bare + // `BStackOwned` holders simply drop as inert handles here. + let _ = (h, h2); +} + +// A home block holding a *strong* cross-file reference. `MacroStrongChild` is +// `#[bstack_block(rc, weak)]`, so it is a shared target; the strong Foreign +// participates in its refcount on the far side. +#[bstack_block] +struct ForeignStrongHolder { + tag: u32, + #[bstack_strong] + link: Foreign, +} + +#[test] +fn macro_foreign_owned_teardown_reclaims_across_files() { + // Cross-file teardown dispatch (option 1): tearing down a block with a + // `#[bstack_owned] Foreign` field frees the target **in the target's own + // file**, resolved through the process-wide registry (adapter → home WAL → + // `free_recorded` → foreign host). Uses the global registry, like real code. + use crate::Foreign; + use crate::registry; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let foreign_alloc = foreign.allocator(); + + // Baseline foreign length, then allocate the owned target in the foreign file. + let base = foreign_alloc.stack().len().unwrap(); + let leaf = MacroLeaf::new(&foreign_alloc, 88).unwrap(); + let off = leaf.handle().range().start(); + let grown = foreign_alloc.stack().len().unwrap(); + assert!(grown > base, "target should have grown the foreign file"); + + // Global registry + attach the foreign file (tolerant of a prior init; several + // tests share the singleton, each attaching its own file → distinct ids). + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let fid = registry::attach(&foreign.path, foreign_alloc).unwrap(); + assert!(!fid.is_self()); + + // A home block owning the foreign target. + let h = ForeignHolder::new(&home_alloc, 7, Foreign::::new(fid, off), None).unwrap(); + + // Tearing the home block down frees the target across the file boundary. + h.bstack_drop(&home_alloc).unwrap(); + + // The foreign file shrank back to its pre-target length: the target was + // reclaimed in its own file (a leak would leave it at `grown`). + let after = registry::with_host(fid, |host| host.stack().len().unwrap()).unwrap(); + assert!( + after <= base, + "foreign owned target was not reclaimed on teardown: {after} > {base}" + ); + + registry::detach(fid); +} + +#[test] +fn macro_foreign_strong_teardown_frees_at_zero_across_files() { + // Cross-file RC teardown: a `#[bstack_strong] Foreign` decrements the target's + // strong count *in the target's own file* (via the foreign host's stack + the + // atomic refcount primitives), and frees data + control when it hits zero. + use crate::Foreign; + use crate::registry; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let foreign_alloc = foreign.allocator(); + + let data_size = size_of::<::OnDisk>() as u64; + let ctrl_size = size_of::<::Control>() as u64; + + // A shared target in the foreign file: strong = 1, weak = 1, back-pointer wired. + let base = foreign_alloc.stack().len().unwrap(); + let data = alloc_block(&foreign_alloc, MacroStrongChild::eightcc(), data_size).unwrap(); + let ctrl = alloc_control(&foreign_alloc, ctrl_tag(), data, ctrl_size).unwrap(); + let data_off = data.start(); + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + assert_eq!( + crate::refcount::load(foreign_alloc.stack(), strong_off).unwrap(), + 1 + ); + let grown = foreign_alloc.stack().len().unwrap(); + assert!(grown > base); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let fid = registry::attach(&foreign.path, foreign_alloc).unwrap(); + + // The home block is the sole strong owner (count is 1) across the file boundary. + let h = ForeignStrongHolder::new( + &home_alloc, + 1, + Foreign::::new(fid, data_off), + ) + .unwrap(); + + // Teardown drives the far-side strong count 1 -> 0, freeing data + control there. + h.bstack_drop(&home_alloc).unwrap(); + + let after = registry::with_host(fid, |host| host.stack().len().unwrap()).unwrap(); + assert!( + after <= base, + "foreign strong target not freed at zero: {after} > {base}" + ); + + registry::detach(fid); } #[test] From 6b04158383460d81f3b7e3fe4b0f8352bc3d3fba Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 03:38:29 -0700 Subject: [PATCH 112/140] Add concurrent tests for teardown --- bstack_raii/src/tests.rs | 131 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index f2118ec..047810f 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7187,6 +7187,137 @@ fn macro_foreign_strong_teardown_frees_at_zero_across_files() { registry::detach(fid); } +#[test] +fn macro_foreign_concurrent_ab_ba_teardown() { + // The AB-BA stress: objects on file A own targets on file B, objects on B own + // targets on A, and BOTH directions are torn down concurrently. This is the + // cross-file analogue of the same-file concurrent-teardown race that the WAL + // mutex was introduced to fix. It exercises the whole cross-file teardown + // locking story — each teardown's WAL transaction on its *home* file (per-file + // mutex) + the registry read lock + plain `dealloc`s into the *other* file — for + // deadlock, double-free, and FirstFit free-list corruption. Completion (no hang) + // ⇒ no deadlock; full reclamation each round ⇒ no leak / no double-free. + use crate::Foreign; + use crate::registry; + use std::sync::Arc; + use std::thread; + + let fa = TempStack::new(); + let fb = TempStack::new(); + // One allocator per file, shared as an `Arc` between direct home teardowns + // (`&**arc`) and the registry's cross-file resolution — the SAME instance on both + // sides, so there is no illegal double-open of a file. + let arc_a = Arc::new(fa.allocator()); + let arc_b = Arc::new(fb.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid_a = reg.attach(&fa.path, arc_a.clone()).unwrap(); + let fid_b = reg.attach(&fb.path, arc_b.clone()).unwrap(); + + // Warm + size both persistent WAL blocks (one holder each way), so the reclaimed + // baseline already accounts for them; then record it. + { + let bl = MacroLeaf::new(&*arc_b, 1).unwrap(); + ForeignHolder::new( + &*arc_a, + 0, + Foreign::::new(fid_b, bl.handle().range().start()), + None, + ) + .unwrap() + .bstack_drop(&*arc_a) + .unwrap(); + let al = MacroLeaf::new(&*arc_a, 1).unwrap(); + ForeignHolder::new( + &*arc_b, + 0, + Foreign::::new(fid_a, al.handle().range().start()), + None, + ) + .unwrap() + .bstack_drop(&*arc_b) + .unwrap(); + } + let base_a = arc_a.stack().len().unwrap(); + let base_b = arc_b.stack().len().unwrap(); + + const N: usize = 48; + const ROUNDS: usize = 3; + const THREADS: usize = 4; + for _ in 0..ROUNDS { + // N A-holders (each owns a DISTINCT leaf on B) + N B-holders (each a distinct + // leaf on A). Distinct objects ⇒ disjoint free sets across the two directions. + let mut a_holders = Vec::with_capacity(N); + let mut b_holders = Vec::with_capacity(N); + for i in 0..N as u32 { + let bl = MacroLeaf::new(&*arc_b, i).unwrap(); + a_holders.push( + ForeignHolder::new( + &*arc_a, + i, + Foreign::::new(fid_b, bl.handle().range().start()), + None, + ) + .unwrap() + .into_inner(), + ); + let al = MacroLeaf::new(&*arc_a, i).unwrap(); + b_holders.push( + ForeignHolder::new( + &*arc_b, + i, + Foreign::::new(fid_a, al.handle().range().start()), + None, + ) + .unwrap() + .into_inner(), + ); + } + + // Tear both directions down at once: A-holder threads free into B while + // B-holder threads free into A — the AB-BA contention. + let chunk = N.div_ceil(THREADS); + thread::scope(|s| { + let arc_a = &arc_a; + let arc_b = &arc_b; + for part in a_holders.chunks(chunk) { + let part = part.to_vec(); + s.spawn(move || { + for h in part { + h.bstack_drop(&**arc_a).unwrap(); + } + }); + } + for part in b_holders.chunks(chunk) { + let part = part.to_vec(); + s.spawn(move || { + for h in part { + h.bstack_drop(&**arc_b).unwrap(); + } + }); + } + }); + + // Both files returned exactly to baseline: every holder shell AND every + // cross-file target was reclaimed, with no leak and no corruption. + assert_eq!( + arc_a.stack().len().unwrap(), + base_a, + "file A not fully reclaimed after concurrent AB-BA teardown" + ); + assert_eq!( + arc_b.stack().len().unwrap(), + base_b, + "file B not fully reclaimed after concurrent AB-BA teardown" + ); + } + + reg.detach(fid_a); + reg.detach(fid_b); +} + #[test] fn foreign_reverse_map_and_bstack_cast() { use crate::registry::{FileId, FileRegistry}; From 8902d892fcb7423924fdbe28cce59d9cb0f6ad3b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 03:56:25 -0700 Subject: [PATCH 113/140] Foreign Clone --- bstack_raii/derive/src/block.rs | 133 +++++++++++- bstack_raii/src/foreign.rs | 77 +++++++ bstack_raii/src/lib.rs | 3 +- bstack_raii/src/tests.rs | 363 ++++++++++++++++++++++++++++++++ 4 files changed, 573 insertions(+), 3 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index cb6c40e..78e899c 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -487,8 +487,137 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result }); } - // Deep clone across files is still DEFERRED — the `ForeignPtr` is - // byte-copied verbatim (an alias) on clone, regardless of annotation. + // Deep clone: per-kind, acting on the target *in its own file*. `owned` + // deep-copies the target (a fresh block, the pointer repointed); `strong` + // / `weak` share it and bump its count; `ref` aliases (byte-copied — no + // clone_stmt). A `SELF` pointer folds into the *home* plan (atomic with the + // home commit); a foreign one acts eagerly via the adapter (best-effort, + // over-provisioning ⇒ leak, never under ⇒ double-free). A detached target + // file makes the clone error (aliasing an owner would double-free later). + let target_od_size = quote! { + ::core::mem::size_of::<<#ftarget as ::bstack_raii::BStackBlock>::OnDisk>() as u64 + }; + let foreign_clone_stmt = match kind { + Kind::Owned => Some(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __od.#fname; + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + // SELF: deep-clone into the home plan (one atomic commit). + let __child = <#ftarget as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #target_od_size), + ); + let __new = __child.__bstack_clone_into(allocator, __plan)?; + __od.#fname = ::bstack_raii::ForeignPtr::new(0, __new.start()); + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| ::std::io::Error::new( + ::std::io::ErrorKind::NotFound, + "cannot deep-clone `#[bstack_owned] Foreign`: \ + target file not attached", + ))?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + let __new_off = unsafe { + ::bstack_raii::foreign_clone_owned::<#ftarget, _>( + &__adapter, __off, + )? + }; + __od.#fname = ::bstack_raii::ForeignPtr::new(__fid, __new_off); + } else { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, + "cannot clone `Foreign`: malformed file id", + )); + } + } + } + }), + Kind::Strong => Some(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __od.#fname; + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + // SELF: bump the strong count via the home plan (atomic). + let __data = unsafe { + ::bstack_raii::BStackRef::<#ftarget>::from_range( + ::bstack_raii::BStackRange::new(__off, #target_od_size), + ) + }; + __plan.bump_strong(__data, allocator)?; + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| ::std::io::Error::new( + ::std::io::ErrorKind::NotFound, + "cannot clone `#[bstack_strong] Foreign`: \ + target file not attached", + ))?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { + ::bstack_raii::foreign_clone_strong::<#ftarget, _>( + &__adapter, __off, + )?; + } + // The pointer is unchanged (shares the same target). + } else { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, + "cannot clone `Foreign`: malformed file id", + )); + } + } + } + }), + Kind::Weak => Some(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __od.#fname; + // For a weak pointer, the offset is the target's control block. + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + // SELF: bump the weak count via the home plan (atomic). + __plan.bump_weak(__off); + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| ::std::io::Error::new( + ::std::io::ErrorKind::NotFound, + "cannot clone `#[bstack_weak] Foreign`: \ + target file not attached", + ))?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { + ::bstack_raii::foreign_clone_weak::<#ftarget, _>( + &__adapter, __off, + )?; + } + } else { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, + "cannot clone `Foreign`: malformed file id", + )); + } + } + } + }), + // Ref aliases (byte-copied verbatim); Pod / Embed already rejected. + Kind::Ref | Kind::Pod | Kind::Embed => None, + }; + if let Some(cs) = foreign_clone_stmt { + clone_stmts.push(cs); + } continue; } diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index 734efc6..2d0a602 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -27,7 +27,10 @@ use bytemuck::{Pod, Zeroable}; use crate::BStackRaiiAllocator; use crate::block::{BStackBlock, BStackShared, BStackWeakable}; +use crate::clone::TryCloneIn; use crate::handle::{OwnedRef, WeakRef}; +use crate::layout; +use crate::refcount; use crate::reference::BStackRef; use crate::registry::{self, FileId, FileRegistry}; use crate::teardown::BStackDrop; @@ -263,3 +266,77 @@ pub unsafe fn foreign_drop_weak( let ctrl = unsafe { BStackRef::::from_range(range) }; WeakRef::(ctrl).bstack_drop(alloc) } + +// --------------------------------------------------------------------------- +// Cross-file deep-clone helpers (the mirror of the teardown helpers above). +// +// A `Foreign` field's clone acts on the target *in the target's own file* per its +// annotation: `#[bstack_owned]` deep-copies the target (a fresh block on that file, +// the pointer repointed); `#[bstack_strong]` / `#[bstack_weak]` share the same target +// and bump its count; `#[bstack_ref]` aliases (byte-copied — no helper). A `SELF` +// pointer instead folds into the *home* clone plan (atomic with the home commit); the +// generated code picks that path. These helpers cover the cross-file case. +// +// **Atomicity across files is best-effort (option-1):** the foreign side is touched +// eagerly (its own commit for `owned`, an atomic increment for `strong`/`weak`) BEFORE +// the home clone commits, and a home failure afterwards does not undo it. That always +// errs toward **over-provisioning** — an orphaned fresh block, or an over-count — which +// leaks, never toward an under-count (which would be a premature free / double-free). +// A target file that is not currently attached makes the clone **error** (aliasing an +// owning pointer would create a second owner ⇒ later double-free); the generated code +// enforces that before calling these. +// --------------------------------------------------------------------------- + +/// Deep-clone an `#[bstack_owned] Foreign` target at `offset` in the file `alloc` +/// addresses; returns the new copy's offset. Self-contained atomic commit on that +/// file; eager (a later home-clone failure leaks the new block). +/// +/// # Safety +/// `offset` names a live `T` block, in the file `alloc` addresses, owned by this +/// foreign pointer. +pub unsafe fn foreign_clone_owned( + alloc: &A, + offset: u64, +) -> io::Result { + let range = BStackRange::new(offset, core::mem::size_of::() as u64); + let src = T::from_range(range); + let new = src.try_clone_in(alloc)?; + Ok(new.handle().range().start()) +} + +/// Bump the strong count of an `#[bstack_strong] Foreign` target at `offset` (its +/// data block) in the file `alloc` addresses — the strong reference the clone +/// acquires. Eager atomic increment. +/// +/// # Safety +/// `offset` names a live shared `T` data block in the file `alloc` addresses. +pub unsafe fn foreign_clone_strong( + alloc: &A, + offset: u64, +) -> io::Result<()> { + let range = BStackRange::new(offset, core::mem::size_of::() as u64); + // SAFETY: `range` is the caller-asserted live data block of a shared `T`. + let data = unsafe { BStackRef::::from_range(range) }; + let (data_ref, ctrl) = ::strong_parts(data, alloc)?; + let off = match ctrl { + None => data_ref.into_range().start() + layout::RC_REFCOUNT_OFFSET, + Some(c) => c.start() + layout::CTRL_STRONG_OFFSET, + }; + refcount::fetch_add(alloc.stack(), off, 1)?; + Ok(()) +} + +/// Bump the weak count of a `#[bstack_weak] Foreign` target's control block at +/// `ctrl_offset` in the file `alloc` addresses — the weak reference the clone +/// acquires. Eager atomic increment. (`T` documents the intended weakable target; the +/// increment needs only the offset.) +/// +/// # Safety +/// `ctrl_offset` names a live `T::Control` block in the file `alloc` addresses. +pub unsafe fn foreign_clone_weak( + alloc: &A, + ctrl_offset: u64, +) -> io::Result<()> { + refcount::fetch_add(alloc.stack(), ctrl_offset + layout::CTRL_WEAK_OFFSET, 1)?; + Ok(()) +} diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 8168639..88501c3 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -80,7 +80,8 @@ pub use construct::{ alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, }; pub use foreign::{ - Foreign, ForeignPtr, foreign_drop_owned, foreign_drop_strong, foreign_drop_weak, + Foreign, ForeignPtr, foreign_clone_owned, foreign_clone_strong, foreign_clone_weak, + foreign_drop_owned, foreign_drop_strong, foreign_drop_weak, }; pub use handle::{OwnedRef, StrongRef, StrongWeakRef, WeakRef}; pub use layout::{BlockHeader, EightCC, get_u64}; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 047810f..f6961b5 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7087,6 +7087,15 @@ struct ForeignStrongHolder { link: Foreign, } +// A home block holding a *weak* cross-file reference. The stored offset is the +// target's CONTROL block (as an in-file weak field stores). +#[bstack_block] +struct ForeignWeakHolder { + tag: u32, + #[bstack_weak] + link: Foreign, +} + #[test] fn macro_foreign_owned_teardown_reclaims_across_files() { // Cross-file teardown dispatch (option 1): tearing down a block with a @@ -7318,6 +7327,360 @@ fn macro_foreign_concurrent_ab_ba_teardown() { reg.detach(fid_b); } +#[test] +fn macro_foreign_owned_clone_deep_copies_across_files() { + // Cross-file deep clone: cloning a block with a `#[bstack_owned] Foreign` field + // deep-copies the target INTO ITS OWN FILE (a fresh block, the pointer repointed), + // so the clone is independent — tearing both down frees both copies, no double-free. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + + let foreign = TempStack::new(); + // Keep the foreign allocator as an Arc so we can both build typed leaves on it + // (`&*arc_b`) and attach it for cross-file resolution (same instance). + let arc_b = Arc::new(foreign.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + // Warm the owned-clone path once (this creates B's persistent WAL block, since the + // cross-file deep copy runs a WAL-backed clone on B); then record the baseline. + { + let l0 = MacroLeaf::new(&*arc_b, 0).unwrap(); + let h0 = ForeignHolder::new( + &home_alloc, + 0, + Foreign::::new(fid, l0.handle().range().start()), + None, + ) + .unwrap(); + let c0 = h0.handle().try_clone_in(&home_alloc).unwrap(); + h0.bstack_drop(&home_alloc).unwrap(); + c0.bstack_drop(&home_alloc).unwrap(); + } + let base_b = arc_b.stack().len().unwrap(); + + // The real target + home holder owning it. + let leaf = MacroLeaf::new(&*arc_b, 42).unwrap(); + let off = leaf.handle().range().start(); + let h = ForeignHolder::new(&home_alloc, 7, Foreign::::new(fid, off), None).unwrap(); + + // Deep clone the home holder: its owned foreign target is copied on B. + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + + // The clone points at a DIFFERENT block on B (a fresh copy, not an alias)… + let clone_link = c.handle().get_owned_link(hstack).unwrap(); + assert_eq!(clone_link.file_id(), fid); + assert_ne!( + clone_link.offset(), + off, + "owned clone must be a fresh copy, not an alias" + ); + // …carrying the same value (a genuine deep copy). + assert_eq!( + clone_link + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 42 + ); + + // Independence: tearing both holders down frees BOTH leaves on B (no double-free, + // no leak) — the file returns exactly to the warmed baseline. + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base_b, + "clone+teardown leaked or double-freed on the foreign file" + ); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_strong_clone_bumps_count_across_files() { + // Cross-file strong clone: cloning a `#[bstack_strong] Foreign` shares the same + // target and bumps its strong count on the far side; both clones releasing it + // (teardown) drive it back to zero and free it. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let data_size = size_of::<::OnDisk>() as u64; + let ctrl_size = size_of::<::Control>() as u64; + + let base = arc_b.stack().len().unwrap(); + let data = alloc_block(&*arc_b, MacroStrongChild::eightcc(), data_size).unwrap(); + let ctrl = alloc_control(&*arc_b, ctrl_tag(), data, ctrl_size).unwrap(); + let data_off = data.start(); + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let load = |o: u64| crate::refcount::load(arc_b.stack(), o).unwrap(); + assert_eq!(load(strong_off), 1); + + // One strong owner across the boundary; cloning it makes two. + let h = ForeignStrongHolder::new( + &home_alloc, + 1, + Foreign::::new(fid, data_off), + ) + .unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + assert_eq!( + load(strong_off), + 2, + "strong clone should bump the far count" + ); + + // Both owners releasing drives the count to zero and frees the target. + h.bstack_drop(&home_alloc).unwrap(); + assert_eq!(load(strong_off), 1); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "target should be reclaimed once both strong owners drop" + ); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_weak_clone_bumps_count_across_files() { + // Cross-file weak clone: cloning a `#[bstack_weak] Foreign` shares the same + // control block and bumps its weak count on the far side; teardown decrements it. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let data_size = size_of::<::OnDisk>() as u64; + let ctrl_size = size_of::<::Control>() as u64; + let data = alloc_block(&*arc_b, MacroStrongChild::eightcc(), data_size).unwrap(); + let ctrl = alloc_control(&*arc_b, ctrl_tag(), data, ctrl_size).unwrap(); + let ctrl_off = ctrl.start(); + let weak_off = ctrl_off + layout::CTRL_WEAK_OFFSET; + // alloc_control leaves strong=1, weak=1 (the phantom the strong owners hold). Add + // one weak for the holder we are about to create (construction does not bump). + crate::refcount::fetch_add(arc_b.stack(), weak_off, 1).unwrap(); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let load = |o: u64| crate::refcount::load(arc_b.stack(), o).unwrap(); + assert_eq!(load(weak_off), 2); // phantom + holder + + // A weak foreign holder points at the CONTROL block; cloning it bumps weak. + let h = ForeignWeakHolder::new( + &home_alloc, + 1, + Foreign::::new(fid, ctrl_off), + ) + .unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + assert_eq!( + load(weak_off), + 3, + "weak clone should bump the far weak count" + ); + + // Both weak owners releasing brings it back down (the phantom keeps it alive). + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + load(weak_off), + 1, + "weak teardown should decrement the far count" + ); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_concurrent_ab_ba_clone() { + // AB-BA stress for CLONE. Unlike teardown (whose cross-file frees are plain + // deallocs), a cross-file owned clone runs a WAL-backed `try_clone_in` on the + // TARGET file — so it takes the *target's* WAL mutex, then its *home* file's WAL + // mutex on commit. This drives clones both ways concurrently to confirm those two + // acquisitions never cycle (they're sequential, not nested) and that every deep + // copy is independent (no double-free / leak on the ensuing teardown). + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + use std::thread; + + let fa = TempStack::new(); + let fb = TempStack::new(); + let arc_a = Arc::new(fa.allocator()); + let arc_b = Arc::new(fb.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid_a = reg.attach(&fa.path, arc_a.clone()).unwrap(); + let fid_b = reg.attach(&fb.path, arc_b.clone()).unwrap(); + + // Warm both files' WAL blocks (each is a clone home AND a cross-file clone target), + // then baseline. + for (ha, hb, tgt) in [(&arc_a, &arc_b, fid_b), (&arc_b, &arc_a, fid_a)] { + let l = MacroLeaf::new(&**hb, 0).unwrap(); + let h = ForeignHolder::new( + &**ha, + 0, + Foreign::::new(tgt, l.handle().range().start()), + None, + ) + .unwrap(); + h.handle() + .try_clone_in(&**ha) + .unwrap() + .bstack_drop(&**ha) + .unwrap(); + h.bstack_drop(&**ha).unwrap(); + } + let base_a = arc_a.stack().len().unwrap(); + let base_b = arc_b.stack().len().unwrap(); + + const N: usize = 32; + const THREADS: usize = 4; + let mut a_orig = Vec::with_capacity(N); + let mut b_orig = Vec::with_capacity(N); + for i in 0..N as u32 { + let bl = MacroLeaf::new(&*arc_b, i).unwrap(); + a_orig.push( + ForeignHolder::new( + &*arc_a, + i, + Foreign::::new(fid_b, bl.handle().range().start()), + None, + ) + .unwrap() + .into_inner(), + ); + let al = MacroLeaf::new(&*arc_a, i).unwrap(); + b_orig.push( + ForeignHolder::new( + &*arc_b, + i, + Foreign::::new(fid_a, al.handle().range().start()), + None, + ) + .unwrap() + .into_inner(), + ); + } + + // Clone both directions at once: A-holder clones deep-copy into B (taking B's WAL + // mutex) while B-holder clones deep-copy into A. + let chunk = N.div_ceil(THREADS); + let (a_clones, b_clones) = thread::scope(|s| { + let arc_a = &arc_a; + let arc_b = &arc_b; + let mut ja = Vec::new(); + let mut jb = Vec::new(); + for part in a_orig.chunks(chunk) { + let part = part.to_vec(); + ja.push(s.spawn(move || { + part.iter() + .map(|h| h.try_clone_in(&**arc_a).unwrap().into_inner()) + .collect::>() + })); + } + for part in b_orig.chunks(chunk) { + let part = part.to_vec(); + jb.push(s.spawn(move || { + part.iter() + .map(|h| h.try_clone_in(&**arc_b).unwrap().into_inner()) + .collect::>() + })); + } + let a_clones: Vec<_> = ja.into_iter().flat_map(|j| j.join().unwrap()).collect(); + let b_clones: Vec<_> = jb.into_iter().flat_map(|j| j.join().unwrap()).collect(); + (a_clones, b_clones) + }); + assert_eq!(a_clones.len(), N); + assert_eq!(b_clones.len(), N); + + // Tear down originals + clones. Every original leaf AND every independent deep copy + // is reclaimed ⇒ both files return exactly to baseline (no leak, no double-free); + // completion ⇒ no deadlock across the two WAL mutexes. + for h in a_orig.into_iter().chain(a_clones) { + h.bstack_drop(&*arc_a).unwrap(); + } + for h in b_orig.into_iter().chain(b_clones) { + h.bstack_drop(&*arc_b).unwrap(); + } + assert_eq!( + arc_a.stack().len().unwrap(), + base_a, + "file A not fully reclaimed after concurrent AB-BA clone" + ); + assert_eq!( + arc_b.stack().len().unwrap(), + base_b, + "file B not fully reclaimed after concurrent AB-BA clone" + ); + + reg.detach(fid_a); + reg.detach(fid_b); +} + +#[test] +fn macro_foreign_owned_clone_errors_when_target_file_detached() { + // Cloning an owning `Foreign` whose target file is not attached must ERROR (not + // silently alias — that would create a second owner and later double-free). + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let leaf = MacroLeaf::new(&*arc_b, 5).unwrap(); + let off = leaf.handle().range().start(); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let h = ForeignHolder::new(&home_alloc, 1, Foreign::::new(fid, off), None).unwrap(); + + // Detach the target file → the deep clone cannot copy the target → error. + reg.detach(fid); + assert!( + h.handle().try_clone_in(&home_alloc).is_err(), + "cloning an owned Foreign with a detached target file must error, not alias" + ); +} + #[test] fn foreign_reverse_map_and_bstack_cast() { use crate::registry::{FileId, FileRegistry}; From 6817999ef425f84b6aa0c550bf083708e037ea45 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 04:59:42 -0700 Subject: [PATCH 114/140] Enhance support for foreign in edge cases --- bstack_raii/derive/src/block.rs | 714 ++++++++++++++++++++++++++++++-- bstack_raii/src/lib.rs | 83 ++++ bstack_raii/src/tests.rs | 485 +++++++++++++++++++++- 3 files changed, 1236 insertions(+), 46 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 78e899c..8348987 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -122,6 +122,11 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result strong: bool, weak: bool, in_ondisk: bool, + /// The parameter is the target of a `#[bstack_owned] Foreign` (scalar or in + /// a container). Unlike a plain owned child (cloned via `__bstack_clone_into`, + /// needing only `BStackBlock`), an owned foreign deep-clone runs a self- + /// contained `try_clone_in` on the target's file, so it needs `TryCloneIn`. + foreign_owned: bool, } let mut usage: Vec<(Ident, Usage)> = type_params .iter() @@ -132,10 +137,28 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result if !type_mentions_any(&field.ty, &type_params) { continue; } + // A foreign field lowers to a `ForeignPtr` (its target `T` is never stored + // inline), so the target parameter is a *block reference*, not a POD/embed — + // regardless of the container it sits in. Detect it up front so the bounds are + // `BStackBlock` (+ `TryCloneIn` for owned, and the usual strong/weak) rather + // than the `Pod`/`in_ondisk` a bare `Foreign` field would otherwise imply. + let foreign_target = field_foreign_target(&field.ty); for (p, u) in usage.iter_mut() { if !type_mentions_any(&field.ty, &[&*p]) { continue; } + if foreign_target.is_some_and(|t| type_mentions_any(t, &[&*p])) { + // The parameter is a foreign *target*: a block reference in its own + // file. Kind names the ownership of that target. + u.blockish = true; + match kind { + Kind::Owned => u.foreign_owned = true, + Kind::Strong => u.strong = true, + Kind::Weak => u.weak = true, + _ => {} + } + continue; + } match kind { Kind::Pod => { u.pod = true; @@ -189,6 +212,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result tp.bounds .push(syn::parse_quote!(::bstack_raii::BStackWeakable)); } + if u.foreign_owned { + // An owned foreign target is deep-cloned via its own `try_clone_in`. + tp.bounds.push(syn::parse_quote!(::bstack_raii::TryCloneIn)); + } } } // A parameter stored inline makes `XOnDisk: Pod` depend on it, and @@ -329,6 +356,24 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // offending construct. Valid mixes (`Option>>`, …) pass. check_container_nesting(eff_ty)?; + // `Foreign` is supported only in a handful of shapes — a scalar `Foreign` / + // `Option>`, a `Vec>` / `Vec>>`, or a + // `[Foreign; N]` (nested / per-element `Option`). If it appears anywhere + // else (inside a tuple, a POD aggregate, a `Vec` of tuples, …) `field_foreign_target` + // can't reach it, yet the type still mentions it — reject with a directed message + // rather than leaking a bare, unresolved `Foreign` type into the output. + if tokens_mention(quote!(#eff_ty), &[&format_ident!("Foreign")]) + && field_foreign_target(eff_ty).is_none() + { + return Err(Error::new_spanned( + &field.ty, + "`Foreign` is nested in an unsupported position (e.g. inside a tuple or another \ + POD aggregate). It is supported as a scalar `Foreign` / `Option>`, \ + a `Vec>` / `Vec>>`, or a `[Foreign; N]` — \ + anywhere else, wrap the `Foreign` inside a `#[bstack_block]` struct and use that.", + )); + } + // `Foreign`: a cross-file wide pointer, stored inline as a 16-byte // `ForeignPtr` `(file_id, offset)` and resolved through the registry (length // recovered from `size_of::()`, so it is not stored). The field's @@ -340,47 +385,19 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // clone (an alias) and freed by nobody on teardown, whatever the annotation; // the annotation is recorded for the eventual per-kind dispatch. if let Some(ftarget) = foreign_inner(opt_inner) { - // A `Foreign` points at a *block* in another file, so it must carry an - // ownership annotation naming the target's kind — never bare (POD is an - // inline value, not a pointer) and never `#[embed]` (can't inline a - // cross-file pointer). The annotation's dispatch (teardown/clone) is - // deferred, but the kind is required now. - match kind { - Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} - Kind::Pod => { - return Err(Error::new_spanned( - &field.ty, - "`Foreign` needs an ownership annotation naming the target's \ - kind in its own file (`#[bstack_owned/strong/weak/ref]`); a bare \ - `Foreign` (POD) is not allowed — a foreign pointer targets a block", - )); - } - Kind::Embed => { - return Err(Error::new_spanned( - &field.ty, - "`Foreign` is a pointer and cannot be `#[embed]`ed", - )); - } - } - // `Foreign>` is almost always a mistake: a nullable foreign - // *pointer* is `Option>` (the pointer is absent), not a pointer - // to a nullable field. - if option_inner(ftarget).is_some() { - return Err(Error::new_spanned( - &field.ty, - "use `Option>` for a nullable foreign pointer, not \ - `Foreign>` (a foreign pointer targets a block, not a nullable field)", - )); - } - // The target must be a bstack block (ref-able) — reject `Foreign`. - let assert_fn = format_ident!("__bstack_foreign_target_{}", fname); - wrapper_defs.push(quote! { - #[doc(hidden)] - const _: fn() = { - fn #assert_fn<__T: ::bstack_raii::BStackBlock>() {} - #assert_fn::<#ftarget> - }; - }); + // A `Foreign` points at a *block* in another file: it must carry an + // ownership annotation, and its target must be a bstack block — not a + // pointer, a container, or a tuple (see `validate_foreign_target`). + // Nullable at the *field* level via `Option>` (handled below). + validate_foreign_target( + kind, + ftarget, + &field.ty, + "`Foreign`", + format_ident!("__bstack_foreign_target_{}", fname), + !type_mentions_any(ftarget, &type_params), + &mut wrapper_defs, + )?; on_disk_fields.push(quote!(#fname: ::bstack_raii::ForeignPtr,)); let field_ty = quote!(::bstack_raii::Foreign<#ftarget>); let cap = format_ident!("__cap_{}", fname); @@ -647,6 +664,167 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result )); } + // `#[ann] Vec>` — a growable vector of cross-file wide + // pointers, each an owning foreign reference per the annotation. Stored as + // a POD-style vector of `ForeignPtr` (16 B each); construction / access map + // to `Foreign`, and teardown / clone dispatch each element cross-file + // exactly like a scalar `Foreign` field. A null/unset element is a + // `Foreign` whose offset is `0` (skipped by teardown / clone). + if let Some(velem) = vec_inner(opt_inner) + && let Some(ftarget) = foreign_inner(option_inner(velem).unwrap_or(velem)) + { + // `Vec>>`: a per-element-nullable vector (a stored + // offset of `0` reads as `None`); `Vec>` is the plain form. + let elem_nullable = option_inner(velem).is_some(); + validate_foreign_target( + kind, + ftarget, + &field.ty, + "`Vec>`", + format_ident!("__bstack_foreign_vec_target_{}", fname), + !type_mentions_any(ftarget, &type_params), + &mut wrapper_defs, + )?; + + let store = quote!(::bstack_raii::BStackVec::<::bstack_raii::ForeignPtr, __A>); + let field_loc = + quote!(self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64); + let field_ty = if elem_nullable { + quote!(::core::option::Option<::bstack_raii::Foreign<#ftarget>>) + } else { + quote!(::bstack_raii::Foreign<#ftarget>) + }; + // Map a stored `ForeignPtr` ↔ the element type (offset 0 ⇒ `None` when + // the element is `Option`-wrapped). + let from_ptr = if elem_nullable { + quote!(|__p: ::bstack_raii::ForeignPtr| if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ftarget>::from_ptr(__p)) + }) + } else { + quote!(::bstack_raii::Foreign::<#ftarget>::from_ptr) + }; + let to_ptr = if elem_nullable { + quote!(|__f: #field_ty| match __f { + ::core::option::Option::Some(__ff) => __ff.ptr(), + ::core::option::Option::None => ::bstack_raii::ForeignPtr::new(0, 0), + }) + } else { + quote!(|__f: #field_ty| __f.ptr()) + }; + + // ---- Accessor: `Vec>` / `Vec>>` (or `Option<..>`) ---- + let (acc_ret, acc_body) = if nullable { + ( + quote!(::core::option::Option<::std::vec::Vec<#field_ty>>), + quote!(match unsafe { #store::from_field_opt(#field_loc, allocator) }? { + ::core::option::Option::Some(__v) => ::core::option::Option::Some( + __v.to_vec()? + .into_iter() + .map(#from_ptr) + .collect()), + ::core::option::Option::None => ::core::option::Option::None, + }), + ) + } else { + ( + quote!(::std::vec::Vec<#field_ty>), + quote!(unsafe { #store::from_field(#field_loc, allocator)? } + .to_vec()? + .into_iter() + .map(#from_ptr) + .collect()), + ) + }; + accessors.push(quote! { + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<#acc_ret> { + ::std::result::Result::Ok(#acc_body) + } + }); + + // ---- Constructor: `Vec>` → a `ForeignPtr` data block ---- + let build = quote! { + let __ptrs: ::std::vec::Vec<::bstack_raii::ForeignPtr> = + __list.into_iter().map(#to_ptr).collect(); + #store::from_slice(allocator, &__ptrs)?.descriptor() + }; + let (param, prep) = if nullable { + ( + quote!(#fname: ::core::option::Option<::std::vec::Vec<#field_ty>>,), + quote! { + let #fname: ::bstack_raii::VecDesc = match #fname { + ::core::option::Option::Some(__list) => { #build } + ::core::option::Option::None => ::core::default::Default::default(), + }; + }, + ) + } else { + ( + quote!(#fname: ::std::vec::Vec<#field_ty>,), + quote! { + let #fname: ::bstack_raii::VecDesc = { let __list = #fname; #build }; + }, + ) + }; + ctor_params.push(param); + ctor_preps.push(prep); + ctor_inits.push(quote!(#fname: #fname,)); + + // ---- Teardown: dispatch each element, then free the data block ---- + let elem_drop = foreign_elem_drop(kind, ftarget); + let drop_loop = if matches!(kind, Kind::Ref) { + quote!() + } else { + quote! { + for __fp in #store::from_desc(__desc, allocator).to_vec()? { #elem_drop } + } + }; + drop_stmts.push(quote! { + { + let __desc: ::bstack_raii::VecDesc = __on_disk.#fname; + if __desc.data_off != 0 { + #drop_loop + #store::from_desc(__desc, allocator).bstack_drop()?; + } + } + }); + + // ---- Clone: dispatch each element into a fresh `ForeignPtr` block ---- + let elem_clone = foreign_elem_clone(kind, ftarget); + clone_stmts.push(quote! { + { + let __srcdesc: ::bstack_raii::VecDesc = __od.#fname; + if __srcdesc.data_off != 0 { + let __src = #store::from_desc(__srcdesc, allocator).to_vec()?; + let mut __new: ::std::vec::Vec<::bstack_raii::ForeignPtr> = + ::std::vec::Vec::with_capacity(__src.len()); + for __fp in __src { + #elem_clone + __new.push(__newfp); + } + __od.#fname = __plan.stage_bytevec( + allocator, ::bstack_raii::bytemuck::cast_slice(&__new))?; + } + } + }); + + // ---- Move: the raw `ForeignPtr` vector handle ---- + let (mvt, mvr) = wrap_vec_move( + quote!(::bstack_raii::BStackVec<'__mv, ::bstack_raii::ForeignPtr, __A>), + quote!(::bstack_raii::BStackVec::from_desc(#cap, __alloc)), + &cap, + nullable, + ); + mv_types.push(mvt); + mv_recon.push(mvr); + continue; + } + // `#[bstack_owned/strong/weak/ref] Vec<[T; N]>` — a vector whose // elements are fixed-size arrays of block references (nested `[[T;N];M]` // and per-element `[Option; N]` allowed). The offsets are stored @@ -1264,6 +1442,154 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } } + // `#[ann] [Foreign; N]` — an inline array of cross-file wide pointers + // (possibly nested `[[Foreign; A]; B]` / per-element `[Option>; N]`). + // Stored flat as `[ForeignPtr; TOTAL]` inline (16 B each, no data block); each + // slot's teardown / clone dispatches cross-file exactly like a scalar `Foreign`. + // A null/unset slot is a `Foreign` whose offset is `0`. Must be annotated. + if let Type::Array(_) = opt_inner { + let (adims, aleaf, aleaf_nullable) = array_shape(opt_inner)?; + if let Some(ftarget) = foreign_inner(aleaf) { + reject_nested_const_dims(&adims, &const_params, &field.ty)?; + validate_foreign_target( + kind, + ftarget, + &field.ty, + "`[Foreign; N]`", + format_ident!("__bstack_foreign_arr_target_{}", fname), + !type_mentions_any(ftarget, &type_params), + &mut wrapper_defs, + )?; + if nullable { + return Err(Error::new_spanned( + &field.ty, + "a whole-array `Option<[Foreign; N]>` is not supported; a null foreign \ + element is a `Foreign` with offset 0, or use `[Option>; N]`", + )); + } + let total = dims_prod(&adims); + let field_ty = quote!(::bstack_raii::Foreign<#ftarget>); + on_disk_fields.push(quote!(#fname: [::bstack_raii::ForeignPtr; #total],)); + + // ---- Accessor: nested `[[Foreign; ..]; ..]` (Option per slot) ---- + let leaf_ty = if aleaf_nullable { + quote!(::core::option::Option<#field_ty>) + } else { + field_ty.clone() + }; + let acc_ret = nested_ty(&adims, &leaf_ty); + let acc_read = |k: &Ident| { + if aleaf_nullable { + quote!({ + let __p = __arr[#k]; + if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ftarget>::from_ptr(__p)) + } + }) + } else { + quote!(::bstack_raii::Foreign::<#ftarget>::from_ptr(__arr[#k])) + } + }; + let acc_body = nested_build(&adims, &leaf_ty, &acc_read); + accessors.push(quote! { + #vis fn #getter( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<#acc_ret> { + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; + let __arr: [::bstack_raii::ForeignPtr; #total] = __od.#fname; + ::std::result::Result::Ok(#acc_body) + } + }); + + // ---- Constructor: nested `[[Foreign; ..]; ..]` → flat `[ForeignPtr; TOTAL]` ---- + let param_leaf = if aleaf_nullable { + quote!(::core::option::Option<#field_ty>) + } else { + field_ty.clone() + }; + let param_ty = nested_ty(&adims, ¶m_leaf); + ctor_params.push(quote!(#fname: #param_ty,)); + let ctor_write = |k: &Ident, leaf: &Ident| { + if aleaf_nullable { + quote!(__slots[#k] = match #leaf { + ::core::option::Option::Some(__f) => __f.ptr(), + ::core::option::Option::None => ::bstack_raii::ForeignPtr::new(0, 0), + };) + } else { + quote!(__slots[#k] = #leaf.ptr();) + } + }; + let flatten = nested_consume(&adims, "e!(#fname), &ctor_write); + ctor_preps.push(quote! { + let #fname: [::bstack_raii::ForeignPtr; #total] = { + let mut __slots = [::bstack_raii::ForeignPtr::new(0, 0); #total]; + #flatten + __slots + }; + }); + ctor_inits.push(quote!(#fname: #fname,)); + + // ---- Teardown: dispatch each slot (inline; nothing else to free) ---- + let elem_drop = foreign_elem_drop(kind, ftarget); + let drop_body = if matches!(kind, Kind::Ref) { + quote!() + } else { + quote! { + let __arr: [::bstack_raii::ForeignPtr; #total] = __on_disk.#fname; + for __k in 0usize..(#total) { + let __fp = __arr[__k]; + #elem_drop + } + } + }; + drop_stmts.push(quote! { { #drop_body } }); + + // ---- Clone: dispatch each slot into a fresh `[ForeignPtr; TOTAL]` ---- + let elem_clone = foreign_elem_clone(kind, ftarget); + clone_stmts.push(quote! { + { + let __arr: [::bstack_raii::ForeignPtr; #total] = __od.#fname; + let mut __narr: [::bstack_raii::ForeignPtr; #total] = __arr; + for __k in 0usize..(#total) { + let __fp = __arr[__k]; + #elem_clone + __narr[__k] = __newfp; + } + __od.#fname = __narr; + } + }); + + // ---- Move: materialize the nested `[[Foreign; ..]; ..]` values ---- + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + let mv_leaf = leaf_ty.clone(); + mv_types.push(nested_ty(&adims, &mv_leaf)); + let mv_read = |k: &Ident| { + if aleaf_nullable { + quote!({ + let __p = #cap[#k]; + if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ftarget>::from_ptr(__p)) + } + }) + } else { + quote!(::bstack_raii::Foreign::<#ftarget>::from_ptr(#cap[#k])) + } + }; + mv_recon.push(nested_build(&adims, &mv_leaf, &mv_read)); + continue; + } + } + // Inline fixed-size array `[T; N]` — possibly *nested* `[[..]; ..]` — of // block references. (A POD array falls through to the POD path below: an // array of `Pod` is `Pod`.) Stored **flat** as `[u64; N0*..*Nk]` inline @@ -2981,6 +3307,312 @@ fn option_inner(ty: &Type) -> Option<&Type> { } } +/// Per-element cross-file **teardown** dispatch, given `__fp: ForeignPtr` and +/// `allocator` in scope. Frees / decrements / releases the target in its own file: +/// `SELF` (`file_id == 0`) via the local `allocator`, a foreign id via a +/// [`ForeignHostAllocator`] over the resolved host (skipped — a permitted leak — if +/// that file is not attached). `offset == 0` (null / unset) is skipped. +/// `#[bstack_ref]` owns nothing → empty. Shared with the scalar `Foreign` field. +fn foreign_elem_drop(kind: Kind, ftarget: &Type) -> TokenStream { + let helper = match kind { + Kind::Owned => quote!(::bstack_raii::foreign_drop_owned), + Kind::Strong => quote!(::bstack_raii::foreign_drop_strong), + Kind::Weak => quote!(::bstack_raii::foreign_drop_weak), + _ => return quote!(), + }; + quote! { + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + unsafe { #helper::<#ftarget, _>(allocator, __off)?; } + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + if let ::core::option::Option::Some(__host) = + ::bstack_raii::registry::host_arc(__id) + { + let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { #helper::<#ftarget, _>(&__adapter, __off)?; } + } + } + } + } +} + +/// Per-element cross-file **clone** dispatch, given `__fp: ForeignPtr`, `allocator`, +/// and `__plan` in scope. Binds `__newfp: ForeignPtr` — `#[bstack_owned]` deep-copies +/// the target (repointing the pointer), `#[bstack_strong]` / `#[bstack_weak]` share it +/// and bump its count (pointer unchanged), `#[bstack_ref]` aliases. `SELF` folds into +/// the home `__plan`; a foreign target goes through a [`ForeignHostAllocator`], and a +/// detached target file **errors** (aliasing an owner would double-free later). +fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { + let od_size = quote! { + ::core::mem::size_of::<<#ftarget as ::bstack_raii::BStackBlock>::OnDisk>() as u64 + }; + let not_attached = |ann: &str| { + let msg = format!("cannot clone `{ann} Foreign` element: target file not attached"); + quote! { + ::std::io::Error::new(::std::io::ErrorKind::NotFound, #msg) + } + }; + let malformed = quote! { + return ::std::result::Result::Err(::std::io::Error::new( + ::std::io::ErrorKind::InvalidData, "cannot clone `Foreign`: malformed file id")); + }; + match kind { + Kind::Owned => { + let err = not_attached("#[bstack_owned]"); + quote! { + let __newfp: ::bstack_raii::ForeignPtr = { + let __off = __fp.offset(); + if __off == 0 { + __fp + } else { + let __fid = __fp.file_id(); + if __fid == 0 { + let __child = <#ftarget as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #od_size)); + let __new = __child.__bstack_clone_into(allocator, __plan)?; + ::bstack_raii::ForeignPtr::new(0, __new.start()) + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| #err)?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + let __new_off = unsafe { + ::bstack_raii::foreign_clone_owned::<#ftarget, _>(&__adapter, __off)? }; + ::bstack_raii::ForeignPtr::new(__fid, __new_off) + } else { + #malformed + } + } + }; + } + } + Kind::Strong => { + let err = not_attached("#[bstack_strong]"); + quote! { + let __newfp: ::bstack_raii::ForeignPtr = { + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + let __data = unsafe { ::bstack_raii::BStackRef::<#ftarget>::from_range( + ::bstack_raii::BStackRange::new(__off, #od_size)) }; + __plan.bump_strong(__data, allocator)?; + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| #err)?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { ::bstack_raii::foreign_clone_strong::<#ftarget, _>(&__adapter, __off)?; } + } else { + #malformed + } + } + __fp + }; + } + } + Kind::Weak => { + let err = not_attached("#[bstack_weak]"); + quote! { + let __newfp: ::bstack_raii::ForeignPtr = { + let __off = __fp.offset(); + if __off != 0 { + let __fid = __fp.file_id(); + if __fid == 0 { + __plan.bump_weak(__off); + } else if let ::core::option::Option::Some(__id) = + ::bstack_raii::registry::FileId::from_u64(__fid) + { + let __host = ::bstack_raii::registry::host_arc(__id) + .ok_or_else(|| #err)?; + let __adapter = + ::bstack_raii::ForeignHostAllocator::new(__host, __id); + unsafe { ::bstack_raii::foreign_clone_weak::<#ftarget, _>(&__adapter, __off)?; } + } else { + #malformed + } + } + __fp + }; + } + } + // Ref aliases the pointer verbatim. + _ => quote! { let __newfp: ::bstack_raii::ForeignPtr = __fp; }, + } +} + +/// Validate a `Foreign` element/field's annotation + target: annotation required +/// (`owned/strong/weak/ref`, not POD/embed), target must be a bstack block (assertion +/// pushed into `wrapper_defs`), and `Foreign>` rejected. `what` names the +/// construct for error messages (e.g. "`Vec>`"). Shared by the vector and +/// array Foreign branches. +fn validate_foreign_target( + kind: Kind, + ftarget: &Type, + span: &Type, + what: &str, + assert_name: Ident, + emit_assert: bool, + wrapper_defs: &mut Vec, +) -> syn::Result<()> { + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + span, + format!( + "{what} needs an ownership annotation naming the target's kind \ + (`#[bstack_owned/strong/weak/ref]`); a bare foreign pointer targets a block" + ), + )); + } + Kind::Embed => { + return Err(Error::new_spanned( + span, + "`Foreign` is a pointer and cannot be `#[embed]`ed", + )); + } + } + reject_bad_foreign_target(ftarget, span, what)?; + // The `T: BStackBlock` check is a non-generic `const`, which can't name a struct + // type parameter. For a generic target the bound is enforced instead through the + // generated impls' where-clauses (see the `Usage`/`aug_generics` machinery), so + // the caller passes `emit_assert = false`. + if emit_assert { + wrapper_defs.push(quote! { + #[doc(hidden)] + const _: fn() = { + fn #assert_name<__T: ::bstack_raii::BStackBlock>() {} + #assert_name::<#ftarget> + }; + }); + } + Ok(()) +} + +/// Reject a `Foreign` whose target `T` is not a plain bstack block — the +/// **"no double bstack pointer"** rule. A `Foreign` is a cross-file *pointer to a +/// block*, so `T` must be a `#[bstack_block]`, never: +/// +/// * **another `Foreign`** (`Foreign>`) — a pointer to a pointer; +/// * a **nullable pointer** (`Foreign>>`) — a double pointer via +/// `Option`; or a plain `Foreign>` (nullability belongs on the *field* +/// as `Option>`); +/// * a **container** (`Foreign>` / `Foreign` / `Foreign<[U; N]>`) — a +/// pointer to a collection; +/// * a **tuple**. +/// +/// This is deliberately **not** the `Vec>`-style container-nesting rule: a +/// `Foreign` is a pointer, so a collection *of* pointers is fine — `Vec>` +/// and `[Foreign; N]` are allowed. It is a pointer *to* a collection/pointer that +/// is barred. In every rejected case the fix is to bridge with an explicit +/// `#[bstack_block]` struct wrapping the offending inner type and point a `Foreign` at +/// *that*. +fn reject_bad_foreign_target(ftarget: &Type, span: &Type, what: &str) -> syn::Result<()> { + let bridge = "bridge it inside an explicit `#[bstack_block]` struct and point the \ + `Foreign` at that struct"; + if foreign_inner(ftarget).is_some() { + return Err(Error::new_spanned( + span, + format!( + "{what}: a `Foreign` cannot point at another `Foreign` — a pointer to a \ + pointer is not allowed. {bridge}. (A collection OF pointers such as \ + `Vec>` / `[Foreign; N]` IS allowed; it is a pointer TO a \ + pointer/collection that is not.)" + ), + )); + } + if let Some(inner) = option_inner(ftarget) { + if foreign_inner(inner).is_some() { + return Err(Error::new_spanned( + span, + format!( + "{what}: `Foreign>>` is a double `Foreign` (a pointer to a \ + nullable pointer). {bridge}." + ), + )); + } + return Err(Error::new_spanned( + span, + format!( + "{what}: use `Option>` for a nullable foreign pointer, not \ + `Foreign>` — nullability belongs on the field/element, and a null \ + element is a `Foreign` with offset 0, not a pointer to a nullable value." + ), + )); + } + if vec_field(ftarget).is_some() || is_str(ftarget) { + return Err(Error::new_spanned( + span, + format!( + "{what}: a `Foreign` target must be a `#[bstack_block]`, not a `Vec` / `String`. \ + `Vec>` (a vector OF pointers) is allowed, but `Foreign>` (a \ + pointer TO a vector) is not — {bridge}." + ), + )); + } + if let Type::Array(_) = ftarget { + return Err(Error::new_spanned( + span, + format!( + "{what}: a `Foreign` target must be a `#[bstack_block]`, not an array. \ + `[Foreign; N]` (an array OF pointers) is allowed, but `Foreign<[T; N]>` is \ + not — {bridge}." + ), + )); + } + if let Type::Tuple(_) = ftarget { + return Err(Error::new_spanned( + span, + format!( + "{what}: a `Foreign` target must be a `#[bstack_block]`, not a tuple — {bridge}." + ), + )); + } + Ok(()) +} + +/// Find the `Foreign` **target** `T` inside a field type, digging through the +/// field-level `Option`, a `Vec` (and its per-element `Option`), and an array (nested, +/// with per-element `Option`) — the shapes the foreign scalar / vec / array branches +/// accept. `None` if the field holds no `Foreign`. Used to compute generic bounds for +/// a foreign field's target parameter. +fn field_foreign_target(ty: &Type) -> Option<&Type> { + // Field-level `Option<..>`. + let t = option_inner(ty).unwrap_or(ty); + // Scalar `Foreign`. + if let Some(x) = foreign_inner(t) { + return Some(x); + } + // `Vec>` / `Vec>>`. + if let Some(ve) = vec_inner(t) { + let ve = option_inner(ve).unwrap_or(ve); + if let Some(x) = foreign_inner(ve) { + return Some(x); + } + } + // `[Foreign; N]` (nested / per-element `Option`). + if let Type::Array(_) = t { + let mut cur = t; + while let Type::Array(a) = cur { + cur = &a.elem; + } + let cur = option_inner(cur).unwrap_or(cur); + if let Some(x) = foreign_inner(cur) { + return Some(x); + } + } + None +} + /// Peek `Foreign` → `T`: a cross-file wide-pointer field. fn foreign_inner(ty: &Type) -> Option<&Type> { let Type::Path(tp) = ty else { diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 88501c3..8ef37f0 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -403,5 +403,88 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// struct Holder { #[bstack_owned] link: Foreign> } /// # fn main() {} /// ``` +/// +/// **No double `Foreign`** (`Foreign>`) — a pointer to a pointer; bridge +/// with a `#[bstack_block]` struct: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign> } +/// # fn main() {} +/// ``` +/// +/// **No double `Foreign` through `Option`** (`Foreign>>`): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign>> } +/// # fn main() {} +/// ``` +/// +/// **No pointer to a `Vec`** (`Foreign>`) — `Vec>` is the allowed +/// form (a vector OF pointers); a pointer TO a vector must be bridged: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign> } +/// # fn main() {} +/// ``` +/// +/// **No pointer to an array** (`Foreign<[T; N]>`) — `[Foreign; N]` is allowed: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign<[Leaf; 4]> } +/// # fn main() {} +/// ``` +/// +/// **No pointer to a tuple** (`Foreign<(A, B)>`): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: Foreign<(Leaf, Leaf)> } +/// # fn main() {} +/// ``` +/// +/// **No pointer to a `String`** (`Foreign`): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] +/// struct Holder { #[bstack_owned] link: bstack_raii::Foreign } +/// # fn main() {} +/// ``` +/// +/// The bad target also applies **inside a container** — e.g. a `Vec` of double +/// foreigns (`Vec>>`) is rejected the same way: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] links: Vec>> } +/// # fn main() {} +/// ``` +/// +/// **`Foreign` inside a tuple** — unsupported position; bridge with a struct: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] t: (u32, Foreign) } +/// # fn main() {} +/// ``` +/// +/// **`Foreign` inside a tuple inside a `Vec`** (`Vec<(_, Foreign)>`): +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block] struct Leaf { v: u32 } +/// #[bstack_block] +/// struct Holder { #[bstack_owned] v: Vec<(u32, Foreign)> } +/// # fn main() {} +/// ``` #[doc(hidden)] pub mod __macro_compile_fail_tests {} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index f6961b5..086470b 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7096,6 +7096,86 @@ struct ForeignWeakHolder { link: Foreign, } +// A home block owning a *vector* of cross-file pointers. +#[bstack_block] +struct ForeignVecHolder { + tag: u32, + #[bstack_owned] + links: Vec>, +} + +// A home block owning a fixed-size *array* of cross-file pointers. +#[bstack_block] +struct ForeignArrHolder { + tag: u32, + #[bstack_owned] + links: [Foreign; 3], +} + +// A home block holding a vector of *strong* cross-file references. +#[bstack_block] +struct ForeignStrongVecHolder { + tag: u32, + #[bstack_strong] + links: Vec>, +} + +// -------- Generic foreign: the target is a struct type parameter -------- + +#[bstack_block] +struct GenForeign { + tag: u32, + #[bstack_owned] + link: Foreign, +} + +#[bstack_block] +struct GenForeignVec { + #[bstack_owned] + links: Vec>, +} + +// -------- Cursed-but-VALID foreign container combinations (must compile) -------- + +// Per-element-`Option` array of 8 owning pointers. +#[bstack_block] +struct CursedArr8 { + tag: u32, + #[bstack_owned] + slots: [Option>; 8], +} + +// A *nested* array of strong pointers. +#[bstack_block] +struct CursedNestedArr { + #[bstack_strong] + grid: [[Foreign; 2]; 3], +} + +// A single block mixing a nullable owned vector-of-pointers, a ref vector-of-pointers, +// a nullable weak scalar pointer, and a plain owned scalar pointer. +#[bstack_block] +struct CursedMix { + #[bstack_owned] + maybe_owned: Option>>, + #[bstack_ref] + refs: Vec>, + #[bstack_weak] + maybe_weak: Option>, + #[bstack_owned] + one: Foreign, + // The deep one: a nullable vector of nullable foreign pointers. + #[bstack_owned] + deep: Option>>>, +} + +// A vector whose elements are *nullable* foreign pointers. +#[bstack_block] +struct OptForeignVecHolder { + #[bstack_owned] + links: Vec>>, +} + #[test] fn macro_foreign_owned_teardown_reclaims_across_files() { // Cross-file teardown dispatch (option 1): tearing down a block with a @@ -7650,6 +7730,394 @@ fn macro_foreign_concurrent_ab_ba_clone() { reg.detach(fid_b); } +#[test] +fn macro_foreign_vec_owned_across_files() { + // `#[bstack_owned] Vec>`: each element owns a cross-file target. + // Construction/access map to `Foreign`; clone deep-copies EVERY element on the + // far side; teardown frees every element there. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + // Warm the owned-vec clone path (creates B's WAL block), then baseline. + { + let l = MacroLeaf::new(&*arc_b, 0).unwrap(); + let h = ForeignVecHolder::new( + &home_alloc, + 0, + vec![Foreign::::new(fid, l.handle().range().start())], + ) + .unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + h.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + // N owned foreign targets on B. + const N: u32 = 5; + let mut links = Vec::new(); + for i in 0..N { + let l = MacroLeaf::new(&*arc_b, 100 + i).unwrap(); + links.push(Foreign::::new(fid, l.handle().range().start())); + } + let h = ForeignVecHolder::new(&home_alloc, 7, links).unwrap(); + + // Accessor yields N `Foreign`s resolving to the right values. + let got = h.handle().get_links(&home_alloc).unwrap(); + assert_eq!(got.len(), N as usize); + for (i, f) in got.iter().enumerate() { + assert_eq!( + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + 100 + i as u32 + ); + } + + // Deep clone: every element is copied to a fresh block on B (different offsets), + // same values. + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + let clinks = c.handle().get_links(&home_alloc).unwrap(); + assert_eq!(clinks.len(), N as usize); + for (o, n) in got.iter().zip(clinks.iter()) { + assert_ne!(o.offset(), n.offset(), "each element must be a fresh copy"); + assert_eq!( + n.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + o.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap() + ); + } + + // Tearing both down frees all 2N targets on B → back to baseline. + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "foreign-vec clone/teardown leaked or double-freed on B" + ); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_array_owned_across_files() { + // `#[bstack_owned] [Foreign; N]`: an inline fixed array of owning cross-file + // pointers. Same per-element teardown / clone as the vector, but stored inline. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // Warm the owned-array clone path (creates B's WAL block), baseline. + { + let h = ForeignArrHolder::new(&home_alloc, 0, [mk(0), mk(0), mk(0)]).unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + h.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + let h = ForeignArrHolder::new(&home_alloc, 7, [mk(10), mk(20), mk(30)]).unwrap(); + let got = h.handle().get_links(hstack).unwrap(); + let vals: Vec = got + .iter() + .map(|f| f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap()) + .collect(); + assert_eq!(vals, vec![10, 20, 30]); + + // Deep clone: every slot copied to a fresh block on B, same values. + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + let clinks = c.handle().get_links(hstack).unwrap(); + for (o, n) in got.iter().zip(clinks.iter()) { + assert_ne!(o.offset(), n.offset(), "each slot must be a fresh copy"); + } + let cvals: Vec = clinks + .iter() + .map(|f| f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap()) + .collect(); + assert_eq!(cvals, vec![10, 20, 30]); + + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "foreign-array clone/teardown leaked or double-freed on B" + ); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_strong_vec_across_files() { + // `#[bstack_strong] Vec>`: cloning bumps EVERY element's strong count + // on the far side; teardown decrements each. (Counts checked directly.) + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let ds = size_of::<::OnDisk>() as u64; + let cs = size_of::<::Control>() as u64; + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + // 3 shared targets on B, strong = 1 each. + let mut links = Vec::new(); + let mut strong_offs = Vec::new(); + for _ in 0..3 { + let d = alloc_block(&*arc_b, MacroStrongChild::eightcc(), ds).unwrap(); + let c = alloc_control(&*arc_b, ctrl_tag(), d, cs).unwrap(); + strong_offs.push(c.start() + layout::CTRL_STRONG_OFFSET); + links.push(Foreign::::new(fid, d.start())); + } + let load = |o: u64| crate::refcount::load(arc_b.stack(), o).unwrap(); + for &o in &strong_offs { + assert_eq!(load(o), 1); + } + + let h = ForeignStrongVecHolder::new(&home_alloc, 1, links).unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + for &o in &strong_offs { + assert_eq!(load(o), 2, "each strong vec element should bump on clone"); + } + + // h releases one ref per element; the clone still holds the other. + h.bstack_drop(&home_alloc).unwrap(); + for &o in &strong_offs { + assert_eq!( + load(o), + 1, + "each element should drop to 1 after one owner tears down" + ); + } + // The clone releasing drives each to zero and frees the targets (no panic). + c.bstack_drop(&home_alloc).unwrap(); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_generic_across_files() { + // A `Foreign` over a struct type parameter `T`: the macro derives `T: + // BStackBlock (+ TryCloneIn for owned)` on the generated impls, so a generic block + // deep-clones / tears down its foreign target exactly like a concrete one. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + // Warm B's WAL block via one owned-clone cycle, then baseline. + { + let l = MacroLeaf::new(&*arc_b, 0).unwrap(); + let h = GenForeign::::new( + &home_alloc, + 0, + Foreign::new(fid, l.handle().range().start()), + ) + .unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + h.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + let l = MacroLeaf::new(&*arc_b, 55).unwrap(); + let off = l.handle().range().start(); + let h = GenForeign::::new(&home_alloc, 7, Foreign::new(fid, off)).unwrap(); + + // Access resolves the generic foreign target. + let link = h.handle().get_link(hstack).unwrap(); + assert_eq!( + link.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 55 + ); + + // Deep clone copies the target on B (fresh offset, same value). + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + let clink = c.handle().get_link(hstack).unwrap(); + assert_ne!(clink.offset(), off); + assert_eq!( + clink + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 55 + ); + + // Teardown both → both leaves reclaimed → baseline. + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!(arc_b.stack().len().unwrap(), base); + + // The generic vector form compiles + tears down (empty ⇒ self-contained). + let gv = GenForeignVec::::new(&home_alloc, vec![]).unwrap(); + assert!(gv.handle().get_links(&home_alloc).unwrap().is_empty()); + gv.bstack_drop(&home_alloc).unwrap(); + + reg.detach(fid); +} + +#[test] +fn macro_foreign_cursed_valid_combos_compile_and_run() { + // The cursed-but-valid combinations above must compile; here we also construct / + // access / clone / tear them down. Everything is null / empty so no registry is + // needed (teardown & clone skip offset-0 elements and empty vectors). + use crate::Foreign; + use crate::TryCloneIn; + use crate::registry::FileId; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + // [Option; 8] all None. + let a = CursedArr8::new(&alloc, 9, [None, None, None, None, None, None, None, None]).unwrap(); + assert_eq!(a.handle().get_tag(stack).unwrap(), 9); + let slots = a.handle().get_slots(stack).unwrap(); + assert_eq!(slots.len(), 8); + assert!(slots.iter().all(::core::option::Option::is_none)); + a.handle() + .try_clone_in(&alloc) + .unwrap() + .bstack_drop(&alloc) + .unwrap(); + a.bstack_drop(&alloc).unwrap(); + + // [[Foreign; 2]; 3] all null (offset-0) strong pointers. + let null = Foreign::::new(FileId::SELF, 0); + let n = CursedNestedArr::new(&alloc, [[null, null], [null, null], [null, null]]).unwrap(); + let grid = n.handle().get_grid(stack).unwrap(); + assert_eq!(grid.len(), 3); + assert_eq!(grid[0].len(), 2); + n.handle() + .try_clone_in(&alloc) + .unwrap() + .bstack_drop(&alloc) + .unwrap(); + n.bstack_drop(&alloc).unwrap(); + + // The grand mix: null / empty everywhere. + let m = CursedMix::new( + &alloc, + None, + vec![], + None, + Foreign::::new(FileId::SELF, 0), + None, + ) + .unwrap(); + assert!(m.handle().get_maybe_owned(&alloc).unwrap().is_none()); + assert!(m.handle().get_refs(&alloc).unwrap().is_empty()); + assert!(m.handle().get_maybe_weak(stack).unwrap().is_none()); + assert!(m.handle().get_deep(&alloc).unwrap().is_none()); + m.handle() + .try_clone_in(&alloc) + .unwrap() + .bstack_drop(&alloc) + .unwrap(); + m.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_foreign_vec_of_option_roundtrips() { + // `Vec>>`: a null element (offset 0) reads back as `None`; a + // present one resolves. Teardown / clone skip the `None`s. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let f = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Some(Foreign::::new(fid, l.handle().range().start())) + }; + // [Some, None, Some]. + let h = OptForeignVecHolder::new(&home_alloc, vec![f(11), None, f(22)]).unwrap(); + let got = h.handle().get_links(&home_alloc).unwrap(); + assert_eq!(got.len(), 3); + assert!(got[1].is_none()); + assert_eq!( + got[0] + .unwrap() + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 11 + ); + assert_eq!( + got[2] + .unwrap() + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 22 + ); + + // Clone: the two present elements are deep-copied, the `None` stays `None`. + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + let cgot = c.handle().get_links(&home_alloc).unwrap(); + assert!(cgot[1].is_none()); + assert_ne!(cgot[0].unwrap().offset(), got[0].unwrap().offset()); + + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + let _ = hstack; + reg.detach(fid); +} + #[test] fn macro_foreign_owned_clone_errors_when_target_file_detached() { // Cloning an owning `Foreign` whose target file is not attached must ERROR (not @@ -7708,15 +8176,22 @@ fn foreign_reverse_map_and_bstack_cast() { ); // foreign -> normal (`bstack_cast!(foreign as BStackRef)`): a SELF pointer is - // always resolvable-in-place; a foreign id is None here (the GLOBAL registry - // that `as_local_ref` consults is uninitialized in tests). + // always resolvable-in-place; a foreign id that is not live-in-the-GLOBAL-registry + // is `None`. Use a high id no test ever attaches, so this holds regardless of what + // other (global-registry) tests are doing concurrently. let selfp = Foreign::::new(FileId::SELF, off); let r: Option> = bstack_cast!(selfp as BStackRef); assert!(r.is_some()); - assert!(Foreign::::new(id, off).as_local_ref().is_none()); + let dead = FileId::from_u64(60_000).unwrap(); + assert!( + Foreign::::new(dead, off) + .as_local_ref() + .is_none() + ); - // normal -> foreign (`bstack_cast!(slice as Foreign)`): needs the GLOBAL - // registry (uninitialized in tests) → None, but the macro arm type-checks. + // normal -> foreign (`bstack_cast!(slice as Foreign)`): the local file is never + // attached to the GLOBAL registry, so its stack has no id → `None`, but the macro + // arm type-checks. let la = local_file.allocator(); let s = la.alloc(16).unwrap().as_range(); let slice = unsafe { BStackSlice::from_raw_range(la.stack(), s) }; From d8e88855d999c3d5058290b0fff1272da8cb474f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 14:43:20 -0700 Subject: [PATCH 115/140] Foreign in tuple and enum --- bstack_raii/derive/src/block.rs | 781 +++++++++++++++++++++++++++++++- bstack_raii/src/lib.rs | 12 +- bstack_raii/src/tests.rs | 491 ++++++++++++++++++++ 3 files changed, 1263 insertions(+), 21 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 8348987..2ed3952 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -138,16 +138,16 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result continue; } // A foreign field lowers to a `ForeignPtr` (its target `T` is never stored - // inline), so the target parameter is a *block reference*, not a POD/embed — - // regardless of the container it sits in. Detect it up front so the bounds are - // `BStackBlock` (+ `TryCloneIn` for owned, and the usual strong/weak) rather - // than the `Pod`/`in_ondisk` a bare `Foreign` field would otherwise imply. - let foreign_target = field_foreign_target(&field.ty); + // inline), so a target parameter is a *block reference*, not a POD/embed — + // regardless of the container it sits in. Detect every foreign target up front + // so the bounds are `BStackBlock` (+ `TryCloneIn` for owned, and the usual + // strong/weak) rather than the `Pod`/`in_ondisk` a bare field would imply. + let ftargets = foreign_targets_in(&field.ty); for (p, u) in usage.iter_mut() { if !type_mentions_any(&field.ty, &[&*p]) { continue; } - if foreign_target.is_some_and(|t| type_mentions_any(t, &[&*p])) { + if ftargets.iter().any(|t| type_mentions_any(t, &[&*p])) { // The parameter is a foreign *target*: a block reference in its own // file. Kind names the ownership of that target. u.blockish = true; @@ -159,6 +159,17 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result } continue; } + // The param is in this field but NOT as a foreign target. If the field + // *also* holds a `Foreign`, the param sits in a non-foreign position of it + // (e.g. a POD element of a foreign tuple), which the per-field lowering + // can't classify generically — require concrete types there. + if !ftargets.is_empty() { + return Err(Error::new_spanned( + &field.ty, + "a generic type parameter in a non-`Foreign` position of a field that also \ + holds a `Foreign` is not supported; use concrete types for the non-foreign parts", + )); + } match kind { Kind::Pod => { u.pod = true; @@ -2072,6 +2083,232 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result (opt_inner, nullable) }; + // A **tuple with ≥1 `Foreign` element**: `#[ann] (A, Foreign, Option>, ..)`. + // POD elements store inline; each foreign element stores as a `ForeignPtr` (so + // the packed wrapper stays `Pod`). The field annotation names the ownership of + // *all* the foreign elements — they are freed / decremented / deep-cloned in + // their own files at teardown / clone. `Option>` elements use the + // offset-0 niche. (Concrete element types only for now — no generic params.) + if let Type::Tuple(tup) = inner_ty + && tup + .elems + .iter() + .any(|e| foreign_inner(option_inner(e).unwrap_or(e)).is_some()) + { + // Generic foreign *targets* are allowed (bounds are inferred above); a + // generic param in a POD element was already rejected in the usage pass. + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + &field.ty, + "a tuple containing a `Foreign` needs an ownership annotation \ + (`#[bstack_owned/strong/weak/ref]`) naming the foreign elements' kind", + )); + } + Kind::Embed => { + return Err(Error::new_spanned(&field.ty, "cannot #[embed] a tuple")); + } + } + if nullable { + return Err(Error::new_spanned( + &field.ty, + "a whole-tuple `Option<(..)>` is not supported; make the individual \ + elements nullable instead", + )); + } + + // Per-element: is it foreign (and null-wrapped), and its target. + let mut is_foreign = Vec::with_capacity(tup.elems.len()); + let mut ftargets: Vec> = Vec::with_capacity(tup.elems.len()); + let mut nulls = Vec::with_capacity(tup.elems.len()); + for e in &tup.elems { + let inner = option_inner(e).unwrap_or(e); + if let Some(ft) = foreign_inner(inner) { + reject_bad_foreign_target(ft, &field.ty, "a `Foreign` tuple element")?; + is_foreign.push(true); + ftargets.push(Some(ft)); + nulls.push(option_inner(e).is_some()); + } else { + is_foreign.push(false); + ftargets.push(None); + nulls.push(false); + pod_types.push(e); + } + } + + let n = tup.elems.len(); + let idx: Vec = (0..n).map(syn::Index::from).collect(); + // The PUBLIC tuple type (accessor / ctor / move): `Foreign` is a token, so + // rewrite each foreign element to the real `::bstack_raii::Foreign` (the + // user's bare `Foreign` isn't in scope in the generated impls). + let pub_elems: Vec = (0..n) + .map(|i| { + if is_foreign[i] { + let ft = ftargets[i].unwrap(); + if nulls[i] { + quote!(::core::option::Option<::bstack_raii::Foreign<#ft>>) + } else { + quote!(::bstack_raii::Foreign<#ft>) + } + } else { + let e = &tup.elems[i]; + quote!(#e) + } + }) + .collect(); + let pub_tuple_ty = quote!(( #(#pub_elems,)* )); + let wrapper = format_ident!("__BstackFTup_{}_{}", name, fname); + // Wrapper element types: POD verbatim, foreign → `ForeignPtr`. + let welem: Vec = tup + .elems + .iter() + .enumerate() + .map(|(i, e)| { + if is_foreign[i] { + quote!(::bstack_raii::ForeignPtr) + } else { + quote!(#e) + } + }) + .collect(); + wrapper_defs.push(quote! { + #[repr(C, packed)] + #[derive(::core::clone::Clone, ::core::marker::Copy)] + #[doc(hidden)] + #vis struct #wrapper( #(#welem),* ); + // SAFETY: `#[repr(C, packed)]` => no padding; every element is `Pod` + // (POD elements asserted via `pod_types`; `ForeignPtr` is `Pod`). + unsafe impl ::bstack_raii::Zeroable for #wrapper {} + unsafe impl ::bstack_raii::Pod for #wrapper {} + }); + on_disk_fields.push(quote!(#fname: #wrapper,)); + + // Accessor: rebuild the tuple, mapping each `ForeignPtr` back to a `Foreign`. + let acc_elems: Vec = (0..n) + .map(|i| { + let ix = &idx[i]; + if is_foreign[i] { + let ft = ftargets[i].unwrap(); + if nulls[i] { + quote!(if __w.#ix.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ft>::from_ptr(__w.#ix)) + }) + } else { + quote!(::bstack_raii::Foreign::<#ft>::from_ptr(__w.#ix)) + } + } else { + quote!(__w.#ix) + } + }) + .collect(); + accessors.push(quote! { + #vis fn #getter( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<#pub_tuple_ty> { + let mut __buf = ::std::vec![0u8; ::core::mem::size_of::<#on_disk_ty>()]; + let __r = unsafe { ::bstack_raii::BStackRef::::from_range(self.0) }; + let __od: #on_disk_ty = *__r.read_on_disk(stack, &mut __buf)?; + let __w = __od.#fname; + ::std::result::Result::Ok(( #(#acc_elems,)* )) + } + }); + + // Constructor: map each foreign element to a `ForeignPtr`, POD verbatim. + let ctor_elems: Vec = (0..n) + .map(|i| { + let ix = &idx[i]; + if is_foreign[i] { + if nulls[i] { + quote!(match #fname.#ix { + ::core::option::Option::Some(__f) => __f.ptr(), + ::core::option::Option::None => ::bstack_raii::ForeignPtr::new(0, 0), + }) + } else { + quote!(#fname.#ix.ptr()) + } + } else { + quote!(#fname.#ix) + } + }) + .collect(); + ctor_params.push(quote!(#fname: #pub_tuple_ty,)); + ctor_preps.push(quote!(let #fname: #wrapper = #wrapper( #(#ctor_elems),* );)); + ctor_inits.push(quote!(#fname: #fname,)); + + // Teardown / clone: dispatch each foreign element from the on-disk wrapper. + let mut tup_drops = Vec::new(); + let mut tup_clones = Vec::new(); + for i in 0..n { + if !is_foreign[i] { + continue; + } + let ix = &idx[i]; + let ft = ftargets[i].unwrap(); + let elem_drop = foreign_elem_drop(kind, ft); + tup_drops.push(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __w.#ix; + #elem_drop + } + }); + let elem_clone = foreign_elem_clone(kind, ft); + tup_clones.push(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = __w.#ix; + #elem_clone + __w.#ix = __newfp; + } + }); + } + if !matches!(kind, Kind::Ref) { + drop_stmts.push(quote! { + { + let __w = __on_disk.#fname; + #(#tup_drops)* + } + }); + clone_stmts.push(quote! { + { + let mut __w = __od.#fname; + #(#tup_clones)* + __od.#fname = __w; + } + }); + } + + // Move: rebuild the tuple (same mapping as the accessor). + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + mv_types.push(quote!(#pub_tuple_ty)); + let mv_elems: Vec = (0..n) + .map(|i| { + let ix = &idx[i]; + if is_foreign[i] { + let ft = ftargets[i].unwrap(); + if nulls[i] { + quote!(if #cap.#ix.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ft>::from_ptr(#cap.#ix)) + }) + } else { + quote!(::bstack_raii::Foreign::<#ft>::from_ptr(#cap.#ix)) + } + } else { + quote!(#cap.#ix) + } + }) + .collect(); + mv_recon.push(quote!(( #(#mv_elems,)* ))); + continue; + } + // A POD **tuple** field `a: (A, B, ..)`: a Rust tuple is not `Pod`, but a // packed struct of its (POD) elements is — alignment is irrelevant on disk // — so store it through a generated wrapper and rebuild the tuple on read. @@ -3610,9 +3847,56 @@ fn field_foreign_target(ty: &Type) -> Option<&Type> { return Some(x); } } + // `(.., Foreign, ..)` — a tuple element (POD / Foreign mix). Returns the first + // foreign element's target (used for the "supported position" guard; the tuple + // branch validates every foreign element and requires concrete targets). + if let Type::Tuple(tup) = t { + for e in &tup.elems { + let e = option_inner(e).unwrap_or(e); + if let Some(x) = foreign_inner(e) { + return Some(x); + } + } + } None } +/// Every `Foreign` **target** `T` reachable in a field type — digging through the +/// field-level `Option`, `Vec` (+ element `Option`), array (nested + element +/// `Option`), and tuple (each element). Used to infer generic bounds: a type param +/// that is a foreign target is a *block reference in its own file*, and every foreign +/// target's kind bound follows the field annotation. +fn foreign_targets_in(ty: &Type) -> Vec<&Type> { + let mut out = Vec::new(); + collect_foreign_targets(ty, &mut out); + out +} + +fn collect_foreign_targets<'a>(ty: &'a Type, out: &mut Vec<&'a Type>) { + let t = option_inner(ty).unwrap_or(ty); + if let Some(x) = foreign_inner(t) { + out.push(x); + return; + } + if let Some(ve) = vec_inner(t) { + collect_foreign_targets(ve, out); + return; + } + if let Type::Array(_) = t { + let mut cur = t; + while let Type::Array(a) = cur { + cur = &a.elem; + } + collect_foreign_targets(cur, out); + return; + } + if let Type::Tuple(tup) = t { + for e in &tup.elems { + collect_foreign_targets(e, out); + } + } +} + /// Peek `Foreign` → `T`: a cross-file wide-pointer field. fn foreign_inner(ty: &Type) -> Option<&Type> { let Type::Path(tp) = ty else { @@ -4973,6 +5257,9 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result)` variant (an + /// owned foreign deep-clone runs `try_clone_in`, needing `TryCloneIn`). + foreign_owned: bool, } let mut eusage: Vec<(Ident, EUsage)> = type_params .iter() @@ -4984,7 +5271,8 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result syn::Result syn::Result + || vec_field(&f.unnamed.first().unwrap().ty).is_some() + || foreign_inner(&f.unnamed.first().unwrap().ty).is_some()) => { needs_payload = true; let ty = &f.unnamed.first().unwrap().ty; @@ -5172,6 +5474,117 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result(&__pl[..16])); + // Annotated **foreign vector** variant `#[..] V(Vec>)` + // (+ `Vec>>`): a `VecDesc` naming a `ForeignPtr` + // data block, the per-variant mirror of a `Vec` field. + if let Some(velem) = vec_inner(ty) + && let Some(ftarget) = foreign_inner(option_inner(velem).unwrap_or(velem)) + { + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + ty, + "a `Vec>` enum variant needs an ownership \ + annotation (`#[bstack_owned/strong/weak/ref]`)", + )); + } + Kind::Embed => { + return Err(Error::new_spanned( + ty, + "`Foreign` is a pointer and cannot be `#[embed]`ed", + )); + } + } + reject_bad_foreign_target(ftarget, ty, "a `Foreign` vec variant")?; + let elem_nullable = option_inner(velem).is_some(); + let store = + quote!(::bstack_raii::BStackVec::<::bstack_raii::ForeignPtr, __A>); + let fty = if elem_nullable { + quote!(::core::option::Option<::bstack_raii::Foreign<#ftarget>>) + } else { + quote!(::bstack_raii::Foreign<#ftarget>) + }; + let from_ptr = if elem_nullable { + quote!(|__p: ::bstack_raii::ForeignPtr| if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ftarget>::from_ptr(__p)) + }) + } else { + quote!(::bstack_raii::Foreign::<#ftarget>::from_ptr) + }; + let to_ptr = if elem_nullable { + quote!(|__f: #fty| match __f { + ::core::option::Option::Some(__ff) => __ff.ptr(), + ::core::option::Option::None => ::bstack_raii::ForeignPtr::new(0, 0), + }) + } else { + quote!(|__f: #fty| __f.ptr()) + }; + data_variants.push(quote!(#vname(::std::vec::Vec<#fty>),)); + view_variants.push(quote!(#vname(::std::vec::Vec<#fty>),)); + new_arms.push(quote! { + #data::#vname(__list) => { + let __ptrs: ::std::vec::Vec<::bstack_raii::ForeignPtr> = + __list.into_iter().map(#to_ptr).collect(); + let __desc = #store::from_slice(allocator, &__ptrs)?.descriptor(); + let mut __pl = [0u8; #payload_const]; + __pl[..16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__desc)); + (#disc, __pl) + } + }); + read_arms.push(quote! { + #disc => #view::#vname( + #store::from_desc(#read_desc, allocator) + .to_vec()?.into_iter().map(#from_ptr).collect()), + }); + move_arms.push(quote! { + #disc => { + let __out: ::std::vec::Vec<#fty> = + #store::from_desc(#read_desc, __alloc) + .to_vec()?.into_iter().map(#from_ptr).collect(); + #store::from_desc(#read_desc, __alloc).bstack_drop()?; + #data::#vname(__out) + } + }); + // Teardown: dispatch each element (non-ref), then free the data + // block (owned by the enum even for `ref`). + let elem_drop = foreign_elem_drop(kind, ftarget); + let drop_loop = if matches!(kind, Kind::Ref) { + quote!() + } else { + quote!(for __fp in #store::from_desc(#read_desc, allocator).to_vec()? { + #elem_drop + }) + }; + drop_arms.push(quote! { + #disc => { + #drop_loop + #store::from_desc(#read_desc, allocator).bstack_drop()?; + } + }); + let elem_clone = foreign_elem_clone(kind, ftarget); + clone_arms.push(quote! { + #disc => { + let __src = #store::from_desc(#read_desc, allocator).to_vec()?; + let mut __new: ::std::vec::Vec<::bstack_raii::ForeignPtr> = + ::std::vec::Vec::with_capacity(__src.len()); + for __fp in __src { + #elem_clone + __new.push(__newfp); + } + let __newdesc = __plan.stage_bytevec( + allocator, ::bstack_raii::bytemuck::cast_slice(&__new))?; + __pl[..16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__newdesc)); + } + }); + continue; + } + // A POD `V(Vec)` / `V(String)` (un-annotated): a plain // `BStackVec` (elem = the whole vec element type, itself // `Pod` — arrays included — or `u8` for `String`). No block @@ -5531,6 +5944,115 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result; N])` + // (nested / per-element `Option`): a flat `[ForeignPtr; TOTAL]` + // (TOTAL*16 bytes) stored INLINE in the payload — the per-variant + // mirror of a `[Foreign; N]` struct field. + if let Some(ftarget) = foreign_inner(elem) { + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + ty, + "a `[Foreign; N]` enum variant needs an ownership \ + annotation (`#[bstack_owned/strong/weak/ref]`)", + )); + } + Kind::Embed => { + return Err(Error::new_spanned( + ty, + "`Foreign` is a pointer and cannot be `#[embed]`ed", + )); + } + } + reject_bad_foreign_target(ftarget, ty, "a `Foreign` array variant")?; + payload_sizes.push(quote!((#total) * 16)); + let fty = if elem_nullable { + quote!(::core::option::Option<::bstack_raii::Foreign<#ftarget>>) + } else { + quote!(::bstack_raii::Foreign<#ftarget>) + }; + let nested = nested_ty(&dims, &fty); + data_variants.push(quote!(#vname(#nested),)); + view_variants.push(quote!(#vname(#nested),)); + + // new: flatten nested handles → `[ForeignPtr; TOTAL]` in `__pl`. + let leaf_write = |k: &Ident, leaf: &Ident| { + let to_fp = if elem_nullable { + quote!(match #leaf { + ::core::option::Option::Some(__f) => __f.ptr(), + ::core::option::Option::None => + ::bstack_raii::ForeignPtr::new(0, 0), + }) + } else { + quote!(#leaf.ptr()) + }; + quote!(__pl[(#k) * 16..(#k) * 16 + 16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&(#to_fp)));) + }; + let flatten = nested_consume(&dims, "e!(__list), &leaf_write); + new_arms.push(quote! { + #data::#vname(__list) => { + let mut __pl = [0u8; #payload_const]; + #flatten + (#disc, __pl) + } + }); + + // read / move: reshape `[ForeignPtr; TOTAL]` → nested handles. + let leaf_read = |k: &Ident| { + let fp = quote!(::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[(#k) * 16..(#k) * 16 + 16])); + if elem_nullable { + quote!({ + let __p = #fp; + if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ftarget>::from_ptr(__p)) + } + }) + } else { + quote!(::bstack_raii::Foreign::<#ftarget>::from_ptr(#fp)) + } + }; + let build = nested_build(&dims, &fty, &leaf_read); + read_arms.push(quote!(#disc => #view::#vname(#build),)); + move_arms.push(quote!(#disc => #data::#vname(#build),)); + + // Teardown / clone: iterate the flat slots (inline — no block). + if !matches!(kind, Kind::Ref) { + let elem_drop = foreign_elem_drop(kind, ftarget); + drop_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { + let __fp = ::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[__k * 16..__k * 16 + 16]); + #elem_drop + } + } + }); + let elem_clone = foreign_elem_clone(kind, ftarget); + clone_arms.push(quote! { + #disc => { + for __k in 0usize..(#total) { + let __fp = ::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[__k * 16..__k * 16 + 16]); + #elem_clone + __pl[__k * 16..__k * 16 + 16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__newfp)); + } + } + }); + } + continue; + } + // Flat byte read/write of leaf `#k`'s `u64` in the payload. let pl_off = |k: &Ident| quote!(::bstack_raii::get_u64(&__pl[(#k) * 8..])); let pl_put = |k: &Ident, off: TokenStream| { @@ -5928,6 +6450,243 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result)`: a cross-file + // wide pointer stored as a 16-byte `ForeignPtr` in the payload. The + // annotation names the target's ownership in its own file (teardown / + // clone dispatch cross-file, like a scalar `Foreign` struct field). + // Concrete target only for now; container-in-variant is not handled. + if let Some(ftarget) = foreign_inner(ty) { + match kind { + Kind::Owned | Kind::Strong | Kind::Weak | Kind::Ref => {} + Kind::Pod => { + return Err(Error::new_spanned( + ty, + "a `Foreign` enum variant needs an ownership annotation \ + (`#[bstack_owned/strong/weak/ref]`) naming the target's kind", + )); + } + Kind::Embed => { + return Err(Error::new_spanned( + ty, + "`Foreign` is a pointer and cannot be `#[embed]`ed", + )); + } + } + // Generic foreign targets are allowed (bounds inferred above). + reject_bad_foreign_target(ftarget, ty, "a `Foreign` enum variant")?; + + payload_sizes.push(quote!(16usize)); + let fty = quote!(::bstack_raii::Foreign<#ftarget>); + let read_fp = quote!(::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[..16])); + data_variants.push(quote!(#vname(#fty),)); + view_variants.push(quote!(#vname(#fty),)); + new_arms.push(quote! { + #data::#vname(__f) => { + let mut __pl = [0u8; #payload_const]; + __pl[..16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__f.ptr())); + (#disc, __pl) + } + }); + read_arms.push( + quote!(#disc => #view::#vname(::bstack_raii::Foreign::from_ptr(#read_fp)),), + ); + move_arms.push( + quote!(#disc => #data::#vname(::bstack_raii::Foreign::from_ptr(#read_fp)),), + ); + // Teardown / clone dispatch (a `#[bstack_ref]` owns nothing → none; + // its `ForeignPtr` is byte-copied by the payload catch-all). + if !matches!(kind, Kind::Ref) { + let elem_drop = foreign_elem_drop(kind, ftarget); + drop_arms.push(quote! { + #disc => { + let __fp: ::bstack_raii::ForeignPtr = #read_fp; + #elem_drop + } + }); + let elem_clone = foreign_elem_clone(kind, ftarget); + clone_arms.push(quote! { + #disc => { + let __fp: ::bstack_raii::ForeignPtr = #read_fp; + #elem_clone + __pl[..16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__newfp)); + } + }); + } + continue; + } + + // Annotated **foreign tuple** variant `#[..] V((A, Foreign, ..))`: + // POD elements packed inline, each foreign element a 16-byte + // `ForeignPtr`, all at cumulative byte offsets in the payload (the + // per-variant mirror of a `#[ann] (A, Foreign)` struct field). The + // annotation names the foreign elements' ownership. + if let Type::Tuple(tup) = ty + && tup + .elems + .iter() + .any(|e| foreign_inner(option_inner(e).unwrap_or(e)).is_some()) + { + if kind == Kind::Embed { + return Err(Error::new_spanned(ty, "cannot #[embed] a tuple")); + } + let nelem = tup.elems.len(); + let mut is_foreign = Vec::with_capacity(nelem); + let mut ftargets: Vec> = Vec::with_capacity(nelem); + let mut nulls = Vec::with_capacity(nelem); + for e in &tup.elems { + let inner = option_inner(e).unwrap_or(e); + if let Some(ft) = foreign_inner(inner) { + reject_bad_foreign_target(ft, ty, "a `Foreign` tuple element")?; + is_foreign.push(true); + ftargets.push(Some(ft)); + nulls.push(option_inner(e).is_some()); + } else { + is_foreign.push(false); + ftargets.push(None); + nulls.push(false); + pod_types.push(e.clone()); + } + } + + // Element byte offsets + the total payload size. + let mut offsets = Vec::with_capacity(nelem); + let mut acc = quote!(0usize); + let mut sizes = Vec::with_capacity(nelem); + for (&frn, e) in is_foreign.iter().zip(&tup.elems) { + offsets.push(acc.clone()); + let sz = if frn { + quote!(16usize) + } else { + quote!(::core::mem::size_of::<#e>()) + }; + sizes.push(sz.clone()); + acc = quote!(#acc + #sz); + } + payload_sizes.push(acc); + + // Public tuple type: `Foreign` → `::bstack_raii::Foreign` (+ Option). + let pub_elems: Vec = (0..nelem) + .map(|i| { + if is_foreign[i] { + let ft = ftargets[i].unwrap(); + if nulls[i] { + quote!(::core::option::Option<::bstack_raii::Foreign<#ft>>) + } else { + quote!(::bstack_raii::Foreign<#ft>) + } + } else { + let e = &tup.elems[i]; + quote!(#e) + } + }) + .collect(); + let pub_tuple_ty = quote!(( #(#pub_elems,)* )); + data_variants.push(quote!(#vname(#pub_tuple_ty),)); + view_variants.push(quote!(#vname(#pub_tuple_ty),)); + + // new: destructure the tuple, write each element into the payload. + let binds: Vec = (0..nelem).map(|i| format_ident!("__f{}", i)).collect(); + let writes: Vec = (0..nelem) + .map(|i| { + let b = &binds[i]; + let off = &offsets[i]; + let sz = &sizes[i]; + if is_foreign[i] { + let to_fp = if nulls[i] { + quote!(match #b { + ::core::option::Option::Some(__x) => __x.ptr(), + ::core::option::Option::None => + ::bstack_raii::ForeignPtr::new(0, 0), + }) + } else { + quote!(#b.ptr()) + }; + quote!(__pl[(#off)..(#off) + 16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&(#to_fp)));) + } else { + quote!(__pl[(#off)..(#off) + #sz].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&#b));) + } + }) + .collect(); + new_arms.push(quote! { + #data::#vname(( #(#binds,)* )) => { + let mut __pl = [0u8; #payload_const]; + #(#writes)* + (#disc, __pl) + } + }); + + // read / move: rebuild the tuple from the payload. + let reads: Vec = (0..nelem) + .map(|i| { + let off = &offsets[i]; + let sz = &sizes[i]; + if is_foreign[i] { + let ft = ftargets[i].unwrap(); + let fp = quote!(::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[(#off)..(#off) + 16])); + if nulls[i] { + quote!({ + let __p = #fp; + if __p.offset() == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + ::bstack_raii::Foreign::<#ft>::from_ptr(__p)) + } + }) + } else { + quote!(::bstack_raii::Foreign::<#ft>::from_ptr(#fp)) + } + } else { + let e = &tup.elems[i]; + quote!(::bstack_raii::bytemuck::pod_read_unaligned::<#e>( + &__pl[(#off)..(#off) + #sz])) + } + }) + .collect(); + read_arms.push(quote!(#disc => #view::#vname(( #(#reads,)* )),)); + move_arms.push(quote!(#disc => #data::#vname(( #(#reads,)* )),)); + + // Teardown / clone: dispatch each foreign element (ref = none). + if !matches!(kind, Kind::Ref) { + let mut drops = Vec::new(); + let mut clones = Vec::new(); + for i in 0..nelem { + if !is_foreign[i] { + continue; + } + let off = &offsets[i]; + let ft = ftargets[i].unwrap(); + let read_fp = quote!(::bstack_raii::bytemuck::pod_read_unaligned::< + ::bstack_raii::ForeignPtr, + >(&__pl[(#off)..(#off) + 16])); + let ed = foreign_elem_drop(kind, ft); + drops.push( + quote! { { let __fp: ::bstack_raii::ForeignPtr = #read_fp; #ed } }, + ); + let ec = foreign_elem_clone(kind, ft); + clones.push(quote! { + { + let __fp: ::bstack_raii::ForeignPtr = #read_fp; + #ec + __pl[(#off)..(#off) + 16].copy_from_slice( + ::bstack_raii::bytemuck::bytes_of(&__newfp)); + } + }); + } + drop_arms.push(quote!(#disc => { #(#drops)* })); + clone_arms.push(quote!(#disc => { #(#clones)* })); + } + continue; + } + match kind { Kind::Pod => unreachable!("guarded out above"), Kind::Owned => { diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 8ef37f0..91b4a7e 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -469,16 +469,8 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// # fn main() {} /// ``` /// -/// **`Foreign` inside a tuple** — unsupported position; bridge with a struct: -/// ```compile_fail -/// use bstack_raii::bstack_block; -/// #[bstack_block] struct Leaf { v: u32 } -/// #[bstack_block] -/// struct Holder { #[bstack_owned] t: (u32, Foreign) } -/// # fn main() {} -/// ``` -/// -/// **`Foreign` inside a tuple inside a `Vec`** (`Vec<(_, Foreign)>`): +/// **`Foreign` inside a tuple inside a `Vec`** (`Vec<(_, Foreign)>`) — a foreign +/// *tuple* field is allowed, but not as a `Vec` element: /// ```compile_fail /// use bstack_raii::bstack_block; /// #[bstack_block] struct Leaf { v: u32 } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 086470b..30c5d50 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7135,6 +7135,22 @@ struct GenForeignVec { links: Vec>, } +// Generic foreign target inside a tuple (POD element is concrete). +#[bstack_block] +struct GenForeignTup { + tag: u32, + #[bstack_owned] + pair: (u32, Foreign), +} + +// Generic foreign target inside an enum variant. +#[bstack_enum] +enum GenForeignEnum { + Empty, + #[bstack_owned] + Far(Foreign), +} + // -------- Cursed-but-VALID foreign container combinations (must compile) -------- // Per-element-`Option` array of 8 owning pointers. @@ -7176,6 +7192,52 @@ struct OptForeignVecHolder { links: Vec>>, } +// An enum with POD variants and an owning cross-file variant. +#[bstack_enum] +enum ForeignEnum { + Nothing, + Local(u32), + #[bstack_owned] + Far(Foreign), +} + +// An enum variant holding a *strong* cross-file reference. +#[bstack_enum] +enum ForeignStrongEnum { + Empty, + #[bstack_strong] + S(Foreign), +} + +// Enum variants holding foreign *containers*. +#[bstack_enum] +enum ForeignContainerEnum { + Empty, + #[bstack_owned] + Many(Vec>), + #[bstack_owned] + Fixed([Foreign; 2]), +} + +// An enum variant holding a tuple that mixes POD and (nullable) foreign elements. +#[bstack_enum] +enum ForeignTupEnum { + Empty, + #[bstack_owned] + Pair((u32, Foreign, Option>)), +} + +// Tuples that mix POD and (nullable) foreign elements. The annotation names the +// foreign elements' ownership. +#[bstack_block] +struct ForeignTupHolder { + tag: u32, + #[bstack_owned] + pair: (u32, Foreign), + #[bstack_owned] + maybe: (u16, Option>, u8), +} + #[test] fn macro_foreign_owned_teardown_reclaims_across_files() { // Cross-file teardown dispatch (option 1): tearing down a block with a @@ -8118,6 +8180,435 @@ fn macro_foreign_vec_of_option_roundtrips() { reg.detach(fid); } +#[test] +fn macro_foreign_in_enum_across_files() { + // A `#[bstack_owned] V(Foreign)` enum variant: constructed, read, deep-cloned, + // and torn down cross-file — alongside plain POD variants. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // A plain POD variant still works. + let n = ForeignEnum::new(&home_alloc, ForeignEnumData::Local(42)).unwrap(); + match n.handle().read(&home_alloc).unwrap() { + ForeignEnumView::Local(x) => assert_eq!(x, 42), + _ => panic!("wrong variant"), + } + n.bstack_drop(&home_alloc).unwrap(); + + // Warm B's WAL block via the foreign variant, baseline. + { + let e = ForeignEnum::new(&home_alloc, ForeignEnumData::Far(mk(0))).unwrap(); + let c = e.handle().try_clone_in(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + let e = ForeignEnum::new(&home_alloc, ForeignEnumData::Far(mk(77))).unwrap(); + let off = match e.handle().read(&home_alloc).unwrap() { + ForeignEnumView::Far(f) => { + assert_eq!( + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + 77 + ); + f.offset() + } + _ => panic!("wrong variant"), + }; + + // Deep clone copies the target on B (fresh offset, same value). + let c = e.handle().try_clone_in(&home_alloc).unwrap(); + match c.handle().read(&home_alloc).unwrap() { + ForeignEnumView::Far(f) => { + assert_ne!(f.offset(), off); + assert_eq!( + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + 77 + ); + } + _ => panic!("wrong variant"), + } + + // Teardown both → both leaves reclaimed → baseline. + e.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "foreign enum variant leaked or double-freed on B" + ); + reg.detach(fid); +} + +#[test] +fn macro_foreign_generic_tuple_and_enum() { + // Generic foreign target inside a tuple field AND inside an enum variant. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // Generic foreign tuple. + let t = GenForeignTup::::new(&home_alloc, 1, (9, mk(11))).unwrap(); + let pair = t.handle().get_pair(hstack).unwrap(); + assert_eq!(pair.0, 9); + assert_eq!( + pair.1 + .with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 11 + ); + let tc = t.handle().try_clone_in(&home_alloc).unwrap(); + assert_ne!( + tc.handle().get_pair(hstack).unwrap().1.offset(), + pair.1.offset() + ); + t.bstack_drop(&home_alloc).unwrap(); + tc.bstack_drop(&home_alloc).unwrap(); + + // Generic foreign enum variant. + let e = GenForeignEnum::::new(&home_alloc, GenForeignEnumData::Far(mk(22))).unwrap(); + let off = match e.handle().read(&home_alloc).unwrap() { + GenForeignEnumView::Far(f) => { + assert_eq!( + f.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()).unwrap(), + 22 + ); + f.offset() + } + _ => panic!("wrong variant"), + }; + let ec = e.handle().try_clone_in(&home_alloc).unwrap(); + match ec.handle().read(&home_alloc).unwrap() { + GenForeignEnumView::Far(f) => assert_ne!(f.offset(), off), + _ => panic!("wrong variant"), + } + e.bstack_drop(&home_alloc).unwrap(); + ec.bstack_drop(&home_alloc).unwrap(); + reg.detach(fid); +} + +#[test] +fn macro_foreign_strong_enum_variant() { + // A `#[bstack_strong] V(Foreign)` enum variant: cloning bumps the far strong + // count, teardown decrements it. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let ds = size_of::<::OnDisk>() as u64; + let cs = size_of::<::Control>() as u64; + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let d = alloc_block(&*arc_b, MacroStrongChild::eightcc(), ds).unwrap(); + let ctrl = alloc_control(&*arc_b, ctrl_tag(), d, cs).unwrap(); + let strong_off = ctrl.start() + layout::CTRL_STRONG_OFFSET; + let load = |o: u64| crate::refcount::load(arc_b.stack(), o).unwrap(); + assert_eq!(load(strong_off), 1); + + let e = ForeignStrongEnum::new( + &home_alloc, + ForeignStrongEnumData::S(Foreign::::new(fid, d.start())), + ) + .unwrap(); + let cl = e.handle().try_clone_in(&home_alloc).unwrap(); + assert_eq!( + load(strong_off), + 2, + "strong enum variant should bump on clone" + ); + e.bstack_drop(&home_alloc).unwrap(); + assert_eq!(load(strong_off), 1); + cl.bstack_drop(&home_alloc).unwrap(); + reg.detach(fid); +} + +#[test] +fn macro_foreign_tuple_in_enum_variant() { + // A `#[bstack_owned] V((A, Foreign, Option>))` variant: POD packed + // inline, foreign elements resolve / deep-clone / tear down cross-file. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // Warm, baseline. + { + let e = ForeignTupEnum::new( + &home_alloc, + ForeignTupEnumData::Pair((0, mk(0), Some(mk(0)))), + ) + .unwrap(); + e.handle() + .try_clone_in(&home_alloc) + .unwrap() + .bstack_drop(&home_alloc) + .unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + let e = ForeignTupEnum::new( + &home_alloc, + ForeignTupEnumData::Pair((100, mk(11), Some(mk(22)))), + ) + .unwrap(); + let (off1, off2) = match e.handle().read(&home_alloc).unwrap() { + ForeignTupEnumView::Pair((a, f1, f2)) => { + assert_eq!(a, 100); + assert_eq!( + f1.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 11 + ); + let f2 = f2.expect("Some"); + assert_eq!( + f2.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 22 + ); + (f1.offset(), f2.offset()) + } + _ => panic!("wrong variant"), + }; + + // Deep clone copies both foreign elements (fresh offsets). + let c = e.handle().try_clone_in(&home_alloc).unwrap(); + match c.handle().read(&home_alloc).unwrap() { + ForeignTupEnumView::Pair((_, f1, f2)) => { + assert_ne!(f1.offset(), off1); + assert_ne!(f2.expect("Some").offset(), off2); + } + _ => panic!("wrong variant"), + } + + e.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "foreign tuple-in-enum variant leaked" + ); + reg.detach(fid); +} + +#[test] +fn macro_foreign_enum_container_variants() { + // Enum variants holding foreign containers: `V(Vec>)` and + // `V([Foreign; N])` — constructed, read, deep-cloned, torn down cross-file. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // Warm both variants' clone paths (B's WAL block), baseline. + { + let e = ForeignContainerEnum::new(&home_alloc, ForeignContainerEnumData::Many(vec![mk(0)])) + .unwrap(); + e.handle() + .try_clone_in(&home_alloc) + .unwrap() + .bstack_drop(&home_alloc) + .unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + let e = + ForeignContainerEnum::new(&home_alloc, ForeignContainerEnumData::Fixed([mk(0), mk(0)])) + .unwrap(); + e.handle() + .try_clone_in(&home_alloc) + .unwrap() + .bstack_drop(&home_alloc) + .unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + // Vec variant. + let e = ForeignContainerEnum::new( + &home_alloc, + ForeignContainerEnumData::Many(vec![mk(1), mk(2), mk(3)]), + ) + .unwrap(); + match e.handle().read(&home_alloc).unwrap() { + ForeignContainerEnumView::Many(v) => { + assert_eq!(v.len(), 3); + assert_eq!( + v[1].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 2 + ); + } + _ => panic!("wrong variant"), + } + let c = e.handle().try_clone_in(&home_alloc).unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + + // Array variant. + let e = ForeignContainerEnum::new(&home_alloc, ForeignContainerEnumData::Fixed([mk(7), mk(8)])) + .unwrap(); + match e.handle().read(&home_alloc).unwrap() { + ForeignContainerEnumView::Fixed(a) => { + assert_eq!( + a[0].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 7 + ); + assert_eq!( + a[1].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap(), + 8 + ); + } + _ => panic!("wrong variant"), + } + let c = e.handle().try_clone_in(&home_alloc).unwrap(); + e.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "enum foreign container variant leaked" + ); + reg.detach(fid); +} + +#[test] +fn macro_foreign_in_tuple_across_files() { + // A tuple field mixing POD and (nullable) foreign elements: the POD parts store + // inline, the foreign parts resolve / deep-clone / tear down cross-file. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.allocator(); + let hstack = home_alloc.stack(); + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + let mk = |v: u32| { + let l = MacroLeaf::new(&*arc_b, v).unwrap(); + Foreign::::new(fid, l.handle().range().start()) + }; + + // Warm B's WAL block, baseline. + { + let h = ForeignTupHolder::new(&home_alloc, 0, (0, mk(0)), (0, Some(mk(0)), 0)).unwrap(); + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + h.bstack_drop(&home_alloc).unwrap(); + } + let base = arc_b.stack().len().unwrap(); + + let h = ForeignTupHolder::new(&home_alloc, 5, (100, mk(11)), (7, Some(mk(22)), 9)).unwrap(); + + // POD parts preserved; foreign parts resolve. + let pair = h.handle().get_pair(hstack).unwrap(); + assert_eq!(pair.0, 100); + assert_eq!( + pair.1 + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 11 + ); + let maybe = h.handle().get_maybe(hstack).unwrap(); + assert_eq!((maybe.0, maybe.2), (7, 9)); + assert_eq!( + maybe + .1 + .unwrap() + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 22 + ); + + // Deep clone copies both foreign elements (fresh offsets, same values). + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + let cpair = c.handle().get_pair(hstack).unwrap(); + assert_ne!(cpair.1.offset(), pair.1.offset()); + assert_eq!( + cpair + .1 + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + 11 + ); + + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base, + "foreign-in-tuple leaked" + ); + reg.detach(fid); +} + #[test] fn macro_foreign_owned_clone_errors_when_target_file_detached() { // Cloning an owning `Foreign` whose target file is not attached must ERROR (not From 6c006d9a25b32bbeeb05e457fc38988b1fd1d4be Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 19:18:25 -0700 Subject: [PATCH 116/140] Update README --- bstack_raii/README.md | 243 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 214 insertions(+), 29 deletions(-) diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 2ff2876..60c349d 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -37,10 +37,13 @@ object model on top. - [Enums: `#[bstack_enum]`](#enums-bstack_enum) - [Field types](#field-types) - [Generic blocks](#generic-blocks) +- [Mutating fields: `#[bstack_mut]`](#mutating-fields-bstack_mut) - [Moving out: `bstack_move!`](#moving-out-bstack_move) - [Cloning: `TryCloneIn` / `TryClone`](#cloning-tryclonein--tryclone) - [Casting: `bstack_cast!`](#casting-bstack_cast) +- [Cross-file pointers: `Foreign`](#cross-file-pointers-foreignt) - [Type tags (`EightCC`)](#type-tags-eightcc) +- [Examples](#examples) - [Limitations](#limitations) ## Quick start @@ -124,13 +127,24 @@ Every non-POD field carries an [ownership annotation](#field-ownership) deciding how it is torn down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, …) are stored inline and copied by value. -> **Requires a real allocator.** This layer needs a `bstack` allocator that -> actually frees (`dealloc`) and reserves offset 0 for its own metadata — e.g. -> `FirstFitBStackAllocator`, `SlabBStackAllocator`, `GhostTreeBstackAllocator`. +> **The allocator bound: [`BStackRaiiAllocator`].** Every operation in this crate +> — constructors, `try_clone_in`, `bstack_drop`, the stdlib collections — is +> generic over [`BStackRaiiAllocator`], the crate's front-door allocator +> capability. It is an `unsafe` trait over a freeing `bstack` allocator asserting +> the **null niche**: offset 0 is never handed out, so a `0` offset reads as +> "none" everywhere in the layer (the [`Option`](#nullable-fields-option) niche, a +> dead weak reference, an absent [`Foreign`](#cross-file-pointers-foreignt), …). It +> also exposes an *optional* WAL anchor — a stable reserved slot that lets teardown +> and clone automatically reclaim crash-orphaned allocations on the next open. +> +> **Every bstack-provided allocator implements it** — `FirstFitBStackAllocator`, +> `SlabBStackAllocator`, `GhostTreeBstackAllocator`, … — and a custom allocator +> that upholds the null niche opts in with a one-line +> `unsafe impl BStackRaiiAllocator for MyAlloc {}` (the anchor defaults to `None`). > **Not `LinearBStackAllocator`**: its `dealloc` is a no-op (teardown would free -> nothing) and it can hand out offset 0 (breaking the `Option` niche). For -> growable fields, use a **realloc-safe** allocator (growth reallocates the -> backing block); `FirstFitBStackAllocator` is realloc-safe. +> nothing) and it can hand out offset 0, so it does *not* implement the trait. For +> growable fields, use a **realloc-safe** allocator (growth reallocates the backing +> block); `FirstFitBStackAllocator` is realloc-safe. ## How it works on disk @@ -193,11 +207,11 @@ cloning is the [`TryClone`] trait, not `Clone`. Each macro generates a small, fixed set of types (for a block named `X` / `E`): -| Source | Types generated | -|---------------------------------------|-------------------------------------------------------------------------------------------------------------------| -| `#[bstack_block] struct X` | `X` — the [handle](#handles--lifetimes); `XOnDisk` — the `#[repr(C, packed)]` on-disk payload | -| `#[bstack_block(rc, weak)] struct X` | the above, plus `XOnDiskRef` — the [control block](#how-it-works-on-disk) (`strong`/`weak` counters) | -| `#[bstack_enum] enum E` | `E`, `EOnDisk` (plus `EOnDiskRef` for `(rc, weak)`), and two companion enums — see [Enums](#enums-bstack_enum): `EData` (owned form) and `EView` (read result) | +| Source | Types generated | +|--------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `#[bstack_block] struct X` | `X` — the [handle](#handles--lifetimes); `XOnDisk` — the `#[repr(C, packed)]` on-disk payload | +| `#[bstack_block(rc, weak)] struct X` | the above, plus `XOnDiskRef` — the [control block](#how-it-works-on-disk) (`strong`/`weak` counters) | +| `#[bstack_enum] enum E` | `E`, `EOnDisk` (plus `EOnDiskRef` for `(rc, weak)`), and two companion enums — see [Enums](#enums-bstack_enum): `EData` (owned form) and `EView` (read result) | Alongside the types come the trait impls (`BStackBlock`, `BStackDrop`, `BStackCast`, `BStackMove`, and for rc modes `BStackShared` / `BStackWeakable`) @@ -216,14 +230,14 @@ declared and what you can put in them. Every non-POD field carries exactly one annotation, which decides its teardown and what [`bstack_move!`](#moving-out-bstack_move) yields: -| Annotation | Child kind required | On teardown | `bstack_move!` yields | -|--------------------|------------------------|------------------------------------|-------------------------| -| `#[bstack_owned]` | any block | recursively frees the child | `BStackOwned` | +| Annotation | Child kind required | On teardown | `bstack_move!` yields | +|--------------------|------------------------|-------------------------------------|-----------------------------| +| `#[bstack_owned]` | any block | recursively frees the child | `BStackOwned` | | `#[embed]` | any block | frees the child's children in place | `BStackOwned` (re-homed) | -| `#[bstack_strong]` | `(rc)` or `(rc, weak)` | decrements refcount; frees at zero | `BStackRc` | -| `#[bstack_weak]` | `(rc, weak)` | decrements weak count only | `Option>` | -| `#[bstack_ref]` | any block | nothing | `BStackRef` | -| *(none)* — POD | `Pod` type | nothing (inline) | the value | +| `#[bstack_strong]` | `(rc)` or `(rc, weak)` | decrements refcount; frees at zero | `BStackRc` | +| `#[bstack_weak]` | `(rc, weak)` | decrements weak count only | `Option>` | +| `#[bstack_ref]` | any block | nothing | `BStackRef` | +| *(none)* — POD | `Pod` type | nothing (inline) | the value | Rules are enforced at compile time: a `#[bstack_weak]` field whose target isn't `(rc, weak)`, or a non-`Pod` field with no annotation, is a compile error. @@ -281,8 +295,11 @@ methods: child handles it takes ownership of (`#[bstack_owned]` → `BStackOwned`, `#[bstack_strong]` → `BStackRc`, `#[bstack_ref]` → `BStackRef`, POD by value; `#[bstack_weak]` fields are **not** parameters); -- **accessors** — `node.get_field(stack)` for each field; -- **`set_`** setters for `#[bstack_weak]` fields (see below); +- **accessors** — `node.get_field(stack)` reads each field; +- **mutators** — writing a field is opt-in per + [`#[bstack_mut]`](#mutating-fields-bstack_mut) (`set_` / `replace_`), + plus a `set_` for wiring each `#[bstack_weak]` + [back-pointer](#reference-counted-blocks) after construction; - recursive teardown, [casting](#casting-bstack_cast), and [moving](#moving-out-bstack_move). @@ -641,6 +658,46 @@ a directed message (`` `Vec` is not a `#[bstack_block]` type … a nested Currently unsupported (a clear compile error): lifetime parameters, a generic block in `rc` / `rc, weak` mode, and const parameters in a generic *enum*. +## Mutating fields: `#[bstack_mut]` + +Every scalar field gets a reader (`get_`). *Writing* one is opt-in: mark +it `#[bstack_mut]` and the macro adds the mutator appropriate to its kind. This +keeps immutability the default — a field is read-only unless you say otherwise — +while each generated write is a single crash-atomic `set`. + +```rust +#[bstack_block] +struct Counter { + #[bstack_mut] hits: u64, // writable + created_at: u64, // read-only (no setter generated) +} + +let c = Counter::new(&alloc, 0, now)?; +c.handle().set_hits(stack, 42)?; // atomic overwrite +assert_eq!(c.handle().get_hits(stack)?, 42); +``` + +The mutator depends on the field's ownership: + +| Field kind | Mutator | Semantics | +|--------------------|--------------------------------------|-------------------------------------------------------------------------------| +| POD | `set_(stack, value)` | overwrite the inline bytes | +| `#[bstack_ref]` | `set_(stack, ref)` | repoint the offset (nullable → `Option`, `None` writes `0`) | +| `#[bstack_owned]` | `replace_(stack, new)` | install `new`, **return the old** `BStackOwned` (neither leaked nor freed) | +| `#[bstack_strong]` | `replace_(&alloc, new)` | install `new`, return the old `BStackRc` (dropping it decrements) | +| `#[bstack_ref]` | *(also)* `replace_(stack, r)` | ref is the only kind with **both** `set_` and `replace_` | + +`replace_` is a persistent `mem::replace`: an owned or strong field can't just be +overwritten (that would strand the old child / leak a strong count), so it hands +the old value back for you to reuse or free. `#[bstack_weak]` fields already have +their own [`set_`](#reference-counted-blocks) wiring; `#[bstack_mut]` on a +weak field is a no-op, and on an `#[embed]` field a compile error. + +There is also a raw escape hatch on **every** scalar field — +`unsafe fn raw__slice(stack) -> BStackSlice` — a view over the field's +inline storage (`.read()` / `.write()`). Reads are always valid; writing bypasses +the typed invariants, hence `unsafe`. + ## Moving out: `bstack_move!` `bstack_move!` destructures a handle, transferring each field/variant out and @@ -697,13 +754,13 @@ let copy: BStackOwned = node.try_clone_in(&alloc)?; Each field is duplicated according to its ownership — the mirror of teardown: -| Field | On clone | -|-----------------------|-------------------------------------------------------------------| -| POD / `#[bstack_ref]` | byte-copied (a ref clone **aliases** the same target) | -| `#[bstack_owned]` | the child is recursively deep-cloned into a fresh block | -| `#[embed]` | the inline child is folded — its own children deep-cloned in place | -| `#[bstack_strong]` | the shared child stays shared; its strong count is bumped | -| `#[bstack_weak]` | stays weak to the same target; its weak count is bumped | +| Field | On clone | +|-----------------------|------------------------------------------------------------------------------------------------------------------------| +| POD / `#[bstack_ref]` | byte-copied (a ref clone **aliases** the same target) | +| `#[bstack_owned]` | the child is recursively deep-cloned into a fresh block | +| `#[embed]` | the inline child is folded — its own children deep-cloned in place | +| `#[bstack_strong]` | the shared child stays shared; its strong count is bumped | +| `#[bstack_weak]` | stays weak to the same target; its weak count is bumped | | `Vec` | per element, by the vector's annotation (POD data copied; owned elements deep-cloned; strong/weak bumped; ref aliased) | So an owned subtree is copied into independent storage while shared children are @@ -762,6 +819,116 @@ let maybe: Option = bstack_cast!(view as Node)?; // borrowed The equivalent methods (`into_slice`, `cast_into::`, `cast_as::`, `as_slice`) can also be called directly. Casting works the same for enums. +## Cross-file pointers: `Foreign` + +Every reference covered so far points *within one file*. A `Foreign` crosses +the boundary: it is a **wide pointer** naming both a target **file** and an +offset inside it, so an object graph can span many `bstack` files — a sharded +store, an index file pointing at a data file, cross-document links — while each +file stays an independent, crash-safe unit. + +### The file registry + +Paths are long and awkward to store on disk, so a process-wide **registry** maps +each file's persistent path ↔ a small, stable numeric [`FileId`]. A `Foreign` +stores `(FileId, offset)`; the id is resolved to a live file through the +registry. It is entirely opt-in — a single-file program never touches it and pays +nothing. + +```rust +use bstack_raii::registry; + +registry::init("registry.bstack")?; // once, at startup +let store_id = registry::attach("store.bstack", store_alloc)?; // hand a file to the registry +``` + +`init` brings up the registry (itself a tiny append-only `bstack` file mapping +paths to ids, so ids survive a restart). `attach` registers a file's path and +installs its allocator as the **live host** for that id — the thing a `Foreign` +into that file resolves through. The host is shared process-wide, so `attach` +takes a [`SyncBStackRaiiAllocator`](src/registry.rs) (a +[`BStackRaiiAllocator`] that is also `Send + Sync`) — every bstack allocator +qualifies. `FileId::SELF` (id `0`) is the current file, resolved against your +local allocator with no registry lookup at all. + +### Declaring a foreign field + +A `Foreign` field **must** carry an [ownership annotation](#field-ownership), +exactly like an in-file reference — it just means the same thing *across* files. +The target `T` must be a `#[bstack_block]` (a foreign pointer targets a block, +never inline data, so an un-annotated / POD / `#[embed]` `Foreign` is a compile +error): + +```rust +#[bstack_block] +struct Card { + title: String, + #[bstack_owned] body: Foreign, // owns a Document in another file +} + +// Construct with an explicit (file, offset) pointer … +let card = Card::new(&catalog, "report", Foreign::::new(store_id, doc_off))?; + +// … and resolve it to read across the boundary. `with` runs a closure against +// the target and *its* file's stack, returning `None` if that file isn't live. +let size = card.handle().get_body(catalog.stack())? + .with(&catalog, |doc, fs| doc.get_size(fs).unwrap()); // Option +``` + +The annotation decides what teardown and clone do **in the target's own file**: + +| Annotation | Cross-file teardown | Cross-file clone | +|--------------------|-------------------------------------------|---------------------------------------------------| +| `#[bstack_owned]` | frees the target in its file | deep-clones it into a fresh block in that file | +| `#[bstack_strong]` | decrements its refcount there (free at 0) | bumps its refcount there (stays shared) | +| `#[bstack_weak]` | decrements its weak count there | bumps its weak count there | +| `#[bstack_ref]` | nothing | byte-copies the pointer (aliases the same target) | + +So tearing down a `Card` reclaims its `Document` in the store file, and +deep-cloning a `Card` gives the copy its own independent `Document` there — the +catalog file never touches the store's bytes directly. (`#[bstack_owned]` needs a +deep-cloneable target, so `#[bstack_owned] Foreign` — a target that +is itself `(rc)` — is a compile error; use `#[bstack_strong]`.) + +> **Nullable & atomicity.** `Option>` is nullable on the usual offset-0 +> niche. Cross-file operations are *best-effort atomic*: the far side is committed +> before the home side, so a mid-op failure errs toward an over-provision (a +> leaked block or an over-count — reclaimable) and never an under-count (a +> premature free). If the target file is detached, teardown leaks (never +> corrupts) and a clone returns an error rather than aliasing an owner. + +### Containers and shapes + +A `Foreign` composes everywhere an in-file reference does — because a foreign +pointer is itself `Pod`, the container storage is reused and only the per-element +cross-file dispatch is added: + +```rust +#[bstack_owned] parts: Vec>, // a growable list of pointers +#[bstack_owned] shards: [Foreign; 4], // an inline fixed array +#[bstack_ref] pair: (u32, Foreign), // a foreign element in a tuple +``` + +`Vec>>`, nested arrays, and generic targets (`Foreign` over a +type parameter) all work, in both struct fields and `#[bstack_enum]` variants — +scalar, `Vec`, array, and tuple variants alike. + +The one firm rule is **no double pointer**: a `Foreign` must target a plain block, +not another pointer or a container — `Foreign>`, `Foreign>`, +`Foreign<[T; N]>`, `Foreign<(A, B)>` are rejected with a directed error (bridge +through a named `#[bstack_block]`). This is distinct from the `Vec` nesting +rule: a *collection of pointers* (`Vec>`) is fine; only a *pointer to a +collection* (`Foreign>`) is barred. + +Finally, [`bstack_cast!`](#casting-bstack_cast) bridges a `Foreign` and a local +handle: `slice as Foreign` tags a local slice with its file identity (via the +reverse registry map), and `foreign as BStackRef` recovers a same-file +reference when the target is local. Both return `Option` (no I/O). + +A full two-file walk-through — resolution, cross-file ownership, deep clone, and +reclamation — is in [`examples/crossfile.rs`](examples/crossfile.rs): +`cargo run --example crossfile`. + ## Type tags (`EightCC`) Each block's header carries an 8-byte tag — the discriminant a @@ -797,6 +964,16 @@ for the coercion warning, or a real `#[allow(deprecated)]` on the item). This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG")] enum Mode { Unit, Val(u32) }`. +## Examples + +Runnable end-to-end programs live in [`examples/`](examples/): + +| Example | Run | Shows | +|-----------------------------------------|---------------------------------|----------------------------------------------------------------------------------------------------------------| +| [`sessions.rs`](examples/sessions.rs) | `cargo run --example sessions` | shared `(rc, weak)` ownership, refcount-driven cleanup, durability across a reopen | +| [`expr.rs`](examples/expr.rs) | `cargo run --example expr` | a recursive `#[bstack_enum]` tree — evaluation, deep clone (`TryCloneIn`), `bstack_move!` | +| [`crossfile.rs`](examples/crossfile.rs) | `cargo run --example crossfile` | [`Foreign`](#cross-file-pointers-foreignt) across two files — resolution, cross-file ownership, reclamation | + ## Limitations - **Fixed-size block payloads.** Fixed-size [arrays](#fixed-size-arrays-t-n) @@ -804,8 +981,9 @@ This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG") sequence lives out-of-line via an inline descriptor: `Vec` / `String`, `#[bstack_owned/strong/weak/ref] Vec`, `Vec<[Thing; N]>`, and their `Option<…>` forms. -- **Requires a freeing allocator** that reserves offset 0 — not - `LinearBStackAllocator` (see [Concepts](#concepts)). +- **Requires a [`BStackRaiiAllocator`]** — a freeing allocator that reserves + offset 0 (the null niche); not `LinearBStackAllocator` (see + [Concepts](#concepts)). - **[Generic blocks](#generic-blocks)** work over type parameters (in every field kind — reference, POD, and `#[embed]`) and `const` array lengths; the exceptions are lifetime parameters, `rc` / `rc, weak` mode, and const parameters in a @@ -817,6 +995,11 @@ This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG") arrays `V([T; N])`, and vectors `V(Vec<…>)` — in all three modes, plus `bstack_move!` / `bstack_cast!`; struct and multi-field tuple variants aren't supported, and a variant can't be `#[embed]`ed. +- **[Cross-file pointers](#cross-file-pointers-foreignt)** (`Foreign`) must + target a plain block, never a pointer or a container (no "double pointer"), and + their cross-file operations are *best-effort atomic* — a failure over-provisions + (a reclaimable leak) rather than under-counts. Resolution requires the target + file to be `attach`ed to the process registry. - The on-disk **ABI is not yet stable**. ## License @@ -827,3 +1010,5 @@ MIT (same as `bstack`). [`std::io::Result`]: https://doc.rust-lang.org/std/io/type.Result.html [`TryClone`]: src/clone.rs [`BStackVec`]: src/vec.rs +[`FileId`]: src/registry.rs +[`BStackRaiiAllocator`]: src/lib.rs From a07fcb7466f1f7a0b53c05566d24d293235040da Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 19:18:35 -0700 Subject: [PATCH 117/140] Add cross file (Foreign) example --- bstack_raii/examples/crossfile.rs | 169 ++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 bstack_raii/examples/crossfile.rs diff --git a/bstack_raii/examples/crossfile.rs b/bstack_raii/examples/crossfile.rs new file mode 100644 index 0000000..332569c --- /dev/null +++ b/bstack_raii/examples/crossfile.rs @@ -0,0 +1,169 @@ +//! # Cross-file ownership with `Foreign` (`bstack_raii`) +//! +//! A `bstack_raii` reference normally points inside *one* file. A `Foreign` +//! is a **wide pointer that crosses the file boundary**: it names both a target +//! file (through a process-wide registry) and an offset within it. An owning +//! foreign field frees — or a deep clone duplicates — its target *in the +//! target's own file*, transparently. +//! +//! This models a common sharding layout. A small **catalog** file holds one +//! lightweight `Card` per document; the heavy `Document` bodies live in a +//! separate **store** file. Each card owns its document across the boundary, so +//! deleting a card reclaims its body in the store — no dangling record, no leak. +//! +//! Run with: `cargo run --example crossfile` + +use std::io; +use std::path::Path; +use std::sync::Arc; + +use bstack::FirstFitBStackAllocator; +use bstack_raii::{ + BStack, BStackAllocator, BStackBlock, BStackDrop, Foreign, TryCloneIn, bstack_block, registry, +}; + +/// A heavy record, living in the *store* file. +#[bstack_block] +struct Document { + size: u64, + checksum: u64, +} + +/// A lightweight catalog entry, living in the *catalog* file. It **owns** its +/// `Document` across the file boundary: the document is freed in the store when +/// this card is torn down. +#[bstack_block] +struct Card { + title: String, + #[bstack_owned] + body: Foreign, +} + +/// A catalog entry that owns *several* store documents at once — a +/// `Vec>` is a growable list of cross-file pointers. +#[bstack_block] +struct Bundle { + name: String, + #[bstack_owned] + parts: Vec>, +} + +fn main() -> io::Result<()> { + let dir = std::env::temp_dir(); + let registry_path = dir.join("bstack_raii_registry.bstack"); + let store_path = dir.join("bstack_raii_store.bstack"); + let catalog_path = dir.join("bstack_raii_catalog.bstack"); + for p in [®istry_path, &store_path, &catalog_path] { + let _ = std::fs::remove_file(p); + } + + // The registry maps each file's persistent path <-> a small numeric id, so a + // `Foreign` can name its target file compactly and stably. Bring it up once. + registry::init(®istry_path)?; + + // The catalog file (our "home") stays a plain local allocator we build with. + let catalog = FirstFitBStackAllocator::new(BStack::open(&catalog_path)?)?; + + // The store file's allocator is shared through an `Arc`: one clone is handed + // to the registry as the live host, and we keep one to build documents with + // and to inspect afterward. + let store = Arc::new(FirstFitBStackAllocator::new(BStack::open(&store_path)?)?); + let store_id = registry::get() + .unwrap() + .attach(&store_path, store.clone())?; + println!("store attached as file id {}", store_id.get()); + + // Populate the store file, remembering each document's offset. + let doc = Document::new(&*store, 4096, 0xDEAD_BEEF)?; + let doc_off = doc.handle().range().start(); + + let extra: Vec = (0u64..3) + .map(|i| { + let d = Document::new(&*store, 1000 + i, i).unwrap(); + d.handle().range().start() + }) + .collect(); + + // --- a single owned foreign pointer ------------------------------------- + + // A card in the catalog file that owns `doc` over in the store file. + let card = Card::new( + &catalog, + "annual-report", + Foreign::::new(store_id, doc_off), + )?; + + // Resolve the foreign pointer and read the far-side document. `with` takes + // the *local* allocator (used only for a same-file `Foreign`) and a closure + // run against the target and its file's stack; it returns `None` if the + // target file is not currently live. + let (size, sum) = card + .handle() + .get_body(catalog.stack())? + .with(&catalog, |d, fs| { + (d.get_size(fs).unwrap(), d.get_checksum(fs).unwrap()) + }) + .expect("store is live"); + println!("card 'annual-report' -> document size {size}, checksum {sum:#x}"); + + // Deep-clone the card. The clone gets its *own* fresh copy of the document, + // allocated in the store file — cross-file `TryCloneIn` follows the pointer. + let card_copy = card.try_clone_in(&catalog)?; + let copy_off = card_copy.handle().get_body(catalog.stack())?.offset(); + println!( + "cloned card -> independent document at store offset {copy_off} (original at {doc_off})" + ); + assert_ne!( + copy_off, doc_off, + "the clone must not alias the original document" + ); + + // --- a vector of owned foreign pointers --------------------------------- + + let ptrs: Vec> = extra + .iter() + .map(|&off| Foreign::::new(store_id, off)) + .collect(); + let bundle = Bundle::new(&catalog, "q3-batch", ptrs)?; + let bundle_sizes: Vec = bundle + .handle() + .get_parts(&catalog)? + .into_iter() + .map(|f| f.with(&catalog, |d, fs| d.get_size(fs).unwrap()).unwrap()) + .collect(); + println!( + "bundle 'q3-batch' -> {} documents, sizes {bundle_sizes:?}", + bundle_sizes.len() + ); + + // --- cross-file teardown reclaims the store ----------------------------- + + let frontier = store.stack().len()?; // the store's high-water mark right now + + // Tear everything down. Each owned foreign pointer frees its target back in + // the store file — the catalog never touches the store's bytes directly. + card.bstack_drop(&catalog)?; + card_copy.bstack_drop(&catalog)?; + bundle.bstack_drop(&catalog)?; + + // Prove the store space was actually reclaimed (not merely unlinked): a fresh + // document lands *inside* the old frontier, reusing a freed slot instead of + // extending the file — a leak-only teardown would have bumped past `frontier`. + let probe = Document::new(&*store, 1, 1)?; + let probe_off = probe.handle().range().start(); + println!( + "after teardown, a new store document reuses freed offset {probe_off} (frontier was {frontier})" + ); + assert!( + probe_off < frontier, + "cross-file teardown should have freed the documents' store space for reuse" + ); + probe.bstack_drop(&*store)?; + println!("every document was reclaimed across the file boundary"); + + registry::detach(store_id); + for p in [®istry_path, &store_path, &catalog_path] { + let _ = std::fs::remove_file(p as &Path); + } + Ok(()) +} From b00d89862951c37839caf5f40888899052551d2d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 19:19:29 -0700 Subject: [PATCH 118/140] Add expression tree example --- bstack_raii/examples/expr.rs | 135 +++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 bstack_raii/examples/expr.rs diff --git a/bstack_raii/examples/expr.rs b/bstack_raii/examples/expr.rs new file mode 100644 index 0000000..b42e9e5 --- /dev/null +++ b/bstack_raii/examples/expr.rs @@ -0,0 +1,135 @@ +//! # A persistent expression tree (`bstack_raii`) +//! +//! This example exercises the object model end to end within a single file: a +//! recursive `#[bstack_enum]`, owned children, a deep clone (`TryCloneIn`), and +//! a structural move-out (`bstack_move!`) — all backed by the on-disk allocator. +//! +//! We build the arithmetic expression `(3 + 4) * 2`, evaluate it by walking the +//! persisted tree, deep-clone the whole thing into independent storage, mutate +//! the clone, and confirm the original is untouched. +//! +//! Run with: `cargo run --example expr` + +use std::io; + +use bstack::FirstFitBStackAllocator; +use bstack_raii::{ + BStack, BStackAllocator, BStackDrop, BStackOwned, TryCloneIn, bstack_block, bstack_enum, + bstack_move, +}; + +/// A binary operation node: an operator byte and two owned operand sub-trees. +/// Being a named block lets `Expr` recurse through it (a block is referenced by +/// offset, so the layout stays fixed-size). +#[bstack_block] +struct BinOp { + op: u8, // b'+' or b'*' + #[bstack_owned] + lhs: Expr, + #[bstack_owned] + rhs: Expr, +} + +/// An expression is either a literal or an owned binary operation. +#[bstack_enum] +enum Expr { + Lit(i64), + #[bstack_owned] + Op(BinOp), +} + +/// Evaluate an expression by walking the on-disk tree. +fn eval(expr: &Expr, alloc: &FirstFitBStackAllocator) -> io::Result { + Ok(match expr.read(alloc)? { + ExprView::Lit(v) => v, + ExprView::Op(node) => { + let lhs = eval(&node.get_lhs(alloc.stack())?, alloc)?; + let rhs = eval(&node.get_rhs(alloc.stack())?, alloc)?; + match node.get_op(alloc.stack())? { + b'+' => lhs + rhs, + b'*' => lhs * rhs, + other => unreachable!("unknown operator {other}"), + } + } + }) +} + +/// Render an expression back to source text. +fn render(expr: &Expr, alloc: &FirstFitBStackAllocator) -> io::Result { + Ok(match expr.read(alloc)? { + ExprView::Lit(v) => v.to_string(), + ExprView::Op(node) => { + let lhs = render(&node.get_lhs(alloc.stack())?, alloc)?; + let rhs = render(&node.get_rhs(alloc.stack())?, alloc)?; + let op = node.get_op(alloc.stack())? as char; + format!("({lhs} {op} {rhs})") + } + }) +} + +/// Build a literal expression node. +fn lit(alloc: &FirstFitBStackAllocator, v: i64) -> io::Result> { + Expr::new(alloc, ExprData::Lit(v)) +} + +/// Build a binary-operation expression node from two owned operands. +fn op( + alloc: &FirstFitBStackAllocator, + operator: u8, + lhs: BStackOwned, + rhs: BStackOwned, +) -> io::Result> { + let node = BinOp::new(alloc, operator, lhs, rhs)?; + Expr::new(alloc, ExprData::Op(node)) +} + +fn main() -> io::Result<()> { + let path = std::env::temp_dir().join("bstack_raii_expr.bstack"); + let _ = std::fs::remove_file(&path); + let alloc = FirstFitBStackAllocator::new(BStack::open(&path)?)?; + + // Build `(3 + 4) * 2` bottom-up. Each `new` consumes the owned children it + // takes, wiring the tree together on disk. + let sum = op(&alloc, b'+', lit(&alloc, 3)?, lit(&alloc, 4)?)?; + let root = op(&alloc, b'*', sum, lit(&alloc, 2)?)?; + + println!("expression: {}", render(&root, &alloc)?); + println!("evaluates to: {}", eval(&root, &alloc)?); + assert_eq!(eval(&root, &alloc)?, 14); + + // Deep-clone the whole tree into fresh, independent storage. Every owned + // child is recursively duplicated, so the copy shares nothing with `root`. + let clone = root.try_clone_in(&alloc)?; + assert_eq!(eval(&clone, &alloc)?, 14); + println!( + "deep clone evaluates to: {} (independent copy)", + eval(&clone, &alloc)? + ); + + // Destructure the clone with `bstack_move!`: the enum shell is freed and the + // active variant is handed back through `ExprData`. The top node was `Op`, so + // we get the owning `BinOp` back — its children are still live on disk. + match bstack_move!(clone, &alloc)? { + ExprData::Op(binop) => { + // The moved-out `BinOp` still owns the multiplication's operands. + let left = binop.handle().get_lhs(alloc.stack())?; + println!( + "moved out the root `Op`; its left operand is `{}`", + render(&left, &alloc)? + ); + // We now own this subtree explicitly; free it (and its children). + binop.bstack_drop(&alloc)?; + } + ExprData::Lit(_) => unreachable!("root was an Op"), + } + + // The original is entirely untouched by the clone's move + teardown. + assert_eq!(eval(&root, &alloc)?, 14); + println!("original still evaluates to: {}", eval(&root, &alloc)?); + + // A uniquely-owned root frees nothing on scope exit — reclaim it explicitly. + root.bstack_drop(&alloc)?; + + let _ = std::fs::remove_file(&path); + Ok(()) +} From ca12c69cc4a1c11cf0d852ad30deeae965e585a6 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 19:50:20 -0700 Subject: [PATCH 119/140] Let replace hand back things --- bstack_raii/README.md | 17 ++- bstack_raii/derive/src/block.rs | 260 +++++++++++++++++++++----------- bstack_raii/src/lib.rs | 2 + bstack_raii/src/replace.rs | 85 +++++++++++ bstack_raii/src/tests.rs | 65 ++++++++ 5 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 bstack_raii/src/replace.rs diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 60c349d..40617ed 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -689,9 +689,20 @@ The mutator depends on the field's ownership: `replace_` is a persistent `mem::replace`: an owned or strong field can't just be overwritten (that would strand the old child / leak a strong count), so it hands -the old value back for you to reuse or free. `#[bstack_weak]` fields already have -their own [`set_`](#reference-counted-blocks) wiring; `#[bstack_mut]` on a -weak field is a no-op, and on an `#[embed]` field a compile error. +the old value back for you to reuse or free. + +Because it **consumes** the new value, `replace_` returns `Result>` (not a bare `io::Result`): on an I/O failure it hands the +consumed value back in `ReplaceError.value`, rather than dropping it into an +unreachable orphan — the same region-hand-back contract as bstack's +`BStackAllocError`. The *old* value is never at risk (the swap is a single atomic +`set`, so on failure the field still holds it). The one `value: None` case is a +strong field whose old handle fails to reconstruct *after* the commit already +landed — then it's the old block that is reclaimable only via crash-recovery. + +`#[bstack_weak]` fields already have their own +[`set_`](#reference-counted-blocks) wiring; `#[bstack_mut]` on a weak field +is a no-op, and on an `#[embed]` field a compile error. There is also a raw escape hatch on **every** scalar field — `unsafe fn raw__slice(stack) -> BStackSlice` — a view over the field's diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 2ed3952..adcc0ac 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -4249,99 +4249,146 @@ fn replace_accessor( let size_od = quote!(::core::mem::size_of::<<#inner_ty as ::bstack_raii::BStackBlock>::OnDisk>() as u64); + // The old value's on-disk range (from the offset stored in the field slot). + let old_range = quote!(::bstack_raii::BStackRange::new(__old_off, #size_od)); + match kind { - // Owned / ref reconstruct the old handle from just the offset — no allocator - // needed — so they take `&BStack` (like their getter/setter). + // Owned / ref reconstruct handles from just an offset — no allocator, no + // I/O — so they take `&BStack` and their reconstructions are infallible. + // `rebuild(range)` rebuilds a handle over a known range, used both for the + // old value (on success) and to hand the new value back (on a failed commit). Kind::Owned => { let handle_ty = quote!(::bstack_raii::BStackOwned<#inner_ty>); - let new_off = quote!({ + // Consume `value` into the new block's range (its block persists on disk). + let new_range = quote!({ let __h = __value.into_inner(); - ::bstack_raii::BStackBlock::range(&__h).start() - }); - let recon = quote!(unsafe { - ::bstack_raii::BStackOwned::from_raw( - <#inner_ty as ::bstack_raii::BStackBlock>::from_range( - ::bstack_raii::BStackRange::new(__old_off, #size_od), - ), - ) + ::bstack_raii::BStackBlock::range(&__h) }); - replace_stack_method(vis, &name, &handle_ty, &off, &new_off, &recon, nullable) + let rebuild = |range: TokenStream| { + quote!(unsafe { + ::bstack_raii::BStackOwned::from_raw( + <#inner_ty as ::bstack_raii::BStackBlock>::from_range(#range), + ) + }) + }; + let recon_old = rebuild(old_range.clone()); + let rebuild_new = rebuild(quote!(__new_range)); + replace_stack_method(vis, &name, &handle_ty, &off, &new_range, &recon_old, &rebuild_new, nullable) } Kind::Ref => { let handle_ty = quote!(::bstack_raii::BStackRef<#inner_ty>); - let new_off = quote!(__value.into_range().start()); - let recon = quote!(unsafe { - ::bstack_raii::BStackRef::<#inner_ty>::from_range( - ::bstack_raii::BStackRange::new(__old_off, #size_od), - ) - }); - replace_stack_method(vis, &name, &handle_ty, &off, &new_off, &recon, nullable) + let new_range = quote!(__value.into_range()); + let rebuild = + |range: TokenStream| quote!(unsafe { ::bstack_raii::BStackRef::<#inner_ty>::from_range(#range) }); + let recon_old = rebuild(old_range.clone()); + let rebuild_new = rebuild(quote!(__new_range)); + replace_stack_method(vis, &name, &handle_ty, &off, &new_range, &recon_old, &rebuild_new, nullable) } - // Strong reconstructs a `BStackRc` (and the new value's count is transferred - // via `into_raw`), which needs the allocator — so it takes `&A`. + // Strong reconstructs a `BStackRc`, which needs the allocator — so it takes + // `&A`. The NEW value hands back with no I/O (its raw parts are already in + // hand); only reconstructing the OLD value reads the block (`strong_parts`), + // the one step that can leave `value: None` on failure. Kind::Strong => { - let handle_ty = quote!(::bstack_raii::BStackRc<'__r, #inner_ty, __A>); - let new_off = quote!({ - let (__d, _) = __value.into_raw(); - __d.into_range().start() - }); + let rc_ty = quote!(::bstack_raii::BStackRc<'__r, #inner_ty, __A>); + let ret_ty = if nullable { + quote!(::core::option::Option<#rc_ty>) + } else { + quote!(#rc_ty) + }; // Reconstruct the old strong ref from its data offset (transfers the // existing count out; dropping the returned `BStackRc` decrements). - let recon = quote! { + let recon_old = quote! { { - let __data = unsafe { - ::bstack_raii::BStackRef::<#inner_ty>::from_range( - ::bstack_raii::BStackRange::new(__old_off, #size_od), - ) + let __old_data = unsafe { + ::bstack_raii::BStackRef::<#inner_ty>::from_range(#old_range) }; - let (__d, __c) = - <#inner_ty as ::bstack_raii::BStackShared>::strong_parts(__data, allocator)?; - unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, allocator) } + match <#inner_ty as ::bstack_raii::BStackShared>::strong_parts(__old_data, allocator) { + ::std::result::Result::Ok((__d, __c)) => { + unsafe { ::bstack_raii::BStackRc::from_raw(__d, __c, allocator) } + } + // The new value is safely installed; the OLD block is now + // reachable only via crash-recovery. Hand back nothing. + ::std::result::Result::Err(__e) => { + return ::core::result::Result::Err(::bstack_raii::ReplaceError::lost(__e)); + } + } } }; - if nullable { + // Consume `value` into `(new_range, ctrl)`; rebuild is infallible. + let (consume_new, rebuild_new): (TokenStream, TokenStream) = ( + quote!({ let (__nd, __nc) = __value.into_raw(); (__nd.into_range(), __nc) }), + quote!(unsafe { + ::bstack_raii::BStackRc::from_raw( + ::bstack_raii::BStackRef::<#inner_ty>::from_range(__new_range), + __nc, + allocator, + ) + }), + ); + + let body = if nullable { quote! { - /// Install `value` and move the previous value out (`None` = the - /// `0` null niche). - #vis fn #name<'__r, __A: ::bstack_raii::BStackRaiiAllocator>( - &self, - allocator: &'__r __A, - value: ::core::option::Option<#handle_ty>, - ) -> ::std::io::Result<::core::option::Option<#handle_ty>> { - let __off = #off; - let __new: u64 = match value { - ::core::option::Option::Some(__value) => #new_off, - ::core::option::Option::None => 0u64, + let __off = #off; + let __stack = allocator.stack(); + let mut __b = [0u8; 8]; + if let ::std::result::Result::Err(__e) = __stack.get_into(__off, &mut __b) { + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, value)); + } + let __old_off = u64::from_le_bytes(__b); + let (__new, __back): (u64, ::core::option::Option<(::bstack_raii::BStackRange, ::core::option::Option<::bstack_raii::BStackRange>)>) = + match value { + ::core::option::Option::Some(__value) => { + let (__new_range, __nc) = #consume_new; + (__new_range.start(), ::core::option::Option::Some((__new_range, __nc))) + } + ::core::option::Option::None => (0u64, ::core::option::Option::None), }; - let __stack = allocator.stack(); - let mut __b = [0u8; 8]; - __stack.get_into(__off, &mut __b)?; - let __old_off = u64::from_le_bytes(__b); - __stack.set(__off, __new.to_le_bytes())?; - if __old_off == 0 { - ::std::result::Result::Ok(::core::option::Option::None) - } else { - ::std::result::Result::Ok(::core::option::Option::Some(#recon)) - } + if let ::std::result::Result::Err(__e) = __stack.set(__off, __new.to_le_bytes()) { + let __handback: #ret_ty = match __back { + ::core::option::Option::Some((__new_range, __nc)) => { + ::core::option::Option::Some(#rebuild_new) + } + ::core::option::Option::None => ::core::option::Option::None, + }; + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, __handback)); + } + if __old_off == 0 { + ::core::result::Result::Ok(::core::option::Option::None) + } else { + ::core::result::Result::Ok(::core::option::Option::Some(#recon_old)) } } } else { quote! { - /// Install `value` and move the previous value out to the caller. - #vis fn #name<'__r, __A: ::bstack_raii::BStackRaiiAllocator>( - &self, - allocator: &'__r __A, - value: #handle_ty, - ) -> ::std::io::Result<#handle_ty> { - let __off = #off; - let __new: u64 = { let __value = value; #new_off }; - let __stack = allocator.stack(); - let mut __b = [0u8; 8]; - __stack.get_into(__off, &mut __b)?; - let __old_off = u64::from_le_bytes(__b); - __stack.set(__off, __new.to_le_bytes())?; - ::std::result::Result::Ok(#recon) + let __off = #off; + let __stack = allocator.stack(); + let mut __b = [0u8; 8]; + if let ::std::result::Result::Err(__e) = __stack.get_into(__off, &mut __b) { + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, value)); } + let __old_off = u64::from_le_bytes(__b); + let (__new_range, __nc) = { let __value = value; #consume_new }; + let __new = __new_range.start(); + if let ::std::result::Result::Err(__e) = __stack.set(__off, __new.to_le_bytes()) { + return ::core::result::Result::Err( + ::bstack_raii::ReplaceError::recovered(__e, #rebuild_new), + ); + } + ::core::result::Result::Ok(#recon_old) + } + }; + + quote! { + /// Install `value` and move the previous value out to the caller. On + /// an I/O failure the *new* value is handed back through + /// [`ReplaceError`](::bstack_raii::ReplaceError) (never lost); the old + /// value is never at risk. + #vis fn #name<'__r, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__r __A, + value: #ret_ty, + ) -> ::core::result::Result<#ret_ty, ::bstack_raii::ReplaceError<#ret_ty>> { + #body } } } @@ -4350,63 +4397,94 @@ fn replace_accessor( } } -/// The `&BStack`-based body shared by owned/ref `replace_`: read the old -/// offset, commit the new one with a single atomic `set`, then reconstruct and -/// return the old handle. `new_off`/`recon` reference `__value` / `__old_off`. +/// The `&BStack`-based body shared by owned/ref `replace_` (both have an +/// infallible reconstruction). The swap never loses the *new* value: the old +/// offset is read **before** `value` is consumed (a read failure hands `value` +/// straight back), and a failed commit rebuilds the new value from its known +/// range. `new_range` references `__value`; `recon_old` returns the old value on +/// success; `rebuild_new` references `__new_range` for the failed-commit handback. +#[allow(clippy::too_many_arguments)] fn replace_stack_method( vis: &syn::Visibility, name: &Ident, handle_ty: &TokenStream, off: &TokenStream, - new_off: &TokenStream, - recon: &TokenStream, + new_range: &TokenStream, + recon_old: &TokenStream, + rebuild_new: &TokenStream, nullable: bool, ) -> TokenStream { if nullable { quote! { /// Install `value` and move the previous value out (`None` = the `0` - /// null niche). + /// null niche). On an I/O failure the *new* value is handed back through + /// [`ReplaceError`](::bstack_raii::ReplaceError), never lost. #vis fn #name( &self, stack: &::bstack_raii::BStack, value: ::core::option::Option<#handle_ty>, - ) -> ::std::io::Result<::core::option::Option<#handle_ty>> { + ) -> ::core::result::Result< + ::core::option::Option<#handle_ty>, + ::bstack_raii::ReplaceError<::core::option::Option<#handle_ty>>, + > { let __off = #off; - let __new: u64 = match value { - ::core::option::Option::Some(__value) => #new_off, - ::core::option::Option::None => 0u64, - }; let mut __b = [0u8; 8]; - stack.get_into(__off, &mut __b)?; + if let ::std::result::Result::Err(__e) = stack.get_into(__off, &mut __b) { + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, value)); + } let __old_off = u64::from_le_bytes(__b); - stack.set(__off, __new.to_le_bytes())?; + let (__new, __new_range_opt): (u64, ::core::option::Option<::bstack_raii::BStackRange>) = + match value { + ::core::option::Option::Some(__value) => { + let __new_range: ::bstack_raii::BStackRange = #new_range; + (__new_range.start(), ::core::option::Option::Some(__new_range)) + } + ::core::option::Option::None => (0u64, ::core::option::Option::None), + }; + if let ::std::result::Result::Err(__e) = stack.set(__off, __new.to_le_bytes()) { + let __handback: ::core::option::Option<#handle_ty> = match __new_range_opt { + ::core::option::Option::Some(__new_range) => ::core::option::Option::Some(#rebuild_new), + ::core::option::Option::None => ::core::option::Option::None, + }; + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, __handback)); + } if __old_off == 0 { - ::std::result::Result::Ok(::core::option::Option::None) + ::core::result::Result::Ok(::core::option::Option::None) } else { - ::std::result::Result::Ok(::core::option::Option::Some(#recon)) + ::core::result::Result::Ok(::core::option::Option::Some(#recon_old)) } } } } else { quote! { - /// Install `value` and move the previous value out to the caller. + /// Install `value` and move the previous value out to the caller. On an + /// I/O failure the *new* value is handed back through + /// [`ReplaceError`](::bstack_raii::ReplaceError), never lost. #vis fn #name( &self, stack: &::bstack_raii::BStack, value: #handle_ty, - ) -> ::std::io::Result<#handle_ty> { + ) -> ::core::result::Result<#handle_ty, ::bstack_raii::ReplaceError<#handle_ty>> { let __off = #off; - let __new: u64 = { let __value = value; #new_off }; let mut __b = [0u8; 8]; - stack.get_into(__off, &mut __b)?; + if let ::std::result::Result::Err(__e) = stack.get_into(__off, &mut __b) { + return ::core::result::Result::Err(::bstack_raii::ReplaceError::recovered(__e, value)); + } let __old_off = u64::from_le_bytes(__b); - stack.set(__off, __new.to_le_bytes())?; - ::std::result::Result::Ok(#recon) + let __new_range: ::bstack_raii::BStackRange = { let __value = value; #new_range }; + let __new = __new_range.start(); + if let ::std::result::Result::Err(__e) = stack.set(__off, __new.to_le_bytes()) { + return ::core::result::Result::Err( + ::bstack_raii::ReplaceError::recovered(__e, #rebuild_new), + ); + } + ::core::result::Result::Ok(#recon_old) } } } } + /// Generate `(param, prep, init)` for one constructor field. Not called for /// `#[bstack_weak]` fields. `nullable` fields take an `Option` (None => 0). fn ctor_field( diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 91b4a7e..36701f9 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -59,6 +59,7 @@ mod layout; mod owned; mod refcount; mod reference; +mod replace; /// Cross-file `Foreign` support: the process-wide path↔id file registry. pub mod registry; mod shared; @@ -88,6 +89,7 @@ pub use layout::{BlockHeader, EightCC, get_u64}; pub use owned::BStackOwned; pub use reference::BStackRef; pub use registry::ForeignHostAllocator; +pub use replace::ReplaceError; pub use shared::{BStackRc, BStackWeak}; pub use stdlib::{ BStackBTreeMap, BStackBTreeSet, BStackBinaryHeap, BStackBox, BStackCountingBloomFilter, diff --git a/bstack_raii/src/replace.rs b/bstack_raii/src/replace.rs new file mode 100644 index 0000000..b8a5acc --- /dev/null +++ b/bstack_raii/src/replace.rs @@ -0,0 +1,85 @@ +//! [`ReplaceError`]: the error a generated `replace_` mutator returns. + +use std::error::Error; +use std::fmt; +use std::io; + +/// The error a [`#[bstack_mut]`](crate::bstack_block) `replace_` mutator +/// returns when the swap fails partway through. +/// +/// `replace_` **consumes** the value you hand it. A bare `io::Result` +/// would then *lose* that value on an I/O failure — its on-disk block would be +/// neither linked into the field nor returned, an unreachable orphan. So a failed +/// `replace_` returns this instead, handing the still-valid value back in +/// [`value`](Self::value) — the same region-hand-back contract as bstack's +/// `BStackAllocError` and [`ForeignAllocError`](crate::registry::ForeignAllocError). +/// +/// The *old* value is never at risk: the swap is a single crash-atomic `set`, so +/// on failure the field still holds it (it is simply not moved out). +/// +/// Implements [`std::error::Error`] (delegating [`Display`](fmt::Display) to +/// [`source`](Self::source)). +pub struct ReplaceError { + /// The underlying I/O error that caused the swap to fail. + pub source: io::Error, + /// The value that was to be installed, handed back if it survived. + /// + /// * `Some` — recovered: the value is intact and yours again. Re-attach it + /// (retry `replace_`) or free it — dropping it as-is may leak, since a bare + /// handle is unrooted (see the crate's *moved-out-is-unrooted* rule). + /// * `None` — unrecoverable here: a post-commit reconstruction of the *old* + /// value failed after the new one was already installed, so it is the old + /// block that is now reachable only through crash-recovery / the WAL. The + /// new value is safely in the field. Treat `None` as "not recoverable + /// here," not as impossible. + pub value: Option, +} + +impl ReplaceError { + /// An error that hands the still-valid `value` back to the caller. + #[inline] + pub fn recovered(source: io::Error, value: V) -> Self { + Self { + source, + value: Some(value), + } + } + + /// An error whose value could not be recovered here (see [`value`](Self::value)). + #[inline] + pub fn lost(source: io::Error) -> Self { + Self { + source, + value: None, + } + } + + /// Discard the recovered value (if any) and take just the underlying + /// `io::Error`. Explicit, because dropping a recovered value may leak. + #[inline] + pub fn into_source(self) -> io::Error { + self.source + } +} + +// Manual, so `V` need not be `Debug` (the handed-back handles generally aren't). +impl fmt::Debug for ReplaceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReplaceError") + .field("source", &self.source) + .field("value", &self.value.as_ref().map(|_| "...")) + .finish() + } +} + +impl fmt::Display for ReplaceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.source, f) + } +} + +impl Error for ReplaceError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 30c5d50..10adf72 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6918,6 +6918,71 @@ fn macro_bstack_mut_owned_replace_moves_old_out() { holder.bstack_drop(&alloc).unwrap(); } +// A failed `replace_` commit must hand the *consumed* new value back through +// `ReplaceError`, never leak it (the realloc-style hand-back contract). Uses +// bstack's fault injection; requires --features fault-injection + debug. +#[cfg(feature = "fault-injection")] +#[test] +fn macro_bstack_mut_replace_hands_new_value_back_on_commit_fault() { + use bstack::fault::FaultPolicy; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + // Fail the first `set` (the replace commit) exactly once. `replace_` reads the + // old offset with `get_into` *before* consuming `value`, so the only `set` in + // the window is the commit itself. + struct FailFirstSet(AtomicBool); + impl FaultPolicy for FailFirstSet { + fn next_fault(&self, op: &'static str, _seq: u64) -> Option { + if op == "set" && !self.0.swap(true, Ordering::SeqCst) { + Some(io::Error::other("injected replace-commit fault")) + } else { + None + } + } + } + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroLeaf::new(&alloc, 1).unwrap(); + let holder = MutOwned::new(&alloc, a).unwrap(); + let b = MacroLeaf::new(&alloc, 2).unwrap(); + let b_off = b.handle().range().start(); + + stack.set_fault_policy(Some(Arc::new(FailFirstSet(AtomicBool::new(false))))); + let r = holder.handle().replace_child(stack, b); + stack.set_fault_policy(None); + + // The commit failed, and the NEW value came back intact — same block, readable, + // not an orphan. + let err = match r { + Ok(_) => panic!("injected fault must fail the replace commit"), + Err(e) => e, + }; + let back = err.value.expect("new value must be handed back, not lost"); + assert_eq!(back.handle().range().start(), b_off); + assert_eq!(back.handle().get_val(stack).unwrap(), 2); + + // The OLD child is untouched — still linked in the field (the swap never + // committed). + assert_eq!( + holder + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 1 + ); + + // No leak / no double-free: free the handed-back value, then the holder (which + // frees the still-linked old child `a`). + back.bstack_drop(&alloc).unwrap(); + holder.bstack_drop(&alloc).unwrap(); +} + #[test] fn macro_bstack_mut_strong_replace_moves_count_out() { let tmp = TempStack::new(); From 1b0c988caf91aa82827c4af93af0a7a23701888c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 19:57:42 -0700 Subject: [PATCH 120/140] Weak and Weak vec setters now has release on failure to prevent leaks --- bstack_raii/derive/src/block.rs | 31 ++++++++++++++---- bstack_raii/src/construct.rs | 10 +++++- bstack_raii/src/lib.rs | 2 +- bstack_raii/src/tests.rs | 58 +++++++++++++++++++++++++++++++++ bstack_raii/src/vec.rs | 29 ++++++++++++++--- 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index adcc0ac..04f59fd 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -4273,16 +4273,33 @@ fn replace_accessor( }; let recon_old = rebuild(old_range.clone()); let rebuild_new = rebuild(quote!(__new_range)); - replace_stack_method(vis, &name, &handle_ty, &off, &new_range, &recon_old, &rebuild_new, nullable) + replace_stack_method( + vis, + &name, + &handle_ty, + &off, + &new_range, + &recon_old, + &rebuild_new, + nullable, + ) } Kind::Ref => { let handle_ty = quote!(::bstack_raii::BStackRef<#inner_ty>); let new_range = quote!(__value.into_range()); - let rebuild = - |range: TokenStream| quote!(unsafe { ::bstack_raii::BStackRef::<#inner_ty>::from_range(#range) }); + let rebuild = |range: TokenStream| quote!(unsafe { ::bstack_raii::BStackRef::<#inner_ty>::from_range(#range) }); let recon_old = rebuild(old_range.clone()); let rebuild_new = rebuild(quote!(__new_range)); - replace_stack_method(vis, &name, &handle_ty, &off, &new_range, &recon_old, &rebuild_new, nullable) + replace_stack_method( + vis, + &name, + &handle_ty, + &off, + &new_range, + &recon_old, + &rebuild_new, + nullable, + ) } // Strong reconstructs a `BStackRc`, which needs the allocator — so it takes // `&A`. The NEW value hands back with no I/O (its raw parts are already in @@ -4316,7 +4333,10 @@ fn replace_accessor( }; // Consume `value` into `(new_range, ctrl)`; rebuild is infallible. let (consume_new, rebuild_new): (TokenStream, TokenStream) = ( - quote!({ let (__nd, __nc) = __value.into_raw(); (__nd.into_range(), __nc) }), + quote!({ + let (__nd, __nc) = __value.into_raw(); + (__nd.into_range(), __nc) + }), quote!(unsafe { ::bstack_raii::BStackRc::from_raw( ::bstack_raii::BStackRef::<#inner_ty>::from_range(__new_range), @@ -4484,7 +4504,6 @@ fn replace_stack_method( } } - /// Generate `(param, prep, init)` for one constructor field. Not called for /// `#[bstack_weak]` fields. `nullable` fields take an `Option` (None => 0). fn ctor_field( diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index e3c8544..96d9b08 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -136,7 +136,15 @@ pub fn set_weak_field<'w, T: BStackWeakable, A: BStackRaiiAllocator>( // observed pointing at a released control block. `new_weak` is consumed // without decrementing — its weak count becomes the field's. let ctrl = new_weak.into_raw(); - stack.set(field_off, ctrl.into_range().start().to_le_bytes())?; + if let Err(e) = stack.set(field_off, ctrl.into_range().start().to_le_bytes()) { + // Commit failed: the field still points at `old`, and `new_weak` was + // already consumed (`into_raw` defused its decrement). Release it now — a + // balancing `WeakRef` drop — so its just-transferred weak count is not + // orphaned. Best-effort: a failure here can leave at most the same + // one-too-high count teardown always tolerates, so keep the original error. + let _ = WeakRef::(ctrl).bstack_drop(allocator); + return Err(e); + } // Only now release the old target — pure reclamation, since the field no // longer refers to it. A crash before this leaks at most the old control diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 36701f9..37b1954 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -59,9 +59,9 @@ mod layout; mod owned; mod refcount; mod reference; -mod replace; /// Cross-file `Foreign` support: the process-wide path↔id file registry. pub mod registry; +mod replace; mod shared; mod stdlib; mod teardown; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 10adf72..7ae4634 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -6983,6 +6983,64 @@ fn macro_bstack_mut_replace_hands_new_value_back_on_commit_fault() { holder.bstack_drop(&alloc).unwrap(); } +// The weak setter consumes a `BStackWeak` (its decrement defused, count moved into +// the field). On a commit fault it must RELEASE that count (balancing drop), not +// orphan it — the `io::Result<()>` analogue of the `replace_` hand-back. +#[cfg(feature = "fault-injection")] +#[test] +fn macro_weak_setter_releases_new_weak_on_commit_fault() { + use bstack::fault::FaultPolicy; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct FailFirstSet(AtomicBool); + impl FaultPolicy for FailFirstSet { + fn next_fault(&self, op: &'static str, _seq: u64) -> Option { + if op == "set" && !self.0.swap(true, Ordering::SeqCst) { + Some(io::Error::other("injected weak-setter commit fault")) + } else { + None + } + } + } + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + let load = |o: u64| crate::refcount::load(stack, o).unwrap(); + + let a = WNode::new(&alloc, 1).unwrap(); // strong = 1, weak = 1 + let b = WNode::new(&alloc, 2).unwrap(); + let a_ctrl = load(a.handle().range().start() + layout::CTRL_BACKPTR_OFFSET); + let weak_off = a_ctrl + layout::CTRL_WEAK_OFFSET; + assert_eq!(load(weak_off), 1); + + // `downgrade` bumps a's weak count; the setter would move that count into the + // field. The `set` fault fires on the commit (the read/decrement use `get`/RMW). + let w = a.downgrade().unwrap(); + assert_eq!(load(weak_off), 2); + + stack.set_fault_policy(Some(Arc::new(FailFirstSet(AtomicBool::new(false))))); + let r = b.handle().set_back(&alloc, w); + stack.set_fault_policy(None); + assert!( + r.is_err(), + "injected fault must fail the weak-setter commit" + ); + + // The consumed weak was released, not orphaned: a's weak count is back to 1 … + assert_eq!( + load(weak_off), + 1, + "weak-setter leaked the consumed weak count on a failed commit" + ); + // … and the field never committed, so it stays unset. + assert!(b.handle().get_back(&alloc).unwrap().is_none()); + + drop(a); // strong 1->0 frees data; weak 1->0 frees control — nothing leaked + drop(b); +} + #[test] fn macro_bstack_mut_strong_replace_moves_count_out() { let tmp = TempStack::new(); diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs index a6b1fd2..6d20a52 100644 --- a/bstack_raii/src/vec.rs +++ b/bstack_raii/src/vec.rs @@ -675,15 +675,34 @@ impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> BStackWeakVec<'a, T, A> { .into_iter() .map(|w| w.into_raw().into_range().start()) .collect(); - Ok(Self { - offsets: BStackVec::from_slice(allocator, &offs)?, - _marker: PhantomData, - }) + match BStackVec::from_slice(allocator, &offs) { + Ok(offsets) => Ok(Self { + offsets, + _marker: PhantomData, + }), + Err(e) => { + // Building the offset array failed *after* every weak was consumed + // (`into_raw` defused each decrement, moving the count in). Release + // each so none is orphaned. Best-effort — a nested failure leaves at + // most the one-too-high count teardown already tolerates. + for off in offs { + let _ = WeakRef::(Self::ctrl_ref(off)).bstack_drop(allocator); + } + Err(e) + } + } } /// Append a weak reference (consumed, its count moved into the vector). pub fn push_weak(&mut self, elem: BStackWeak<'a, T, A>) -> io::Result<()> { - self.offsets.push(elem.into_raw().into_range().start()) + let ctrl = elem.into_raw(); + if let Err(e) = self.offsets.push(ctrl.into_range().start()) { + // Push failed after `elem` was consumed (its decrement defused by + // `into_raw`). Release its transferred weak count rather than orphan it. + let _ = WeakRef::(ctrl).bstack_drop(self.offsets.allocator()); + return Err(e); + } + Ok(()) } /// Release every weak reference (freeing control blocks that reach zero), From 9902895af46c9b67e0d483952677bd2b650cbad9 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 20:28:34 -0700 Subject: [PATCH 121/140] Problems --- bstack_raii/PROBLEMS.md | 232 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 bstack_raii/PROBLEMS.md diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md new file mode 100644 index 0000000..88bc470 --- /dev/null +++ b/bstack_raii/PROBLEMS.md @@ -0,0 +1,232 @@ +# `bstack_raii` audit — problem list + +A breadth-first audit (2026-08-11), grouped by the requested categories. Items +are observations to triage, not verified defects unless marked; each is brief and +shallow by design (flagged on suspicion, not deeply investigated). Solutions are +omitted except where trivial. + +## 1. Missing / incomplete features + +- **Deep clone / teardown never use bulk alloc/free.** `alloc_many`/`free_many` + are wired **only** into the 2-block `(rc, weak)` constructor. `ClonePlan` + allocates each new block via sequential `alloc_raw`, and teardown frees + sequentially — the "prefer `alloc_bulk`/`dealloc_bulk` when the allocator + supports it" design is unrealized for exactly the N-alloc / N-free paths it was + meant for. +- **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a + struct with a `Foreign` field, and casting for the wide-pointer relationship, + were flagged as still-open in the project notes; needs confirmation that + `bstack_move!` yields the right typed value at the foreign location. +- **Registry lazy-init not implemented** — only explicit `registry::init(path)`; + the intended "init on first live attach" path was left unresolved (no registry + path source). +- **`ForeignHost` lacks batched/generator ops**, so a cross-file clone's home + commit and foreign side cannot be one atomic unit (best-effort only — see §3). + +## 2. Code quality + +- **Crate-wide `#![allow(dead_code, unused_imports, unused_variables)]`** + ([lib.rs:45](src/lib.rs#L45)) suppresses three whole warning classes across the + entire crate — it hides real dead code (below) and would mask unused-variable + bugs. Should be removed and warnings addressed per-item. +- **Dead / superseded public helpers.** `init_rc` and `alloc_control` + ([construct.rs](src/construct.rs)) are unused by codegen (the batched + constructor path replaced them) yet still `pub use`d. `alloc_control` is also + **non-atomic** (allocates + writes payload, then a separate `set` of the data + block's back-pointer) — a public primitive whose behavior contradicts the atomic + path that superseded it. +- **Pervasive `.to_le_bytes().to_vec()`** — every counter/pointer write allocates + a fresh 8-byte heap `Vec` to feed the `set_batched` / `ClonePlan::write` + batch APIs (dozens per operation in `list`/`deque`/`map`). A small-buffer / + inline representation for batch entries would remove most of these allocations. +- **Stale crate-level docs** ([lib.rs](src/lib.rs) module comment): "Status: + method bodies marked `todo!()` are the work ahead" and "procedural macros come + after the runtime is filled in" describe a half-built crate; it is now + feature-complete. The module-map table also omits `construct`, `vec`, `wal`, + `registry`, `foreign`, `stdlib`, `bulk`, `cast`, `replace`. + +## 3. Atomicity / crash safety + +Core paths are sound (constructors commit via one `write_range` / `set_batched`; +deep clone is two-phase allocate-then-atomic-commit; owned teardown is WAL-backed). +Residual points, all *leak-only* (permitted) but worth recording: + +- **`BStackWeak::upgrade`** ([shared.rs:238](src/shared.rs#L238)) increments the + strong count, then reads the data forward-pointer; if that read fails the strong + increment is orphaned (over-count → the block can never reach zero). Same class + as the weak-setter leak just fixed; could reuse the release-on-failure idea. +- **`BStackRc::try_move`** ([shared.rs:162](src/shared.rs#L162)): after the CAS + `strong 1→0`, a failure inside `T::bstack_move` leaves the block unwrapped with + the shell possibly unfreed — an error-path leak. +- **Cross-file teardown frees are not WAL-protected on the *target* file.** The + home WAL logs `(foreign_id, range)` and `free_recorded` replays them via the + registry — but only if the foreign file is *attached at recovery time*. A crash + where the foreign file isn't re-attached on the next open loses those frees + (leak). Worth documenting as a recovery precondition. +- **`alloc_control`** (public, non-codegen): transient half-wired window where the + data block's `ctrl == 0` between its two writes (see §2). + +## 4. General bugs + +- **Fragile lifetime `transmute`** ([teardown.rs:111](src/teardown.rs#L111)): + `transmute::<&[u8], _>(&flip[..])` launders the lifetime of a 1-byte stack local + so the `inplace_gen` closure can capture it; sound only because `flip` outlives + the call. A refactor that moves/reorders `flip` would silently make it UB — it + relies on an invariant the compiler no longer checks. +- No concrete logic defect surfaced in the sampled runtime paths; the atomic + counter ops (`refcount.rs`) and the map/list/deque algorithms look correct. + +## 5. Semantics violations (safe code → UB) + +- No path found where a *safe* public API leads to UB (the risky constructors are + all `unsafe fn from_raw` / `from_range`, correctly marked). +- **21 lifetime-laundering `transmute::<&[u8], _>` / `<&mut [u8], _>`** calls + across `teardown`, `clone`, and the stdlib collections (the `inplace_gen` + buffers-outlive-the-call pattern) are the crate's main UB exposure: each is sound + only while its buffer provably outlives the generator call. They should be + funneled through a single audited helper rather than open-coded 21 times (see §9). + +## 6. Missing documentation + +- **The entire `stdlib` collection suite is absent from the README** (0 mentions): + `BStackHashMap`, `BStackBTreeMap`, `BStackHashSet`, `BStackBTreeSet`, + `BStackDeque`, `BStackLinkedList`, `BStackBinaryHeap`, `BStackBox`, `BStackCow`, + `BStackString`, `BStackCountingBloomFilter` and their iterators — a large, + user-facing feature with no README presence. +- **Large WAL surface exported with unclear audience**: `AllocReq`, `Reduced`, + `WalEntry`, `WalHeader`, `WalLog`, `WalOp`, `WalStatus`, `finish`, `persist_at`, + `reduce`, `STD_WAL_ANCHOR` are all `pub use`d at the crate root. If they are + internal machinery they should be `pub(crate)`; if public, they need docs on how + a user is meant to use them. +- README does not mention `alloc_many` / `free_many` or the `foreign_*` runtime + helpers (acceptable if intentionally internal, but they are publicly exported). + +## 7. Bad use experience + +- **Field reads always require an explicit `stack` / allocator argument** + (`h.get_field(alloc.stack())`). Callers almost always hold the allocator, so + `.stack()` is constant boilerplate; accessor forms taking `&A` directly would cut + it. +- **`BStackRc` / `BStackWeak` have no `Deref`** (only `BStackOwned` does), so a + shared handle needs `rc.handle().get_field(...)` while an owned one allows + `owned.get_field(...)` — inconsistent ergonomics for the same operation. +- **`Foreign::with` returns `Option`** (None conflates "null pointer" and "target + file not attached"); a `Result` (or distinct sentinel) would let callers tell a + missing file from a genuinely null `Foreign`. + +## 8. Performance potentials + +- Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single most + pervasive avoidable allocation. +- No bulk alloc/free in clone/teardown (§1) even when the concrete allocator + implements `BStackBulkAllocator`. +- **Double read per strong child in clone**: `ClonePlan::bump_strong` calls + `strong_parts` (a read to find the control offset) during planning, then the + commit's `inplace_gen` reads the same counter again. +- Reads are buffer-copy based (no mmap zero-copy) — documented inherent limitation. + +## 9. Duplicated code + +- **The `inplace_gen` commit pattern is open-coded repeatedly** — buffers hoisted + to outlive the call, a phased read→compute→write generator, and the lifetime + `transmute`s — in `teardown::wal_free_all`, `clone::commit_inner`, and each + stdlib collection's commit path. A single `batched_commit` helper would remove + the duplication *and* shrink the unsafe surface in §5. +- **`(offset, value.to_le_bytes().to_vec())` write-tuple construction** is repeated + hundreds of times across `stdlib/*` and the codegen; a tiny constructor helper + (`w8(off, val)`) would compress it. +- Every stdlib collection repeats a "read `OnDisk` header → mutate counters → + `set_batched`" shape; some of it could share a helper (this is runtime code, not + the struct-vs-enum codegen that was explicitly excluded). + +## 10. Feature interactions + +Cross-feature combinations, both the undesirable and the confirmed-sound (recorded +so they are not re-flagged). + +### Undesirable / risky + +- **Raw `ForeignPtr` bypasses cross-file ownership.** `Foreign` is deliberately + **not `Pod`** (only `Copy`), so it is correctly rejected from every `T: Pod` + container (`BStackBox`, `BStackVec`, a `Foreign` map/set/heap + key all fail to compile — good). But `ForeignPtr` **is** `Pod` and is publicly + re-exported, so `BStackBox` / `BStackVec` / a `ForeignPtr` + Pod-key compile and store a cross-file pointer as opaque bytes with **no** owning + dispatch → the target is leaked on teardown and aliased on clone. The macro's + "a `Foreign` field must carry an annotation" guard has no analogue here. Low + severity (raw form), but it is a hole in the "a foreign pointer always carries + ownership dispatch" invariant. +- **Stdlib collections composed into blocks are entirely untested.** No test puts a + collection in a `#[bstack_block]` field (`#[bstack_owned] d: BStackDeque`, + `Option>`), an enum variant, a `Vec`/array element, or a + `Foreign` target. The bounds line up (collections are + `BStackBlock + TryCloneIn` and override the nested `__bstack_*` hooks, so it + *should* work), but deep-clone/teardown of these compositions is unverified — a + large, plausible-but-unexercised surface. +- **Generic struct owning a generic collection may not infer bounds.** For + `struct S { #[bstack_owned] d: BStackDeque }`, the macro's + generic-bound inference is built around direct type params and `Foreign` + targets; whether it propagates the needed `T`-bounds for a field typed + `BStackDeque` (an arbitrary generic block type) is unverified and may surface a + confusing trait-bound error. +- **`#[embed]` of a collection is semantically dubious.** `#[embed] BStackDeque` + would inline only the fixed descriptor while the ring/nodes stay out-of-line; + whether embed teardown/move handle a block whose `OnDisk` is a descriptor (not a + self-contained payload) is unverified — embed was designed for self-contained + blocks. +- **`#[bstack_mut]` is silently ignored on Vec / array / tuple / `Foreign` fields.** + The mutator injection ([block.rs:2501](derive/src/block.rs#L2501)) runs *after* + those field branches `continue` (e.g. the `Foreign` branch at + [block.rs:649](derive/src/block.rs#L649)), so no `set_`/`replace_`/`raw__slice` + is generated and **no error or warning** is emitted — only `#[embed]` errors. A + user marking such a field `#[bstack_mut]` gets a silent no-op. (When the container + mutator gap is eventually filled, a `Foreign` `replace_` must also free/repoint + the *old cross-file target*, or it leaks — like the scalar owned `replace_`.) +- **`Foreign(SELF)` resolution trusts the caller's `local` with no check.** + `Foreign::with(local, f)` resolves a `FileId::SELF` pointer against `local.stack()` + unconditionally ([foreign.rs:148](src/foreign.rs#L148)) — nothing verifies `local` + is the file the block actually lives in. A SELF `Foreign` read out of a + foreign-resident block and resolved with the *home* allocator silently reads the + wrong file. Relatedly, byte-copying a SELF `Foreign` across files (a plain clone + of a field holding one) rebinds it to the destination file — a position-dependent + pointer that silently changes meaning when it moves. +- **`bstack_cast!(foreign as BStackRef)` yields an offset-only ref** valid only + in the target's *own* file; resolving it against the local stack reads garbage + (documented internally, not UB, but an easy silent-wrong-data footgun). +- **No explicit guard against `#[embed]` of an `(rc)` / `(rc, weak)` block.** Embed + folds the child's data inline and frees its shell; an `(rc, weak)` child's + *separate* control block would then keep a stale forward pointer to the freed data + offset → corruption. It is currently prevented only incidentally (an rc block + yields `BStackRc`, not the `BStackOwned` that embed's `new` requires), not + by an explicit rejection. + +### Limitation (by construction) + +- **A collection cannot be shared (`#[bstack_strong]`/`#[bstack_weak]`).** + Collections aren't `(rc)`/`(rc, weak)` blocks, so they don't implement + `BStackShared`/`BStackWeakable`; two structs cannot share one collection the way + they share an rc block. The only path is hand-rolling an rc wrapper block around + it. Worth documenting so users don't expect a shared collection. +- **`bstack_move!` works only on `BStackBox`, not the other collections.** Only + `BStackBox` implements `BStackMove` ([boxed.rs:169](src/stdlib/boxed.rs#L169)); + `map`/`deque`/`list`/`set`/`tree`/`string` do not, so `bstack_move!(collection)` + won't compile. Probably intended (a map has no meaningful field-destructure), but + it is an undocumented asymmetry. (It does *not* block a collection from being a + moved-out `#[bstack_owned]` field — that path needs only `BStackBlock`.) +- **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` + growth allocates a fresh backing block, copies into it, flips the descriptor, and + frees the old block. Whether each is a single atomic descriptor flip (leak-only on + crash) or has a torn window was not checked — a category to verify, likely + leak-only. + +### Confirmed sound (do not re-flag) + +- **Nested collections work** (`BStackHashMap>`, etc.): every + collection overrides `__bstack_clone_into` / `__bstack_drop_children`, so when a + collection is a value inside another block/collection, deep clone and teardown + recurse correctly instead of byte-copy-aliasing the descriptor. +- **A block value with a `Foreign` field, stored in a collection**, dispatches the + cross-file clone/free correctly — the map/deque/list clone/drop each value through + the value block's generated `__bstack_*`, which include the `Foreign` handling. +- **rc/weak blocks can't be smuggled into collections as owned values**: `insert` + et al. take `BStackOwned`, which an `(rc)` block cannot produce. From f7ab9a2fdfe438d18f14cd8aeb852d00be74d7e7 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 20:42:23 -0700 Subject: [PATCH 122/140] Fix clippy issues --- bstack_raii/src/bulk.rs | 2 +- bstack_raii/src/cast.rs | 2 +- bstack_raii/src/clone.rs | 2 +- bstack_raii/src/foreign.rs | 5 ++++- bstack_raii/src/layout.rs | 6 ------ bstack_raii/src/lib.rs | 2 -- bstack_raii/src/registry.rs | 5 +---- bstack_raii/src/stdlib/bloom.rs | 3 +-- bstack_raii/src/stdlib/deque.rs | 2 +- bstack_raii/src/tests.rs | 27 ++++++++++++++++++++++++++- bstack_raii/src/wal.rs | 4 ++-- 11 files changed, 38 insertions(+), 22 deletions(-) diff --git a/bstack_raii/src/bulk.rs b/bstack_raii/src/bulk.rs index 3716bc8..b2473b1 100644 --- a/bstack_raii/src/bulk.rs +++ b/bstack_raii/src/bulk.rs @@ -23,7 +23,7 @@ use std::io; -use bstack::{BStackAllocator, BStackRange}; +use bstack::BStackRange; use crate::BStackRaiiAllocator; use crate::teardown::dealloc_range; diff --git a/bstack_raii/src/cast.rs b/bstack_raii/src/cast.rs index 48d1259..d9e27c0 100644 --- a/bstack_raii/src/cast.rs +++ b/bstack_raii/src/cast.rs @@ -10,7 +10,7 @@ use std::io; use crate::BStackRaiiAllocator; use bstack::{BStackOwnedSlice, BStackSlice}; -use crate::block::{BStackBlock, BStackCast}; +use crate::block::BStackBlock; use crate::layout::EightCC; use crate::owned::BStackOwned; use crate::teardown::AutoDrop; diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 4f022d0..0261ded 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -40,7 +40,7 @@ use std::io; use bstack::{BStackGenOp, BStackRange}; use crate::BStackRaiiAllocator; -use crate::block::{BStackBlock, BStackShared}; +use crate::block::BStackShared; use crate::layout; use crate::owned::BStackOwned; use crate::reference::BStackRef; diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index 2d0a602..40df58b 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -32,7 +32,9 @@ use crate::handle::{OwnedRef, WeakRef}; use crate::layout; use crate::refcount; use crate::reference::BStackRef; -use crate::registry::{self, FileId, FileRegistry}; +#[cfg(test)] +use crate::registry::FileRegistry; +use crate::registry::{self, FileId}; use crate::teardown::BStackDrop; /// The on-disk form of a [`Foreign`] pointer: a file identity plus an offset in @@ -180,6 +182,7 @@ impl Foreign { /// Like [`with`](Self::with) but against an explicit `registry` — crate-internal, /// for tests (the global is a one-shot `OnceLock`, awkward to exercise in unit /// tests). Production code uses [`with`](Self::with) against the sole registry. + #[cfg(test)] pub(crate) fn with_in( self, registry: &FileRegistry, diff --git a/bstack_raii/src/layout.rs b/bstack_raii/src/layout.rs index 7238650..3f06062 100644 --- a/bstack_raii/src/layout.rs +++ b/bstack_raii/src/layout.rs @@ -108,12 +108,6 @@ pub struct BlockHeader { /// begins. pub const HEADER_SIZE: u64 = core::mem::size_of::() as u64; -/// On-disk width of a reference. Per RAII.md, an on-disk `BStackRef` stores -/// only the `u64` offset; the length is recovered at resolve time from the -/// target type's fixed `size_of::()`. (This is why the RAII layer is, -/// for now, a fixed-size-block model.) -pub const REF_SIZE: u64 = 8; - // -- Injected-field offsets ------------------------------------------------ // // RAII.md injects the refcount / control back-pointer / control counters diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 37b1954..a0d93fd 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -42,8 +42,6 @@ //! ([`macro@bstack_block`], [`bstack_move`], [`bstack_cast`]) come after the //! runtime is filled in. -#![allow(dead_code, unused_imports, unused_variables)] - // Lets code generated by `#[bstack_block]` reference this crate as // `::bstack_raii::…` even from within the crate's own tests. extern crate self as bstack_raii; diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs index da213b6..3e1749d 100644 --- a/bstack_raii/src/registry.rs +++ b/bstack_raii/src/registry.rs @@ -41,10 +41,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use bstack::{ - BStack, BStackAllocError, BStackAllocator, BStackOwnedSlice, BStackOwnedSliceAllocator, - BStackRange, -}; +use bstack::{BStack, BStackAllocError, BStackAllocator, BStackOwnedSlice, BStackRange}; use parking_lot::RwLock; use crate::BStackRaiiAllocator; diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index f4e7351..79643f0 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -38,7 +38,7 @@ use core::mem::size_of; use std::io; use crate::BStackRaiiAllocator; -use bstack::{BStack, BStackGenOp, BStackOwnedSliceAllocator, BStackRange}; +use bstack::{BStack, BStackGenOp, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::double_hash; @@ -69,7 +69,6 @@ pub struct BloomOnDisk { const DATA_OFF: u64 = HEADER_SIZE; // 16 const M_OFF: u64 = HEADER_SIZE + 8; // 24 -const K_OFF: u64 = HEADER_SIZE + 16; // 32 const N_OFF: u64 = HEADER_SIZE + 24; // 40 const BLOOM_SIZE: u64 = size_of::() as u64; diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index d9bcd20..4f6204c 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -362,7 +362,7 @@ impl BStackDeque { // Abort if, at commit time, the ring already has room or is already at // least this big (another thread grew it) — then our `newring` is wasted. - let abort = |head: u64, len: u64, cap: u64| (cap != 0 && len < cap) || newcap <= cap; + let abort = |_head: u64, len: u64, cap: u64| (cap != 0 && len < cap) || newcap <= cap; atomic_update( allocator, diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 7ae4634..ded69a7 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -1766,6 +1766,19 @@ fn macro_enum_rc() { unsafe { dealloc_range(&alloc, reused).unwrap() }; } +#[test] +fn macro_enum_rc_val_variant() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let rc = RcNode::new(&alloc, RcNodeData::Val(7)).unwrap(); + match rc.handle().read(&alloc).unwrap() { + RcNodeView::Val(v) => assert_eq!(v, 7), + _ => panic!("expected Val"), + } + drop(rc); +} + #[bstack_enum(rc, weak)] enum RcwNode { Nil, @@ -4418,6 +4431,19 @@ fn macro_generic_enum_owned() { } } +#[test] +fn macro_generic_enum_tag_variant() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let e = BoxEnumG::::new(&alloc, BoxEnumGData::Tag(99)).unwrap(); + match e.handle().read(&alloc).unwrap() { + BoxEnumGView::Tag(t) => assert_eq!(t, 99), + _ => panic!("expected Tag"), + } + e.bstack_drop(&alloc).unwrap(); +} + #[bstack_enum] enum StrongEnumG { Empty, @@ -6218,7 +6244,6 @@ fn stdlib_heap_pop_ascending() { fn stdlib_heap_duplicate_keys() { let tmp = TempStack::new(); let alloc = tmp.allocator(); - let stack = alloc.stack(); let heap = BStackBinaryHeap::::new(&alloc).unwrap(); for (k, v) in [(3u32, 30u32), (1, 10), (3, 31), (1, 11), (2, 20)] { diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index d1c741f..75fdbc4 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -57,11 +57,11 @@ use std::collections::HashMap; use std::io; use std::sync::{Arc, Mutex, OnceLock}; -use bstack::{BStackAllocator, BStackOwnedSliceAllocator, BStackRange}; +use bstack::BStackRange; use bytemuck::{Pod, Zeroable}; use crate::BStackRaiiAllocator; -use crate::registry::{self, FileId, ForeignHost}; +use crate::registry::{self, FileId}; use crate::teardown::dealloc_range; /// `R'`: an allocation requirement carrying identity — a length whose address has From d1d709a674da8bdf238ba4ffbe3d6e60df670513 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 20:57:15 -0700 Subject: [PATCH 123/140] Fix dead helpers --- bstack_raii/PROBLEMS.md | 9 +++++--- bstack_raii/src/construct.rs | 45 +----------------------------------- bstack_raii/src/lib.rs | 4 +--- bstack_raii/src/tests.rs | 30 ++++++++++++++++++++++-- 4 files changed, 36 insertions(+), 52 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 88bc470..b83d6f0 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -25,16 +25,19 @@ omitted except where trivial. ## 2. Code quality -- **Crate-wide `#![allow(dead_code, unused_imports, unused_variables)]`** +- [FIXED] **Crate-wide `#![allow(dead_code, unused_imports, unused_variables)]`** ([lib.rs:45](src/lib.rs#L45)) suppresses three whole warning classes across the entire crate — it hides real dead code (below) and would mask unused-variable bugs. Should be removed and warnings addressed per-item. -- **Dead / superseded public helpers.** `init_rc` and `alloc_control` +- [FIXED] **Dead / superseded public helpers.** `init_rc` and `alloc_control` ([construct.rs](src/construct.rs)) are unused by codegen (the batched constructor path replaced them) yet still `pub use`d. `alloc_control` is also **non-atomic** (allocates + writes payload, then a separate `set` of the data block's back-pointer) — a public primitive whose behavior contradicts the atomic - path that superseded it. + path that superseded it. `init_rc` removed (truly dead); `alloc_control` removed + from `construct.rs` and replaced in `tests.rs` with a test-local equivalent that + commits the control payload and the data block's back-pointer in one + `set_batched`, matching the atomic path. - **Pervasive `.to_le_bytes().to_vec()`** — every counter/pointer write allocates a fresh 8-byte heap `Vec` to feed the `set_batched` / `ClonePlan::write` batch APIs (dozens per operation in `list`/`deque`/`map`). A small-buffer / diff --git a/bstack_raii/src/construct.rs b/bstack_raii/src/construct.rs index 96d9b08..8277b1c 100644 --- a/bstack_raii/src/construct.rs +++ b/bstack_raii/src/construct.rs @@ -19,7 +19,7 @@ use crate::handle::WeakRef; use crate::layout::{self, BlockHeader, EightCC, put_u64}; use crate::reference::BStackRef; use crate::shared::{BStackRc, BStackWeak}; -use crate::teardown::{BStackDrop, dealloc_range}; +use crate::teardown::BStackDrop; #[inline(always)] fn read_u64_at(stack: &BStack, off: u64) -> io::Result { @@ -47,49 +47,6 @@ pub fn alloc_block( Ok(slice.as_range()) } -/// Initialize a plain `#[bstack_block(rc)]` block's inline refcount to 1. -/// -/// Call once after [`alloc_block`] and after the payload is written. One is the -/// count the single returned `BStackRc` accounts for. -pub fn init_rc(allocator: &A, data: BStackRange) -> io::Result<()> { - let off = data.start() + layout::RC_REFCOUNT_OFFSET; - allocator.stack().set(off, 1u64.to_le_bytes()) -} - -/// Allocate and wire the control block for an already-allocated -/// `#[bstack_block(rc, weak)]` data block. -/// -/// Writes the control header, `strong = 1`, `weak = 1` (the phantom weak held by -/// the strong owners), and the `x` forward pointer to the data block; then -/// writes the data block's `ctrl` back-pointer. Returns the control block's -/// range. `control_size` is `size_of::()`. -/// -/// On failure the control block is released; the caller still owns (and must -/// release) the data block. -pub fn alloc_control( - allocator: &A, - ctrl_tag: EightCC, - data: BStackRange, - control_size: u64, -) -> io::Result { - let payload = build_control_payload(ctrl_tag, data.start(), control_size); - let mut slice = allocator.alloc(control_size)?; - let ctrl = slice.as_range(); - if let Err(e) = slice.write_range(0, &payload) { - let _ = allocator.dealloc(slice); - return Err(e); - } - - // The data block's `ctrl` back-pointer lives in a different block, so it is - // one more (unavoidable) write into that region. - let backptr = data.start() + layout::CTRL_BACKPTR_OFFSET; - if let Err(e) = allocator.stack().set(backptr, ctrl.start().to_le_bytes()) { - let _ = unsafe { dealloc_range(allocator, ctrl) }; - return Err(e); - } - Ok(ctrl) -} - /// Build a `(rc, weak)` control-block payload image in memory (no allocation, no /// write): header, `strong = 1`, `weak = 1` (the phantom weak the strong owners /// hold), and the `x` forward pointer to the data block at `data_start`. diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index a0d93fd..2315179 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -75,9 +75,7 @@ pub use block::{ pub use bulk::{alloc_many, free_many}; pub use cast::{BStackCastAs, BStackCastInto}; pub use clone::{ClonePlan, TryClone, TryCloneIn}; -pub use construct::{ - alloc_block, alloc_control, build_control_payload, init_rc, set_weak_field, upgrade_weak_field, -}; +pub use construct::{alloc_block, build_control_payload, set_weak_field, upgrade_weak_field}; pub use foreign::{ Foreign, ForeignPtr, foreign_clone_owned, foreign_clone_strong, foreign_clone_weak, foreign_drop_owned, foreign_drop_strong, foreign_drop_weak, diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index ded69a7..5debe4a 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -16,8 +16,8 @@ use crate::{ BStackBox, BStackCast, BStackCastAs, BStackCastInto, BStackCountingBloomFilter, BStackCow, BStackDeque, BStackDrop, BStackHashMap, BStackHashSet, BStackLinkedList, BStackOwned, BStackRaiiAllocator, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, - TryClone, TryCloneIn, alloc_block, alloc_control, bstack_block, bstack_cast, bstack_enum, - bstack_move, dealloc_range, + TryClone, TryCloneIn, alloc_block, bstack_block, bstack_cast, bstack_enum, bstack_move, + build_control_payload, dealloc_range, }; // -------------------------------------------------------------------------- @@ -139,6 +139,32 @@ fn ctrl_tag() -> EightCC { EightCC::from_name("TESTCTRL") } +/// Allocate and wire the control block for an already-allocated `(rc, weak)` +/// data block, mirroring the atomic path the macro's `RcWeak` constructor +/// uses: the control payload write and the data block's `ctrl` back-pointer +/// write commit together in one [`bstack::BStack::set_batched`], so there is +/// no transient state where one is written and not the other. +fn alloc_control( + allocator: &A, + ctrl_tag: EightCC, + data: BStackRange, + control_size: u64, +) -> io::Result { + let slice = allocator.alloc(control_size)?; + let ctrl = slice.as_range(); + let payload = build_control_payload(ctrl_tag, data.start(), control_size); + let backptr_off = data.start() + layout::CTRL_BACKPTR_OFFSET; + let writes: [(u64, Vec); 2] = [ + (ctrl.start(), payload), + (backptr_off, ctrl.start().to_le_bytes().to_vec()), + ]; + if let Err(e) = allocator.stack().set_batched(writes) { + let _ = allocator.dealloc(slice); + return Err(e); + } + Ok(ctrl) +} + /// Allocate and fully wire an `(rc, weak)` `TestBlock` (data + control), /// returning both ranges. `strong = 1`, `weak = 1` on return. fn build_rc_weak(alloc: &FirstFitBStackAllocator) -> (BStackRange, BStackRange) { From d5f2c18404dc0946b1263e6a768eae1cf6fe3ea4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 21:01:37 -0700 Subject: [PATCH 124/140] Fix docs --- bstack_raii/PROBLEMS.md | 6 ++++-- bstack_raii/src/lib.rs | 22 +++++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index b83d6f0..0b778b6 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -42,11 +42,13 @@ omitted except where trivial. a fresh 8-byte heap `Vec` to feed the `set_batched` / `ClonePlan::write` batch APIs (dozens per operation in `list`/`deque`/`map`). A small-buffer / inline representation for batch entries would remove most of these allocations. -- **Stale crate-level docs** ([lib.rs](src/lib.rs) module comment): "Status: +- [FIXED] **Stale crate-level docs** ([lib.rs](src/lib.rs) module comment): "Status: method bodies marked `todo!()` are the work ahead" and "procedural macros come after the runtime is filled in" describe a half-built crate; it is now feature-complete. The module-map table also omits `construct`, `vec`, `wal`, - `registry`, `foreign`, `stdlib`, `bulk`, `cast`, `replace`. + `registry`, `foreign`, `stdlib`, `bulk`, `cast`, `replace`. Status rewritten to + reflect feature-completeness (naming the one real open gap, `Foreign` + cross-file teardown/deep-clone); module-map table now lists all 18 modules. ## 3. Atomicity / crash safety diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 2315179..19634ce 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -18,12 +18,21 @@ //! | [`layout`] | On-disk primitives: [`EightCC`], [`BlockHeader`] (both Pod). | //! | [`reference`] | [`BStackRef`]: typed range wrapper + buffered `OnDisk` read. | //! | [`teardown`] | [`BStackDrop`] trait, [`AutoDrop`] RAII guard, [`dealloc_range`]. | +//! | `construct` | Block creation: allocate, stamp the header, wire refcounts / control blocks — the build-side counterpart to `teardown`. | //! | [`block`] | Block-type contracts: [`BStackCast`], [`BStackBlock`], [`BStackWeakable`]. | //! | [`refcount`] | Little-endian atomic CAS ops over on-disk `u64` counters. | -//! | [`clone`] | [`TryClone`]: fallible clone for handles that touch disk. | +//! | `bulk` | [`alloc_many`] / [`free_many`]: multi-block alloc with all-or-nothing rollback. | +//! | [`clone`] | [`TryClone`] / [`TryCloneIn`]: fallible clone for handles that touch disk. | //! | [`handle`] | Without-allocator inner handles: [`OwnedRef`], [`StrongRef`], [`StrongWeakRef`], [`WeakRef`]. | //! | [`owned`] | [`BStackOwned`]: the without-allocator, uniquely-owned block handle. | //! | [`shared`] | [`BStackRc`] + [`BStackWeak`]: with-allocator shared handles.| +//! | `cast` | Typed ↔ untyped handle conversion — the runtime behind `bstack_cast!`. | +//! | `vec` | [`VecDesc`]-backed growable vectors reached through a fixed-size field. | +//! | [`replace`] | [`ReplaceError`]: the error a generated `replace_` mutator returns. | +//! | [`registry`] | Process-wide path↔[`FileId`](registry::FileId) registry underlying `Foreign`. | +//! | `foreign` | [`Foreign`]: a cross-file pointer (file identity + offset).| +//! | `wal` | Write-ahead log for atomic multi-slice transactions and leak reclamation. | +//! | `stdlib` | Ergonomic handle/collection types built entirely on the above (e.g. [`BStackHashMap`], [`BStackDeque`], [`BStackCow`]). | //! //! ## Conventions fixed by the ABI //! @@ -37,10 +46,13 @@ //! //! ## Status //! -//! Types and traits are laid out with their final signatures; method bodies -//! marked `todo!()` are the work ahead. Procedural macros -//! ([`macro@bstack_block`], [`bstack_move`], [`bstack_cast`]) come after the -//! runtime is filled in. +//! Feature-complete: the runtime primitives, the procedural macros +//! ([`macro@bstack_block`], [`macro@bstack_enum`], [`bstack_move`], +//! [`bstack_cast`]), and the [`stdlib`] collection suite are all implemented and +//! exercised by the test suite. The main open gap is `Foreign` cross-file +//! deep-clone/teardown dispatch, which is still deferred (a `Foreign` field is +//! byte-copied on clone and freed by nobody on teardown regardless of its +//! ownership annotation) — see the [`foreign`] module docs. // Lets code generated by `#[bstack_block]` reference this crate as // `::bstack_raii::…` even from within the crate's own tests. From f50ddc5c27231771e976f8d86f97e922bb889039 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Tue, 11 Aug 2026 21:10:21 -0700 Subject: [PATCH 125/140] Fix some problems and mark some as not fixing --- bstack_raii/PROBLEMS.md | 37 +++++++++++--------- bstack_raii/README.md | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 0b778b6..db4e6b8 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -68,36 +68,37 @@ Residual points, all *leak-only* (permitted) but worth recording: registry — but only if the foreign file is *attached at recovery time*. A crash where the foreign file isn't re-attached on the next open loses those frees (leak). Worth documenting as a recovery precondition. -- **`alloc_control`** (public, non-codegen): transient half-wired window where the - data block's `ctrl == 0` between its two writes (see §2). +- [WONTFIX] **`alloc_control`** (public, non-codegen): transient half-wired window where the + data block's `ctrl == 0` between its two writes (see §2). `alloc_control` has been removed from the crate and replaced in `tests.rs`. ## 4. General bugs -- **Fragile lifetime `transmute`** ([teardown.rs:111](src/teardown.rs#L111)): +- [WONTFIX] **Fragile lifetime `transmute`** ([teardown.rs:111](src/teardown.rs#L111)): `transmute::<&[u8], _>(&flip[..])` launders the lifetime of a 1-byte stack local so the `inplace_gen` closure can capture it; sound only because `flip` outlives the call. A refactor that moves/reorders `flip` would silently make it UB — it relies on an invariant the compiler no longer checks. -- No concrete logic defect surfaced in the sampled runtime paths; the atomic - counter ops (`refcount.rs`) and the map/list/deque algorithms look correct. + This is the desired workaround for the `inplace_gen` lifetime problem. See `inplace_gen` usage example. ## 5. Semantics violations (safe code → UB) -- No path found where a *safe* public API leads to UB (the risky constructors are - all `unsafe fn from_raw` / `from_range`, correctly marked). -- **21 lifetime-laundering `transmute::<&[u8], _>` / `<&mut [u8], _>`** calls +- [WONTFIX] **21 lifetime-laundering `transmute::<&[u8], _>` / `<&mut [u8], _>`** calls across `teardown`, `clone`, and the stdlib collections (the `inplace_gen` buffers-outlive-the-call pattern) are the crate's main UB exposure: each is sound only while its buffer provably outlives the generator call. They should be funneled through a single audited helper rather than open-coded 21 times (see §9). + See previous comment for `inplace_gen`. ## 6. Missing documentation -- **The entire `stdlib` collection suite is absent from the README** (0 mentions): - `BStackHashMap`, `BStackBTreeMap`, `BStackHashSet`, `BStackBTreeSet`, +- [FIXED] **The entire `stdlib` collection suite is absent from the README** (0 + mentions): `BStackHashMap`, `BStackBTreeMap`, `BStackHashSet`, `BStackBTreeSet`, `BStackDeque`, `BStackLinkedList`, `BStackBinaryHeap`, `BStackBox`, `BStackCow`, `BStackString`, `BStackCountingBloomFilter` and their iterators — a large, - user-facing feature with no README presence. + user-facing feature with no README presence. Added a "Standard library + collections" section (TOC entry, type table, two compile-checked usage + snippets, a sharing/`bstack_move!` limitations note) between "Type tags" and + "Examples". - **Large WAL surface exported with unclear audience**: `AllocReq`, `Reduced`, `WalEntry`, `WalHeader`, `WalLog`, `WalOp`, `WalStatus`, `finish`, `persist_at`, `reduce`, `STD_WAL_ANCHOR` are all `pub use`d at the crate root. If they are @@ -108,10 +109,14 @@ Residual points, all *leak-only* (permitted) but worth recording: ## 7. Bad use experience -- **Field reads always require an explicit `stack` / allocator argument** +- [WONTFIX] **Field reads always require an explicit `stack` / allocator argument** (`h.get_field(alloc.stack())`). Callers almost always hold the allocator, so `.stack()` is constant boilerplate; accessor forms taking `&A` directly would cut it. + This is the classic "Zig Problem" - the allocator is always in scope, but the API + requires it to be passed explicitly. The design choice was deliberate to avoid storing + the allocator in the handle, but it does make the ergonomics worse. Note that multi- + file (Foreign) access does not have this concern. - **`BStackRc` / `BStackWeak` have no `Deref`** (only `BStackOwned` does), so a shared handle needs `rc.handle().get_field(...)` while an owned one allows `owned.get_field(...)` — inconsistent ergonomics for the same operation. @@ -132,7 +137,7 @@ Residual points, all *leak-only* (permitted) but worth recording: ## 9. Duplicated code -- **The `inplace_gen` commit pattern is open-coded repeatedly** — buffers hoisted +- [WONTFIX] **The `inplace_gen` commit pattern is open-coded repeatedly** — buffers hoisted to outlive the call, a phased read→compute→write generator, and the lifetime `transmute`s — in `teardown::wal_free_all`, `clone::commit_inner`, and each stdlib collection's commit path. A single `batched_commit` helper would remove @@ -207,18 +212,18 @@ so they are not re-flagged). ### Limitation (by construction) -- **A collection cannot be shared (`#[bstack_strong]`/`#[bstack_weak]`).** +- [WONTFIX] **A collection cannot be shared (`#[bstack_strong]`/`#[bstack_weak]`).** Collections aren't `(rc)`/`(rc, weak)` blocks, so they don't implement `BStackShared`/`BStackWeakable`; two structs cannot share one collection the way they share an rc block. The only path is hand-rolling an rc wrapper block around it. Worth documenting so users don't expect a shared collection. -- **`bstack_move!` works only on `BStackBox`, not the other collections.** Only +- [WONTFIX] **`bstack_move!` works only on `BStackBox`, not the other collections.** Only `BStackBox` implements `BStackMove` ([boxed.rs:169](src/stdlib/boxed.rs#L169)); `map`/`deque`/`list`/`set`/`tree`/`string` do not, so `bstack_move!(collection)` won't compile. Probably intended (a map has no meaningful field-destructure), but it is an undocumented asymmetry. (It does *not* block a collection from being a moved-out `#[bstack_owned]` field — that path needs only `BStackBlock`.) -- **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` +- [WONTFIX] **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` growth allocates a fresh backing block, copies into it, flips the descriptor, and frees the old block. Whether each is a single atomic descriptor flip (leak-only on crash) or has a torn window was not checked — a category to verify, likely diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 40617ed..ff41f81 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -43,6 +43,7 @@ object model on top. - [Casting: `bstack_cast!`](#casting-bstack_cast) - [Cross-file pointers: `Foreign`](#cross-file-pointers-foreignt) - [Type tags (`EightCC`)](#type-tags-eightcc) +- [Standard library collections](#standard-library-collections) - [Examples](#examples) - [Limitations](#limitations) @@ -975,6 +976,63 @@ for the coercion warning, or a real `#[allow(deprecated)]` on the item). This also works for `#[bstack_enum]` — e.g. `#[bstack_enum(rc, tag = "ENMTAG")] enum Mode { Unit, Val(u32) }`. +## Standard library collections + +Built entirely on the primitives above — nothing here has privileged access, so +each type doubles as a worked example of composing the ownership model. Every +collection is itself a plain [`BStackBlock`] (`BStackDrop` + `TryCloneIn`), so it +can be used bare — a top-level `BStackOwned<...>`, freed with `bstack_drop` — or +composed as a `#[bstack_owned]` field inside another block, nested inside +another collection, or held in a `#[bstack_enum]` variant. + +| Type | Rust analogue | What it holds | +|------------------------------------|-----------------------------------|----------------| +| [`BStackCow`] | `std::borrow::Cow` | either a borrowed reference or an owned block, deep-copying on first write. | +| [`BStackBox`] | `std::boxed::Box` | a single owned `Pod` value in its own block — the macro-free way to own a bare scalar/POD struct. | +| [`BStackLinkedList`] | `std::collections::LinkedList` | an owned doubly-linked list of block values. Prefer `BStackDeque` / `BStackBlockVec` unless you need O(1) end/splice ops. | +| [`BStackDeque`] | `std::collections::VecDeque` | an owned double-ended queue: a contiguous ring, O(1) amortized push/pop at both ends. | +| [`BStackHashMap`] | `std::collections::HashMap` | an owned open-addressing map from a `Pod` key to a block value. | +| [`BStackBTreeMap`] | `std::collections::BTreeMap` | an owned **ordered** map (copy-on-write B-tree) with sorted iteration. Keys are `Pod + Ord`. | +| [`BStackString`] | `std::string::String` | a standalone owned, growable UTF-8 string block — the first-class way to own text (a deque element, a map value). | +| [`BStackCountingBloomFilter`] | (Bloom filter) | a probabilistic set: no false negatives, supports removal — a cheap fast-reject front for exact lookups. | +| [`BStackHashSet`] | `std::collections::HashSet` | an owned open-addressing set of `Pod` keys, with an embedded Bloom-filter fast-reject front. | +| [`BStackBTreeSet`] | `std::collections::BTreeSet` | an owned **ordered** set (copy-on-write B-tree), with an embedded Bloom-filter front. Keys are `Pod + Ord`. | +| [`BStackBinaryHeap`] | `std::collections::BinaryHeap` | an owned priority queue (array-backed binary **min**-heap): `pop` returns the smallest-key entry. Keys are `Pod + Ord`. | + +Each is constructed with `new` (or `with_capacity` where it applies), torn down +with `bstack_drop`, and deep-cloned with `try_clone_in` — same as any other +owned handle: + +```rust +use bstack_raii::{BStackDrop, BStackHashMap, BStackString}; + +let map = BStackHashMap::::new(&alloc)?; +map.insert(&alloc, 1, BStackString::new(&alloc, "one")?)?; +map.insert(&alloc, 2, BStackString::new(&alloc, "two")?)?; + +let v = map.get(alloc.stack(), &1)?.unwrap(); // -> a BStackString handle +assert_eq!(v.to_string(alloc.stack())?, "one"); + +map.bstack_drop(&alloc)?; // frees the map AND every owned BStackString value +``` + +Composing one into a block field works like any other owned type — deep clone +and teardown recurse through it automatically: + +```rust +#[bstack_block] +struct Session { + id: u64, + #[bstack_owned] + log: BStackDeque, +} +``` + +Each collection's iterator (`HashMapIter`, `DequeIter`, `ListIter`, +`BTreeMapIter`, `BTreeSetIter`, `HashSetIter`, …) borrows the allocator's +[`BStack`] and yields owned element handles — see the type's own docs for the +exact borrow shape. + ## Examples Runnable end-to-end programs live in [`examples/`](examples/): @@ -1011,6 +1069,11 @@ Runnable end-to-end programs live in [`examples/`](examples/): their cross-file operations are *best-effort atomic* — a failure over-provisions (a reclaimable leak) rather than under-counts. Resolution requires the target file to be `attach`ed to the process registry. +- **[Standard library collections](#standard-library-collections)** can't be + shared (`#[bstack_strong]` / `#[bstack_weak]`) — they aren't `(rc)` / + `(rc, weak)` blocks, so two structs can't share one collection the way they + share an `rc` block. `bstack_move!` only works on `BStackBox`; the others have + no meaningful field-destructure. - The on-disk **ABI is not yet stable**. ## License @@ -1023,3 +1086,16 @@ MIT (same as `bstack`). [`BStackVec`]: src/vec.rs [`FileId`]: src/registry.rs [`BStackRaiiAllocator`]: src/lib.rs +[`BStackBlock`]: src/block.rs +[`BStack`]: https://docs.rs/bstack +[`BStackCow`]: src/stdlib/cow.rs +[`BStackBox`]: src/stdlib/boxed.rs +[`BStackLinkedList`]: src/stdlib/list.rs +[`BStackDeque`]: src/stdlib/deque.rs +[`BStackHashMap`]: src/stdlib/map.rs +[`BStackBTreeMap`]: src/stdlib/tree.rs +[`BStackString`]: src/stdlib/string.rs +[`BStackCountingBloomFilter`]: src/stdlib/bloom.rs +[`BStackHashSet`]: src/stdlib/hashset.rs +[`BStackBTreeSet`]: src/stdlib/btreeset.rs +[`BStackBinaryHeap`]: src/stdlib/heap.rs From f6d58cfbeb55a278a849ebb4cb56376aa517773d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 00:25:32 -0700 Subject: [PATCH 126/140] Fix wal surface issue --- bstack_raii/PROBLEMS.md | 23 ++++++++++++++++++-- bstack_raii/README.md | 11 ++++++++++ bstack_raii/src/lib.rs | 5 +---- bstack_raii/src/tests.rs | 10 ++++----- bstack_raii/src/wal.rs | 46 ++++++++++++++++++++++------------------ 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index db4e6b8..e578a11 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -22,6 +22,11 @@ omitted except where trivial. path source). - **`ForeignHost` lacks batched/generator ops**, so a cross-file clone's home commit and foreign side cannot be one atomic unit (best-effort only — see §3). +- **`wal::reduce`'s groupoid slice-reuse optimization is unwired.** Fully + implemented and unit-tested (`AllocReq` / `Reduced` / `reduce`, now + `#[cfg(test)]` — see §6), but nothing in `bulk`/`clone`/`teardown` calls it: + no commit path currently repurposes a same-length freed slice for a fresh + allocation instead of freeing then reallocating. ## 2. Code quality @@ -99,11 +104,25 @@ Residual points, all *leak-only* (permitted) but worth recording: collections" section (TOC entry, type table, two compile-checked usage snippets, a sharing/`bstack_move!` limitations note) between "Type tags" and "Examples". -- **Large WAL surface exported with unclear audience**: `AllocReq`, `Reduced`, +- [FIXED] **Large WAL surface exported with unclear audience**: `AllocReq`, `Reduced`, `WalEntry`, `WalHeader`, `WalLog`, `WalOp`, `WalStatus`, `finish`, `persist_at`, `reduce`, `STD_WAL_ANCHOR` are all `pub use`d at the crate root. If they are internal machinery they should be `pub(crate)`; if public, they need docs on how - a user is meant to use them. + a user is meant to use them. `finish` and `STD_WAL_ANCHOR` are the only two a + caller ever needs (call `finish` once after `open`; `STD_WAL_ANCHOR` for a custom + `wal_anchor()` impl) — kept public and documented in the README's allocator-bound + callout. Everything else (`AllocReq`, `Reduced`, `WalEntry`, `WalHeader`, + `WalLog`, `WalOp`, `WalStatus`, `persist_at`, `reduce`) is internal transaction + machinery, moved to `pub(crate)`. That surfaced real dead code masked by the old + public re-export: `WalStatus::advance`/`recover`, `WalEntry::dealloc` (the bare, + non-`_in` form), and `WalLog::fresh_id`/`as_bytes` are exercised only by `wal.rs`'s + own unit tests (now `#[cfg(test)]`); `WalEntry::set_status` and + `WalLog::entries_mut`/`is_empty` were unused even there (deleted). Separately, + `AllocReq`/`Reduced`/`reduce` — the groupoid-reduction slice-reuse optimization — + turned out to be fully implemented and unit-tested but **never wired into any + real commit path** (`bulk`/`clone`/`teardown` don't call it); kept `#[cfg(test)]` + rather than deleted, noted here as a distinct §1-adjacent gap for whoever wires + it in. - README does not mention `alloc_many` / `free_many` or the `foreign_*` runtime helpers (acceptable if intentionally internal, but they are publicly exported). diff --git a/bstack_raii/README.md b/bstack_raii/README.md index ff41f81..a990737 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -146,6 +146,15 @@ how it is torn down. Plain-old-data fields (anything `Pod` — integers, `[u8; N > nothing) and it can hand out offset 0, so it does *not* implement the trait. For > growable fields, use a **realloc-safe** allocator (growth reallocates the backing > block); `FirstFitBStackAllocator` is realloc-safe. +> +> **The WAL anchor in practice.** Two items are public because a caller may need +> them; everything else in the WAL's internal transaction log is crate-private. +> Call [`wal::finish`]`(&allocator)` once after `open` to complete any transaction +> a prior crash left in flight (reclaiming the slices it orphaned) — +> `try_clone_in` and `bstack_drop` also call it opportunistically, so this is a +> deterministic point to do it, not the only one. [`STD_WAL_ANCHOR`] is the anchor +> offset every bstack-provided allocator reserves; a custom `wal_anchor()` impl +> returns it (or its own, if it reserves a different user region). ## How it works on disk @@ -1087,6 +1096,8 @@ MIT (same as `bstack`). [`FileId`]: src/registry.rs [`BStackRaiiAllocator`]: src/lib.rs [`BStackBlock`]: src/block.rs +[`wal::finish`]: src/wal.rs +[`STD_WAL_ANCHOR`]: src/wal.rs [`BStack`]: https://docs.rs/bstack [`BStackCow`]: src/stdlib/cow.rs [`BStackBox`]: src/stdlib/boxed.rs diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 19634ce..ad32110 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -108,10 +108,7 @@ pub use stdlib::{ }; pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_teardown}; pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; -pub use wal::{ - AllocReq, Reduced, STD_WAL_ANCHOR, WalEntry, WalHeader, WalLog, WalOp, WalStatus, finish, - persist_at, reduce, -}; +pub use wal::{STD_WAL_ANCHOR, finish}; // Re-exports for use by `#[bstack_block]`-generated code (and callers), so that // generated code can name everything through `::bstack_raii::…` and downstream diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 5debe4a..ee8d5ff 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2540,8 +2540,8 @@ fn macro_clone_embed() { #[test] fn wal_finish_rolls_forward_committed() { + use crate::wal::{WalEntry, WalLog, WalStatus}; use crate::wal::{finish, persist_at}; - use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); // FirstFit: wal_anchor() == Some(8), zeroed on a fresh file @@ -2567,8 +2567,8 @@ fn wal_finish_rolls_forward_committed() { #[test] fn wal_finish_abandons_uncommitted() { + use crate::wal::{WalEntry, WalLog, WalStatus}; use crate::wal::{finish, persist_at}; - use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); @@ -2588,8 +2588,8 @@ fn wal_finish_abandons_uncommitted() { #[test] fn wal_anchor_trait_reclaims_via_finish() { + use crate::wal::{WalEntry, WalLog, WalStatus}; use crate::wal::{finish, persist_at}; - use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); // FirstFitBStackAllocator: wal_anchor() == Some(8) @@ -2608,8 +2608,8 @@ fn wal_anchor_trait_reclaims_via_finish() { #[test] fn wal_finish_reclaims_abandoned_allocs() { + use crate::wal::{WalEntry, WalLog, WalStatus}; use crate::wal::{finish, persist_at}; - use crate::{WalEntry, WalLog, WalStatus}; let tmp = TempStack::new(); let alloc = tmp.allocator(); @@ -2647,8 +2647,8 @@ fn wal_finish_reclaims_foreign_orphan_via_registry() { // (`free_recorded`) resolves foreign frees through it, exactly as real teardown / // clone will. use crate::registry; + use crate::wal::{WalEntry, WalLog, WalStatus}; use crate::wal::{finish, persist_at}; - use crate::{WalEntry, WalLog, WalStatus}; // The op's home file (where the WAL is staged) and a separate foreign file. let home = TempStack::new(); diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 75fdbc4..aa8c11a 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -72,8 +72,15 @@ use crate::teardown::dealloc_range; /// ([`FileId::SELF`], the common case), non-zero = a foreign file. [`reduce`] only /// repurposes a freed slice for a requirement in the **same** file, so a foreign /// requirement never reuses local storage (or vice versa). +// `reduce` (and the `AllocReq` / `Reduced` shapes it operates on) implements the +// groupoid-reduction optimisation described above, but nothing in the crate +// currently calls it — no commit path (`bulk`, `clone`, `teardown`) reuses a +// freed slice for a same-length allocation. Kept `#[cfg(test)]` (exercised by +// the unit tests below) rather than deleted, since wiring it in is tracked +// separately (see PROBLEMS.md §1). +#[cfg(test)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct AllocReq { +pub(crate) struct AllocReq { pub id: u64, pub len: u64, /// Target file: `0` = local ([`FileId::SELF`]), non-zero = a foreign [`FileId`] @@ -90,7 +97,7 @@ pub struct AllocReq { /// `{None < Pending < Complete}` plus the recovery sink `Abandon`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] -pub enum WalStatus { +pub(crate) enum WalStatus { None = 0, Pending = 1, Complete = 2, @@ -100,6 +107,7 @@ pub enum WalStatus { impl WalStatus { /// Normal monotonic progress: `None → Pending → Complete` (idempotent at /// `Complete`; `Abandon` is terminal). + #[cfg(test)] pub fn advance(self) -> Self { match self { WalStatus::None => WalStatus::Pending, @@ -110,6 +118,7 @@ impl WalStatus { /// Recovery monotonic map: `Pending → Abandon` (an in-flight op is abandoned, /// its slice leaked rather than re-run); `None` and `Complete` are unchanged. + #[cfg(test)] pub fn recover(self) -> Self { match self { WalStatus::Pending => WalStatus::Abandon, @@ -132,7 +141,7 @@ impl WalStatus { /// The two morphisms of the slice groupoid, as recorded in the log. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u8)] -pub enum WalOp { +pub(crate) enum WalOp { Alloc = 0, Dealloc = 1, } @@ -156,7 +165,7 @@ impl WalOp { /// on recovery. 32 bytes, 8-aligned, `Pod`. #[derive(Clone, Copy, Debug, Pod, Zeroable)] #[repr(C)] -pub struct WalEntry { +pub(crate) struct WalEntry { status: u8, op: u8, _pad: [u8; 6], @@ -205,6 +214,7 @@ impl WalEntry { /// A `Dealloc` entry recording a concrete **local** slice `S = (ptr, len)` /// (`file_id 0`, [`FileId::SELF`]). See [`dealloc_in`](Self::dealloc_in) for a /// slice in a foreign file. + #[cfg(test)] pub fn dealloc(status: WalStatus, slice: BStackRange) -> Self { Self::dealloc_in(status, FileId::SELF, slice) } @@ -238,10 +248,6 @@ impl WalEntry { self.file_id as u64 } - pub fn set_status(&mut self, status: WalStatus) { - self.status = status as u8; - } - /// The recorded slice `S`, if this is an `Alloc` entry (to be freed on abandon). pub fn as_alloc(&self) -> Option { match self.op() { @@ -264,8 +270,9 @@ impl WalEntry { /// [`with_capacity`](Self::with_capacity) pre-reserves to the known operation /// count (a transaction knows how many allocs/deallocs it will log up front), so /// [`append`](Self::append) never reallocates mid-transaction. -pub struct WalLog { +pub(crate) struct WalLog { entries: Vec, + #[cfg(test)] next_id: u64, } @@ -274,11 +281,13 @@ impl WalLog { pub fn with_capacity(ops: usize) -> Self { WalLog { entries: Vec::with_capacity(ops), + #[cfg(test)] next_id: 0, } } /// The next `R'` identity — a wrapping autoincrement. + #[cfg(test)] pub fn fresh_id(&mut self) -> u64 { let id = self.next_id; self.next_id = self.next_id.wrapping_add(1); @@ -294,15 +303,8 @@ impl WalLog { &self.entries } - pub fn entries_mut(&mut self) -> &mut [WalEntry] { - &mut self.entries - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - /// The log's on-disk image (a packed array of [`WalEntry`]). + #[cfg(test)] pub fn as_bytes(&self) -> &[u8] { bytemuck::cast_slice(&self.entries) } @@ -322,8 +324,9 @@ impl WalLog { /// The result of [`reduce`]: allocation requirements that were satisfied by /// repurposing a freed slice (`reused`), and the physical operations that remain. +#[cfg(test)] #[derive(Debug, Default)] -pub struct Reduced { +pub(crate) struct Reduced { /// `(requirement, repurposed slice)` — no physical alloc *or* dealloc needed. /// The slice lives in `requirement.file_id` (reuse is always same-file). pub reused: Vec<(AllocReq, BStackRange)>, @@ -340,7 +343,8 @@ pub struct Reduced { /// in one file can never satisfy a requirement in another (a cross-file `Foreign` /// alloc and a local free do not cancel), so the `file_id`s must match. Only the /// unpaired remainder becomes physical work. -pub fn reduce(allocs: Vec, mut deallocs: Vec<(u32, BStackRange)>) -> Reduced { +#[cfg(test)] +pub(crate) fn reduce(allocs: Vec, mut deallocs: Vec<(u32, BStackRange)>) -> Reduced { let mut reused = Vec::new(); let mut rem_allocs = Vec::new(); for req in allocs { @@ -391,7 +395,7 @@ const WAL_MIN_CAP: u64 = 8; /// `capacity` is the number of [`WalEntry`] slots the block was allocated for. #[derive(Clone, Copy, Debug, Pod, Zeroable)] #[repr(C)] -pub struct WalHeader { +pub(crate) struct WalHeader { magic: u64, txn_status: u8, _pad: [u8; 7], @@ -555,7 +559,7 @@ fn wal_ensure_block(allocator: &A, needed: u64) -> io::R /// block's range. The anchor slot comes from the allocator itself /// ([`wal_anchor`](BStackRaiiAllocator::wal_anchor)); the caller must hold the /// file's WAL lock (the crate-internal `wal_lock_for`). -pub fn persist_at( +pub(crate) fn persist_at( allocator: &A, log: &WalLog, txn_status: WalStatus, From 20c961d935d9307bd28e492e7ad051aa9a982c4a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 00:39:17 -0700 Subject: [PATCH 127/140] Add deref for rc --- bstack_raii/PROBLEMS.md | 10 ++++++++-- bstack_raii/README.md | 5 ++++- bstack_raii/src/shared.rs | 24 ++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index e578a11..512dba1 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -136,9 +136,15 @@ Residual points, all *leak-only* (permitted) but worth recording: requires it to be passed explicitly. The design choice was deliberate to avoid storing the allocator in the handle, but it does make the ergonomics worse. Note that multi- file (Foreign) access does not have this concern. -- **`BStackRc` / `BStackWeak` have no `Deref`** (only `BStackOwned` does), so a +- [FIXED] **`BStackRc` / `BStackWeak` have no `Deref`** (only `BStackOwned` does), so a shared handle needs `rc.handle().get_field(...)` while an owned one allows - `owned.get_field(...)` — inconsistent ergonomics for the same operation. + `owned.get_field(...)` — inconsistent ergonomics for the same operation. Added + `Deref` to `BStackRc` (a cached `T` handle, populated once in + `from_raw`, since `deref` can't construct-and-return a temporary); verified + `rc.get_field(stack)` compiles without `.handle()`. `BStackWeak` intentionally + left without `Deref` — like `std::rc::Weak`, it may not observe a live block + (the target can be gone), so derefing it isn't sound; `upgrade()` to a + `BStackRc` first, same as `std::rc::Weak`. - **`Foreign::with` returns `Option`** (None conflates "null pointer" and "target file not attached"); a `Result` (or distinct sentinel) would let callers tell a missing file from a genuinely null `Foreign`. diff --git a/bstack_raii/README.md b/bstack_raii/README.md index a990737..36ed6d1 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -211,7 +211,10 @@ owned.bstack_drop(&alloc)?; // free it now, explicitly … Shared handles (`BStackRc` / `BStackWeak`) *do* manage their counts on `Drop`, like `std::rc`. Because duplicating one bumps an on-disk counter (fallible I/O), -cloning is the [`TryClone`] trait, not `Clone`. +cloning is the [`TryClone`] trait, not `Clone`. `BStackRc` also derefs to `X` +(`rc.get_field(stack)?`, no `.handle()` needed), same as `BStackOwned`; a +`BStackWeak` doesn't — like `std::rc::Weak`, it may not observe a live block, so +`upgrade` to a `BStackRc` first. ## Generated types diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index e46f741..2ddd371 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -6,6 +6,7 @@ //! scope exit. use core::mem::size_of; +use core::ops::Deref; use std::io; use crate::BStackRaiiAllocator; @@ -60,6 +61,11 @@ impl BStackDrop for StrongCore { /// ([`BStackWeak::upgrade`], `bstack_move!`). `downgrade` relies on this. pub struct BStackRc<'a, T: BStackBlock, A: BStackRaiiAllocator> { inner: AutoDrop<'a, StrongCore, A>, + /// A standing copy of the typed handle, purely so [`Deref`] can hand back + /// `&T` — [`Deref::deref`] can't construct a temporary and return a + /// reference to it. Reconstructed once in [`from_raw`](Self::from_raw), same + /// as [`handle`](Self::handle) computes on demand; doesn't touch the refcount. + handle: T, } impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { @@ -76,8 +82,10 @@ impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { ctrl: Option, allocator: &'a A, ) -> Self { + let handle = ::from_range(data.into_range()); Self { inner: unsafe { AutoDrop::from_raw(StrongCore { data, ctrl }, allocator) }, + handle, } } @@ -94,10 +102,11 @@ impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { } /// The underlying typed handle, e.g. to call generated field accessors: - /// `rc.handle().get_field(stack)`. Cheap: it just re-wraps the data ref and does + /// `rc.handle().get_field(stack)` (or just `rc.get_field(stack)` via + /// [`Deref`]). Cheap: it just re-wraps the cached handle's range and does /// not touch the refcount. pub fn handle(&self) -> T { - ::from_range(self.data().into_range()) + ::from_range(self.handle.range()) } /// Consume the handle into its raw parts **without** decrementing the strong @@ -117,6 +126,17 @@ impl<'a, T: BStackBlock, A: BStackRaiiAllocator> BStackRc<'a, T, A> { } } +/// Field access without the `.handle()` indirection: `rc.get_field(stack)` +/// instead of `rc.handle().get_field(stack)`, matching [`BStackOwned`]'s +/// `Deref`. Same handle [`handle`](Self::handle) returns, just borrowed rather +/// than re-wrapped fresh each call. +impl<'a, T: BStackBlock, A: BStackRaiiAllocator> Deref for BStackRc<'a, T, A> { + type Target = T; + fn deref(&self) -> &T { + &self.handle + } +} + /// Cloning a strong handle bumps the block's strong count and returns another /// handle to the **same** block — sharing, not copying (like `Rc::clone`). This /// is the clone semantics for a shared block; there is deliberately no From 8d7a8e13a3d10a798f486d2fe148947250091fe6 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 01:11:50 -0700 Subject: [PATCH 128/140] Change foreign signature --- bstack_raii/PROBLEMS.md | 9 +++- bstack_raii/README.md | 7 ++- bstack_raii/examples/crossfile.rs | 16 ++++--- bstack_raii/src/foreign.rs | 55 +++++++++++++++++------- bstack_raii/src/tests.rs | 71 ++++++++++++++++++++++++------- 5 files changed, 118 insertions(+), 40 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 512dba1..5afe0c8 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -145,9 +145,14 @@ Residual points, all *leak-only* (permitted) but worth recording: left without `Deref` — like `std::rc::Weak`, it may not observe a live block (the target can be gone), so derefing it isn't sound; `upgrade()` to a `BStackRc` first, same as `std::rc::Weak`. -- **`Foreign::with` returns `Option`** (None conflates "null pointer" and "target +- [FIXED] **`Foreign::with` returns `Option`** (None conflates "null pointer" and "target file not attached"); a `Result` (or distinct sentinel) would let callers tell a - missing file from a genuinely null `Foreign`. + missing file from a genuinely null `Foreign`. Changed `with` (and its + crate-internal test twin `with_in`) to `io::Result>`: `Ok(None)` is + the null-pointer niche (`offset == 0`), `Err(io::ErrorKind::NotFound)` covers + both a malformed/out-of-range file id and a file that isn't currently attached. + Updated every call site (~24, mostly `tests.rs` plus `examples/crossfile.rs`) + and the README snippet. ## 8. Performance potentials diff --git a/bstack_raii/README.md b/bstack_raii/README.md index 36ed6d1..b271dcd 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -894,9 +894,12 @@ struct Card { let card = Card::new(&catalog, "report", Foreign::::new(store_id, doc_off))?; // … and resolve it to read across the boundary. `with` runs a closure against -// the target and *its* file's stack, returning `None` if that file isn't live. +// the target and *its* file's stack: `Ok(None)` for a null pointer, `Err` if +// that file isn't currently live — the two failure modes are kept apart rather +// than conflated into one `Option`. let size = card.handle().get_body(catalog.stack())? - .with(&catalog, |doc, fs| doc.get_size(fs).unwrap()); // Option + .with(&catalog, |doc, fs| doc.get_size(fs).unwrap())? // io::Result> + .expect("owned Foreign is never null"); ``` The annotation decides what teardown and clone do **in the target's own file**: diff --git a/bstack_raii/examples/crossfile.rs b/bstack_raii/examples/crossfile.rs index 332569c..0b0b676 100644 --- a/bstack_raii/examples/crossfile.rs +++ b/bstack_raii/examples/crossfile.rs @@ -95,15 +95,17 @@ fn main() -> io::Result<()> { // Resolve the foreign pointer and read the far-side document. `with` takes // the *local* allocator (used only for a same-file `Foreign`) and a closure - // run against the target and its file's stack; it returns `None` if the - // target file is not currently live. + // run against the target and its file's stack. It returns `Ok(None)` for a + // null pointer (not this field — it's `#[bstack_owned]`, never null) and + // `Err` if the target file isn't currently attached — propagate that with + // `?`, same as any other I/O failure. let (size, sum) = card .handle() .get_body(catalog.stack())? .with(&catalog, |d, fs| { (d.get_size(fs).unwrap(), d.get_checksum(fs).unwrap()) - }) - .expect("store is live"); + })? + .expect("owned Foreign is never null"); println!("card 'annual-report' -> document size {size}, checksum {sum:#x}"); // Deep-clone the card. The clone gets its *own* fresh copy of the document, @@ -129,7 +131,11 @@ fn main() -> io::Result<()> { .handle() .get_parts(&catalog)? .into_iter() - .map(|f| f.with(&catalog, |d, fs| d.get_size(fs).unwrap()).unwrap()) + .map(|f| { + f.with(&catalog, |d, fs| d.get_size(fs).unwrap()) + .unwrap() + .expect("owned Foreign is never null") + }) .collect(); println!( "bundle 'q3-batch' -> {} documents, sizes {bundle_sizes:?}", diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index 40df58b..256e425 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -136,23 +136,37 @@ impl Foreign { /// Resolve the pointer and run `f` with a `T` handle at the target plus the /// [`BStack`] of the file it lives in. /// - /// There is exactly one registry — the process-wide one ([`crate::registry`]) — - /// so resolution never takes a registry argument: a `Foreign` (e.g. one moved - /// out via `bstack_move!`) is always resolvable on its own. [`SELF`](FileId::SELF) - /// resolves against `local` directly (no registry, no lock); a foreign id - /// resolves via the global registry, yielding `None` if it is uninitialized, or - /// the target file is unknown / not currently attached / the id is malformed. - pub fn with(self, local: &A, f: impl FnOnce(T, &BStack) -> R) -> Option + /// The two failure modes are kept apart rather than conflated into one + /// `Option`: `Ok(None)` is the [null niche](self) (`offset == 0`, a genuinely + /// absent pointer — not an error, same as reading a null `#[bstack_ref]` + /// field); `Err` is an I/O-shaped [`io::ErrorKind::NotFound`] meaning the + /// pointer is non-null but its target file can't currently be reached (a + /// malformed / out-of-range file id, or a file that is unknown / not + /// currently attached to the [registry](crate::registry)). + /// + /// There is exactly one registry — the process-wide one — so resolution never + /// takes a registry argument: a `Foreign` (e.g. one moved out via + /// `bstack_move!`) is always resolvable on its own. [`SELF`](FileId::SELF) + /// resolves against `local` directly (no registry, no lock). + pub fn with(self, local: &A, f: impl FnOnce(T, &BStack) -> R) -> io::Result> where A: BStackAllocator, { + if self.ptr.offset == 0 { + return Ok(None); + } let t = T::from_range(self.range()); if self.ptr.file_id == 0 { - Some(f(t, local.stack())) - } else { - let id = FileId::from_u64(self.ptr.file_id)?; - registry::with_host(id, |host| f(t, host.stack())) + return Ok(Some(f(t, local.stack()))); } + let id = FileId::from_u64(self.ptr.file_id).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Foreign: file id out of range") + })?; + registry::with_host(id, |host| f(t, host.stack())) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Foreign: target file not attached") + }) + .map(Some) } /// **normal → foreign** (`bstack_cast!(slice as Foreign)`): name the block a @@ -188,17 +202,26 @@ impl Foreign { registry: &FileRegistry, local: &A, f: impl FnOnce(T, &BStack) -> R, - ) -> Option + ) -> io::Result> where A: BStackAllocator, { + if self.ptr.offset == 0 { + return Ok(None); + } let t = T::from_range(self.range()); if self.ptr.file_id == 0 { - Some(f(t, local.stack())) - } else { - let id = FileId::from_u64(self.ptr.file_id)?; - registry.with_host(id, |host| f(t, host.stack())) + return Ok(Some(f(t, local.stack()))); } + let id = FileId::from_u64(self.ptr.file_id).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Foreign: file id out of range") + })?; + registry + .with_host(id, |host| f(t, host.stack())) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Foreign: target file not attached") + }) + .map(Some) } } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index ee8d5ff..f728931 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7160,15 +7160,18 @@ fn foreign_resolves_across_files_and_self() { // A Foreign pointing at that leaf resolves + reads through the registry. let fp = Foreign::::new(id, off); assert_eq!( - fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), + fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()) + .unwrap(), Some(77) ); - // Detaching the host makes resolution fail (None), not panic. + // Detaching the host makes resolution fail (not-attached I/O error), not panic. reg.detach(id); assert_eq!( - fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), - None + fp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()) + .unwrap_err() + .kind(), + io::ErrorKind::NotFound ); // SELF resolves against `local` directly — no registry entry needed. @@ -7176,7 +7179,9 @@ fn foreign_resolves_across_files_and_self() { let selfp = Foreign::::new(FileId::SELF, lleaf.handle().range().start()); assert!(selfp.is_self()); assert_eq!( - selfp.with_in(®, &local, |t, stack| t.get_val(stack).unwrap()), + selfp + .with_in(®, &local, |t, stack| t.get_val(stack).unwrap()) + .unwrap(), Some(9) ); lleaf.bstack_drop(&local).unwrap(); @@ -7223,7 +7228,8 @@ fn macro_foreign_field() { h.handle() .get_owned_link(stack) .unwrap() - .with_in(®, &local, |t, fs| t.get_val(fs).unwrap()), + .with_in(®, &local, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), Some(88) ); assert!(h.handle().get_maybe(stack).unwrap().is_none()); // the `None` niche @@ -7238,7 +7244,8 @@ fn macro_foreign_field() { .unwrap(); let m = h2.handle().get_maybe(stack).unwrap().expect("Some link"); assert_eq!( - m.with_in(®, &local, |t, fs| t.get_val(fs).unwrap()), + m.with_in(®, &local, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), Some(88) ); @@ -7703,6 +7710,7 @@ fn macro_foreign_owned_clone_deep_copies_across_files() { assert_eq!( clone_link .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 42 ); @@ -8015,7 +8023,9 @@ fn macro_foreign_vec_owned_across_files() { assert_eq!(got.len(), N as usize); for (i, f) in got.iter().enumerate() { assert_eq!( - f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap(), 100 + i as u32 ); } @@ -8028,8 +8038,12 @@ fn macro_foreign_vec_owned_across_files() { for (o, n) in got.iter().zip(clinks.iter()) { assert_ne!(o.offset(), n.offset(), "each element must be a fresh copy"); assert_eq!( - n.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), - o.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap() + n.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap(), + o.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap() ); } @@ -8083,7 +8097,11 @@ fn macro_foreign_array_owned_across_files() { let got = h.handle().get_links(hstack).unwrap(); let vals: Vec = got .iter() - .map(|f| f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap()) + .map(|f| { + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap() + }) .collect(); assert_eq!(vals, vec![10, 20, 30]); @@ -8095,7 +8113,11 @@ fn macro_foreign_array_owned_across_files() { } let cvals: Vec = clinks .iter() - .map(|f| f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap()) + .map(|f| { + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap() + }) .collect(); assert_eq!(cvals, vec![10, 20, 30]); @@ -8210,6 +8232,7 @@ fn macro_foreign_generic_across_files() { let link = h.handle().get_link(hstack).unwrap(); assert_eq!( link.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 55 ); @@ -8221,6 +8244,7 @@ fn macro_foreign_generic_across_files() { assert_eq!( clink .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 55 ); @@ -8331,6 +8355,7 @@ fn macro_foreign_vec_of_option_roundtrips() { got[0] .unwrap() .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 11 ); @@ -8338,6 +8363,7 @@ fn macro_foreign_vec_of_option_roundtrips() { got[2] .unwrap() .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 22 ); @@ -8397,7 +8423,9 @@ fn macro_foreign_in_enum_across_files() { let off = match e.handle().read(&home_alloc).unwrap() { ForeignEnumView::Far(f) => { assert_eq!( - f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap(), 77 ); f.offset() @@ -8411,7 +8439,9 @@ fn macro_foreign_in_enum_across_files() { ForeignEnumView::Far(f) => { assert_ne!(f.offset(), off); assert_eq!( - f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()).unwrap(), + f.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap(), 77 ); } @@ -8457,6 +8487,7 @@ fn macro_foreign_generic_tuple_and_enum() { assert_eq!( pair.1 .with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 11 ); @@ -8473,7 +8504,9 @@ fn macro_foreign_generic_tuple_and_enum() { let off = match e.handle().read(&home_alloc).unwrap() { GenForeignEnumView::Far(f) => { assert_eq!( - f.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()).unwrap(), + f.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() + .unwrap(), 22 ); f.offset() @@ -8579,12 +8612,14 @@ fn macro_foreign_tuple_in_enum_variant() { assert_eq!(a, 100); assert_eq!( f1.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 11 ); let f2 = f2.expect("Some"); assert_eq!( f2.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 22 ); @@ -8667,6 +8702,7 @@ fn macro_foreign_enum_container_variants() { assert_eq!(v.len(), 3); assert_eq!( v[1].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 2 ); @@ -8684,11 +8720,13 @@ fn macro_foreign_enum_container_variants() { ForeignContainerEnumView::Fixed(a) => { assert_eq!( a[0].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 7 ); assert_eq!( a[1].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .unwrap() .unwrap(), 8 ); @@ -8747,6 +8785,7 @@ fn macro_foreign_in_tuple_across_files() { assert_eq!( pair.1 .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 11 ); @@ -8757,6 +8796,7 @@ fn macro_foreign_in_tuple_across_files() { .1 .unwrap() .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 22 ); @@ -8769,6 +8809,7 @@ fn macro_foreign_in_tuple_across_files() { cpair .1 .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() .unwrap(), 11 ); From cab4c567461f3fb33b504128be38d6505cd199d2 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 18:40:19 -0700 Subject: [PATCH 129/140] Modify machinary --- bstack_raii/src/stdlib/util.rs | 55 +++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 4d8075e..4eb6d16 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -19,6 +19,45 @@ pub(super) fn read_u64(stack: &BStack, off: u64) -> io::Result { Ok(u64::from_le_bytes(b)) } +/// A write payload for the `Vec<(u64, SmallBuf)>` batches [`atomic_update`] / +/// [`probe_commit`] / `BStack::set_batched` take. Two on-disk shapes recur +/// often enough in the stdlib collections to inline without a heap allocation: +/// a single `u64` field (every counter/offset/length bump — the overwhelming +/// majority of writes) and a [`crate::stdlib::list`] node's whole image (the +/// 16-byte [`crate::layout::BlockHeader`] plus `prev`/`next`/`val`, 3 `u64`s — +/// 40 bytes). Deliberately **no length field** — each inline variant is +/// exact-size-only (never "up to N bytes"), so there is nothing to track; +/// anything that isn't exactly 8 or 40 bytes (a B-tree node, a bucket-table +/// image, a generic-`K`-sized heap slot, …) goes through [`SmallBuf::Heap`]. +pub(super) enum SmallBuf { + Buf8([u8; 8]), + Buf40([u8; 40]), + Heap(Box<[u8]>), +} + +impl SmallBuf { + pub(super) fn as_slice(&self) -> &[u8] { + match self { + SmallBuf::Buf8(b) => b.as_slice(), + SmallBuf::Buf40(b) => b.as_slice(), + SmallBuf::Heap(b) => b.as_ref(), + } + } +} + +impl AsRef<[u8]> for SmallBuf { + fn as_ref(&self) -> &[u8] { + self.as_slice() + } +} + +/// Build a `(offset, value)` write-tuple for a `u64` field: little-endian into +/// an inline [`SmallBuf::Buf8`], no allocation. Replaces the repeated +/// `(off, val.to_le_bytes().to_vec())` shape. +pub(super) fn w8(off: u64, val: u64) -> (u64, SmallBuf) { + (off, SmallBuf::Buf8(val.to_le_bytes())) +} + /// Read `N` **contiguous** little-endian `u64` fields starting at `off` in a /// *single* I/O call, returning them as an array. Use this instead of several /// [`read_u64`] calls when the fields are adjacent (e.g. a handle's metadata) — @@ -99,7 +138,9 @@ pub(super) fn alloc_image( /// are handed to `reads2` to compute a second round of offsets that may *depend* /// on the first (e.g. the `prev`/`value` slots of a node found via a pointer, or /// the live element slots of a ring found via `head`/`cap`). `plan` then turns -/// both read rounds into the writes to commit. +/// both read rounds into the writes to commit, returned as a borrow of a buffer +/// the caller owns (typically declared just above the [`atomic_update`] call) — +/// `plan` is `FnOnce`, so that buffer only needs to outlive this one call. /// /// The point of routing every mutator through this: all reads happen **inside** /// the generator, under bstack's single write lock, so the values reflect the @@ -111,7 +152,7 @@ pub(super) fn alloc_image( /// change the stack's size, are done by the caller *around* it (a freshly /// allocated block is an orphan until the commit links it; a freed block is /// already unlinked), so a crash can at worst leak, never tear the structure. -pub(super) fn atomic_update( +pub(super) fn atomic_update<'w, A, R2, W>( allocator: &A, reads1: &[u64], reads2: R2, @@ -120,7 +161,7 @@ pub(super) fn atomic_update( where A: BStackRaiiAllocator, R2: FnOnce(&[u64]) -> Vec, - W: FnOnce(&[u64], &[u64]) -> Vec<(u64, Vec)>, + W: FnOnce(&[u64], &[u64]) -> &'w [(u64, SmallBuf)], { // Buffers that must outlive the whole `inplace_gen` call (bstack's documented // generator pattern): read-back values and the computed writes. @@ -128,7 +169,7 @@ where let mut vals1: Vec = Vec::new(); let mut offs2: Vec = Vec::new(); let mut buf2: Vec<[u8; 8]> = Vec::new(); - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: &'w [(u64, SmallBuf)] = &[]; let mut reads2 = Some(reads2); let mut plan = Some(plan); @@ -207,7 +248,7 @@ pub(super) enum ProbeStep { /// This bucket isn't the target — keep probing. Continue, /// Stop here and commit these writes (empty = commit nothing). - Stop(Vec<(u64, Vec)>), + Stop(Vec<(u64, SmallBuf)>), } /// Run an atomic, external-lock-free linear probe over an open-addressing bucket @@ -231,11 +272,11 @@ pub(super) fn probe_commit( where A: BStackRaiiAllocator, I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, - E: FnOnce(&Meta) -> Vec<(u64, Vec)>, + E: FnOnce(&Meta) -> Vec<(u64, SmallBuf)>, { let mut meta_buf = [0u8; 32]; let mut bucket_buf = vec![0u8; stride as usize]; - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: Vec<(u64, SmallBuf)> = Vec::new(); let mut meta_issued = false; let mut meta: Option = None; From d558baff6771b28cf3ed978637637c90885ed528 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 19:04:23 -0700 Subject: [PATCH 130/140] Fix allocation problem for performance --- bstack_raii/PROBLEMS.md | 16 ++++- bstack_raii/src/stdlib/bloom.rs | 9 ++- bstack_raii/src/stdlib/btreeset.rs | 15 +++-- bstack_raii/src/stdlib/deque.rs | 78 +++++++++++----------- bstack_raii/src/stdlib/hashset.rs | 32 +++++---- bstack_raii/src/stdlib/heap.rs | 30 ++++++--- bstack_raii/src/stdlib/list.rs | 102 +++++++++++++++-------------- bstack_raii/src/stdlib/map.rs | 37 +++++++---- bstack_raii/src/stdlib/tree.rs | 15 +++-- bstack_raii/src/stdlib/util.rs | 31 +++++++++ 10 files changed, 221 insertions(+), 144 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 5afe0c8..5bea1e6 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -43,10 +43,19 @@ omitted except where trivial. from `construct.rs` and replaced in `tests.rs` with a test-local equivalent that commits the control payload and the data block's back-pointer in one `set_batched`, matching the atomic path. -- **Pervasive `.to_le_bytes().to_vec()`** — every counter/pointer write allocates +- [FIXED] **Pervasive `.to_le_bytes().to_vec()`** — every counter/pointer write allocates a fresh 8-byte heap `Vec` to feed the `set_batched` / `ClonePlan::write` batch APIs (dozens per operation in `list`/`deque`/`map`). A small-buffer / inline representation for batch entries would remove most of these allocations. + `w8(off, val)` in `stdlib::util` replaced the `(off, val.to_le_bytes().to_vec())` + literal at all 54 `stdlib/*` call sites. `atomic_update`/`probe_commit`/`w8`'s + write-tuple value type is now `SmallBuf` (`stdlib::util`, `AsRef<[u8]>`): + `Buf8([u8;8])` and `Buf40([u8;40])` no-length inline variants for the two exact + sizes that recur (a `u64` field; a `stdlib::list` node image — 16B header + + 3×`u64`), `Heap(Box<[u8]>)` otherwise (B-tree nodes, bucket-table images, + `K`-sized heap slots). `ClonePlan::write` / derive codegen untouched: its one + call site always writes a whole on-disk image (over 24B), so it's `Heap` + either way — no allocation to remove there. - [FIXED] **Stale crate-level docs** ([lib.rs](src/lib.rs) module comment): "Status: method bodies marked `todo!()` are the work ahead" and "procedural macros come after the runtime is filled in" describe a half-built crate; it is now @@ -156,8 +165,9 @@ Residual points, all *leak-only* (permitted) but worth recording: ## 8. Performance potentials -- Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single most - pervasive avoidable allocation. +- [FIXED] Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single + most pervasive avoidable allocation. See §2 — replaced with `SmallBuf` in + `stdlib/*` (no-length inline `Buf8`/`Buf40`, `Heap` fallback). - No bulk alloc/free in clone/teardown (§1) even when the concrete allocator implements `BStackBulkAllocator`. - **Double read per strong child in clone**: `ClonePlan::bump_strong` calls diff --git a/bstack_raii/src/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs index 79643f0..83feec7 100644 --- a/bstack_raii/src/stdlib/bloom.rs +++ b/bstack_raii/src/stdlib/bloom.rs @@ -42,7 +42,7 @@ use bstack::{BStack, BStackGenOp, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::double_hash; -use super::util::{alloc_image, read_fields, read_u64}; +use super::util::{SmallBuf, alloc_image, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -215,8 +215,11 @@ impl BStackCountingBloomFilter { let handle = self.range.start(); let [data, m] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; allocator.stack().set_batched([ - (data, vec![0u8; m as usize]), - (handle + N_OFF, 0u64.to_le_bytes().to_vec()), + ( + data, + SmallBuf::Heap(vec![0u8; m as usize].into_boxed_slice()), + ), + w8(handle + N_OFF, 0u64), ]) } diff --git a/bstack_raii/src/stdlib/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs index 948ddf2..15b8950 100644 --- a/bstack_raii/src/stdlib/btreeset.rs +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -28,7 +28,7 @@ use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; -use super::util::{Scratch, alloc_image, read_fields, read_u64}; +use super::util::{Scratch, SmallBuf, alloc_image, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -86,7 +86,7 @@ struct Build<'a, A: BStackRaiiAllocator> { node_size: u64, ksize: usize, children_off: usize, - writes: Vec<(u64, Vec)>, + writes: Vec<(u64, SmallBuf)>, freed: Vec, } @@ -104,7 +104,8 @@ impl<'a, A: BStackRaiiAllocator> Build<'a, A> { b[co..co + 8].copy_from_slice(&c.to_le_bytes()); } let off = self.allocator.alloc(self.node_size)?.as_range().start(); - self.writes.push((off, b)); + self.writes + .push((off, SmallBuf::Heap(b.into_boxed_slice()))); Ok(off) } } @@ -327,8 +328,8 @@ impl BStackBTreeSet { Ok(new_root) => { let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); let mut writes = core::mem::take(&mut build.writes); - writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); - writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + writes.push(w8(handle + ROOT_OFF, new_root)); + writes.push(w8(handle + LEN_OFF, len + 1)); match stack.set_batched(writes) { Ok(()) => { for off in &build.freed { @@ -604,8 +605,8 @@ impl BStackBTreeSet { Ok(new_root) => { let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); let mut writes = core::mem::take(&mut build.writes); - writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); - writes.push((handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + writes.push(w8(handle + ROOT_OFF, new_root)); + writes.push(w8(handle + LEN_OFF, len - 1)); match stack.set_batched(writes) { Ok(()) => { for off in &build.freed { diff --git a/bstack_raii/src/stdlib/deque.rs b/bstack_raii/src/stdlib/deque.rs index 4f6204c..cec482e 100644 --- a/bstack_raii/src/stdlib/deque.rs +++ b/bstack_raii/src/stdlib/deque.rs @@ -40,7 +40,7 @@ use crate::BStackRaiiAllocator; use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, read_fields, read_u64}; +use super::util::{SmallBuf, WriteBuf, alloc_image, atomic_update, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -181,6 +181,7 @@ impl BStackDeque { let val_off = value.into_inner().range().start(); loop { let full = Cell::new(false); + let mut w: WriteBuf<2> = WriteBuf::new(); atomic_update( allocator, &[ @@ -194,14 +195,12 @@ impl BStackDeque { let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); if len < cap { let slot = data + ((head + len) % cap) * 8; - vec![ - (slot, val_off.to_le_bytes().to_vec()), - (handle + LEN_OFF, (len + 1).to_le_bytes().to_vec()), - ] + w.push(w8(slot, val_off)); + w.push(w8(handle + LEN_OFF, len + 1)); } else { full.set(true); - Vec::new() } + w.as_slice() }, )?; if !full.get() { @@ -221,6 +220,7 @@ impl BStackDeque { let val_off = value.into_inner().range().start(); loop { let full = Cell::new(false); + let mut w: WriteBuf<3> = WriteBuf::new(); atomic_update( allocator, &[ @@ -235,15 +235,13 @@ impl BStackDeque { if len < cap { let idx = (head + cap - 1) % cap; let slot = data + idx * 8; - vec![ - (slot, val_off.to_le_bytes().to_vec()), - (handle + HEAD_OFF, idx.to_le_bytes().to_vec()), - (handle + LEN_OFF, (len + 1).to_le_bytes().to_vec()), - ] + w.push(w8(slot, val_off)); + w.push(w8(handle + HEAD_OFF, idx)); + w.push(w8(handle + LEN_OFF, len + 1)); } else { full.set(true); - Vec::new() } + w.as_slice() }, )?; if !full.get() { @@ -264,6 +262,7 @@ impl BStackDeque { let handle = self.range.start(); let got = Cell::new(false); let val = Cell::new(0u64); + let mut w: WriteBuf<1> = WriteBuf::new(); atomic_update( allocator, &[ @@ -282,12 +281,12 @@ impl BStackDeque { }, |v1, v2| { let len = v1[1]; - if len == 0 { - return Vec::new(); + if len != 0 { + got.set(true); + val.set(v2[0]); + w.push(w8(handle + LEN_OFF, len - 1)); } - got.set(true); - val.set(v2[0]); - vec![(handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())] + w.as_slice() }, )?; if !got.get() { @@ -308,6 +307,7 @@ impl BStackDeque { let handle = self.range.start(); let got = Cell::new(false); let val = Cell::new(0u64); + let mut w: WriteBuf<2> = WriteBuf::new(); atomic_update( allocator, &[ @@ -326,15 +326,13 @@ impl BStackDeque { }, |v1, v2| { let (head, len, cap) = (v1[0], v1[1], v1[2]); - if len == 0 { - return Vec::new(); + if len != 0 { + got.set(true); + val.set(v2[0]); + w.push(w8(handle + HEAD_OFF, (head + 1) % cap)); + w.push(w8(handle + LEN_OFF, len - 1)); } - got.set(true); - val.set(v2[0]); - vec![ - (handle + HEAD_OFF, ((head + 1) % cap).to_le_bytes().to_vec()), - (handle + LEN_OFF, (len - 1).to_le_bytes().to_vec()), - ] + w.as_slice() }, )?; if !got.get() { @@ -363,6 +361,7 @@ impl BStackDeque { // Abort if, at commit time, the ring already has room or is already at // least this big (another thread grew it) — then our `newring` is wasted. let abort = |_head: u64, len: u64, cap: u64| (cap != 0 && len < cap) || newcap <= cap; + let mut w: Vec<(u64, SmallBuf)> = Vec::new(); atomic_update( allocator, @@ -383,22 +382,21 @@ impl BStackDeque { }, |v1, v2| { let (head, len, cap, data) = (v1[0], v1[1], v1[2], v1[3]); - if abort(head, len, cap) { - return Vec::new(); - } - grown.set(true); - old_ring.set(data); - old_cap.set(cap); - let mut w = Vec::with_capacity(v2.len() + 3); - // Copy every live element to the front of the new ring. - for (i, &r) in v2.iter().enumerate() { - w.push((newring + (i as u64) * 8, r.to_le_bytes().to_vec())); + if !abort(head, len, cap) { + grown.set(true); + old_ring.set(data); + old_cap.set(cap); + w.reserve(v2.len() + 3); + // Copy every live element to the front of the new ring. + for (i, &r) in v2.iter().enumerate() { + w.push(w8(newring + (i as u64) * 8, r)); + } + // Swap the descriptor to the new ring, re-based at head 0. + w.push(w8(handle + DATA_OFF, newring)); + w.push(w8(handle + CAP_OFF, newcap)); + w.push(w8(handle + HEAD_OFF, 0u64)); } - // Swap the descriptor to the new ring, re-based at head 0. - w.push((handle + DATA_OFF, newring.to_le_bytes().to_vec())); - w.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); - w.push((handle + HEAD_OFF, 0u64.to_le_bytes().to_vec())); - w + w.as_slice() }, )?; diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index 26a233d..48fb0cd 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -39,7 +39,9 @@ use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; use super::hash::fnv1a; -use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_fields, read_u64}; +use super::util::{ + Meta, ProbeStep, Scratch, SmallBuf, alloc_image, probe_commit, read_fields, read_u64, w8, +}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -92,16 +94,19 @@ fn place_writes( target: u64, slot_was_empty: bool, key_bytes: &[u8], -) -> Vec<(u64, Vec)> { +) -> Vec<(u64, SmallBuf)> { let mut img = Vec::with_capacity(8 + key_bytes.len()); img.extend_from_slice(&OCCUPIED.to_le_bytes()); img.extend_from_slice(key_bytes); let mut w = vec![ - (m.table + target * stride, img), - (handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), + ( + m.table + target * stride, + SmallBuf::Heap(img.into_boxed_slice()), + ), + w8(handle + LEN_OFF, m.len + 1), ]; if slot_was_empty { - w.push((handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); + w.push(w8(handle + USED_OFF, m.used + 1)); } w } @@ -323,8 +328,8 @@ impl BStackHashSet { } else if state == OCCUPIED && buf[8..8 + ksz] == *key_bytes { found.set(true); ProbeStep::Stop(vec![ - (m.table + idx * stride, TOMBSTONE.to_le_bytes().to_vec()), - (handle + LEN_OFF, (m.len - 1).to_le_bytes().to_vec()), + w8(m.table + idx * stride, TOMBSTONE), + w8(handle + LEN_OFF, m.len - 1), ]) } else { ProbeStep::Continue @@ -385,7 +390,7 @@ impl BStackHashSet { let mut abort = false; let mut read_i = 0u64; let mut built = false; - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: Vec<(u64, SmallBuf)> = Vec::new(); let mut w = 0usize; allocator.stack().inplace_gen(|_feedback| { @@ -455,10 +460,13 @@ impl BStackHashSet { idx = (idx + 1) & newmask; } } - writes.push((newtable, std::mem::take(&mut new_image))); - writes.push((handle + TABLE_OFF, newtable.to_le_bytes().to_vec())); - writes.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); - writes.push((handle + USED_OFF, m.len.to_le_bytes().to_vec())); + writes.push(( + newtable, + SmallBuf::Heap(std::mem::take(&mut new_image).into_boxed_slice()), + )); + writes.push(w8(handle + TABLE_OFF, newtable)); + writes.push(w8(handle + CAP_OFF, newcap)); + writes.push(w8(handle + USED_OFF, m.len)); } if w < writes.len() { let i = w; diff --git a/bstack_raii/src/stdlib/heap.rs b/bstack_raii/src/stdlib/heap.rs index 3217c3f..88f8dd2 100644 --- a/bstack_raii/src/stdlib/heap.rs +++ b/bstack_raii/src/stdlib/heap.rs @@ -34,7 +34,7 @@ use crate::BStackRaiiAllocator; use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, read_fields, read_u64}; +use super::util::{SmallBuf, alloc_image, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -205,19 +205,25 @@ impl BStackBinaryHeap { // Sift up: walk toward the root, moving greater parents down into the // hole, until the new key is `>=` its parent. let mut hole = len; - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: Vec<(u64, SmallBuf)> = Vec::new(); while hole > 0 { let parent = (hole - 1) / 2; let parent_slot = Self::read_slot(allocator.stack(), data, parent)?; if Self::read_key(&parent_slot) > key { - writes.push((data + hole * stride, parent_slot)); + writes.push(( + data + hole * stride, + SmallBuf::Heap(parent_slot.into_boxed_slice()), + )); hole = parent; } else { break; } } - writes.push((data + hole * stride, new_slot)); - writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + writes.push(( + data + hole * stride, + SmallBuf::Heap(new_slot.into_boxed_slice()), + )); + writes.push(w8(handle + LEN_OFF, len + 1)); allocator.stack().set_batched(writes)?; return Ok(()); } @@ -254,7 +260,7 @@ impl BStackBinaryHeap { let last_key = Self::read_key(&last_slot); let newlen = len - 1; let mut hole = 0u64; - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: Vec<(u64, SmallBuf)> = Vec::new(); loop { let mut child = 2 * hole + 1; if child >= newlen { @@ -272,14 +278,20 @@ impl BStackBinaryHeap { } } if smaller_key < last_key { - writes.push((data + hole * stride, smaller)); + writes.push(( + data + hole * stride, + SmallBuf::Heap(smaller.into_boxed_slice()), + )); hole = child; } else { break; } } - writes.push((data + hole * stride, last_slot)); - writes.push((handle + LEN_OFF, newlen.to_le_bytes().to_vec())); + writes.push(( + data + hole * stride, + SmallBuf::Heap(last_slot.into_boxed_slice()), + )); + writes.push(w8(handle + LEN_OFF, newlen)); allocator.stack().set_batched(writes)?; // SAFETY: the value block's ownership transfers to the caller. diff --git a/bstack_raii/src/stdlib/list.rs b/bstack_raii/src/stdlib/list.rs index 46879a6..390bb90 100644 --- a/bstack_raii/src/stdlib/list.rs +++ b/bstack_raii/src/stdlib/list.rs @@ -37,7 +37,7 @@ use crate::BStackRaiiAllocator; use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{alloc_image, atomic_update, read_fields, read_u64}; +use super::util::{SmallBuf, WriteBuf, alloc_image, atomic_update, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE}; @@ -88,6 +88,12 @@ const NVAL_OFF: u64 = HEADER_SIZE + 16; // 32 const LIST_SIZE: u64 = size_of::() as u64; const NODE_SIZE: u64 = size_of::() as u64; +// `push_front`/`push_back` inline a node's full image into a `SmallBuf::Buf40` +// (see `super::util::SmallBuf`) — no length field, so it's exact-size-only. +const _: () = assert!( + NODE_SIZE == 40, + "SmallBuf::Buf40 assumes a 40-byte NodeOnDisk" +); /// An owned, doubly-linked list of `T` blocks. /// @@ -177,6 +183,7 @@ impl BStackLinkedList { let val_off = value.into_inner().range().start(); // Allocate the node up front; it stays an orphan until the commit links it. let node = allocator.alloc(NODE_SIZE)?.as_range().start(); + let mut w: WriteBuf<4> = WriteBuf::new(); let res = atomic_update( allocator, @@ -184,22 +191,21 @@ impl BStackLinkedList { |_v1| Vec::new(), |v1, _v2| { let (old_tail, len) = (v1[0], v1[1]); - let mut w = Vec::with_capacity(4); // The node's full image (with `prev` wired to the read tail). - w.push(( - node, - bytemuck::bytes_of(&Self::node_image(old_tail, 0, val_off)).to_vec(), - )); + let image: [u8; 40] = bytemuck::bytes_of(&Self::node_image(old_tail, 0, val_off)) + .try_into() + .unwrap(); + w.push((node, SmallBuf::Buf40(image))); // Link the old tail (or the head, if the list was empty) to it. let link = if old_tail != 0 { old_tail + NNEXT_OFF } else { list + HEAD_OFF }; - w.push((link, node.to_le_bytes().to_vec())); - w.push((list + TAIL_OFF, node.to_le_bytes().to_vec())); - w.push((list + LEN_OFF, (len + 1).to_le_bytes().to_vec())); - w + w.push(w8(link, node)); + w.push(w8(list + TAIL_OFF, node)); + w.push(w8(list + LEN_OFF, len + 1)); + w.as_slice() }, ); if res.is_err() { @@ -220,6 +226,7 @@ impl BStackLinkedList { let list = self.range.start(); let val_off = value.into_inner().range().start(); let node = allocator.alloc(NODE_SIZE)?.as_range().start(); + let mut w: WriteBuf<4> = WriteBuf::new(); let res = atomic_update( allocator, @@ -227,20 +234,19 @@ impl BStackLinkedList { |_v1| Vec::new(), |v1, _v2| { let (old_head, len) = (v1[0], v1[1]); - let mut w = Vec::with_capacity(4); - w.push(( - node, - bytemuck::bytes_of(&Self::node_image(0, old_head, val_off)).to_vec(), - )); + let image: [u8; 40] = bytemuck::bytes_of(&Self::node_image(0, old_head, val_off)) + .try_into() + .unwrap(); + w.push((node, SmallBuf::Buf40(image))); let link = if old_head != 0 { old_head + NPREV_OFF } else { list + TAIL_OFF }; - w.push((link, node.to_le_bytes().to_vec())); - w.push((list + HEAD_OFF, node.to_le_bytes().to_vec())); - w.push((list + LEN_OFF, (len + 1).to_le_bytes().to_vec())); - w + w.push(w8(link, node)); + w.push(w8(list + HEAD_OFF, node)); + w.push(w8(list + LEN_OFF, len + 1)); + w.as_slice() }, ); if res.is_err() { @@ -266,6 +272,7 @@ impl BStackLinkedList { let list = self.range.start(); let node = Cell::new(0u64); let val = Cell::new(0u64); + let mut w: WriteBuf<3> = WriteBuf::new(); atomic_update( allocator, @@ -280,22 +287,20 @@ impl BStackLinkedList { }, |v1, v2| { let (tail, len) = (v1[0], v1[1]); - if tail == 0 { - return Vec::new(); - } - let (prev, value) = (v2[0], v2[1]); - node.set(tail); - val.set(value); - let mut w = Vec::with_capacity(3); - if prev != 0 { - w.push((prev + NNEXT_OFF, 0u64.to_le_bytes().to_vec())); - w.push((list + TAIL_OFF, prev.to_le_bytes().to_vec())); - } else { - w.push((list + HEAD_OFF, 0u64.to_le_bytes().to_vec())); - w.push((list + TAIL_OFF, 0u64.to_le_bytes().to_vec())); + if tail != 0 { + let (prev, value) = (v2[0], v2[1]); + node.set(tail); + val.set(value); + if prev != 0 { + w.push(w8(prev + NNEXT_OFF, 0u64)); + w.push(w8(list + TAIL_OFF, prev)); + } else { + w.push(w8(list + HEAD_OFF, 0u64)); + w.push(w8(list + TAIL_OFF, 0u64)); + } + w.push(w8(list + LEN_OFF, len - 1)); } - w.push((list + LEN_OFF, (len - 1).to_le_bytes().to_vec())); - w + w.as_slice() }, )?; @@ -321,6 +326,7 @@ impl BStackLinkedList { let list = self.range.start(); let node = Cell::new(0u64); let val = Cell::new(0u64); + let mut w: WriteBuf<3> = WriteBuf::new(); atomic_update( allocator, @@ -335,22 +341,20 @@ impl BStackLinkedList { }, |v1, v2| { let (head, len) = (v1[0], v1[1]); - if head == 0 { - return Vec::new(); - } - let (next, value) = (v2[0], v2[1]); - node.set(head); - val.set(value); - let mut w = Vec::with_capacity(3); - if next != 0 { - w.push((next + NPREV_OFF, 0u64.to_le_bytes().to_vec())); - w.push((list + HEAD_OFF, next.to_le_bytes().to_vec())); - } else { - w.push((list + HEAD_OFF, 0u64.to_le_bytes().to_vec())); - w.push((list + TAIL_OFF, 0u64.to_le_bytes().to_vec())); + if head != 0 { + let (next, value) = (v2[0], v2[1]); + node.set(head); + val.set(value); + if next != 0 { + w.push(w8(next + NPREV_OFF, 0u64)); + w.push(w8(list + HEAD_OFF, next)); + } else { + w.push(w8(list + HEAD_OFF, 0u64)); + w.push(w8(list + TAIL_OFF, 0u64)); + } + w.push(w8(list + LEN_OFF, len - 1)); } - w.push((list + LEN_OFF, (len - 1).to_le_bytes().to_vec())); - w + w.as_slice() }, )?; diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 7960733..717a855 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -42,7 +42,10 @@ use bstack::{BStack, BStackGenOp, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; -use super::util::{Meta, ProbeStep, Scratch, alloc_image, probe_commit, read_fields, read_u64}; +use super::util::{ + Meta, ProbeStep, Scratch, SmallBuf, WriteBuf, alloc_image, probe_commit, read_fields, read_u64, + w8, +}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -100,18 +103,21 @@ fn new_bucket_writes( m: &Meta, target: u64, slot_was_empty: bool, -) -> Vec<(u64, Vec)> { +) -> Vec<(u64, SmallBuf)> { let mut img = Vec::with_capacity(16 + e.ksz); img.extend_from_slice(&OCCUPIED.to_le_bytes()); img.extend_from_slice(e.key_bytes); img.extend_from_slice(&e.val_ref.to_le_bytes()); let mut w = vec![ - (m.table + target * e.stride, img), - (e.handle + LEN_OFF, (m.len + 1).to_le_bytes().to_vec()), + ( + m.table + target * e.stride, + SmallBuf::Heap(img.into_boxed_slice()), + ), + w8(e.handle + LEN_OFF, m.len + 1), ]; if slot_was_empty { - w.push((e.handle + USED_OFF, (m.used + 1).to_le_bytes().to_vec())); + w.push(w8(e.handle + USED_OFF, m.used + 1)); } w } @@ -236,7 +242,7 @@ impl BStackHashMap { old_value.set(get_u64(&buf[8 + ksz..8 + ksz + 8])); is_new.set(false); let value_off = m.table + idx * stride + 8 + ksz as u64; - ProbeStep::Stop(vec![(value_off, val_ref.to_le_bytes().to_vec())]) + ProbeStep::Stop(vec![w8(value_off, val_ref)]) } else { if state == TOMBSTONE && first_tomb.get().is_none() { first_tomb.set(Some(idx)); @@ -299,8 +305,8 @@ impl BStackHashMap { found.set(true); old_value.set(get_u64(&buf[8 + ksz..8 + ksz + 8])); ProbeStep::Stop(vec![ - (m.table + idx * stride, TOMBSTONE.to_le_bytes().to_vec()), - (handle + LEN_OFF, (m.len - 1).to_le_bytes().to_vec()), + w8(m.table + idx * stride, TOMBSTONE), + w8(handle + LEN_OFF, m.len - 1), ]) } else { ProbeStep::Continue @@ -446,7 +452,7 @@ impl BStackHashMap { let mut abort = false; let mut read_i = 0u64; let mut built = false; - let mut writes: Vec<(u64, Vec)> = Vec::new(); + let mut writes: WriteBuf<4> = WriteBuf::new(); let mut w = 0usize; allocator.stack().inplace_gen(|_feedback| { @@ -521,16 +527,19 @@ impl BStackHashMap { idx = (idx + 1) & newmask; } } - writes.push((newtable, std::mem::take(&mut new_image))); - writes.push((handle + TABLE_OFF, newtable.to_le_bytes().to_vec())); - writes.push((handle + CAP_OFF, newcap.to_le_bytes().to_vec())); + writes.push(( + newtable, + SmallBuf::Heap(std::mem::take(&mut new_image).into_boxed_slice()), + )); + writes.push(w8(handle + TABLE_OFF, newtable)); + writes.push(w8(handle + CAP_OFF, newcap)); // Tombstones dropped: used == len now. - writes.push((handle + USED_OFF, m.len.to_le_bytes().to_vec())); + writes.push(w8(handle + USED_OFF, m.len)); } if w < writes.len() { let i = w; w += 1; - let (off, ref bytes) = writes[i]; + let (off, ref bytes) = writes.as_slice()[i]; // SAFETY: `writes` outlives the call and is not mutated after build. let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; return Some(BStackGenOp::Write { diff --git a/bstack_raii/src/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs index 0ceb50a..2dfe36b 100644 --- a/bstack_raii/src/stdlib/tree.rs +++ b/bstack_raii/src/stdlib/tree.rs @@ -45,7 +45,7 @@ use crate::BStackRaiiAllocator; use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; -use super::util::{Scratch, alloc_image, read_fields, read_u64}; +use super::util::{Scratch, SmallBuf, alloc_image, read_fields, read_u64, w8}; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; use crate::layout::{BlockHeader, EightCC, HEADER_SIZE, get_u64}; @@ -104,7 +104,7 @@ struct Build<'a, A: BStackRaiiAllocator> { vals_off: usize, children_off: usize, /// New node images `(offset, bytes)`, committed together. - writes: Vec<(u64, Vec)>, + writes: Vec<(u64, SmallBuf)>, /// Old path nodes, freed after the commit succeeds. freed: Vec, } @@ -129,7 +129,8 @@ impl<'a, A: BStackRaiiAllocator> Build<'a, A> { b[co..co + 8].copy_from_slice(&c.to_le_bytes()); } let off = self.allocator.alloc(self.node_size)?.as_range().start(); - self.writes.push((off, b)); + self.writes + .push((off, SmallBuf::Heap(b.into_boxed_slice()))); Ok(off) } } @@ -377,9 +378,9 @@ impl BStackBTreeMap { Ok((new_root, added, old)) => { let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); let mut writes = core::mem::take(&mut build.writes); - writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); + writes.push(w8(handle + ROOT_OFF, new_root)); if added { - writes.push((handle + LEN_OFF, (len + 1).to_le_bytes().to_vec())); + writes.push(w8(handle + LEN_OFF, len + 1)); } match stack.set_batched(writes) { Ok(()) => { @@ -750,8 +751,8 @@ impl BStackBTreeMap { Ok((new_root, val)) => { let new_node_offs: Vec = build.writes.iter().map(|(o, _)| *o).collect(); let mut writes = core::mem::take(&mut build.writes); - writes.push((handle + ROOT_OFF, new_root.to_le_bytes().to_vec())); - writes.push((handle + LEN_OFF, (len - 1).to_le_bytes().to_vec())); + writes.push(w8(handle + ROOT_OFF, new_root)); + writes.push(w8(handle + LEN_OFF, len - 1)); match stack.set_batched(writes) { Ok(()) => { for off in &build.freed { diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index 4eb6d16..c352fcf 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -58,6 +58,37 @@ pub(super) fn w8(off: u64, val: u64) -> (u64, SmallBuf) { (off, SmallBuf::Buf8(val.to_le_bytes())) } +/// A `Vec<(u64, SmallBuf)>` substitute for [`atomic_update`] `plan` closures +/// whose write count is a small compile-time constant (metadata bumps, a +/// single slot write) — the array sits on the stack, so building the batch +/// takes no heap allocation. `push` panics past `N`; callers size `N` to the +/// closure's exact, statically-known maximum. Not for batches whose size +/// depends on runtime data (e.g. copying every live element on a resize) — +/// those still need `Vec`. +pub(super) struct WriteBuf { + buf: [(u64, SmallBuf); N], + len: usize, +} + +impl WriteBuf { + pub(super) fn new() -> Self { + Self { + buf: core::array::from_fn(|_| (0, SmallBuf::Buf8([0; 8]))), + len: 0, + } + } + pub(super) fn push(&mut self, item: (u64, SmallBuf)) { + self.buf[self.len] = item; + self.len += 1; + } + pub(super) fn len(&self) -> usize { + self.len + } + pub(super) fn as_slice(&self) -> &[(u64, SmallBuf)] { + &self.buf[..self.len] + } +} + /// Read `N` **contiguous** little-endian `u64` fields starting at `off` in a /// *single* I/O call, returning them as an array. Use this instead of several /// [`read_u64`] calls when the fields are adjacent (e.g. a handle's metadata) — From 9fe3ff024f07f0205addf6ff3f95155b0ff8dfd0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 19:51:31 -0700 Subject: [PATCH 131/140] Fix duplication in map --- bstack_raii/PROBLEMS.md | 39 ++++++-- bstack_raii/src/stdlib/hashset.rs | 140 +++------------------------ bstack_raii/src/stdlib/map.rs | 148 +++------------------------- bstack_raii/src/stdlib/util.rs | 156 ++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 276 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 5bea1e6..18f3cd2 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -17,16 +17,17 @@ omitted except where trivial. struct with a `Foreign` field, and casting for the wide-pointer relationship, were flagged as still-open in the project notes; needs confirmation that `bstack_move!` yields the right typed value at the foreign location. -- **Registry lazy-init not implemented** — only explicit `registry::init(path)`; +- [WONTFIX] **Registry lazy-init not implemented** — only explicit `registry::init(path)`; the intended "init on first live attach" path was left unresolved (no registry - path source). + path source). This is an explicit limitation since we do not know the path to the registry file at first attach, so it is not a bug. - **`ForeignHost` lacks batched/generator ops**, so a cross-file clone's home commit and foreign side cannot be one atomic unit (best-effort only — see §3). -- **`wal::reduce`'s groupoid slice-reuse optimization is unwired.** Fully +- [WONTFIX] **`wal::reduce`'s groupoid slice-reuse optimization is unwired.** Fully implemented and unit-tested (`AllocReq` / `Reduced` / `reduce`, now `#[cfg(test)]` — see §6), but nothing in `bulk`/`clone`/`teardown` calls it: no commit path currently repurposes a same-length freed slice for a fresh allocation instead of freeing then reallocating. + This plan seems nice, but cloning only allocates, teardown only frees, and resizing never reuses a freed slice, so in practice there is no case where this optimization would be used. If such a path exists in the future, it can be wired in then. ## 2. Code quality @@ -170,10 +171,18 @@ Residual points, all *leak-only* (permitted) but worth recording: `stdlib/*` (no-length inline `Buf8`/`Buf40`, `Heap` fallback). - No bulk alloc/free in clone/teardown (§1) even when the concrete allocator implements `BStackBulkAllocator`. -- **Double read per strong child in clone**: `ClonePlan::bump_strong` calls - `strong_parts` (a read to find the control offset) during planning, then the - commit's `inplace_gen` reads the same counter again. -- Reads are buffer-copy based (no mmap zero-copy) — documented inherent limitation. +- [WONTFIX] **Two round trips per `(rc, weak)` strong child in clone**: `ClonePlan::bump_strong` + reads the child's back-pointer field during planning (`strong_parts` → + `read_ctrl_ref`, to locate the control block), then the commit's `inplace_gen` + separately reads the strong *counter* field at that location (to compute the + increment and check overflow before any write). Two different fields, not a + redundant re-read of the same one, but two lock acquisitions where one might + theoretically suffice via a dependent-read round (as `atomic_update` does). + Not worth it: the saving is dwarfed by the clone's already-unbatched + `alloc()` calls per new block, and merging the reads means threading a + direct/indirect distinction through `ClonePlan.bumps` and re-verifying the + overflow-before-write guarantee in the crate's most crash-sensitive commit + path — real risk for an unmeasurable win. ## 9. Duplicated code @@ -182,12 +191,22 @@ Residual points, all *leak-only* (permitted) but worth recording: `transmute`s — in `teardown::wal_free_all`, `clone::commit_inner`, and each stdlib collection's commit path. A single `batched_commit` helper would remove the duplication *and* shrink the unsafe surface in §5. -- **`(offset, value.to_le_bytes().to_vec())` write-tuple construction** is repeated +- [FIXED] **`(offset, value.to_le_bytes().to_vec())` write-tuple construction** is repeated hundreds of times across `stdlib/*` and the codegen; a tiny constructor helper (`w8(off, val)`) would compress it. -- Every stdlib collection repeats a "read `OnDisk` header → mutate counters → +- [FIXED] Every stdlib collection repeats a "read `OnDisk` header → mutate counters → `set_batched`" shape; some of it could share a helper (this is runtime code, not the struct-vs-enum codegen that was explicitly excluded). + The fixed-metadata push/pop shape (`deque`/`list`) and the probe-based + insert/remove shape (`map`/`hashset`/`btreeset`/`tree`) were already unified via + `atomic_update`/`probe_commit` (`stdlib/util.rs`). The remaining bespoke sites + (`heap` sift, `bloom` counters, B-tree split/merge) are structurally too + different to unify without over-abstracting. But `BStackHashMap::grow` and + `BStackHashSet::grow` were near line-for-line duplicates of the same + rehash-into-bigger-table algorithm (differing only in whether a bucket carries + a trailing value ref) — extracted into a shared `grow_table` in `stdlib/util.rs` + that treats the trailing bytes as opaque payload copied alongside the key. + Both `grow` methods are now thin wrappers. ## 10. Feature interactions @@ -263,7 +282,7 @@ so they are not re-flagged). won't compile. Probably intended (a map has no meaningful field-destructure), but it is an undocumented asymmetry. (It does *not* block a collection from being a moved-out `#[bstack_owned]` field — that path needs only `BStackBlock`.) -- [WONTFIX] **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` +- **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` growth allocates a fresh backing block, copies into it, flips the descriptor, and frees the old block. Whether each is a single atomic descriptor flip (leak-only on crash) or has a torn window was not checked — a category to verify, likely diff --git a/bstack_raii/src/stdlib/hashset.rs b/bstack_raii/src/stdlib/hashset.rs index 48fb0cd..72e1a8b 100644 --- a/bstack_raii/src/stdlib/hashset.rs +++ b/bstack_raii/src/stdlib/hashset.rs @@ -34,13 +34,14 @@ use core::mem::size_of; use std::io; use crate::BStackRaiiAllocator; -use bstack::{BStack, BStackGenOp, BStackRange}; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; use super::hash::fnv1a; use super::util::{ - Meta, ProbeStep, Scratch, SmallBuf, alloc_image, probe_commit, read_fields, read_u64, w8, + Meta, ProbeStep, Scratch, SmallBuf, alloc_image, grow_table, probe_commit, read_fields, + read_u64, w8, }; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; @@ -371,133 +372,14 @@ impl BStackHashSet { /// Grow the table to at least double its capacity, rehashing every live key /// (and dropping tombstones) atomically. fn grow(&self, allocator: &A) -> io::Result<()> { - let handle = self.range.start(); - let stride = Self::stride(); - let ksz = Self::ksize(); - let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; - let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; - let newtable = allocator.alloc(newcap * stride)?.as_range().start(); - - let mut meta_buf = [0u8; 32]; - let mut old_buf = vec![0u8; (cap0 * stride) as usize]; - let mut new_image: Vec = Vec::new(); - let grown = Cell::new(false); - let old_table = Cell::new(0u64); - let old_cap = Cell::new(0u64); - - let mut meta_issued = false; - let mut meta: Option = None; - let mut abort = false; - let mut read_i = 0u64; - let mut built = false; - let mut writes: Vec<(u64, SmallBuf)> = Vec::new(); - let mut w = 0usize; - - allocator.stack().inplace_gen(|_feedback| { - if !meta_issued { - meta_issued = true; - // SAFETY: `meta_buf` outlives the call. - let b: &mut [u8] = - unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; - return Some(BStackGenOp::Read { - offset: handle + TABLE_OFF, - buf: b, - }); - } - if meta.is_none() { - let m = Meta { - table: get_u64(&meta_buf[0..8]), - cap: get_u64(&meta_buf[8..16]), - len: get_u64(&meta_buf[16..24]), - used: get_u64(&meta_buf[24..32]), - }; - if newcap <= m.cap { - abort = true; - } - meta = Some(m); - } - if abort { - return None; - } - let m = meta.as_ref().unwrap(); - - if read_i < m.cap { - let i = read_i; - read_i += 1; - let lo = (i * stride) as usize; - let hi = lo + stride as usize; - // SAFETY: `old_buf` outlives the call; each slice read once. - let b: &mut [u8] = - unsafe { core::mem::transmute::<&mut [u8], _>(&mut old_buf[lo..hi]) }; - return Some(BStackGenOp::Read { - offset: m.table + i * stride, - buf: b, - }); - } - - if !built { - built = true; - grown.set(true); - old_table.set(m.table); - old_cap.set(m.cap); - - new_image = vec![0u8; (newcap * stride) as usize]; - let newmask = newcap - 1; - for j in 0..m.cap { - let lo = (j * stride) as usize; - if get_u64(&old_buf[lo..lo + 8]) != OCCUPIED { - continue; - } - let kb = &old_buf[lo + 8..lo + 8 + ksz]; - let mut idx = fnv1a(kb) & newmask; - loop { - let nlo = (idx * stride) as usize; - if get_u64(&new_image[nlo..nlo + 8]) == EMPTY { - new_image[nlo..nlo + 8].copy_from_slice(&OCCUPIED.to_le_bytes()); - new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); - break; - } - idx = (idx + 1) & newmask; - } - } - writes.push(( - newtable, - SmallBuf::Heap(std::mem::take(&mut new_image).into_boxed_slice()), - )); - writes.push(w8(handle + TABLE_OFF, newtable)); - writes.push(w8(handle + CAP_OFF, newcap)); - writes.push(w8(handle + USED_OFF, m.len)); - } - if w < writes.len() { - let i = w; - w += 1; - let (off, ref bytes) = writes[i]; - // SAFETY: `writes` outlives the call and is not mutated after build. - let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { - offset: off, - data: d, - }); - } - None - })?; - - if grown.get() { - if old_cap.get() > 0 { - // SAFETY: the descriptor no longer points at the old table. - let _ = unsafe { - dealloc_range( - allocator, - BStackRange::new(old_table.get(), old_cap.get() * stride), - ) - }; - } - } else { - // SAFETY: `newtable` was never linked into the descriptor. - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; - } - Ok(()) + grow_table( + allocator, + self.range.start(), + Self::stride(), + Self::ksize(), + OCCUPIED, + MIN_CAP, + ) } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. diff --git a/bstack_raii/src/stdlib/map.rs b/bstack_raii/src/stdlib/map.rs index 717a855..8e7b6e0 100644 --- a/bstack_raii/src/stdlib/map.rs +++ b/bstack_raii/src/stdlib/map.rs @@ -38,13 +38,13 @@ use core::mem::size_of; use std::io; use crate::BStackRaiiAllocator; -use bstack::{BStack, BStackGenOp, BStackRange}; +use bstack::{BStack, BStackRange}; use bytemuck::{Pod, Zeroable}; use super::hash::fnv1a; use super::util::{ - Meta, ProbeStep, Scratch, SmallBuf, WriteBuf, alloc_image, probe_commit, read_fields, read_u64, - w8, + Meta, ProbeStep, Scratch, SmallBuf, alloc_image, grow_table, probe_commit, read_fields, + read_u64, w8, }; use crate::block::{BStackBlock, BStackCast}; use crate::clone::{ClonePlan, TryCloneIn}; @@ -432,140 +432,14 @@ impl BStackHashMap { /// (and dropping tombstones) atomically. A no-op (beyond a freed spare block) /// if another thread already grew it. fn grow(&self, allocator: &A) -> io::Result<()> { - let handle = self.range.start(); - let stride = Self::stride(); - let ksz = Self::ksize(); - let cap0 = read_u64(allocator.stack(), handle + CAP_OFF)?; - let newcap = if cap0 == 0 { MIN_CAP } else { cap0 * 2 }; - // Allocate the new bucket block up front (an orphan until the swap). - let newtable = allocator.alloc(newcap * stride)?.as_range().start(); - - let mut meta_buf = [0u8; 32]; - let mut old_buf = vec![0u8; (cap0 * stride) as usize]; - let mut new_image: Vec = Vec::new(); - let grown = Cell::new(false); - let old_table = Cell::new(0u64); - let old_cap = Cell::new(0u64); - - let mut meta_issued = false; - let mut meta: Option = None; - let mut abort = false; - let mut read_i = 0u64; - let mut built = false; - let mut writes: WriteBuf<4> = WriteBuf::new(); - let mut w = 0usize; - - allocator.stack().inplace_gen(|_feedback| { - if !meta_issued { - meta_issued = true; - // SAFETY: `meta_buf` outlives the call. - let b: &mut [u8] = - unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; - return Some(BStackGenOp::Read { - offset: handle + TABLE_OFF, - buf: b, - }); - } - if meta.is_none() { - let m = Meta { - table: get_u64(&meta_buf[0..8]), - cap: get_u64(&meta_buf[8..16]), - len: get_u64(&meta_buf[16..24]), - used: get_u64(&meta_buf[24..32]), - }; - // Abort if someone already grew to at least this size. - if newcap <= m.cap { - abort = true; - } - meta = Some(m); - } - if abort { - return None; // commit nothing - } - let m = meta.as_ref().unwrap(); - - // Snapshot every old bucket. - if read_i < m.cap { - let i = read_i; - read_i += 1; - let lo = (i * stride) as usize; - let hi = lo + stride as usize; - // SAFETY: `old_buf` outlives the call; each slice read once. - let b: &mut [u8] = - unsafe { core::mem::transmute::<&mut [u8], _>(&mut old_buf[lo..hi]) }; - return Some(BStackGenOp::Read { - offset: m.table + i * stride, - buf: b, - }); - } - - // Rebuild the table into the new block (dropping tombstones). - if !built { - built = true; - grown.set(true); - old_table.set(m.table); - old_cap.set(m.cap); - - new_image = vec![0u8; (newcap * stride) as usize]; // all EMPTY - let newmask = newcap - 1; - for j in 0..m.cap { - let lo = (j * stride) as usize; - if get_u64(&old_buf[lo..lo + 8]) != OCCUPIED { - continue; - } - let kb = &old_buf[lo + 8..lo + 8 + ksz]; - let vref = &old_buf[lo + 8 + ksz..lo + 16 + ksz]; - let mut idx = fnv1a(kb) & newmask; - loop { - let nlo = (idx * stride) as usize; - if get_u64(&new_image[nlo..nlo + 8]) == EMPTY { - new_image[nlo..nlo + 8].copy_from_slice(&OCCUPIED.to_le_bytes()); - new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); - new_image[nlo + 8 + ksz..nlo + 16 + ksz].copy_from_slice(vref); - break; - } - idx = (idx + 1) & newmask; - } - } - writes.push(( - newtable, - SmallBuf::Heap(std::mem::take(&mut new_image).into_boxed_slice()), - )); - writes.push(w8(handle + TABLE_OFF, newtable)); - writes.push(w8(handle + CAP_OFF, newcap)); - // Tombstones dropped: used == len now. - writes.push(w8(handle + USED_OFF, m.len)); - } - if w < writes.len() { - let i = w; - w += 1; - let (off, ref bytes) = writes.as_slice()[i]; - // SAFETY: `writes` outlives the call and is not mutated after build. - let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; - return Some(BStackGenOp::Write { - offset: off, - data: d, - }); - } - None - })?; - - if grown.get() { - if old_cap.get() > 0 { - // SAFETY: the descriptor no longer points at the old table. - let _ = unsafe { - dealloc_range( - allocator, - BStackRange::new(old_table.get(), old_cap.get() * stride), - ) - }; - } - } else { - // SAFETY: `newtable` was never linked into the descriptor. - let _ = - unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; - } - Ok(()) + grow_table( + allocator, + self.range.start(), + Self::stride(), + Self::ksize(), + OCCUPIED, + MIN_CAP, + ) } /// Attach an allocator to make an auto-freeing [`AutoDrop`] guard. diff --git a/bstack_raii/src/stdlib/util.rs b/bstack_raii/src/stdlib/util.rs index c352fcf..ce28f19 100644 --- a/bstack_raii/src/stdlib/util.rs +++ b/bstack_raii/src/stdlib/util.rs @@ -5,12 +5,15 @@ //! atomic mutators on: a `u64` field read, a whole-image block allocation, and //! the [`atomic_update`] read-modify-write generator. +use core::cell::Cell; use std::io; use crate::BStackRaiiAllocator; use bstack::{BStack, BStackGenOp, BStackRange}; +use super::hash::fnv1a; use crate::layout::{HEADER_SIZE, get_u64}; +use crate::teardown::dealloc_range; /// Read a little-endian `u64` at absolute offset `off`. pub(super) fn read_u64(stack: &BStack, off: u64) -> io::Result { @@ -389,3 +392,156 @@ where None }) } + +/// Grow an open-addressing bucket table to at least double its capacity, +/// rehashing every live bucket (dropping tombstones) atomically. A no-op +/// (beyond a freed spare block) if another writer already grew it at least +/// this far. +/// +/// Shared by [`crate::BStackHashMap`] and [`crate::stdlib::BStackHashSet`] — +/// the two bucket layouts differ only in whether the `stride - 8 - ksz` +/// trailing bytes after the key hold a value ref (map) or nothing (set), and +/// this treats those bytes as opaque payload: copied alongside the key, +/// never hashed or interpreted. So one rehash loop covers both. `ksz` is the +/// key size in bytes, `occupied` the bucket-state value marking a live entry +/// (a rebuilt bucket starts zeroed, so `0` always means empty), and `min_cap` +/// the capacity an empty table grows to. +pub(super) fn grow_table( + allocator: &A, + handle: u64, + stride: u64, + ksz: usize, + occupied: u64, + min_cap: u64, +) -> io::Result<()> { + let cap0 = read_u64(allocator.stack(), handle + HEADER_SIZE + 8)?; + let newcap = if cap0 == 0 { min_cap } else { cap0 * 2 }; + // Allocate the new bucket block up front (an orphan until the swap). + let newtable = allocator.alloc(newcap * stride)?.as_range().start(); + + let mut meta_buf = [0u8; 32]; + let mut old_buf = vec![0u8; (cap0 * stride) as usize]; + let mut new_image: Vec = Vec::new(); + let grown = Cell::new(false); + let old_table = Cell::new(0u64); + let old_cap = Cell::new(0u64); + + let mut meta_issued = false; + let mut meta: Option = None; + let mut abort = false; + let mut read_i = 0u64; + let mut built = false; + let mut writes: WriteBuf<4> = WriteBuf::new(); + let mut w = 0usize; + + allocator.stack().inplace_gen(|_feedback| { + if !meta_issued { + meta_issued = true; + // SAFETY: `meta_buf` outlives the call. + let b: &mut [u8] = unsafe { core::mem::transmute::<&mut [u8], _>(&mut meta_buf[..]) }; + return Some(BStackGenOp::Read { + offset: handle + HEADER_SIZE, + buf: b, + }); + } + if meta.is_none() { + let m = Meta { + table: get_u64(&meta_buf[0..8]), + cap: get_u64(&meta_buf[8..16]), + len: get_u64(&meta_buf[16..24]), + used: get_u64(&meta_buf[24..32]), + }; + // Abort if someone already grew to at least this size. + if newcap <= m.cap { + abort = true; + } + meta = Some(m); + } + if abort { + return None; // commit nothing + } + let m = meta.as_ref().unwrap(); + + // Snapshot every old bucket. + if read_i < m.cap { + let i = read_i; + read_i += 1; + let lo = (i * stride) as usize; + let hi = lo + stride as usize; + // SAFETY: `old_buf` outlives the call; each slice read once. + let b: &mut [u8] = + unsafe { core::mem::transmute::<&mut [u8], _>(&mut old_buf[lo..hi]) }; + return Some(BStackGenOp::Read { + offset: m.table + i * stride, + buf: b, + }); + } + + // Rebuild the table into the new block (dropping tombstones). + if !built { + built = true; + grown.set(true); + old_table.set(m.table); + old_cap.set(m.cap); + + new_image = vec![0u8; (newcap * stride) as usize]; // all EMPTY (0) + let newmask = newcap - 1; + for j in 0..m.cap { + let lo = (j * stride) as usize; + if get_u64(&old_buf[lo..lo + 8]) != occupied { + continue; + } + let kb = &old_buf[lo + 8..lo + 8 + ksz]; + // Trailing payload after the key (a map's value ref; empty for a set). + let rest = &old_buf[lo + 8 + ksz..lo + stride as usize]; + let mut idx = fnv1a(kb) & newmask; + loop { + let nlo = (idx * stride) as usize; + if get_u64(&new_image[nlo..nlo + 8]) == 0 { + new_image[nlo..nlo + 8].copy_from_slice(&occupied.to_le_bytes()); + new_image[nlo + 8..nlo + 8 + ksz].copy_from_slice(kb); + new_image[nlo + 8 + ksz..nlo + stride as usize].copy_from_slice(rest); + break; + } + idx = (idx + 1) & newmask; + } + } + writes.push(( + newtable, + SmallBuf::Heap(std::mem::take(&mut new_image).into_boxed_slice()), + )); + writes.push(w8(handle + HEADER_SIZE, newtable)); + writes.push(w8(handle + HEADER_SIZE + 8, newcap)); + // Tombstones dropped: used == len now. + writes.push(w8(handle + HEADER_SIZE + 24, m.len)); + } + if w < writes.len() { + let i = w; + w += 1; + let (off, ref bytes) = writes.as_slice()[i]; + // SAFETY: `writes` outlives this call and is not mutated after build. + let d: &[u8] = unsafe { core::mem::transmute::<&[u8], _>(bytes.as_slice()) }; + return Some(BStackGenOp::Write { + offset: off, + data: d, + }); + } + None + })?; + + if grown.get() { + if old_cap.get() > 0 { + // SAFETY: the descriptor no longer points at the old table. + let _ = unsafe { + dealloc_range( + allocator, + BStackRange::new(old_table.get(), old_cap.get() * stride), + ) + }; + } + } else { + // SAFETY: `newtable` was never linked into the descriptor. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(newtable, newcap * stride)) }; + } + Ok(()) +} From c8ff36b7835f02de283815806665204ec2f9c321 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 20:33:58 -0700 Subject: [PATCH 132/140] Fix weak upgrade --- bstack_raii/PROBLEMS.md | 10 +++++++--- bstack_raii/src/shared.rs | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 18f3cd2..0de05e3 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -71,11 +71,15 @@ Core paths are sound (constructors commit via one `write_range` / `set_batched`; deep clone is two-phase allocate-then-atomic-commit; owned teardown is WAL-backed). Residual points, all *leak-only* (permitted) but worth recording: -- **`BStackWeak::upgrade`** ([shared.rs:238](src/shared.rs#L238)) increments the +- [FIXED] **`BStackWeak::upgrade`** ([shared.rs:238](src/shared.rs#L238)) increments the strong count, then reads the data forward-pointer; if that read fails the strong increment is orphaned (over-count → the block can never reach zero). Same class - as the weak-setter leak just fixed; could reuse the release-on-failure idea. -- **`BStackRc::try_move`** ([shared.rs:162](src/shared.rs#L162)): after the CAS + as the weak-setter leak just fixed; reused the release-on-failure idea: on a + failed read, `fetch_sub` the claimed strong count back, and if that lands on the + last-owner case, re-read the forward pointer once more just to run + `strong_release_ctrl`'s teardown (tolerating a second failure there as a bounded, + already-permitted leak, unlike the unbounded over-count this replaces). +- [WONTFIX] **`BStackRc::try_move`** ([shared.rs:162](src/shared.rs#L162)): after the CAS `strong 1→0`, a failure inside `T::bstack_move` leaves the block unwrapped with the shell possibly unfreed — an error-path leak. - **Cross-file teardown frees are not WAL-protected on the *target* file.** The diff --git a/bstack_raii/src/shared.rs b/bstack_raii/src/shared.rs index 2ddd371..bd0ce68 100644 --- a/bstack_raii/src/shared.rs +++ b/bstack_raii/src/shared.rs @@ -266,7 +266,24 @@ impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> BStackWeak<'a, T, A> { // Strong is now claimed; recover the data ref from the forward pointer. let data_pos = ctrl_range.start() + layout::CTRL_DATA_OFFSET; let mut bytes = [0u8; 8]; - stack.get_into(data_pos, &mut bytes)?; + if let Err(e) = stack.get_into(data_pos, &mut bytes) { + // The claim above already landed; release it here rather than + // orphan it (same release-on-failure idea as the weak-setter fix) — + // otherwise the strong count is permanently one too high and the + // block can never reach zero. `strong_release_ctrl` needs the data + // range only on the last-owner path; re-read it just for that case, + // tolerating a second failure there (a bounded, already-permitted + // leak, unlike the unbounded over-count this guards against). + if refcount::fetch_sub(stack, strong_off, 1)? == 1 { + let mut retry = [0u8; 8]; + if stack.get_into(data_pos, &mut retry).is_ok() { + let data_range = + BStackRange::new(u64::from_le_bytes(retry), size_of::() as u64); + let _ = strong_release_ctrl::(allocator, data_range, ctrl_range); + } + } + return Err(e); + } let data_range = BStackRange::new(u64::from_le_bytes(bytes), size_of::() as u64); let data = unsafe { BStackRef::::from_range(data_range) }; // SAFETY: the increment above claimed the strong count this handle holds. From ba260547d67f18e97432b41c507f7c24ca74173b Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 12 Aug 2026 20:39:34 -0700 Subject: [PATCH 133/140] Resolve private problem --- bstack_raii/PROBLEMS.md | 10 ++++++++-- bstack_raii/derive/src/block.rs | 32 ++++++++++++++++---------------- bstack_raii/src/lib.rs | 20 ++++++++++++++------ 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 0de05e3..7b6b89b 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -137,8 +137,14 @@ Residual points, all *leak-only* (permitted) but worth recording: real commit path** (`bulk`/`clone`/`teardown` don't call it); kept `#[cfg(test)]` rather than deleted, noted here as a distinct §1-adjacent gap for whoever wires it in. -- README does not mention `alloc_many` / `free_many` or the `foreign_*` runtime - helpers (acceptable if intentionally internal, but they are publicly exported). +- [FIXED] **`alloc_many` / `free_many` and the `foreign_*` runtime helpers were + publicly exported with no README mention**: they exist only for + `#[bstack_block]`/`#[bstack_enum]`-generated code to call via a + fully-qualified path from downstream crates, not for direct use. Moved off + the crate-root `pub use` into `#[doc(hidden)] pub mod __private`, and + repointed the derive macro's generated `::bstack_raii::…` paths at + `::bstack_raii::__private::…`. `Foreign` / `ForeignPtr` (the types users + actually write) stay public at the root. ## 7. Bad use experience diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 04f59fd..ef026b7 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -475,9 +475,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result // are tagged (via `wal_file_id`) with the target's file so the home WAL // reclaims them there. `#[bstack_ref]` owns nothing → no teardown. let foreign_drop_helper = match kind { - Kind::Owned => Some(quote!(::bstack_raii::foreign_drop_owned)), - Kind::Strong => Some(quote!(::bstack_raii::foreign_drop_strong)), - Kind::Weak => Some(quote!(::bstack_raii::foreign_drop_weak)), + Kind::Owned => Some(quote!(::bstack_raii::__private::foreign_drop_owned)), + Kind::Strong => Some(quote!(::bstack_raii::__private::foreign_drop_strong)), + Kind::Weak => Some(quote!(::bstack_raii::__private::foreign_drop_weak)), // Ref: non-owning. Pod / Embed: already rejected above. Kind::Ref | Kind::Pod | Kind::Embed => None, }; @@ -551,7 +551,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); let __new_off = unsafe { - ::bstack_raii::foreign_clone_owned::<#ftarget, _>( + ::bstack_raii::__private::foreign_clone_owned::<#ftarget, _>( &__adapter, __off, )? }; @@ -591,7 +591,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); unsafe { - ::bstack_raii::foreign_clone_strong::<#ftarget, _>( + ::bstack_raii::__private::foreign_clone_strong::<#ftarget, _>( &__adapter, __off, )?; } @@ -627,7 +627,7 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); unsafe { - ::bstack_raii::foreign_clone_weak::<#ftarget, _>( + ::bstack_raii::__private::foreign_clone_weak::<#ftarget, _>( &__adapter, __off, )?; } @@ -3552,9 +3552,9 @@ fn option_inner(ty: &Type) -> Option<&Type> { /// `#[bstack_ref]` owns nothing → empty. Shared with the scalar `Foreign` field. fn foreign_elem_drop(kind: Kind, ftarget: &Type) -> TokenStream { let helper = match kind { - Kind::Owned => quote!(::bstack_raii::foreign_drop_owned), - Kind::Strong => quote!(::bstack_raii::foreign_drop_strong), - Kind::Weak => quote!(::bstack_raii::foreign_drop_weak), + Kind::Owned => quote!(::bstack_raii::__private::foreign_drop_owned), + Kind::Strong => quote!(::bstack_raii::__private::foreign_drop_strong), + Kind::Weak => quote!(::bstack_raii::__private::foreign_drop_weak), _ => return quote!(), }; quote! { @@ -3620,7 +3620,7 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); let __new_off = unsafe { - ::bstack_raii::foreign_clone_owned::<#ftarget, _>(&__adapter, __off)? }; + ::bstack_raii::__private::foreign_clone_owned::<#ftarget, _>(&__adapter, __off)? }; ::bstack_raii::ForeignPtr::new(__fid, __new_off) } else { #malformed @@ -3647,7 +3647,7 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { .ok_or_else(|| #err)?; let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); - unsafe { ::bstack_raii::foreign_clone_strong::<#ftarget, _>(&__adapter, __off)?; } + unsafe { ::bstack_raii::__private::foreign_clone_strong::<#ftarget, _>(&__adapter, __off)?; } } else { #malformed } @@ -3672,7 +3672,7 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { .ok_or_else(|| #err)?; let __adapter = ::bstack_raii::ForeignHostAllocator::new(__host, __id); - unsafe { ::bstack_raii::foreign_clone_weak::<#ftarget, _>(&__adapter, __off)?; } + unsafe { ::bstack_raii::__private::foreign_clone_weak::<#ftarget, _>(&__adapter, __off)?; } } else { #malformed } @@ -4794,7 +4794,7 @@ fn constructor( #(#preps)* // Allocate data + control up front (atomically when the // allocator supports bulk); both are orphans until the commit. - let __blocks = ::bstack_raii::alloc_many(allocator, &[#size, #ctrl_size])?; + let __blocks = ::bstack_raii::__private::alloc_many(allocator, &[#size, #ctrl_size])?; let __data = __blocks[0]; let __ctrl = __blocks[1]; let __on_disk = #on_disk_ctor { @@ -4817,7 +4817,7 @@ fn constructor( if let ::std::result::Result::Err(__e) = allocator.stack().set_batched(__writes) { - let _ = ::bstack_raii::free_many(allocator, [__data, __ctrl]); + let _ = ::bstack_raii::__private::free_many(allocator, [__data, __ctrl]); return ::std::result::Result::Err(__e); } #(#post)* @@ -7328,7 +7328,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result Date: Wed, 12 Aug 2026 21:41:04 -0700 Subject: [PATCH 134/140] Batched, WAL, clone and teardown --- bstack_raii/PROBLEMS.md | 21 ++++++++--- bstack_raii/derive/src/block.rs | 8 ++-- bstack_raii/src/bulk.rs | 44 ++++++++++++---------- bstack_raii/src/lib.rs | 49 +++++++++++++++++++++++- bstack_raii/src/teardown.rs | 13 ++++++- bstack_raii/src/tests.rs | 66 ++++++++++++++++++++++++++++++++- bstack_raii/src/wal.rs | 59 +++++++++++++++++++++++++++-- 7 files changed, 223 insertions(+), 37 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 7b6b89b..e379fbf 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -7,12 +7,21 @@ omitted except where trivial. ## 1. Missing / incomplete features -- **Deep clone / teardown never use bulk alloc/free.** `alloc_many`/`free_many` - are wired **only** into the 2-block `(rc, weak)` constructor. `ClonePlan` - allocates each new block via sequential `alloc_raw`, and teardown frees - sequentially — the "prefer `alloc_bulk`/`dealloc_bulk` when the allocator - supports it" design is unrealized for exactly the N-alloc / N-free paths it was - meant for. +- **Deep clone / teardown never use bulk alloc/free.** *(Foundation laid + 2026-08-12: `alloc_many`/`free_many` are now provided methods on + `BStackRaiiAllocator`, overridden by the bulk-capable allocators — GhostTree, + Linear — to route through atomic `alloc_bulk`/`dealloc_bulk`; ordinary trait + dispatch picks the override through generic code, so the "prefer bulk when + available" design is finally realizable.)* **Teardown DONE 2026-08-12:** + `wal_teardown` now frees a same-file subtree with one atomic `dealloc_bulk` when + `allocator.atomic_bulk()` (GhostTree/Linear), skipping the WAL entirely — since + `dealloc_bulk` is itself atomic + self-recovering, the WAL would be redundant and + can't compose safely with it (opaque recovery direction → double-free risk). + Cross-file (mixed `FileId`) still uses the WAL path for registry routing. The + **alloc side** remains: `ClonePlan` still allocates each new block via sequential + `alloc_raw`. Wiring `alloc_many` into it (the intention-first `AllocReq`/`fresh_id` + path — bulk allocators skip the alloc-WAL entirely, non-bulk backfill each address + per op) is the clone-WAL round, still to be done. - **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a struct with a `Foreign` field, and casting for the wide-pointer relationship, were flagged as still-open in the project notes; needs confirmation that diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index ef026b7..4a6508d 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -4794,7 +4794,7 @@ fn constructor( #(#preps)* // Allocate data + control up front (atomically when the // allocator supports bulk); both are orphans until the commit. - let __blocks = ::bstack_raii::__private::alloc_many(allocator, &[#size, #ctrl_size])?; + let __blocks = ::bstack_raii::BStackRaiiAllocator::alloc_many(allocator, &[#size, #ctrl_size])?; let __data = __blocks[0]; let __ctrl = __blocks[1]; let __on_disk = #on_disk_ctor { @@ -4817,7 +4817,7 @@ fn constructor( if let ::std::result::Result::Err(__e) = allocator.stack().set_batched(__writes) { - let _ = ::bstack_raii::__private::free_many(allocator, [__data, __ctrl]); + let _ = ::bstack_raii::BStackRaiiAllocator::free_many(allocator, [__data, __ctrl]); return ::std::result::Result::Err(__e); } #(#post)* @@ -7328,7 +7328,7 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result syn::Result( +/// Sequential fallback for [`BStackRaiiAllocator::alloc_many`]: allocate one block +/// per entry in `sizes`, in order. On any failure the blocks already allocated are +/// freed (reverse order) before the error is returned, so a partial allocation +/// never leaks within the call. +pub(crate) fn seq_alloc_many( allocator: &A, sizes: &[u64], ) -> io::Result> { @@ -51,9 +54,10 @@ pub fn alloc_many( Ok(out) } -/// Free every range in turn. Stops and propagates on the first error (the -/// remaining ranges are left allocated for the caller to handle). -pub fn free_many( +/// Sequential fallback for [`BStackRaiiAllocator::free_many`]: free every range in +/// turn. Stops and propagates on the first error (the remaining ranges are left +/// allocated for the caller to handle). +pub(crate) fn seq_free_many( allocator: &A, ranges: impl IntoIterator, ) -> io::Result<()> { diff --git a/bstack_raii/src/lib.rs b/bstack_raii/src/lib.rs index 193a1ae..b0bcc64 100644 --- a/bstack_raii/src/lib.rs +++ b/bstack_raii/src/lib.rs @@ -21,7 +21,7 @@ //! | `construct` | Block creation: allocate, stamp the header, wire refcounts / control blocks — the build-side counterpart to `teardown`. | //! | [`block`] | Block-type contracts: [`BStackCast`], [`BStackBlock`], [`BStackWeakable`]. | //! | [`refcount`] | Little-endian atomic CAS ops over on-disk `u64` counters. | -//! | `bulk` | `alloc_many` / `free_many`: multi-block alloc with all-or-nothing rollback (internal; called by generated code). | +//! | `bulk` | Sequential fallbacks for [`BStackRaiiAllocator::alloc_many`] / [`free_many`](BStackRaiiAllocator::free_many); bulk allocators override those trait methods. | //! | [`clone`] | [`TryClone`] / [`TryCloneIn`]: fallible clone for handles that touch disk. | //! | [`handle`] | Without-allocator inner handles: [`OwnedRef`], [`StrongRef`], [`StrongWeakRef`], [`WeakRef`]. | //! | [`owned`] | [`BStackOwned`]: the without-allocator, uniquely-owned block handle. | @@ -166,6 +166,52 @@ pub unsafe trait BStackRaiiAllocator: BStackOwnedSliceAllocator { fn wal_file_id(&self) -> crate::registry::FileId { crate::registry::FileId::SELF } + + /// Allocate one block per length in `sizes`, returning their ranges in order. + /// + /// The default is a **sequential** fallback: each `alloc` is individually + /// crash-atomic, but the set is not (a crash mid-sequence orphans the blocks + /// done so far — a leak the WAL layer reclaims, never a torn structure). It + /// unwinds already-allocated blocks on any failure, so a partial allocation + /// never leaks *within* the call. + /// + /// A bulk-capable allocator ([`bstack::BStackBulkAllocator`]) **overrides** this + /// to route through the atomic [`alloc_bulk`](bstack::BStackBulkAllocator::alloc_bulk), + /// so the whole set becomes one crash-atomic operation recovered by the + /// allocator's own machinery. Ordinary trait dispatch picks the override at + /// monomorphization, so compound ops generic over `A` get the fast path for free. + fn alloc_many(&self, sizes: &[u64]) -> std::io::Result> { + crate::bulk::seq_alloc_many(self, sizes) + } + + /// Free every range in `ranges`. The default is a **sequential** fallback + /// (each `dealloc` individually atomic); a bulk-capable allocator overrides it + /// to route through the atomic [`dealloc_bulk`](bstack::BStackBulkAllocator::dealloc_bulk). + /// + /// # Safety-adjacent contract + /// Each range must be a live allocation owned by `self` that no other live + /// handle will also free (as for [`crate::teardown`]'s `dealloc_range`). + fn free_many(&self, ranges: impl IntoIterator) -> std::io::Result<()> { + crate::bulk::seq_free_many(self, ranges) + } + + /// Whether this allocator provides **atomic, self-recovering** bulk + /// alloc/free — i.e. it implements [`bstack::BStackBulkAllocator`] and + /// overrides [`alloc_many`](Self::alloc_many) / [`free_many`](Self::free_many) + /// to route through it. Default `false`. + /// + /// When `true`, a compound op whose blocks all live in **this** file can free + /// (or allocate) them as one atomic `dealloc_bulk` / `alloc_bulk` and **skip the + /// WAL** entirely: the WAL exists to emulate atomic batch alloc/free for + /// allocators that lack it, and wrapping an already-atomic bulk op in it is both + /// redundant and unsound (the allocator's crash-recovery direction is opaque, so + /// a WAL retry on reopen could double-free). A crash mid-bulk is left to the + /// allocator's own recovery — consistent, leak-at-worst, exactly the guarantee + /// the WAL would have provided. Cross-file (mixed [`FileId`](crate::registry::FileId)) + /// batches fall back to the WAL for its registry routing. + fn atomic_bulk(&self) -> bool { + false + } } // Re-exported whole so generated code can call `::bstack_raii::bytemuck::bytes_of`. pub use bytemuck; @@ -178,7 +224,6 @@ pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move /// no stability guarantee, use directly at your own risk. #[doc(hidden)] pub mod __private { - pub use crate::bulk::{alloc_many, free_many}; pub use crate::foreign::{ foreign_clone_owned, foreign_clone_strong, foreign_clone_weak, foreign_drop_owned, foreign_drop_strong, foreign_drop_weak, diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs index 7580eb5..bf48d1e 100644 --- a/bstack_raii/src/teardown.rs +++ b/bstack_raii/src/teardown.rs @@ -66,7 +66,18 @@ pub fn wal_teardown( let slices = TEARDOWN_SINK .with(|s| s.borrow_mut().take()) .unwrap_or_default(); - wal_free_all(allocator, slices)?; + // Bulk-capable allocator, same-file subtree: `dealloc_bulk` is itself atomic and + // self-recovering, so free the whole subtree as one atomic batch and **skip the + // WAL** — wrapping an already-atomic bulk free in the WAL is redundant and + // unsound (the allocator's recovery direction is opaque, so a WAL retry could + // double-free). A crash mid-bulk is reclaimed by the allocator's own recovery. + // A cross-file (mixed `FileId`) teardown still routes through the WAL so its + // foreign frees are replayed via the registry on recovery. + if allocator.atomic_bulk() && slices.iter().all(|(fid, _)| *fid == FileId::SELF) { + allocator.free_many(slices.into_iter().map(|(_, r)| r))?; + } else { + wal_free_all(allocator, slices)?; + } result } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index f728931..06fe856 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -8,7 +8,9 @@ use core::mem::size_of; use std::io; use std::sync::atomic::{AtomicU64, Ordering}; -use bstack::{BStack, BStackAllocator, BStackRange, FirstFitBStackAllocator}; +use bstack::{ + BStack, BStackAllocator, BStackRange, FirstFitBStackAllocator, GhostTreeBstackAllocator, +}; use crate::layout::{self, BlockHeader}; use crate::{ @@ -50,6 +52,14 @@ impl TempStack { fn allocator(&self) -> FirstFitBStackAllocator { FirstFitBStackAllocator::new(self.open()).unwrap() } + + /// A `GhostTree` allocator — the one bstack-provided allocator that both + /// anchors a WAL and implements `BStackBulkAllocator`, so it exercises the + /// atomic-bulk override of `alloc_many` / `free_many` (FirstFit hits the + /// sequential fallback). + fn ghost_allocator(&self) -> GhostTreeBstackAllocator { + GhostTreeBstackAllocator::new(self.open()).unwrap() + } } impl Drop for TempStack { @@ -390,6 +400,28 @@ fn macro_recursive_drop() { }); } +// Same recursive teardown, but on a `GhostTree` allocator (`atomic_bulk() == true`): +// `wal_teardown` frees the whole same-file subtree with one atomic `dealloc_bulk`, +// skipping the WAL. Build + tear down twice and assert the stack returns to baseline +// — a leak (e.g. the child not freed by the bulk path) would show as growth. +#[test] +fn macro_recursive_drop_on_bulk_allocator() { + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let build = || { + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + MacroParent::new(&alloc, leaf, 7).unwrap() + }; + build().bstack_drop(&alloc).unwrap(); + let base = alloc.stack().len().unwrap(); + build().bstack_drop(&alloc).unwrap(); + assert_eq!( + alloc.stack().len().unwrap(), + base, + "bulk teardown leaked (child not freed by dealloc_bulk?)" + ); +} + // -------------------------------------------------------------------------- // AutoDrop: the RAII guard vs. bare / manual teardown // -------------------------------------------------------------------------- @@ -713,6 +745,38 @@ fn macro_new_rc_weak() { drop(weak); } +// The same (rc, weak) constructor/clone/teardown lifecycle, but on a `GhostTree` +// allocator — which implements `BStackBulkAllocator`, so the two-block constructor +// routes through the atomic `alloc_bulk` override of `alloc_many` (and the rollback +// path through `dealloc_bulk`). This is the only test that exercises the bulk +// branch at runtime; every other test uses FirstFit's sequential fallback. +#[test] +fn macro_new_rc_weak_on_bulk_allocator() { + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let stack = alloc.stack(); + + // Two-block (data + control) constructor via the bulk `alloc_many` override. + let leaf = MacroLeaf::new(&alloc, 7).unwrap(); + let rc = MacroShared::new(&alloc, leaf).unwrap(); + assert_eq!( + rc.handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 7 + ); + + // Full shared lifecycle, so the strong/weak release + block frees run too. + let rc2 = rc.try_clone().unwrap(); + let weak = rc.downgrade().unwrap(); + drop(rc2); + drop(rc); + assert!(weak.upgrade().unwrap().is_none()); + drop(weak); +} + // -------------------------------------------------------------------------- // #[bstack_weak] field — constructor (null init), setter, upgrade accessor, and // sound teardown when the target's data is freed first (the cycle case). diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index aa8c11a..54387db 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -57,7 +57,7 @@ use std::collections::HashMap; use std::io; use std::sync::{Arc, Mutex, OnceLock}; -use bstack::BStackRange; +use bstack::{BStackBulkAllocator, BStackOwnedSlice, BStackRange}; use bytemuck::{Pod, Zeroable}; use crate::BStackRaiiAllocator; @@ -452,6 +452,17 @@ unsafe impl BStackRaiiAllocator for bstack::GhostTreeBstackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } + // GhostTree implements `BStackBulkAllocator`, so route the multi-block helpers + // through the atomic bulk ops (see [`bulk_alloc_many`] / [`bulk_free_many`]). + fn alloc_many(&self, sizes: &[u64]) -> io::Result> { + bulk_alloc_many(self, sizes) + } + fn free_many(&self, ranges: impl IntoIterator) -> io::Result<()> { + bulk_free_many(self, ranges) + } + fn atomic_bulk(&self) -> bool { + true + } } unsafe impl BStackRaiiAllocator for bstack::SlabBStackAllocator { fn wal_anchor(&self) -> Option { @@ -464,8 +475,50 @@ unsafe impl BStackRaiiAllocator for bstack::CheckedSlabBStackAllocator { } } // `LinearBStackAllocator`'s `dealloc` is a no-op (nothing to reclaim), so it opts -// out via the default `None` — but it still needs the impl to satisfy the bound. -unsafe impl BStackRaiiAllocator for bstack::LinearBStackAllocator {} +// out of WAL reclamation via the default `None` — but it still implements +// `BStackBulkAllocator`, so it can still route the multi-block helpers through the +// atomic bulk ops. +unsafe impl BStackRaiiAllocator for bstack::LinearBStackAllocator { + fn alloc_many(&self, sizes: &[u64]) -> io::Result> { + bulk_alloc_many(self, sizes) + } + fn free_many(&self, ranges: impl IntoIterator) -> io::Result<()> { + bulk_free_many(self, ranges) + } + fn atomic_bulk(&self) -> bool { + true + } +} + +/// The bulk override shared by every [`BStackBulkAllocator`]: allocate all `sizes` +/// as one atomic [`alloc_bulk`](BStackBulkAllocator::alloc_bulk) and hand back their +/// ranges. Either all blocks are allocated or none is (and the store is unchanged); +/// a crash mid-op is reclaimed by the allocator's own recovery, so this needs no WAL. +fn bulk_alloc_many(allocator: &A, sizes: &[u64]) -> io::Result> +where + A: BStackRaiiAllocator + BStackBulkAllocator, +{ + let slices = allocator.alloc_bulk(sizes)?; + Ok(slices.into_iter().map(|s| s.as_range()).collect()) +} + +/// The bulk override shared by every [`BStackBulkAllocator`]: free all `ranges` as +/// one atomic [`dealloc_bulk`](BStackBulkAllocator::dealloc_bulk). Reconstructs an +/// owned slice per range (as [`crate::teardown::dealloc_range`] does) and frees them +/// together; on failure the error's `source` is surfaced (the un-freed handles it +/// carries back are dropped — they are non-RAII, so dropping does not double-free). +fn bulk_free_many(allocator: &A, ranges: impl IntoIterator) -> io::Result<()> +where + A: BStackRaiiAllocator + BStackBulkAllocator, +{ + let handles = ranges + .into_iter() + // SAFETY: each range is a live allocation owned by `allocator` that no other + // live handle will also free (the `free_many` contract). + .map(|r| unsafe { BStackOwnedSlice::from_raw_range(allocator, r) }) + .collect::>(); + allocator.dealloc_bulk(handles).map_err(|e| e.source) +} // --------------------------------------------------------------------------- // In-memory serialization of WAL transactions. From 73aeb59ec24674995bebd540bdf88f136496d6f4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 03:41:43 -0700 Subject: [PATCH 135/140] Clone now use WAL --- bstack_raii/PROBLEMS.md | 25 +++-- bstack_raii/src/clone.rs | 233 +++++++++++++++++++++++++++++---------- bstack_raii/src/tests.rs | 39 +++++++ bstack_raii/src/wal.rs | 31 ++++++ 4 files changed, 261 insertions(+), 67 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index e379fbf..0ca999e 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -17,11 +17,20 @@ omitted except where trivial. `allocator.atomic_bulk()` (GhostTree/Linear), skipping the WAL entirely — since `dealloc_bulk` is itself atomic + self-recovering, the WAL would be redundant and can't compose safely with it (opaque recovery direction → double-free risk). - Cross-file (mixed `FileId`) still uses the WAL path for registry routing. The - **alloc side** remains: `ClonePlan` still allocates each new block via sequential - `alloc_raw`. Wiring `alloc_many` into it (the intention-first `AllocReq`/`fresh_id` - path — bulk allocators skip the alloc-WAL entirely, non-bulk backfill each address - per op) is the clone-WAL round, still to be done. + Cross-file (mixed `FileId`) still uses the WAL path for registry routing. + **Alloc side (intention-first WAL) DONE 2026-08-14:** `ClonePlan::alloc_raw` now + logs each allocation to the persistent WAL `Pending` *during the descent* (a cheap + append; a full re-`persist_at` only when the block grows), holding the file's WAL + lock for the whole clone; the commit flips that txn `Complete` in the same atomic + batch. So a crash mid-descent is reclaimed by `finish` on reopen down to a one-block + window (was: whole partially-built subtree leaked, since the WAL was only written at + commit). No codegen change — every clone allocation funnels through `alloc_raw`. + Trade-off (deliberate, per user choice of option A over a two-pass): clone still does + **sequential** per-op `alloc`, not `alloc_bulk` — clone's descent needs each block's + real address immediately (the parent payload embeds the child offset), so gather-then- + `alloc_bulk` would require a second measure pass. That bulk-for-clone two-pass is the + only remaining alloc-side item, and is a leak/atomicity *optimization*, not correctness + (intention-first already makes a descent crash reclaimable). - **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a struct with a `Foreign` field, and casting for the wide-pointer relationship, were flagged as still-open in the project notes; needs confirmation that @@ -188,8 +197,10 @@ Residual points, all *leak-only* (permitted) but worth recording: - [FIXED] Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single most pervasive avoidable allocation. See §2 — replaced with `SmallBuf` in `stdlib/*` (no-length inline `Buf8`/`Buf40`, `Heap` fallback). -- No bulk alloc/free in clone/teardown (§1) even when the concrete allocator - implements `BStackBulkAllocator`. +- No bulk alloc/free in **clone** (§1) even when the concrete allocator implements + `BStackBulkAllocator` — clone's descent needs each block's real address up front, so + bulk needs a two-pass measure/build (deferred). Teardown already uses bulk (§1); + clone is intention-first WAL'd but still per-op `alloc`. - [WONTFIX] **Two round trips per `(rc, weak)` strong child in clone**: `ClonePlan::bump_strong` reads the child's back-pointer field during planning (`strong_parts` → `read_ctrl_ref`, to locate the control block), then the commit's `inplace_gen` diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index 0261ded..e4106e6 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -31,11 +31,23 @@ //! and flush every payload write as one crash-atomic [`BStack::set_batched`] //! batch (the new blocks are distinct ranges, so the writes never overlap). //! -//! A crash *between* the phases leaks the allocated-but-uncommitted blocks (a -//! recoverable leak, since the clone result is not yet reachable from any -//! persistent root) but never a torn write. +//! A crash *between* the phases would leak the allocated-but-uncommitted blocks, +//! never a torn write (the clone result is not yet reachable from any persistent +//! root). When the allocator names a WAL anchor ([`BStackRaiiAllocator::wal_anchor`] +//! returns `Some`), phase 1 is additionally **intention-first**: each allocation is +//! logged `Pending` to the persistent WAL block the instant it is made +//! ([`ClonePlan::alloc_raw`]), so a crash mid-descent is *reclaimed* by +//! [`crate::wal::finish`] on the next open — down to a one-block window (the block +//! allocated but not yet logged). The phase-2 commit flips that transaction +//! `Complete` inside the same atomic batch, so "clone committed" and "WAL Complete" +//! are one event. Because the WAL block is a single-writer-per-file singleton, the +//! plan holds the file's WAL lock for the whole descent; concurrent deep clones on +//! the *same* file therefore serialize (they still run fully concurrently across +//! different files). An allocator that names no anchor keeps the plain two-phase +//! behaviour (mid-descent crash ⇒ orphan leak). use std::io; +use std::sync::{Arc, Mutex, MutexGuard}; use bstack::{BStackGenOp, BStackRange}; @@ -47,7 +59,8 @@ use crate::reference::BStackRef; use crate::teardown::{BStackDrop, dealloc_range}; use crate::vec::{BYTEVEC_HEADER, VecDesc}; use crate::wal::{ - WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_lock_for, wal_set_idle, + WalEntry, WalLog, WalStatus, finish_at_locked, persist_at, wal_append_alloc, wal_capacity_of, + wal_lock_for, wal_set_idle, }; /// Duplicate `self`, performing any fallible I/O the duplication requires, @@ -104,6 +117,60 @@ pub struct ClonePlan { /// Absolute offsets of `u64` counters to increment by 1 at commit (the /// strong/weak counts a `#[bstack_strong]` / `#[bstack_weak]` clone acquires). bumps: Vec, + /// The intention-first WAL transaction, lazily begun on the first allocation + /// through [`alloc_raw`](Self::alloc_raw) when the allocator names a WAL anchor + /// (`None` until then, or forever if the allocator opts out of reclamation). Its + /// [`HeldLock`] pins the file's WAL lock for the whole descent + commit; the + /// invariant is that its logged `Pending` `Alloc` entries are *exactly* + /// [`allocated`](Self::allocated), so [`finish`](crate::wal::finish) reclaims + /// precisely those on abandon. + wal: Option, +} + +/// The in-flight intention-first WAL transaction of a [`ClonePlan`]: the file's +/// WAL lock held for the descent, plus the persistent block's offset, entry-slot +/// capacity, and how many entries have been published so far. +struct CloneWal { + /// Holds the file's WAL lock until the plan is committed / rolled back. + _held: HeldLock, + /// Offset of the persistent WAL block (moves if a grow reallocates it). + block_off: u64, + /// Entry slots the block currently has. + capacity: u64, + /// Entries published so far (== `ClonePlan::allocated.len()`). + logged: u64, +} + +/// The file's WAL [`Mutex`] held across a whole clone transaction. It owns the +/// `Arc` so the lifetime-extended guard can never outlive the mutex it borrows; +/// `Drop` releases the guard *before* the `Arc` is dropped. +struct HeldLock { + /// `Some` while held; taken in `Drop` so the guard releases before `_arc`. + guard: Option>, + /// Keeps the mutex alive for as long as `guard` borrows it. + _arc: Arc>, +} + +impl HeldLock { + fn acquire(arc: Arc>) -> Self { + let guard = arc.lock().unwrap_or_else(|e| e.into_inner()); + // SAFETY: the guard borrows the `Mutex` owned by `arc`, which this struct + // keeps alive; `Drop` releases the guard before `arc` is dropped, so the + // borrow never dangles. The transmute only extends the guard's lifetime to + // `'static` to store it alongside its owning `Arc`. + let guard: MutexGuard<'static, ()> = unsafe { core::mem::transmute(guard) }; + HeldLock { + guard: Some(guard), + _arc: arc, + } + } +} + +impl Drop for HeldLock { + fn drop(&mut self) { + // Release the guard before `_arc` drops (which would free the `Mutex`). + self.guard = None; + } } impl Default for ClonePlan { @@ -119,6 +186,7 @@ impl ClonePlan { allocated: Vec::new(), writes: Vec::new(), bumps: Vec::new(), + wal: None, } } @@ -126,6 +194,15 @@ impl ClonePlan { /// for rollback. The caller supplies the block's bytes later via /// [`write`](Self::write). The allocator's owned-slice handle is not RAII, so /// letting it drop here does not free the block. + /// + /// This is the single funnel for every deep-clone allocation (plain child + /// blocks and, via [`stage_bytevec`](Self::stage_bytevec), vec data blocks), so + /// it is also where the **intention-first WAL** hooks in: when the allocator + /// names a WAL anchor, the freshly allocated block is logged `Pending` before + /// this returns, so a crash mid-descent is reclaimed on the next open. The + /// allocation and its log entry are kept in lockstep — if logging fails the + /// block is freed again before returning the error — so the WAL's live entries + /// always equal [`allocated`](Self::allocated). pub fn alloc_raw( &mut self, allocator: &A, @@ -133,10 +210,66 @@ impl ClonePlan { ) -> io::Result { let slice = allocator.alloc(size)?; let range = slice.as_range(); + if allocator.wal_anchor().is_some() + && let Err(e) = self.wal_log_alloc(allocator, range) + { + // Keep `allocated`/logged in sync: undo this alloc, log nothing. + let _ = allocator.dealloc(slice); + return Err(e); + } self.allocated.push(range); Ok(range) } + /// Log a just-made allocation to the intention-first WAL, (lazily) beginning + /// the transaction on the first call. Cheap append (one entry + a `count` bump) + /// while the block has spare slots; a full re-[`persist_at`] — which grows the + /// block — when it is full. `self.allocated` does **not** yet contain `range` + /// (the caller pushes it only after this succeeds), so the grow path logs the + /// whole of `allocated` *plus* `range`. + fn wal_log_alloc( + &mut self, + allocator: &A, + range: BStackRange, + ) -> io::Result<()> { + match &mut self.wal { + None => { + // First allocation: take the file's WAL lock for the whole descent + // and stage a `Pending` block holding this one entry. + let held = HeldLock::acquire(wal_lock_for(allocator)); + let mut log = WalLog::with_capacity(1); + log.append(WalEntry::alloc(WalStatus::Pending, range)); + let block = persist_at(allocator, &log, WalStatus::Pending)?; + self.wal = Some(CloneWal { + _held: held, + block_off: block.start(), + capacity: wal_capacity_of(block), + logged: 1, + }); + Ok(()) + } + Some(w) if w.logged < w.capacity => { + wal_append_alloc(allocator, w.block_off, w.logged, range)?; + w.logged += 1; + Ok(()) + } + Some(w) => { + // Block full: re-persist the whole log (all of `allocated` plus this + // new entry), which grows the block to the next power of two. + let mut log = WalLog::with_capacity(self.allocated.len() + 1); + for &r in &self.allocated { + log.append(WalEntry::alloc(WalStatus::Pending, r)); + } + log.append(WalEntry::alloc(WalStatus::Pending, range)); + let block = persist_at(allocator, &log, WalStatus::Pending)?; + w.block_off = block.start(); + w.capacity = wal_capacity_of(block); + w.logged = self.allocated.len() as u64 + 1; + Ok(()) + } + } + } + /// Record a pending in-place write of `bytes` at absolute `offset`. pub fn write(&mut self, offset: u64, bytes: Vec) { self.writes.push((offset, bytes)); @@ -195,10 +328,22 @@ impl ClonePlan { self.bumps.push(ctrl_off + layout::CTRL_WEAK_OFFSET); } - /// Free everything allocated so far, in reverse order. The error path, taken - /// when planning fails before [`commit`](Self::commit). + /// Free everything allocated so far. The error path, taken when planning fails + /// before [`commit`](Self::commit). + /// + /// With an intention-first WAL in flight, the fresh allocations are logged + /// `Pending`, so this abandons that transaction via [`finish`](crate::wal::finish) + /// (freeing exactly the logged allocs — which equal `allocated` — and marking the + /// persistent block idle), the same path a real crash takes; the WAL lock is held + /// by `self` and released as it drops. Without a WAL the ranges are freed directly. pub fn rollback(self, allocator: &A) { - Self::free_all(self.allocated, allocator); + if self.wal.is_some() { + // Abandon the still-`Pending` transaction; `self` (and its held WAL lock) + // drops at the end of this call, after the reclamation has run. + let _ = finish_at_locked(allocator); + } else { + Self::free_all(self.allocated, allocator); + } } /// Commit the whole plan as **one** crash-atomic unit: every refcount bump @@ -215,59 +360,25 @@ impl ClonePlan { /// /// **Crash reclamation is automatic**: when the allocator names a WAL anchor /// ([`BStackRaiiAllocator::wal_anchor`] returns `Some`), the plan's fresh - /// allocations are logged `Pending` before the commit and reclaimed by - /// [`crate::wal::finish`] on the next open if the process dies mid-commit; the + /// allocations were already logged `Pending` *during the descent* + /// ([`alloc_raw`](Self::alloc_raw)) and are reclaimed by [`crate::wal::finish`] + /// on the next open if the process dies before the commit lands; here that /// transaction is flipped `Complete` *inside* the commit batch, so "clone /// committed" and "WAL Complete" are the same atomic event. An allocator that /// returns `None` behaves exactly as before (mid-commit crash ⇒ orphan leak). pub fn commit(self, allocator: &A) -> io::Result<()> { - let anchor = allocator.wal_anchor(); - self.commit_inner(allocator, anchor) - } - - fn commit_inner( - self, - allocator: &A, - anchor: Option, - ) -> io::Result<()> { let ClonePlan { allocated, writes, mut bumps, + wal, } = self; let stack = allocator.stack(); - // Serialize the WAL transaction against other WAL-backed ops on this file - // (the persistent block + anchor are single-writer). Held for the whole - // commit; `None` when this clone isn't WAL-backed. `_wal_lock` must outlive - // `_wal_guard`, so it is bound first. - let _wal_lock = anchor.map(|_| wal_lock_for(allocator)); - let _wal_guard = _wal_lock - .as_ref() - .map(|l| l.lock().unwrap_or_else(|e| e.into_inner())); - - // With a WAL anchor, protect this clone's fresh allocations: log them - // `Pending` before the commit, so a crash mid-commit is reclaimed by - // `finish` on the next open. The transaction is flipped `Complete` *inside* - // the commit batch below, so "clone committed" and "WAL Complete" are the - // same atomic event. - let wal: Option = match anchor { - Some(_) if !allocated.is_empty() => { - let mut log = WalLog::with_capacity(allocated.len()); - for &r in &allocated { - log.append(WalEntry::alloc(WalStatus::Pending, r)); - } - match persist_at(allocator, &log, WalStatus::Pending) { - Ok(range) => Some(range), - Err(e) => { - // Couldn't stage the WAL — fall back to an immediate rollback. - Self::free_all(allocated, allocator); - return Err(e); - } - } - } - _ => None, - }; + // The WAL transaction (if any) was staged `Pending` incrementally during the + // descent and its file lock is held by `wal._held`; here we only flip it + // `Complete` inside the commit batch. A clone with no allocations has no WAL + // (and needs no lock — the bumps ride bstack's own per-op atomicity). // De-duplicate bump offsets into distinct `(counter, delta)`. bumps.sort_unstable(); @@ -290,7 +401,7 @@ impl ClonePlan { // The WAL commit marker (`WalHeader.txn_status`, the byte after the u64 // magic). Written last, so it lands in the same atomic batch as the clone. let flip = [WalStatus::Complete as u8]; - let flip_off = wal.map(|r| r.start() + 8); + let flip_off = wal.as_ref().map(|w| w.block_off + 8); let mut flipped = flip_off.is_none(); let mut read_i = 0usize; @@ -364,7 +475,7 @@ impl ClonePlan { match result { Ok(()) if overflow => { - Self::reclaim(allocated, wal, allocator); + Self::reclaim(&wal, allocated, allocator); Err(io::Error::new( io::ErrorKind::InvalidData, "refcount overflow while committing clone", @@ -376,33 +487,35 @@ impl ClonePlan { // nothing to roll forward: just mark the persistent block idle for // reuse (a crash before this is harmlessly finished on the next open, // freeing nothing). The block itself is never freed. - if let Some(wal_range) = wal { - let _ = wal_set_idle(allocator, wal_range.start()); + if let Some(w) = &wal { + let _ = wal_set_idle(allocator, w.block_off); } Ok(()) } // `inplace_gen` is atomic: on error nothing committed. Reclaim the // plan's allocations (via the WAL if present, else directly). Err(e) => { - Self::reclaim(allocated, wal, allocator); + Self::reclaim(&wal, allocated, allocator); Err(e) } } + // `wal` (holding the file's WAL lock) drops here, after reclamation. } /// Reclaim a failed commit's allocations. With a WAL, `finish_at_locked` /// abandons the still-`Pending` transaction — freeing the logged alloc orphans - /// and marking the persistent block idle — matching exactly what `finish` would - /// do after a real crash; without one, free them directly. The WAL lock is - /// already held by the caller (`commit_inner`), so the *locked* variant is used. + /// (exactly `allocated`) and marking the persistent block idle — matching what + /// `finish` does after a real crash; without one, free them directly. The WAL + /// lock is held by the still-live `wal` in the caller, so the *locked* variant + /// is used. fn reclaim( + wal: &Option, allocated: Vec, - wal: Option, allocator: &A, ) { match wal { - // A WAL block was staged: abandon its still-`Pending` transaction via - // the allocator's own anchor (same as a real crash's `finish`). + // A WAL transaction is in flight: abandon it via the allocator's own + // anchor (same as a real crash's `finish`); it frees the logged allocs. Some(_) => { let _ = finish_at_locked(allocator); } diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 06fe856..88d0ac3 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -2702,6 +2702,45 @@ fn wal_finish_reclaims_abandoned_allocs() { assert_eq!(finish(&alloc).unwrap(), 0); } +#[test] +fn wal_clone_descent_orphans_reclaimed_by_finish() { + // Intention-first clone WAL: `ClonePlan::alloc_raw` logs every allocation to the + // persistent WAL *during the descent*, before any commit. Model a hard crash + // mid-descent by dropping the plan without `commit` or `rollback` — `ClonePlan` + // has no freeing `Drop`, so the two blocks stay allocated and logged `Pending`, + // exactly as a crashed process would leave them. `finish` on reopen must then + // reclaim both — the window this closes (before, a mid-descent crash leaked the + // whole partially-built subtree, since the WAL was only written at commit time). + use crate::ClonePlan; + use crate::wal::finish; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); // FirstFit names a WAL anchor + let base = alloc.stack().len().unwrap(); + + { + let mut plan = ClonePlan::new(); + let _a = plan.alloc_raw(&alloc, 48).unwrap(); + let _b = plan.alloc_raw(&alloc, 64).unwrap(); + // Drop `plan` here without committing: a crash mid-descent. The held WAL lock + // releases as the plan drops; the two orphans remain logged `Pending`. + } + assert!( + alloc.stack().len().unwrap() > base, + "descent allocated its blocks (+ the WAL block)" + ); + + // Recovery abandons the still-`Pending` transaction, freeing exactly the two + // descent-logged orphans (the persistent WAL block itself stays, idle). + assert_eq!( + finish(&alloc).unwrap(), + 2, + "both mid-descent orphans reclaimed" + ); + // Idempotent: nothing left to reclaim. + assert_eq!(finish(&alloc).unwrap(), 0); +} + #[test] fn wal_finish_reclaims_foreign_orphan_via_registry() { // Option-1 cross-file reclamation: the WAL lives on the op's HOME file, but a diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 54387db..53aef46 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -666,6 +666,37 @@ pub(crate) fn wal_set_idle( .set(block_off + 8, [WalStatus::None as u8]) } +/// Offset of the header `count` field (the `u64` after the magic + `txn_status`). +const WAL_COUNT_OFFSET: u64 = 16; + +/// Entry-slot capacity of a persistent WAL block, from its full range. +pub(crate) fn wal_capacity_of(block: BStackRange) -> u64 { + (block.len() - size_of::() as u64) / size_of::() as u64 +} + +/// Append one `Pending` `Alloc` entry to an already-`Pending` WAL block at slot +/// `index` (0-based, `< capacity`), then **publish** it by bumping the header +/// `count` to `index + 1`. The entry payload is written *before* the count bump, +/// so a crash between the two leaves the new entry unseen (recovery reads only the +/// `count` live entries) — the incremental, intention-first form of [`persist_at`] +/// used by a deep clone to log each allocation the instant it is made. The caller +/// holds the file's WAL lock and guarantees the block has a slot free at `index`. +pub(crate) fn wal_append_alloc( + allocator: &A, + block_off: u64, + index: u64, + slice: BStackRange, +) -> io::Result<()> { + let stack = allocator.stack(); + let hsz = size_of::() as u64; + let esz = size_of::() as u64; + let entry = WalEntry::alloc(WalStatus::Pending, slice); + // Write the entry first; only then advance `count` to make it live. + stack.set(block_off + hsz + index * esz, bytemuck::bytes_of(&entry))?; + stack.set(block_off + WAL_COUNT_OFFSET, (index + 1).to_le_bytes())?; + Ok(()) +} + /// Free one WAL-recorded slice during recovery, in whichever file it lives in. /// /// * `file_id == 0` ([`FileId::SELF`]) — the WAL's own file: free through the local From 619aa2da0f09de978437f41892cef143dc669b51 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 04:30:38 -0700 Subject: [PATCH 136/140] Clone now also use bulk when available --- bstack_raii/PROBLEMS.md | 39 +++-- bstack_raii/derive/src/block.rs | 48 +++--- bstack_raii/src/clone.rs | 230 ++++++++++++++++++++++++--- bstack_raii/src/tests.rs | 270 ++++++++++++++++++++++++++++++++ bstack_raii/src/wal.rs | 71 +++++++-- 5 files changed, 587 insertions(+), 71 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 0ca999e..79292cd 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -7,8 +7,9 @@ omitted except where trivial. ## 1. Missing / incomplete features -- **Deep clone / teardown never use bulk alloc/free.** *(Foundation laid - 2026-08-12: `alloc_many`/`free_many` are now provided methods on +- [DONE 2026-08-14] **Deep clone / teardown never use bulk alloc/free.** Both sides + now use bulk when the concrete allocator supports it; history below. + *(Foundation laid 2026-08-12: `alloc_many`/`free_many` are now provided methods on `BStackRaiiAllocator`, overridden by the bulk-capable allocators — GhostTree, Linear — to route through atomic `alloc_bulk`/`dealloc_bulk`; ordinary trait dispatch picks the override through generic code, so the "prefer bulk when @@ -25,12 +26,26 @@ omitted except where trivial. batch. So a crash mid-descent is reclaimed by `finish` on reopen down to a one-block window (was: whole partially-built subtree leaked, since the WAL was only written at commit). No codegen change — every clone allocation funnels through `alloc_raw`. - Trade-off (deliberate, per user choice of option A over a two-pass): clone still does - **sequential** per-op `alloc`, not `alloc_bulk` — clone's descent needs each block's - real address immediately (the parent payload embeds the child offset), so gather-then- - `alloc_bulk` would require a second measure pass. That bulk-for-clone two-pass is the - only remaining alloc-side item, and is a leak/atomicity *optimization*, not correctness - (intention-first already makes a descent crash reclaimable). + **Bulk-for-clone (two-pass) DONE 2026-08-14:** `ClonePlan::run_clone` now drives the + whole clone. On a bulk allocator (`atomic_bulk()`) it takes a **two-pass** path — + descend once in `Mode::Measure` (gather every home-block size, allocate nothing, do + no cross-file work), allocate them all in one atomic `alloc_bulk` (`allocate`, staging + the whole alloc→commit window `Pending` in one `persist_at`), then descend again in + `Mode::Build` against the real addresses and commit. Non-bulk allocators keep the + single-pass intention-first `Mode::Direct` path (1-block window; a sequential allocator + gains nothing from a post-hoc bulk log). The descent needs each block's real address + immediately (parent payload embeds the child offset), which is why measure/build is + needed rather than deferring allocation in one pass. Codegen change: both `try_clone_in` + wrappers now call `run_clone(allocator, |p| self.__bstack_clone_into(allocator, p))` + (the closure may run twice); the eager cross-file (`Foreign`) arms are gated + `!__plan.is_measuring()` so a foreign deep-clone / refcount bump runs exactly once (in + build) — the mode is global to the pass, so this holds at any nesting depth. Assumes the + source is not concurrently mutated between the two passes (already unsupported for clone; + a divergence trips a `debug_assert` in build). `finish_at_locked` was made **bulk-aware** + (reverse a clone's bulk orphans with one `dealloc_bulk`; individual frees don't reclaim a + split `alloc_bulk` region — this fixed a real leak the bulk commit-fault test caught). + Tests: `macro_deep_clone_on_bulk_allocator`(+`_no_leak`), `macro_clone_pod_vec_on_bulk_allocator`, + `macro_foreign_owned_clone_on_bulk_home_copies_once` (the guard), `wal_clone_reclaims_bulk_orphans_on_commit_fault`. - **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a struct with a `Foreign` field, and casting for the wide-pointer relationship, were flagged as still-open in the project notes; needs confirmation that @@ -197,10 +212,10 @@ Residual points, all *leak-only* (permitted) but worth recording: - [FIXED] Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single most pervasive avoidable allocation. See §2 — replaced with `SmallBuf` in `stdlib/*` (no-length inline `Buf8`/`Buf40`, `Heap` fallback). -- No bulk alloc/free in **clone** (§1) even when the concrete allocator implements - `BStackBulkAllocator` — clone's descent needs each block's real address up front, so - bulk needs a two-pass measure/build (deferred). Teardown already uses bulk (§1); - clone is intention-first WAL'd but still per-op `alloc`. +- [DONE 2026-08-14] Bulk alloc/free in clone + teardown (§1): both now use the atomic + bulk ops when the concrete allocator implements `BStackBulkAllocator` (clone via the + two-pass measure/build in `run_clone`; teardown via `dealloc_bulk`). Non-bulk + allocators keep the intention-first single-pass clone. - [WONTFIX] **Two round trips per `(rc, weak)` strong child in clone**: `ClonePlan::bump_strong` reads the child's back-pointer field during planning (`strong_parts` → `read_ctrl_ref`, to locate the control block), then the commit's `inplace_gen` diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs index 4a6508d..7f91739 100644 --- a/bstack_raii/derive/src/block.rs +++ b/bstack_raii/derive/src/block.rs @@ -539,6 +539,10 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ); let __new = __child.__bstack_clone_into(allocator, __plan)?; __od.#fname = ::bstack_raii::ForeignPtr::new(0, __new.start()); + } else if __plan.is_measuring() { + // Foreign deep-clone is eager cross-file work; the + // measure pass (home-file sizes only) skips it, so it + // runs exactly once in the build pass. } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -579,6 +583,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result ) }; __plan.bump_strong(__data, allocator)?; + } else if __plan.is_measuring() { + // Foreign refcount bump is eager cross-file work; done + // once, in the build pass (measure skips it). } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -615,6 +622,9 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result if __fid == 0 { // SELF: bump the weak count via the home plan (atomic). __plan.bump_weak(__off); + } else if __plan.is_measuring() { + // Foreign refcount bump is eager cross-file work; done + // once, in the build pass (measure skips it). } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -2864,15 +2874,12 @@ pub fn expand(attr: TokenStream, input: ItemStruct) -> syn::Result allocator: &__A, ) -> ::std::io::Result<::bstack_raii::BStackOwned> { use ::bstack_raii::BStackBlock as _; - let mut __plan = ::bstack_raii::ClonePlan::new(); - let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { - ::std::result::Result::Ok(__d) => __d, - ::std::result::Result::Err(__e) => { - __plan.rollback(allocator); - return ::std::result::Result::Err(__e); - } - }; - __plan.commit(allocator)?; + // The clone strategy (single-pass intention-first, or two-pass + // atomic bulk on a `BStackBulkAllocator`) is chosen inside + // `run_clone`, which may run this descent twice (measure + build). + let __dst = ::bstack_raii::ClonePlan::run_clone(allocator, |__plan| { + self.__bstack_clone_into(allocator, __plan) + })?; ::std::result::Result::Ok(unsafe { ::bstack_raii::BStackOwned::from_raw( ::from_range(__dst), @@ -3612,6 +3619,10 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { ::bstack_raii::BStackRange::new(__off, #od_size)); let __new = __child.__bstack_clone_into(allocator, __plan)?; ::bstack_raii::ForeignPtr::new(0, __new.start()) + } else if __plan.is_measuring() { + // Foreign deep-clone is build-only; this value is discarded + // in the measure pass (home-file sizes only). + __fp } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -3640,6 +3651,8 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { let __data = unsafe { ::bstack_raii::BStackRef::<#ftarget>::from_range( ::bstack_raii::BStackRange::new(__off, #od_size)) }; __plan.bump_strong(__data, allocator)?; + } else if __plan.is_measuring() { + // Foreign refcount bump is build-only (measure skips it). } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -3665,6 +3678,8 @@ fn foreign_elem_clone(kind: Kind, ftarget: &Type) -> TokenStream { let __fid = __fp.file_id(); if __fid == 0 { __plan.bump_weak(__off); + } else if __plan.is_measuring() { + // Foreign refcount bump is build-only (measure skips it). } else if let ::core::option::Option::Some(__id) = ::bstack_raii::registry::FileId::from_u64(__fid) { @@ -7541,15 +7556,12 @@ pub fn expand_enum(attr: TokenStream, input: syn::ItemEnum) -> syn::Result ::std::io::Result<::bstack_raii::BStackOwned> { use ::bstack_raii::BStackBlock as _; - let mut __plan = ::bstack_raii::ClonePlan::new(); - let __dst = match self.__bstack_clone_into(allocator, &mut __plan) { - ::std::result::Result::Ok(__d) => __d, - ::std::result::Result::Err(__e) => { - __plan.rollback(allocator); - return ::std::result::Result::Err(__e); - } - }; - __plan.commit(allocator)?; + // The clone strategy (single-pass intention-first, or two-pass + // atomic bulk on a `BStackBulkAllocator`) is chosen inside + // `run_clone`, which may run this descent twice (measure + build). + let __dst = ::bstack_raii::ClonePlan::run_clone(allocator, |__plan| { + self.__bstack_clone_into(allocator, __plan) + })?; ::std::result::Result::Ok(unsafe { ::bstack_raii::BStackOwned::from_raw( ::from_range(__dst), diff --git a/bstack_raii/src/clone.rs b/bstack_raii/src/clone.rs index e4106e6..1b2c523 100644 --- a/bstack_raii/src/clone.rs +++ b/bstack_raii/src/clone.rs @@ -110,23 +110,53 @@ pub trait TryCloneIn: BStackDrop + Sized { /// by the generated `__bstack_clone_into` methods during the recursive descent, /// then either [`commit`](Self::commit)ted or [`rollback`](Self::rollback)ed. pub struct ClonePlan { - /// New blocks allocated during planning; freed in reverse on rollback. + /// Which allocation strategy this plan is running (see [`Mode`]). + mode: Mode, + /// New blocks the clone will occupy. In [`Direct`](Mode::Direct) these are the + /// eagerly-allocated blocks (freed in reverse on rollback); in [`Build`](Mode::Build) + /// this is the pre-allocated pool handed out in order by [`cursor`](Self::cursor). allocated: Vec, + /// [`Measure`](Mode::Measure) phase only: the size of every block the descent + /// wants, in descent order — allocated together by [`allocate`](Self::allocate). + sizes: Vec, + /// [`Build`](Mode::Build) phase only: index of the next pre-allocated range in + /// [`allocated`](Self::allocated) to hand back from [`alloc_raw`](Self::alloc_raw). + cursor: usize, + /// [`Measure`](Mode::Measure) phase only: a monotonic, non-zero placeholder + /// offset handed back for each measured block. Its bytes are never committed + /// (measure discards all writes), so any distinct non-zero value is sound. + fake_next: u64, /// Pending in-place payload writes `(offset, bytes)`, flushed as one batch. writes: Vec<(u64, Vec)>, /// Absolute offsets of `u64` counters to increment by 1 at commit (the /// strong/weak counts a `#[bstack_strong]` / `#[bstack_weak]` clone acquires). bumps: Vec, - /// The intention-first WAL transaction, lazily begun on the first allocation - /// through [`alloc_raw`](Self::alloc_raw) when the allocator names a WAL anchor - /// (`None` until then, or forever if the allocator opts out of reclamation). Its - /// [`HeldLock`] pins the file's WAL lock for the whole descent + commit; the - /// invariant is that its logged `Pending` `Alloc` entries are *exactly* + /// The WAL transaction protecting the fresh allocations' alloc→commit orphan + /// window: in [`Direct`](Mode::Direct) it is begun intention-first on the first + /// [`alloc_raw`](Self::alloc_raw); in [`Build`](Mode::Build) it is staged in one + /// shot by [`allocate`](Self::allocate) after the atomic bulk alloc. `None` when + /// the allocator opts out of reclamation ([`wal_anchor`](BStackRaiiAllocator::wal_anchor) + /// is `None`) or nothing was allocated. Its [`HeldLock`] pins the file's WAL lock + /// through commit; its logged `Pending` `Alloc` entries are *exactly* /// [`allocated`](Self::allocated), so [`finish`](crate::wal::finish) reclaims /// precisely those on abandon. wal: Option, } +/// How a [`ClonePlan`] turns the descent's allocation requests into real blocks. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Single pass: allocate each block eagerly and log it intention-first (the + /// non-bulk allocators — one descent, a one-block crash window). + Direct, + /// Two-pass phase 1 (bulk allocators): gather every block's *size*, allocate + /// nothing, and discard all writes / skip all cross-file work. + Measure, + /// Two-pass phase 2 (bulk allocators): hand out the pre-allocated ranges in + /// descent order and record writes / refcount bumps as usual. + Build, +} + /// The in-flight intention-first WAL transaction of a [`ClonePlan`]: the file's /// WAL lock held for the descent, plus the persistent block's offset, entry-slot /// capacity, and how many entries have been published so far. @@ -180,16 +210,31 @@ impl Default for ClonePlan { } impl ClonePlan { - /// A fresh, empty plan. + /// A fresh, empty plan in the single-pass [`Direct`](Mode::Direct) mode (the + /// two-pass driver [`run_clone`](Self::run_clone) flips it to measure/build). pub fn new() -> Self { ClonePlan { + mode: Mode::Direct, allocated: Vec::new(), + sizes: Vec::new(), + cursor: 0, + // Non-zero: `0` is the crate's null niche, and a measured payload may + // embed a child's placeholder offset (harmlessly, since it is discarded). + fake_next: 1, writes: Vec::new(), bumps: Vec::new(), wal: None, } } + /// Whether the plan is in the measure phase of the two-pass bulk clone. Generated + /// clone code checks this to make eager cross-file (`Foreign`) work **build-only**, + /// so re-running the descent to measure the home file's sizes never + /// double-executes a foreign deep-clone or refcount bump. + pub fn is_measuring(&self) -> bool { + matches!(self.mode, Mode::Measure) + } + /// Allocate a `size`-byte block **without writing anything**, recording it /// for rollback. The caller supplies the block's bytes later via /// [`write`](Self::write). The allocator's owned-slice handle is not RAII, so @@ -208,17 +253,43 @@ impl ClonePlan { allocator: &A, size: u64, ) -> io::Result { - let slice = allocator.alloc(size)?; - let range = slice.as_range(); - if allocator.wal_anchor().is_some() - && let Err(e) = self.wal_log_alloc(allocator, range) - { - // Keep `allocated`/logged in sync: undo this alloc, log nothing. - let _ = allocator.dealloc(slice); - return Err(e); + match self.mode { + // Two-pass phase 1: just record the size; hand back a placeholder range + // (never allocated, never written) so the descent can proceed. + Mode::Measure => { + self.sizes.push(size); + let off = self.fake_next; + self.fake_next = self.fake_next.saturating_add(size.max(1)); + Ok(BStackRange::new(off, size)) + } + // Two-pass phase 2: hand back the next pre-allocated range (allocated in + // one atomic bulk by `allocate`), in the same order it was measured. + Mode::Build => { + let range = self.allocated[self.cursor]; + self.cursor += 1; + debug_assert_eq!( + range.len(), + size, + "clone build/measure size mismatch (source mutated mid-clone?)" + ); + Ok(range) + } + // Single pass: allocate eagerly and log it intention-first, keeping the + // allocation and its WAL entry in lockstep with `allocated`. + Mode::Direct => { + let slice = allocator.alloc(size)?; + let range = slice.as_range(); + if allocator.wal_anchor().is_some() + && let Err(e) = self.wal_log_alloc(allocator, range) + { + // Keep `allocated`/logged in sync: undo this alloc, log nothing. + let _ = allocator.dealloc(slice); + return Err(e); + } + self.allocated.push(range); + Ok(range) + } } - self.allocated.push(range); - Ok(range) } /// Log a just-made allocation to the intention-first WAL, (lazily) beginning @@ -270,15 +341,22 @@ impl ClonePlan { } } - /// Record a pending in-place write of `bytes` at absolute `offset`. + /// Record a pending in-place write of `bytes` at absolute `offset`. A no-op in + /// the [`Measure`](Mode::Measure) phase (the destination does not exist yet). pub fn write(&mut self, offset: u64, bytes: Vec) { - self.writes.push((offset, bytes)); + if self.mode != Mode::Measure { + self.writes.push((offset, bytes)); + } } /// Register an already-allocated range for rollback — for allocations made - /// outside [`alloc_raw`](Self::alloc_raw). + /// outside [`alloc_raw`](Self::alloc_raw). Only meaningful in the single-pass + /// [`Direct`](Mode::Direct) mode; the two-pass path allocates everything through + /// [`alloc_raw`](Self::alloc_raw). (Currently unused by generated code.) pub fn track_alloc(&mut self, range: BStackRange) { - self.allocated.push(range); + if matches!(self.mode, Mode::Direct) { + self.allocated.push(range); + } } /// Stage a fresh `BStackByteVec` data block holding `data` into the plan: @@ -297,8 +375,11 @@ impl ClonePlan { let len = data.len() as u64; let size = BYTEVEC_HEADER + len; let range = self.alloc_raw(allocator, size)?; - // cap == len: a fresh clone carries no spare capacity. - self.write(range.start(), crate::vec::bytevec_image(len, len, data)); + // cap == len: a fresh clone carries no spare capacity. Skip building the + // image in the measure phase (the write would be discarded anyway). + if self.mode != Mode::Measure { + self.write(range.start(), crate::vec::bytevec_image(len, len, data)); + } Ok(VecDesc { data_off: range.start(), data_size: size, @@ -312,6 +393,11 @@ impl ClonePlan { data: BStackRef, allocator: &A, ) -> io::Result<()> { + // Measure gathers only home-file allocation sizes; the bump (and its locating + // read) is done for real in the build phase. + if self.mode == Mode::Measure { + return Ok(()); + } let (data_ref, ctrl) = T::strong_parts(data, allocator)?; let off = match ctrl { None => data_ref.into_range().start() + layout::RC_REFCOUNT_OFFSET, @@ -325,7 +411,102 @@ impl ClonePlan { /// bumped by one — the weak reference this clone's `#[bstack_weak]` field /// acquires. pub fn bump_weak(&mut self, ctrl_off: u64) { - self.bumps.push(ctrl_off + layout::CTRL_WEAK_OFFSET); + if self.mode != Mode::Measure { + self.bumps.push(ctrl_off + layout::CTRL_WEAK_OFFSET); + } + } + + /// Drive a whole deep clone: run the `descend` closure (a call to the block's + /// generated `__bstack_clone_into`) under the right allocation strategy, then + /// commit. Returns the root copy's range. + /// + /// * **Bulk allocator** ([`atomic_bulk`](BStackRaiiAllocator::atomic_bulk)) — the + /// two-pass path: descend once in [`Measure`](Mode::Measure) to gather every + /// home-file block size (allocating nothing, doing no cross-file work), allocate + /// them all in one atomic [`alloc_bulk`](bstack::BStackBulkAllocator::alloc_bulk) + /// via [`allocate`](Self::allocate), then descend again in [`Build`](Mode::Build) + /// against the real addresses and commit. The whole alloc→commit orphan window is + /// covered by a single WAL transaction. `descend` must be deterministic on the + /// (unmutated) source, since the two passes must agree on the allocation sequence. + /// * **Non-bulk allocator** — the single-pass [`Direct`](Mode::Direct) path: + /// descend once, allocating eagerly and logging each block intention-first (a + /// one-block crash window, strictly better than a sequential allocator would get + /// from a post-hoc bulk log). + pub fn run_clone(allocator: &A, mut descend: F) -> io::Result + where + A: BStackRaiiAllocator, + F: FnMut(&mut ClonePlan) -> io::Result, + { + if allocator.atomic_bulk() { + let mut plan = ClonePlan::new(); + plan.mode = Mode::Measure; + // Phase 1: gather sizes. Nothing is allocated, so a failure here needs no + // cleanup (the placeholder ranges are pure bookkeeping). + descend(&mut plan)?; + // Allocate every measured block atomically and stage the WAL. + plan.allocate(allocator)?; + // Phase 2: build the payloads against the real addresses. + plan.mode = Mode::Build; + match descend(&mut plan) { + Ok(dst) => { + plan.commit(allocator)?; + Ok(dst) + } + Err(e) => { + plan.rollback(allocator); + Err(e) + } + } + } else { + let mut plan = ClonePlan::new(); + match descend(&mut plan) { + Ok(dst) => { + plan.commit(allocator)?; + Ok(dst) + } + Err(e) => { + plan.rollback(allocator); + Err(e) + } + } + } + } + + /// Two-pass phase transition: allocate every [`Measure`](Mode::Measure)-gathered + /// size as one atomic bulk allocation and stage the fresh blocks `Pending` in the + /// WAL (one `persist_at`, covering the whole alloc→commit orphan window; the + /// commit flips it `Complete`). On a WAL-staging failure the just-allocated blocks + /// are freed and the error propagates. Leaves the plan ready for the build phase + /// (`allocated` = the pool, `cursor` = 0). + fn allocate(&mut self, allocator: &A) -> io::Result<()> { + let ranges = allocator.alloc_many(&self.sizes)?; + if allocator.wal_anchor().is_some() && !ranges.is_empty() { + let held = HeldLock::acquire(wal_lock_for(allocator)); + let mut log = WalLog::with_capacity(ranges.len()); + for &r in &ranges { + log.append(WalEntry::alloc(WalStatus::Pending, r)); + } + match persist_at(allocator, &log, WalStatus::Pending) { + Ok(block) => { + self.wal = Some(CloneWal { + _held: held, + block_off: block.start(), + capacity: wal_capacity_of(block), + logged: ranges.len() as u64, + }); + } + Err(e) => { + // Couldn't stage the WAL: free the fresh blocks and abort. Release + // the lock first — `free_many` does no WAL work. + drop(held); + let _ = allocator.free_many(ranges); + return Err(e); + } + } + } + self.allocated = ranges; + self.cursor = 0; + Ok(()) } /// Free everything allocated so far. The error path, taken when planning fails @@ -372,6 +553,7 @@ impl ClonePlan { writes, mut bumps, wal, + .. } = self; let stack = allocator.stack(); diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 88d0ac3..36ee01a 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -682,6 +682,95 @@ fn macro_clone_deep_owned() { parent.bstack_drop(&alloc).unwrap(); } +#[test] +fn macro_deep_clone_on_bulk_allocator() { + // Exercises the two-pass clone path (measure sizes -> one atomic `alloc_bulk` -> + // build against real addresses): a bulk allocator (GhostTree) takes `run_clone`'s + // bulk branch. A parent with an owned child means two home blocks are measured, + // allocated together, then built — the child's real address must land in the + // parent payload during the build pass exactly as the single-pass path does. + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + let parent = MacroParent::new(&alloc, leaf, 7).unwrap(); + let orig_child = parent.handle().get_child(stack).unwrap(); + + let clone = parent.try_clone_in(&alloc).unwrap(); + + // Deep copy read back through the clone. + assert_eq!(clone.handle().get_tag(stack).unwrap(), 7); + assert_eq!( + clone + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 42 + ); + // Independent storage (fresh blocks, distinct from the originals) — proves the + // build pass repointed the parent at the newly bulk-allocated child. + assert_ne!( + clone.handle().range().start(), + parent.handle().range().start() + ); + assert_ne!( + clone.handle().get_child(stack).unwrap().range().start(), + orig_child.range().start() + ); + + clone.bstack_drop(&alloc).unwrap(); + // Original intact after the clone's subtree is freed. + assert_eq!( + parent + .handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 42 + ); + parent.bstack_drop(&alloc).unwrap(); +} + +#[test] +fn macro_deep_clone_on_bulk_allocator_no_leak() { + // The two-pass bulk clone must allocate each block *exactly once* — the measure + // pass counts, the build pass consumes the pre-allocated pool. A divergence (or a + // block allocated but not handed out) would over-allocate and leak. Warm the + // allocator + WAL block once, then assert a clone+drop cycle returns to a steady + // length. + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let stack = alloc.stack(); + + let build = || { + let leaf = MacroLeaf::new(&alloc, 1).unwrap(); + MacroParent::new(&alloc, leaf, 2).unwrap() + }; + + // Warm: the first clone lazily allocates the persistent WAL block (kept for reuse). + let p0 = build(); + p0.try_clone_in(&alloc) + .unwrap() + .bstack_drop(&alloc) + .unwrap(); + p0.bstack_drop(&alloc).unwrap(); + + let base = stack.len().unwrap(); + let p = build(); + let c = p.try_clone_in(&alloc).unwrap(); + c.bstack_drop(&alloc).unwrap(); + p.bstack_drop(&alloc).unwrap(); + assert_eq!( + stack.len().unwrap(), + base, + "two-pass bulk clone leaked or double-allocated" + ); +} + #[test] fn macro_clone_bumps_shared_refcount() { let tmp = TempStack::new(); @@ -1457,6 +1546,54 @@ fn macro_clone_pod_vec() { rec.bstack_drop(&alloc).unwrap(); } +#[test] +fn macro_clone_pod_vec_on_bulk_allocator() { + // Two-pass bulk clone through vec data blocks: `stage_bytevec` routes each string + // / POD-vec block through `alloc_raw`, so it is measured (size only, image skipped) + // then built (real address, image written). A `Record` has both a string and a + // POD `u32` vec, plus its own block — three home blocks bulk-allocated as one. + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let stack = alloc.stack(); + + let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42).unwrap(); + let orig_name_off = rec.handle().get_name(&alloc).unwrap().descriptor().data_off; + + let clone = rec.try_clone_in(&alloc).unwrap(); + assert_eq!(clone.handle().get_id(stack).unwrap(), 42); + assert_eq!( + clone.handle().get_name(&alloc).unwrap().to_vec().unwrap(), + b"hello" + ); + assert_eq!( + clone.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3] + ); + // Fresh, independent data block (built against a real bulk-allocated address). + let clone_name_off = clone + .handle() + .get_name(&alloc) + .unwrap() + .descriptor() + .data_off; + assert_ne!(clone_name_off, orig_name_off); + + clone.bstack_drop(&alloc).unwrap(); + rec.bstack_drop(&alloc).unwrap(); + + // No leak / double-alloc across a warmed clone+drop cycle. + let base = stack.len().unwrap(); + let r = Record::new(&alloc, "world", &[7u32, 8], 1).unwrap(); + let c = r.try_clone_in(&alloc).unwrap(); + c.bstack_drop(&alloc).unwrap(); + r.bstack_drop(&alloc).unwrap(); + assert_eq!( + stack.len().unwrap(), + base, + "two-pass bulk vec clone leaked or double-allocated" + ); +} + #[test] fn macro_clone_owned_vec() { let tmp = TempStack::new(); @@ -6742,6 +6879,63 @@ fn wal_clone_reclaims_orphans_on_commit_fault() { src.bstack_drop(&alloc).unwrap(); } +#[cfg(feature = "fault-injection")] +#[test] +fn wal_clone_reclaims_bulk_orphans_on_commit_fault() { + use bstack::fault::FaultPolicy; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + // Same crash as the FirstFit test, but on a BULK allocator (GhostTree): the clone + // takes the two-pass path, so its blocks are `alloc_bulk`'d and staged `Pending` + // in the WAL by `allocate` *before* the commit's `inplace_gen`. GhostTree's + // `alloc_bulk` uses no `inplace_gen`, so failing the first one hits the commit, + // after the bulk alloc + WAL staging — the WAL must then reclaim the whole bulk. + struct FailFirstInplaceGen(AtomicBool); + impl FaultPolicy for FailFirstInplaceGen { + fn next_fault(&self, op: &'static str, _seq: u64) -> Option { + if op == "inplace_gen" && !self.0.swap(true, Ordering::SeqCst) { + Some(io::Error::other("injected bulk clone-commit fault")) + } else { + None + } + } + } + + let tmp = TempStack::new(); + let alloc = tmp.ghost_allocator(); + let stack = alloc.stack(); + + let leaf = MacroLeaf::new(&alloc, 7).unwrap(); + let src = MacroParent::new(&alloc, leaf, 1).unwrap(); + + let mut prev: Option = None; + for i in 0..30 { + stack.set_fault_policy(Some(Arc::new(FailFirstInplaceGen(AtomicBool::new(false))))); + let r = src.try_clone_in(&alloc); + stack.set_fault_policy(None); + assert!(r.is_err(), "injected fault must fail the bulk clone commit"); + let len = stack.len().unwrap(); + if i >= 3 { + assert_eq!(len, prev.unwrap(), "faulted bulk clone leaked at iter {i}"); + } + prev = Some(len); + } + + // A real (unfaulted) clone still succeeds, reusing the reclaimed space. + let cl = src.try_clone_in(&alloc).unwrap(); + assert_eq!( + cl.handle() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 7 + ); + cl.bstack_drop(&alloc).unwrap(); + src.bstack_drop(&alloc).unwrap(); +} + #[cfg(feature = "fault-injection")] #[test] fn wal_teardown_reclaims_on_free_fault() { @@ -7831,6 +8025,82 @@ fn macro_foreign_owned_clone_deep_copies_across_files() { reg.detach(fid); } +#[test] +fn macro_foreign_owned_clone_on_bulk_home_copies_once() { + // The foreign guard under the two-pass clone: when the HOME allocator is bulk + // (GhostTree), cloning a `#[bstack_owned] Foreign` runs the measure->build + // descent twice. The cross-file deep-copy is eager and must be BUILD-ONLY — if + // the measure pass also ran it, the foreign file would get TWO copies (a leak). + // Assert the foreign file returns exactly to baseline, proving it was copied once. + use crate::registry; + use crate::{Foreign, TryCloneIn}; + use std::sync::Arc; + + let home = TempStack::new(); + let home_alloc = home.ghost_allocator(); // bulk => two-pass clone + let hstack = home_alloc.stack(); + + let foreign = TempStack::new(); + let arc_b = Arc::new(foreign.allocator()); + + let reg_file = TempStack::new(); + let _ = registry::init(®_file.path); + let reg = registry::get().unwrap(); + let fid = reg.attach(&foreign.path, arc_b.clone()).unwrap(); + + // Warm B's WAL block via one owned-clone cycle, then record B's baseline length. + { + let l0 = MacroLeaf::new(&*arc_b, 0).unwrap(); + let h0 = ForeignHolder::new( + &home_alloc, + 0, + Foreign::::new(fid, l0.handle().range().start()), + None, + ) + .unwrap(); + h0.handle() + .try_clone_in(&home_alloc) + .unwrap() + .bstack_drop(&home_alloc) + .unwrap(); + h0.bstack_drop(&home_alloc).unwrap(); + } + let base_b = arc_b.stack().len().unwrap(); + + let leaf = MacroLeaf::new(&*arc_b, 42).unwrap(); + let off = leaf.handle().range().start(); + let h = ForeignHolder::new(&home_alloc, 7, Foreign::::new(fid, off), None).unwrap(); + + // Two-pass clone on the bulk home allocator. + let c = h.handle().try_clone_in(&home_alloc).unwrap(); + + // A single fresh copy on B, carrying the value. + let clone_link = c.handle().get_owned_link(hstack).unwrap(); + assert_eq!(clone_link.file_id(), fid); + assert_ne!( + clone_link.offset(), + off, + "must be a fresh copy, not an alias" + ); + assert_eq!( + clone_link + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .unwrap() + .unwrap(), + 42 + ); + + h.bstack_drop(&home_alloc).unwrap(); + c.bstack_drop(&home_alloc).unwrap(); + assert_eq!( + arc_b.stack().len().unwrap(), + base_b, + "measure pass double-cloned the foreign target (guard missing/broken)" + ); + + reg.detach(fid); +} + #[test] fn macro_foreign_strong_clone_bumps_count_across_files() { // Cross-file strong clone: cloning a `#[bstack_strong] Foreign` shares the same diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 53aef46..f5976fb 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -752,26 +752,63 @@ pub(crate) fn finish_at_locked(allocator: &A) -> io::Res } let committed = txn == WalStatus::Complete; let base = wal_range.start() + size_of::() as u64; + let esz = size_of::() as u64; let mut completed = 0usize; - for (i, e) in entries.iter().enumerate() { - if e.status() != WalStatus::Pending { - continue; + // Each orphan entry (committed ⇒ its `Dealloc`, abandoned ⇒ its `Alloc`) is + // persisted `Complete` *before* its slice is freed, so a second crash can never + // re-free it. + if allocator.atomic_bulk() { + // Bulk allocator: reverse the whole batch of local orphans with one atomic + // `dealloc_bulk` (a clone's `alloc_bulk`'d region is *not* reclaimed cleanly by + // freeing its split slices one at a time). Mark every entry `Complete` first, + // then bulk-free the local slices; foreign slices still go one by one through + // the registry. + let mut local: Vec = Vec::new(); + let mut foreign: Vec<(u64, BStackRange)> = Vec::new(); + for (i, e) in entries.iter().enumerate() { + if e.status() != WalStatus::Pending { + continue; + } + let slice = if committed { + e.as_dealloc() + } else { + e.as_alloc() + }; + if let Some(slice) = slice { + stack.set(base + i as u64 * esz, [WalStatus::Complete as u8])?; + if e.file_id() == 0 { + local.push(slice); + } else { + foreign.push((e.file_id(), slice)); + } + completed += 1; + } } - // Committed: the `Dealloc`s (old blocks) must go. Abandoned: the `Alloc`s - // (new orphans) must go. Everything else is kept. - let slice = if committed { - e.as_dealloc() - } else { - e.as_alloc() - }; - if let Some(slice) = slice { - // Persist Complete for this entry (its status is byte 0), THEN free — - // so a second crash can't double-free it. - let entry_off = base + (i * size_of::()) as u64; - stack.set(entry_off, [WalStatus::Complete as u8])?; - free_recorded(allocator, e.file_id(), slice)?; - completed += 1; + if !local.is_empty() { + allocator.free_many(local)?; + } + for (fid, s) in foreign { + free_recorded(allocator, fid, s)?; + } + } else { + for (i, e) in entries.iter().enumerate() { + if e.status() != WalStatus::Pending { + continue; + } + // Committed: the `Dealloc`s (old blocks) must go. Abandoned: the `Alloc`s + // (new orphans) must go. Everything else is kept. + let slice = if committed { + e.as_dealloc() + } else { + e.as_alloc() + }; + if let Some(slice) = slice { + let entry_off = base + i as u64 * esz; + stack.set(entry_off, [WalStatus::Complete as u8])?; + free_recorded(allocator, e.file_id(), slice)?; + completed += 1; + } } } From a428564def6aa3e00f398f254b7b45ddd144428c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 04:37:00 -0700 Subject: [PATCH 137/140] Add automatic impl (macro) for bulk paths --- bstack_raii/src/wal.rs | 49 +++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index f5976fb..54f90ba 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -443,6 +443,31 @@ pub const STD_WAL_ANCHOR: u64 = 8; // SAFETY: each of these allocators documents a user-reserved region at payload // offset 0 (≥ 16 bytes) that it never allocates from and never writes to; the // `[8, 16)` slot sits inside it and persists across open/close. +/// Emit the three `BStackRaiiAllocator` bulk overrides — `alloc_many` / `free_many` +/// routed through the atomic [`alloc_bulk`](BStackBulkAllocator::alloc_bulk) / +/// [`dealloc_bulk`](BStackBulkAllocator::dealloc_bulk), and `atomic_bulk` returning +/// `true` — for a concrete allocator that also implements [`BStackBulkAllocator`]. +/// +/// Invoked inside an `unsafe impl BStackRaiiAllocator` body; the rest of the impl +/// (e.g. `wal_anchor`) is still written per type. This is a macro rather than a +/// blanket `impl` because that impl can't exist: it would +/// collide with the per-type `unsafe impl`s (coherence), can't vary `wal_anchor` by +/// type, and can't blanket-assert each allocator's null-niche safety — and stable +/// Rust has no specialization to say "override only when also bulk". +macro_rules! bulk_raii_methods { + () => { + fn alloc_many(&self, sizes: &[u64]) -> io::Result> { + bulk_alloc_many(self, sizes) + } + fn free_many(&self, ranges: impl IntoIterator) -> io::Result<()> { + bulk_free_many(self, ranges) + } + fn atomic_bulk(&self) -> bool { + true + } + }; +} + unsafe impl BStackRaiiAllocator for bstack::FirstFitBStackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) @@ -452,17 +477,9 @@ unsafe impl BStackRaiiAllocator for bstack::GhostTreeBstackAllocator { fn wal_anchor(&self) -> Option { Some(STD_WAL_ANCHOR) } - // GhostTree implements `BStackBulkAllocator`, so route the multi-block helpers - // through the atomic bulk ops (see [`bulk_alloc_many`] / [`bulk_free_many`]). - fn alloc_many(&self, sizes: &[u64]) -> io::Result> { - bulk_alloc_many(self, sizes) - } - fn free_many(&self, ranges: impl IntoIterator) -> io::Result<()> { - bulk_free_many(self, ranges) - } - fn atomic_bulk(&self) -> bool { - true - } + // GhostTree implements `BStackBulkAllocator` — route the multi-block helpers + // through the atomic bulk ops. + bulk_raii_methods!(); } unsafe impl BStackRaiiAllocator for bstack::SlabBStackAllocator { fn wal_anchor(&self) -> Option { @@ -479,15 +496,7 @@ unsafe impl BStackRaiiAllocator for bstack::CheckedSlabBStackAllocator { // `BStackBulkAllocator`, so it can still route the multi-block helpers through the // atomic bulk ops. unsafe impl BStackRaiiAllocator for bstack::LinearBStackAllocator { - fn alloc_many(&self, sizes: &[u64]) -> io::Result> { - bulk_alloc_many(self, sizes) - } - fn free_many(&self, ranges: impl IntoIterator) -> io::Result<()> { - bulk_free_many(self, ranges) - } - fn atomic_bulk(&self) -> bool { - true - } + bulk_raii_methods!(); } /// The bulk override shared by every [`BStackBulkAllocator`]: allocate all `sizes` From 0838ae9334aafad84755918cb5235111deb1f7d4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 04:44:57 -0700 Subject: [PATCH 138/140] Doc updates --- bstack_raii/PROBLEMS.md | 15 ++++++++++----- bstack_raii/README.md | 14 ++++++++++---- bstack_raii/src/wal.rs | 12 +++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 79292cd..4aec26e 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -10,12 +10,17 @@ omitted except where trivial. - [DONE 2026-08-14] **Deep clone / teardown never use bulk alloc/free.** Both sides now use bulk when the concrete allocator supports it; history below. *(Foundation laid 2026-08-12: `alloc_many`/`free_many` are now provided methods on - `BStackRaiiAllocator`, overridden by the bulk-capable allocators — GhostTree, - Linear — to route through atomic `alloc_bulk`/`dealloc_bulk`; ordinary trait - dispatch picks the override through generic code, so the "prefer bulk when - available" design is finally realizable.)* **Teardown DONE 2026-08-12:** + `BStackRaiiAllocator`, overridden by a bulk-capable allocator — **GhostTree** (the + only bstack allocator that is both `BStackRaiiAllocator` and `BStackBulkAllocator`) + — to route through atomic `alloc_bulk`/`dealloc_bulk`; ordinary trait dispatch picks + the override through generic code, so the "prefer bulk when available" design is + finally realizable. NB: `LinearBStackAllocator` implements `BStackBulkAllocator` but + is **not** a `BStackRaiiAllocator` — its bump `alloc` hands out payload offset 0 (the + null niche) and its `dealloc` is a no-op — so it never participates here; a + `bulk_raii_methods!` macro emits the three overrides for any future bulk RAII + allocator.)* **Teardown DONE 2026-08-12:** `wal_teardown` now frees a same-file subtree with one atomic `dealloc_bulk` when - `allocator.atomic_bulk()` (GhostTree/Linear), skipping the WAL entirely — since + `allocator.atomic_bulk()` (GhostTree), skipping the WAL entirely — since `dealloc_bulk` is itself atomic + self-recovering, the WAL would be redundant and can't compose safely with it (opaque recovery direction → double-free risk). Cross-file (mixed `FileId`) still uses the WAL path for registry routing. diff --git a/bstack_raii/README.md b/bstack_raii/README.md index b271dcd..11c45cb 100644 --- a/bstack_raii/README.md +++ b/bstack_raii/README.md @@ -792,10 +792,16 @@ So an owned subtree is copied into independent storage while shared children are original's owned data, and a shared target stays live as long as either handle holds it. -> **Atomicity.** A clone allocates the whole new subtree up front, then commits -> every payload write as one crash-atomic batch (`BStack::set_batched`): a -> mid-clone allocation failure rolls back with nothing written, and a crash can -> leak the fresh allocations but never leaves a torn copy. +> **Atomicity & crash-safety.** A clone allocates the whole new subtree up front, +> then commits every payload write *and* refcount bump as one crash-atomic batch +> (`BStack::inplace_gen`): a mid-clone allocation failure rolls back with nothing +> written, and a crash never leaves a torn copy. When the allocator names a WAL +> anchor, the fresh allocations are logged as they are made, so a crash *mid-clone* +> is reclaimed on the next open rather than leaked (down to a one-block window) — +> you don't opt in, and [`wal::finish`] completes it deterministically after `open`. +> On a bulk-capable allocator (one that also implements `BStackBulkAllocator`, such +> as `GhostTreeBstackAllocator`) the whole subtree is allocated in a single atomic +> `alloc_bulk` instead of block by block. ### Duplicate a shared handle: `TryClone` diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs index 54f90ba..4f8f142 100644 --- a/bstack_raii/src/wal.rs +++ b/bstack_raii/src/wal.rs @@ -491,13 +491,11 @@ unsafe impl BStackRaiiAllocator for bstack::CheckedSlabBStackAllocator { Some(STD_WAL_ANCHOR) } } -// `LinearBStackAllocator`'s `dealloc` is a no-op (nothing to reclaim), so it opts -// out of WAL reclamation via the default `None` — but it still implements -// `BStackBulkAllocator`, so it can still route the multi-block helpers through the -// atomic bulk ops. -unsafe impl BStackRaiiAllocator for bstack::LinearBStackAllocator { - bulk_raii_methods!(); -} +// `LinearBStackAllocator` deliberately does **not** implement `BStackRaiiAllocator`: +// its `alloc` is a bare `BStack::extend`, so its first allocation hands out payload +// offset 0 — the crate's null niche — and its `dealloc` is a no-op (teardown would +// free nothing). Both violate the trait's safety contract, so it stays out (even +// though it implements `BStackBulkAllocator`). /// The bulk override shared by every [`BStackBulkAllocator`]: allocate all `sizes` /// as one atomic [`alloc_bulk`](BStackBulkAllocator::alloc_bulk) and hand back their From b373413afde85b9faefb77d23a642b4e2b01be97 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 04:47:39 -0700 Subject: [PATCH 139/140] Remove addressed problems --- bstack_raii/PROBLEMS.md | 267 ---------------------------------------- 1 file changed, 267 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 4aec26e..04dc048 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -7,256 +7,20 @@ omitted except where trivial. ## 1. Missing / incomplete features -- [DONE 2026-08-14] **Deep clone / teardown never use bulk alloc/free.** Both sides - now use bulk when the concrete allocator supports it; history below. - *(Foundation laid 2026-08-12: `alloc_many`/`free_many` are now provided methods on - `BStackRaiiAllocator`, overridden by a bulk-capable allocator — **GhostTree** (the - only bstack allocator that is both `BStackRaiiAllocator` and `BStackBulkAllocator`) - — to route through atomic `alloc_bulk`/`dealloc_bulk`; ordinary trait dispatch picks - the override through generic code, so the "prefer bulk when available" design is - finally realizable. NB: `LinearBStackAllocator` implements `BStackBulkAllocator` but - is **not** a `BStackRaiiAllocator` — its bump `alloc` hands out payload offset 0 (the - null niche) and its `dealloc` is a no-op — so it never participates here; a - `bulk_raii_methods!` macro emits the three overrides for any future bulk RAII - allocator.)* **Teardown DONE 2026-08-12:** - `wal_teardown` now frees a same-file subtree with one atomic `dealloc_bulk` when - `allocator.atomic_bulk()` (GhostTree), skipping the WAL entirely — since - `dealloc_bulk` is itself atomic + self-recovering, the WAL would be redundant and - can't compose safely with it (opaque recovery direction → double-free risk). - Cross-file (mixed `FileId`) still uses the WAL path for registry routing. - **Alloc side (intention-first WAL) DONE 2026-08-14:** `ClonePlan::alloc_raw` now - logs each allocation to the persistent WAL `Pending` *during the descent* (a cheap - append; a full re-`persist_at` only when the block grows), holding the file's WAL - lock for the whole clone; the commit flips that txn `Complete` in the same atomic - batch. So a crash mid-descent is reclaimed by `finish` on reopen down to a one-block - window (was: whole partially-built subtree leaked, since the WAL was only written at - commit). No codegen change — every clone allocation funnels through `alloc_raw`. - **Bulk-for-clone (two-pass) DONE 2026-08-14:** `ClonePlan::run_clone` now drives the - whole clone. On a bulk allocator (`atomic_bulk()`) it takes a **two-pass** path — - descend once in `Mode::Measure` (gather every home-block size, allocate nothing, do - no cross-file work), allocate them all in one atomic `alloc_bulk` (`allocate`, staging - the whole alloc→commit window `Pending` in one `persist_at`), then descend again in - `Mode::Build` against the real addresses and commit. Non-bulk allocators keep the - single-pass intention-first `Mode::Direct` path (1-block window; a sequential allocator - gains nothing from a post-hoc bulk log). The descent needs each block's real address - immediately (parent payload embeds the child offset), which is why measure/build is - needed rather than deferring allocation in one pass. Codegen change: both `try_clone_in` - wrappers now call `run_clone(allocator, |p| self.__bstack_clone_into(allocator, p))` - (the closure may run twice); the eager cross-file (`Foreign`) arms are gated - `!__plan.is_measuring()` so a foreign deep-clone / refcount bump runs exactly once (in - build) — the mode is global to the pass, so this holds at any nesting depth. Assumes the - source is not concurrently mutated between the two passes (already unsupported for clone; - a divergence trips a `debug_assert` in build). `finish_at_locked` was made **bulk-aware** - (reverse a clone's bulk orphans with one `dealloc_bulk`; individual frees don't reclaim a - split `alloc_bulk` region — this fixed a real leak the bulk commit-fault test caught). - Tests: `macro_deep_clone_on_bulk_allocator`(+`_no_leak`), `macro_clone_pod_vec_on_bulk_allocator`, - `macro_foreign_owned_clone_on_bulk_home_copies_once` (the guard), `wal_clone_reclaims_bulk_orphans_on_commit_fault`. - **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a struct with a `Foreign` field, and casting for the wide-pointer relationship, were flagged as still-open in the project notes; needs confirmation that `bstack_move!` yields the right typed value at the foreign location. -- [WONTFIX] **Registry lazy-init not implemented** — only explicit `registry::init(path)`; - the intended "init on first live attach" path was left unresolved (no registry - path source). This is an explicit limitation since we do not know the path to the registry file at first attach, so it is not a bug. - **`ForeignHost` lacks batched/generator ops**, so a cross-file clone's home commit and foreign side cannot be one atomic unit (best-effort only — see §3). -- [WONTFIX] **`wal::reduce`'s groupoid slice-reuse optimization is unwired.** Fully - implemented and unit-tested (`AllocReq` / `Reduced` / `reduce`, now - `#[cfg(test)]` — see §6), but nothing in `bulk`/`clone`/`teardown` calls it: - no commit path currently repurposes a same-length freed slice for a fresh - allocation instead of freeing then reallocating. - This plan seems nice, but cloning only allocates, teardown only frees, and resizing never reuses a freed slice, so in practice there is no case where this optimization would be used. If such a path exists in the future, it can be wired in then. - -## 2. Code quality - -- [FIXED] **Crate-wide `#![allow(dead_code, unused_imports, unused_variables)]`** - ([lib.rs:45](src/lib.rs#L45)) suppresses three whole warning classes across the - entire crate — it hides real dead code (below) and would mask unused-variable - bugs. Should be removed and warnings addressed per-item. -- [FIXED] **Dead / superseded public helpers.** `init_rc` and `alloc_control` - ([construct.rs](src/construct.rs)) are unused by codegen (the batched - constructor path replaced them) yet still `pub use`d. `alloc_control` is also - **non-atomic** (allocates + writes payload, then a separate `set` of the data - block's back-pointer) — a public primitive whose behavior contradicts the atomic - path that superseded it. `init_rc` removed (truly dead); `alloc_control` removed - from `construct.rs` and replaced in `tests.rs` with a test-local equivalent that - commits the control payload and the data block's back-pointer in one - `set_batched`, matching the atomic path. -- [FIXED] **Pervasive `.to_le_bytes().to_vec()`** — every counter/pointer write allocates - a fresh 8-byte heap `Vec` to feed the `set_batched` / `ClonePlan::write` - batch APIs (dozens per operation in `list`/`deque`/`map`). A small-buffer / - inline representation for batch entries would remove most of these allocations. - `w8(off, val)` in `stdlib::util` replaced the `(off, val.to_le_bytes().to_vec())` - literal at all 54 `stdlib/*` call sites. `atomic_update`/`probe_commit`/`w8`'s - write-tuple value type is now `SmallBuf` (`stdlib::util`, `AsRef<[u8]>`): - `Buf8([u8;8])` and `Buf40([u8;40])` no-length inline variants for the two exact - sizes that recur (a `u64` field; a `stdlib::list` node image — 16B header + - 3×`u64`), `Heap(Box<[u8]>)` otherwise (B-tree nodes, bucket-table images, - `K`-sized heap slots). `ClonePlan::write` / derive codegen untouched: its one - call site always writes a whole on-disk image (over 24B), so it's `Heap` - either way — no allocation to remove there. -- [FIXED] **Stale crate-level docs** ([lib.rs](src/lib.rs) module comment): "Status: - method bodies marked `todo!()` are the work ahead" and "procedural macros come - after the runtime is filled in" describe a half-built crate; it is now - feature-complete. The module-map table also omits `construct`, `vec`, `wal`, - `registry`, `foreign`, `stdlib`, `bulk`, `cast`, `replace`. Status rewritten to - reflect feature-completeness (naming the one real open gap, `Foreign` - cross-file teardown/deep-clone); module-map table now lists all 18 modules. ## 3. Atomicity / crash safety -Core paths are sound (constructors commit via one `write_range` / `set_batched`; -deep clone is two-phase allocate-then-atomic-commit; owned teardown is WAL-backed). -Residual points, all *leak-only* (permitted) but worth recording: - -- [FIXED] **`BStackWeak::upgrade`** ([shared.rs:238](src/shared.rs#L238)) increments the - strong count, then reads the data forward-pointer; if that read fails the strong - increment is orphaned (over-count → the block can never reach zero). Same class - as the weak-setter leak just fixed; reused the release-on-failure idea: on a - failed read, `fetch_sub` the claimed strong count back, and if that lands on the - last-owner case, re-read the forward pointer once more just to run - `strong_release_ctrl`'s teardown (tolerating a second failure there as a bounded, - already-permitted leak, unlike the unbounded over-count this replaces). -- [WONTFIX] **`BStackRc::try_move`** ([shared.rs:162](src/shared.rs#L162)): after the CAS - `strong 1→0`, a failure inside `T::bstack_move` leaves the block unwrapped with - the shell possibly unfreed — an error-path leak. - **Cross-file teardown frees are not WAL-protected on the *target* file.** The home WAL logs `(foreign_id, range)` and `free_recorded` replays them via the registry — but only if the foreign file is *attached at recovery time*. A crash where the foreign file isn't re-attached on the next open loses those frees (leak). Worth documenting as a recovery precondition. -- [WONTFIX] **`alloc_control`** (public, non-codegen): transient half-wired window where the - data block's `ctrl == 0` between its two writes (see §2). `alloc_control` has been removed from the crate and replaced in `tests.rs`. - -## 4. General bugs - -- [WONTFIX] **Fragile lifetime `transmute`** ([teardown.rs:111](src/teardown.rs#L111)): - `transmute::<&[u8], _>(&flip[..])` launders the lifetime of a 1-byte stack local - so the `inplace_gen` closure can capture it; sound only because `flip` outlives - the call. A refactor that moves/reorders `flip` would silently make it UB — it - relies on an invariant the compiler no longer checks. - This is the desired workaround for the `inplace_gen` lifetime problem. See `inplace_gen` usage example. - -## 5. Semantics violations (safe code → UB) - -- [WONTFIX] **21 lifetime-laundering `transmute::<&[u8], _>` / `<&mut [u8], _>`** calls - across `teardown`, `clone`, and the stdlib collections (the `inplace_gen` - buffers-outlive-the-call pattern) are the crate's main UB exposure: each is sound - only while its buffer provably outlives the generator call. They should be - funneled through a single audited helper rather than open-coded 21 times (see §9). - See previous comment for `inplace_gen`. - -## 6. Missing documentation - -- [FIXED] **The entire `stdlib` collection suite is absent from the README** (0 - mentions): `BStackHashMap`, `BStackBTreeMap`, `BStackHashSet`, `BStackBTreeSet`, - `BStackDeque`, `BStackLinkedList`, `BStackBinaryHeap`, `BStackBox`, `BStackCow`, - `BStackString`, `BStackCountingBloomFilter` and their iterators — a large, - user-facing feature with no README presence. Added a "Standard library - collections" section (TOC entry, type table, two compile-checked usage - snippets, a sharing/`bstack_move!` limitations note) between "Type tags" and - "Examples". -- [FIXED] **Large WAL surface exported with unclear audience**: `AllocReq`, `Reduced`, - `WalEntry`, `WalHeader`, `WalLog`, `WalOp`, `WalStatus`, `finish`, `persist_at`, - `reduce`, `STD_WAL_ANCHOR` are all `pub use`d at the crate root. If they are - internal machinery they should be `pub(crate)`; if public, they need docs on how - a user is meant to use them. `finish` and `STD_WAL_ANCHOR` are the only two a - caller ever needs (call `finish` once after `open`; `STD_WAL_ANCHOR` for a custom - `wal_anchor()` impl) — kept public and documented in the README's allocator-bound - callout. Everything else (`AllocReq`, `Reduced`, `WalEntry`, `WalHeader`, - `WalLog`, `WalOp`, `WalStatus`, `persist_at`, `reduce`) is internal transaction - machinery, moved to `pub(crate)`. That surfaced real dead code masked by the old - public re-export: `WalStatus::advance`/`recover`, `WalEntry::dealloc` (the bare, - non-`_in` form), and `WalLog::fresh_id`/`as_bytes` are exercised only by `wal.rs`'s - own unit tests (now `#[cfg(test)]`); `WalEntry::set_status` and - `WalLog::entries_mut`/`is_empty` were unused even there (deleted). Separately, - `AllocReq`/`Reduced`/`reduce` — the groupoid-reduction slice-reuse optimization — - turned out to be fully implemented and unit-tested but **never wired into any - real commit path** (`bulk`/`clone`/`teardown` don't call it); kept `#[cfg(test)]` - rather than deleted, noted here as a distinct §1-adjacent gap for whoever wires - it in. -- [FIXED] **`alloc_many` / `free_many` and the `foreign_*` runtime helpers were - publicly exported with no README mention**: they exist only for - `#[bstack_block]`/`#[bstack_enum]`-generated code to call via a - fully-qualified path from downstream crates, not for direct use. Moved off - the crate-root `pub use` into `#[doc(hidden)] pub mod __private`, and - repointed the derive macro's generated `::bstack_raii::…` paths at - `::bstack_raii::__private::…`. `Foreign` / `ForeignPtr` (the types users - actually write) stay public at the root. - -## 7. Bad use experience - -- [WONTFIX] **Field reads always require an explicit `stack` / allocator argument** - (`h.get_field(alloc.stack())`). Callers almost always hold the allocator, so - `.stack()` is constant boilerplate; accessor forms taking `&A` directly would cut - it. - This is the classic "Zig Problem" - the allocator is always in scope, but the API - requires it to be passed explicitly. The design choice was deliberate to avoid storing - the allocator in the handle, but it does make the ergonomics worse. Note that multi- - file (Foreign) access does not have this concern. -- [FIXED] **`BStackRc` / `BStackWeak` have no `Deref`** (only `BStackOwned` does), so a - shared handle needs `rc.handle().get_field(...)` while an owned one allows - `owned.get_field(...)` — inconsistent ergonomics for the same operation. Added - `Deref` to `BStackRc` (a cached `T` handle, populated once in - `from_raw`, since `deref` can't construct-and-return a temporary); verified - `rc.get_field(stack)` compiles without `.handle()`. `BStackWeak` intentionally - left without `Deref` — like `std::rc::Weak`, it may not observe a live block - (the target can be gone), so derefing it isn't sound; `upgrade()` to a - `BStackRc` first, same as `std::rc::Weak`. -- [FIXED] **`Foreign::with` returns `Option`** (None conflates "null pointer" and "target - file not attached"); a `Result` (or distinct sentinel) would let callers tell a - missing file from a genuinely null `Foreign`. Changed `with` (and its - crate-internal test twin `with_in`) to `io::Result>`: `Ok(None)` is - the null-pointer niche (`offset == 0`), `Err(io::ErrorKind::NotFound)` covers - both a malformed/out-of-range file id and a file that isn't currently attached. - Updated every call site (~24, mostly `tests.rs` plus `examples/crossfile.rs`) - and the README snippet. - -## 8. Performance potentials - -- [FIXED] Thousands of tiny `Vec` allocations for 8-byte writes (§2) — the single - most pervasive avoidable allocation. See §2 — replaced with `SmallBuf` in - `stdlib/*` (no-length inline `Buf8`/`Buf40`, `Heap` fallback). -- [DONE 2026-08-14] Bulk alloc/free in clone + teardown (§1): both now use the atomic - bulk ops when the concrete allocator implements `BStackBulkAllocator` (clone via the - two-pass measure/build in `run_clone`; teardown via `dealloc_bulk`). Non-bulk - allocators keep the intention-first single-pass clone. -- [WONTFIX] **Two round trips per `(rc, weak)` strong child in clone**: `ClonePlan::bump_strong` - reads the child's back-pointer field during planning (`strong_parts` → - `read_ctrl_ref`, to locate the control block), then the commit's `inplace_gen` - separately reads the strong *counter* field at that location (to compute the - increment and check overflow before any write). Two different fields, not a - redundant re-read of the same one, but two lock acquisitions where one might - theoretically suffice via a dependent-read round (as `atomic_update` does). - Not worth it: the saving is dwarfed by the clone's already-unbatched - `alloc()` calls per new block, and merging the reads means threading a - direct/indirect distinction through `ClonePlan.bumps` and re-verifying the - overflow-before-write guarantee in the crate's most crash-sensitive commit - path — real risk for an unmeasurable win. - -## 9. Duplicated code - -- [WONTFIX] **The `inplace_gen` commit pattern is open-coded repeatedly** — buffers hoisted - to outlive the call, a phased read→compute→write generator, and the lifetime - `transmute`s — in `teardown::wal_free_all`, `clone::commit_inner`, and each - stdlib collection's commit path. A single `batched_commit` helper would remove - the duplication *and* shrink the unsafe surface in §5. -- [FIXED] **`(offset, value.to_le_bytes().to_vec())` write-tuple construction** is repeated - hundreds of times across `stdlib/*` and the codegen; a tiny constructor helper - (`w8(off, val)`) would compress it. -- [FIXED] Every stdlib collection repeats a "read `OnDisk` header → mutate counters → - `set_batched`" shape; some of it could share a helper (this is runtime code, not - the struct-vs-enum codegen that was explicitly excluded). - The fixed-metadata push/pop shape (`deque`/`list`) and the probe-based - insert/remove shape (`map`/`hashset`/`btreeset`/`tree`) were already unified via - `atomic_update`/`probe_commit` (`stdlib/util.rs`). The remaining bespoke sites - (`heap` sift, `bloom` counters, B-tree split/merge) are structurally too - different to unify without over-abstracting. But `BStackHashMap::grow` and - `BStackHashSet::grow` were near line-for-line duplicates of the same - rehash-into-bigger-table algorithm (differing only in whether a bucket carries - a trailing value ref) — extracted into a shared `grow_table` in `stdlib/util.rs` - that treats the trailing bytes as opaque payload copied alongside the key. - Both `grow` methods are now thin wrappers. ## 10. Feature interactions @@ -318,34 +82,3 @@ so they are not re-flagged). offset → corruption. It is currently prevented only incidentally (an rc block yields `BStackRc`, not the `BStackOwned` that embed's `new` requires), not by an explicit rejection. - -### Limitation (by construction) - -- [WONTFIX] **A collection cannot be shared (`#[bstack_strong]`/`#[bstack_weak]`).** - Collections aren't `(rc)`/`(rc, weak)` blocks, so they don't implement - `BStackShared`/`BStackWeakable`; two structs cannot share one collection the way - they share an rc block. The only path is hand-rolling an rc wrapper block around - it. Worth documenting so users don't expect a shared collection. -- [WONTFIX] **`bstack_move!` works only on `BStackBox`, not the other collections.** Only - `BStackBox` implements `BStackMove` ([boxed.rs:169](src/stdlib/boxed.rs#L169)); - `map`/`deque`/`list`/`set`/`tree`/`string` do not, so `bstack_move!(collection)` - won't compile. Probably intended (a map has no meaningful field-destructure), but - it is an undocumented asymmetry. (It does *not* block a collection from being a - moved-out `#[bstack_owned]` field — that path needs only `BStackBlock`.) -- **stdlib grow/realloc multi-block atomicity unverified.** `hashmap`/`deque`/`tree` - growth allocates a fresh backing block, copies into it, flips the descriptor, and - frees the old block. Whether each is a single atomic descriptor flip (leak-only on - crash) or has a torn window was not checked — a category to verify, likely - leak-only. - -### Confirmed sound (do not re-flag) - -- **Nested collections work** (`BStackHashMap>`, etc.): every - collection overrides `__bstack_clone_into` / `__bstack_drop_children`, so when a - collection is a value inside another block/collection, deep clone and teardown - recurse correctly instead of byte-copy-aliasing the descriptor. -- **A block value with a `Foreign` field, stored in a collection**, dispatches the - cross-file clone/free correctly — the map/deque/list clone/drop each value through - the value block's generated `__bstack_*`, which include the `Foreign` handling. -- **rc/weak blocks can't be smuggled into collections as owned values**: `insert` - et al. take `BStackOwned`, which an `(rc)` block cannot produce. From 4fe46a3dc2fa7ba22b070219157882a6453e4bd3 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Fri, 14 Aug 2026 05:05:13 -0700 Subject: [PATCH 140/140] Documentation --- bstack_raii/PROBLEMS.md | 27 ++++++----------- bstack_raii/src/foreign.rs | 22 ++++++++++---- bstack_raii/src/tests.rs | 60 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 24 deletions(-) diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md index 04dc048..c4d02aa 100644 --- a/bstack_raii/PROBLEMS.md +++ b/bstack_raii/PROBLEMS.md @@ -5,30 +5,21 @@ are observations to triage, not verified defects unless marked; each is brief an shallow by design (flagged on suspicion, not deeply investigated). Solutions are omitted except where trivial. -## 1. Missing / incomplete features - -- **`Foreign` ↔ `bstack_move` / `bstack_cast` semantics incomplete.** Moving a - struct with a `Foreign` field, and casting for the wide-pointer relationship, - were flagged as still-open in the project notes; needs confirmation that - `bstack_move!` yields the right typed value at the foreign location. -- **`ForeignHost` lacks batched/generator ops**, so a cross-file clone's home - commit and foreign side cannot be one atomic unit (best-effort only — see §3). - -## 3. Atomicity / crash safety - -- **Cross-file teardown frees are not WAL-protected on the *target* file.** The - home WAL logs `(foreign_id, range)` and `free_recorded` replays them via the - registry — but only if the foreign file is *attached at recovery time*. A crash - where the foreign file isn't re-attached on the next open loses those frees - (leak). Worth documenting as a recovery precondition. - -## 10. Feature interactions +## Feature interactions Cross-feature combinations, both the undesirable and the confirmed-sound (recorded so they are not re-flagged). ### Undesirable / risky +- **`bstack_move!` of an `#[bstack_owned] Foreign` hands back a non-RAII pointer.** + Moving an owned *in-file* field out yields a `BStackOwned` (a typed owning + handle with `.bstack_drop`); moving an owned *foreign* field out yields a bare + `Foreign` — a `Copy` wide pointer with no owning-drop method — so the caller must + re-store it or free it via the `unsafe foreign_drop_*` helpers, and simply dropping + it leaks the target. Consistent with `Foreign` being a non-RAII pointer (and with + `BStackOwned` also freeing nothing on `Drop`), but an ergonomic asymmetry; there is + no `BStackOwned`-equivalent RAII wrapper for a moved-out cross-file owner. - **Raw `ForeignPtr` bypasses cross-file ownership.** `Foreign` is deliberately **not `Pod`** (only `Copy`), so it is correctly rejected from every `T: Pod` container (`BStackBox`, `BStackVec`, a `Foreign` map/set/heap diff --git a/bstack_raii/src/foreign.rs b/bstack_raii/src/foreign.rs index 256e425..4de4c99 100644 --- a/bstack_raii/src/foreign.rs +++ b/bstack_raii/src/foreign.rs @@ -12,12 +12,22 @@ //! annotations as an in-file field — `#[bstack_owned/strong/weak/ref]` (or none) — //! but applied to the target `T` **in its own file**: an owning foreign pointer //! frees / decrements / releases the target *on the other side* at teardown, and a -//! deep clone copies it across files. Those cross-file **teardown** and **deep -//! clone** dispatches are still deferred; today the field is byte-copied on clone -//! (an alias) and freed by nobody on teardown, regardless of annotation. The -//! annotation is recorded so the eventual dispatch is per-kind. Construction, -//! nullability (`Option>`, `offset == 0` niche), and resolution are -//! implemented here. +//! deep clone copies (owned) or re-references (strong/weak) it across files. Those +//! cross-file **teardown** ([`foreign_drop_owned`]/`_strong`/`_weak`) and **deep +//! clone** ([`foreign_clone_owned`]/`_strong`/`_weak`) dispatches run the ordinary +//! generic machinery against a [`ForeignHostAllocator`](crate::registry::ForeignHostAllocator) +//! over the target's live host, selected per-annotation by the generated field code; +//! `#[bstack_ref]` aliases (byte-copied, owns nothing). Construction, nullability +//! (`Option>`, `offset == 0` niche), and resolution are here. +//! +//! **Cross-file atomicity is best-effort**, and inherently so: two independent +//! bstack files have no shared commit, so a deep clone / teardown that spans the home +//! file and a foreign file cannot be one atomic unit. Each *file's own* commit is +//! atomic (the foreign side runs its own crash-safe `try_clone_in` / teardown through +//! the adapter), and the ordering always errs toward **over-provisioning** — an +//! orphaned fresh block or an over-count, which leaks — never toward an under-count +//! (a premature free / double-free). A target file not attached at the time makes an +//! owning clone *error* rather than silently alias. use core::marker::PhantomData; use std::io; diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs index 36ee01a..577ad52 100644 --- a/bstack_raii/src/tests.rs +++ b/bstack_raii/src/tests.rs @@ -7555,6 +7555,66 @@ fn macro_foreign_field() { let _ = (h, h2); } +#[test] +fn macro_foreign_field_bstack_move() { + // `bstack_move!` on a block with `Foreign` fields: it frees only the holder shell + // and hands each field back by value — a foreign link comes out as a resolvable + // `Foreign` (or `Option>`) still pointing at its far-file target. It + // does NOT run the owning link's cross-file teardown (move defuses teardown), so the + // target stays live and ownership transfers to the returned pointer. + use crate::Foreign; + use crate::registry::FileRegistry; + use std::sync::Arc; + + let reg_file = TempStack::new(); + let foreign_file = TempStack::new(); + let local_file = TempStack::new(); + + let reg = FileRegistry::open(®_file.path).unwrap(); + let local = local_file.allocator(); + + let foreign_alloc = foreign_file.allocator(); + let leaf = MacroLeaf::new(&foreign_alloc, 88).unwrap(); + let off = leaf.handle().range().start(); + let id = reg + .attach(&foreign_file.path, Arc::new(foreign_alloc)) + .unwrap(); + + let h = ForeignHolder::new( + &local, + 5, + Foreign::::new(id, off), + Some(Foreign::::new(id, off)), + ) + .unwrap(); + + // Fields come back in declaration order: POD, owned link, optional ref link. + let (tag, owned_link, maybe): (u32, Foreign, Option>) = + bstack_move!(h, &local).unwrap(); + + assert_eq!(tag, 5); + // The moved-out owned link is the right typed value at the far-file location: it + // resolves through the registry to the live target. + assert_eq!( + owned_link + .with_in(®, &local, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + Some(88) + ); + // The optional ref link too. + assert_eq!( + maybe + .expect("Some link") + .with_in(®, &local, |t, fs| t.get_val(fs).unwrap()) + .unwrap(), + Some(88) + ); + + // The move did not free the target — `leaf` is still the live block on the foreign + // file (inert handle; the temp foreign file is cleaned up at end of test). + let _ = leaf; +} + // A home block holding a *strong* cross-file reference. `MacroStrongChild` is // `#[bstack_block(rc, weak)]`, so it is a shared target; the strong Foreign // participates in its refcount on the far side.