diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f899136..8a0bb3f 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/bstack_raii/Cargo.lock b/bstack_raii/Cargo.lock new file mode 100644 index 0000000..a57832b --- /dev/null +++ b/bstack_raii/Cargo.lock @@ -0,0 +1,173 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5099c95e4494ce92b330ddc52d626a07195d7731975e42c1761c7f746ec98758" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "bstack_raii" +version = "0.0.0" +dependencies = [ + "bstack", + "bstack_raii_derive", + "bytemuck", + "parking_lot", +] + +[[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 = "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" +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 = "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" +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 new file mode 100644 index 0000000..a02ad69 --- /dev/null +++ b/bstack_raii/Cargo.toml @@ -0,0 +1,33 @@ +[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"] + +[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). +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.25", features = ["derive"] } +parking_lot = "0.12.5" diff --git a/bstack_raii/PROBLEMS.md b/bstack_raii/PROBLEMS.md new file mode 100644 index 0000000..c4d02aa --- /dev/null +++ b/bstack_raii/PROBLEMS.md @@ -0,0 +1,75 @@ +# `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. + +## 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 + 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. diff --git a/bstack_raii/README.md b/bstack_raii/README.md new file mode 100644 index 0000000..11c45cb --- /dev/null +++ b/bstack_raii/README.md @@ -0,0 +1,1124 @@ +# bstack_raii + +Typed, RAII-style ownership for persistent objects — `Rc`/`Weak` semantics that +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 +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 +atomicity, crash-safety, and single-ownership guarantees. This crate adds the +object model on top. + +> **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. + +## Contents + +- [Quick start](#quick-start) +- [Concepts](#concepts) +- [How it works on disk](#how-it-works-on-disk) +- [Handles & lifetimes](#handles--lifetimes) +- [Generated types](#generated-types) +- [Blocks](#blocks) + - [Field ownership](#field-ownership) + - [Structs](#structs) + - [Reference-counted blocks](#reference-counted-blocks) + - [Vectors and strings](#vectors-and-strings) + - [Fixed-size arrays: `[T; N]`](#fixed-size-arrays-t-n) + - [Nullable fields: `Option`](#nullable-fields-option) + - [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) +- [Standard library collections](#standard-library-collections) +- [Examples](#examples) +- [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" +``` + +```rust +use std::io; +use bstack::FirstFitBStackAllocator; +use bstack_raii::{BStack, BStackAllocator, BStackDrop, 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().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 + Ok(()) +} +``` + +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): +`cargo run --example sessions`. + +## Concepts + +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. + +Both macros take the same three **modes** (chosen by their arguments — e.g. +`#[bstack_block(rc)]` or `#[bstack_enum(rc)]`): + +| 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](#field-ownership) deciding +how it is torn down. Plain-old-data fields (anything `Pod` — integers, `[u8; N]`, +…) are stored inline and copied by value. + +> **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, 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 + +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 +let owned: BStackOwned = Node::new(&alloc, /* … */)?; +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 +``` + +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`. `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 + +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. + +## Blocks + +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` | +| `#[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 | + +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().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]` / +`#[bstack_enum]`), but not a tuple, a `Vec`, or an `Option`. + +### Structs + +```rust +#[bstack_block] +struct Node { + #[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 +} +``` + +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.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). + +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.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. + +### Reference-counted blocks + +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().get_back(&alloc)? { // accessor upgrades + println!("a still alive: {}", a2.handle().get_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, + tags: Vec, // POD vectors are un-annotated (any Pod element type) + id: u64, +} + +let rec = Record::new(&alloc, "hello", &[1u32, 2, 3], 42)?; // &str / &[T] / value +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 +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). + +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`): + +| 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 | +| `#[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 | + +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_*`). + +A vector's element may itself be a [fixed-size array](#fixed-size-arrays-t-n): +`Vec<[T; N]>` (and nested `Vec<[[T; N]; M]>`) is a growable sequence of +reference-arrays. It works for POD (`Vec<[u16; 4]>`, just a POD vector) and for +every annotated kind (`#[bstack_owned/strong/weak/ref] Vec<[Thing; N]>`), with +the accessor materializing `Vec<[Thing; N]>`. + +A `Vec` element must be a single leaf, though: `Vec>` (nested vectors) and +`Vec<(A, B)>` (a tuple element) are rejected with a directed error — wrap the +inner value in a named `#[bstack_block]` and store `Vec`. + +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. + +### Fixed-size arrays: `[T; N]` + +A fixed-size array `[T; N]` is stored **inline** — no separate data block. As with +a scalar field, the annotation states the elements' ownership; an un-annotated +array of `Pod` is itself `Pod`. + +```rust +#[bstack_block] +struct Board { + cells: [u16; 9], // POD array (un-annotated) — inline bytes + #[bstack_owned] tiles: [Leaf; 3], // 3 owned children (freed on teardown) + #[bstack_ref] marks: [Leaf; 2], // 2 borrowed refs (free nothing) + #[embed] kids: [Child; 2], // 2 children embedded verbatim, inline +} + +let b = Board::new(&alloc, [0; 9], [a, b, c], [r0, r1], [k0, k1])?; +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 +constructor takes an array of the matching handle (`[BStackOwned; N]` / +`[BStackRc; N]` / `[BStackRef; N]`) and the accessor hands back `[T; N]` +block views; teardown frees/releases each element per the annotation, exactly +like the vector kinds. `#[bstack_weak]` is wired per index +(`set_field(&alloc, i, weak)`) and its accessor upgrades each slot to +`[Option>; N]`. `#[embed] [Child; N]` stores the N children's on-disk +forms back-to-back. + +Arrays compose freely: + +- **Per-element `Option`** — `[Option; N]` makes each slot nullable (offset + `0` == `None`), so the accessor/constructor use `[Option; N]`. A + whole-array `Option<[T; N]>` is rejected — put the `Option` on the element. +- **Nesting to any depth** — `[[T; N]; M]`, `[[[T; N]; M]; K]`, … work for every + kind (POD / owned / strong / weak / ref / embed), in both structs and enums, + the accessor/constructor trafficking in the matching nested `[[Handle; …]; …]`. + +### 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 Node { + #[bstack_owned] left: Option, // may be absent + #[bstack_strong] shared: Option, + labels: Option>, // nullable POD vector +} +``` + +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 +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. 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 (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)) + #[bstack_weak] Watch(Thing), // a weak ref (Thing is (rc, weak)) +} + +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.get_val(stack)?, 7), + _ => {} +} +node.bstack_drop(&alloc)?; // frees the owned child too +``` + +The two [companion enums](#generated-types) are duals of each other's directions: + +- **`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). + +A single-field variant carries the same shapes a struct field does — not just a +scalar block, but a [fixed-size array](#fixed-size-arrays-t-n) or a +[vector](#vectors-and-strings): + +```rust +#[bstack_enum] +enum Cell { + Empty, + Tags(Vec), // POD vector variant + Text(String), // POD string variant + #[bstack_owned] Kids(Vec), // owned vector (freed on teardown) + #[bstack_ref] Row([Leaf; 3]), // inline reference array + #[bstack_owned] Grid(Vec<[Leaf; 2]>), // vector of reference-arrays +} +``` + +An array variant `V([T; N])` mirrors a scalar `V(T)` per element (its offsets +sit inline in the payload); a vector variant `V(Vec<…>)` stores a descriptor in +the payload (build it as a `BStackVec` / `BStackBlockVec` / … and pass it in +`CellData::Kids(vec)`). A `Vec<[T; N]>` variant reads back as `Vec<[T; N]>`. The +same nesting and directed-error rules apply as for struct fields. + +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 +match bstack_move!(node, &alloc)? { + NodeData::Child(owned) => { /* owned: BStackOwned — you now own it */ } + _ => {} +} +``` + +An enum is a block, so it is **always referenced** — store it as a struct field +(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 + +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 +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. + +A fixed-size [array](#fixed-size-arrays-t-n) `[T; N]` is likewise recognized — +inline, per-element ownership, nestable to any depth. + +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. A tuple +is *not* a valid `Vec`/array element, though (it can't carry per-element +annotations) — `Vec<(A, B)>` is rejected in favor of a named `#[bstack_block]`. + +These spellings **compose**, with two limits that draw a directed compile error +rather than a confusing one: a field takes at most one `Option` layer +(`Option>` → use a `#[bstack_enum]`), and a `Vec` element must be a +single leaf (`Vec>` / `Vec` → wrap the inner one in a named +`#[bstack_block]` and store `Vec`). So `Option>`, +`[Option; N]`, and `Vec<[[T; N]; M]>` are fine; `Vec>` and +`Option>` are not. + +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))]`. + +### 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*. + +## 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. + +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 +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 +freeing only the parent *shell* — the children stay live on disk, now owned +independently. + +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 +let pair: BStackOwned = /* … */; +let (left, shared, right) = bstack_move!(pair, &alloc)?; +// ^BStackOwned ^BStackRc ^u32 +``` + +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 +match bstack_move!(rc)? { + Ok((left, shared, right)) => { /* we were the only owner */ } + Err(rc) => { /* someone else still holds it */ } +} +``` + +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. + +## 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 & 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` + +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 +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 + +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. 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: `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())? // io::Result> + .expect("owned Foreign is never null"); +``` + +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 +[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 +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 `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) }`. + +## 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/): + +| 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) + `[T; N]` (nested to any depth) are stored *inline*, but a *variable-length* + 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 [`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 + 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]`. +- **Enums** support unit / POD / all four annotated variant kinds — as scalars, + 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. +- **[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 + +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 +[`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 +[`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 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" diff --git a/bstack_raii/derive/src/block.rs b/bstack_raii/derive/src/block.rs new file mode 100644 index 0000000..7f91739 --- /dev/null +++ b/bstack_raii/derive/src/block.rs @@ -0,0 +1,7813 @@ +//! 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, Expr, ExprLit, Fields, GenericArgument, Ident, ItemStruct, Lit, Meta, PathArguments, + 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, + /// `#[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, +} + +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]", + )); + } + + // 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(); + // 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::Lifetime(_)) { + return Err(Error::new_spanned( + p, + "a generic #[bstack_block] currently supports type and const parameters, \ + not lifetimes", + )); + } + } + 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 + // 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; + let vis = &input.vis; + let on_disk = format_ident!("{}OnDisk", name); + let control = format_ident!("{}OnDiskRef", name); + + // 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, + /// 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() + .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; + } + // A foreign field lowers to a `ForeignPtr` (its target `T` is never stored + // 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 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; + match kind { + Kind::Owned => u.foreign_owned = true, + Kind::Strong => u.strong = true, + Kind::Weak => u.weak = true, + _ => {} + } + 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; + 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; + } + } + } + } + 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() { + 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)); + } + 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 + // `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(); + // 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(); + // 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_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_empty { + quote!(#on_disk) + } 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< + fn() -> (#(#type_params,)* #(#const_markers,)*)>), + 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(); + 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(); + // `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 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(); + // 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(); + 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 (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 `&`. + let eff_ty: &Type = match &field.ty { + Type::Reference(r) => &r.elem, + other => other, + }; + if matches!(&field.ty, Type::Reference(_)) { + ref_coerced = true; + } + + // 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), + }; + + // 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)?; + + // `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 + // 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: 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); + 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))); + } + + // 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::__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, + }; + 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: 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 __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) + { + 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::__private::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 __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) + { + 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::__private::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 __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) + { + 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::__private::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; + } + + // `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(opt_inner) { + Some(VecInfo { + elem: quote!(u8), + is_string: true, + }) + } else { + vec_field(opt_inner) + }; + if let Some(vinfo) = vinfo { + let elem = &vinfo.elem; + // 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;)); + + // `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, + "`String` is always POD; remove the ownership annotation", + )); + } + + // `#[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 + // **flat** as a `BStackVec` (one per leaf, row-major), exactly like + // a scalar block-element vector — so per-offset teardown / clone are the + // same; only the accessor (reshape to `[[T;..];..]`) and constructor + // (flatten) differ. POD `Vec<[Pod; N]>` rides the normal POD vec path. + if kind != Kind::Pod + && let Some(velem) = vec_inner(opt_inner) + && 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::< + <#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_ty, #fname) as u64); + let is_weak = kind == Kind::Weak; + let (ctrl_ty, ctrl_size) = ( + quote!(<#elem_ty as ::bstack_raii::BStackWeakable>::Control), + quote!(::core::mem::size_of::< + <#elem_ty as ::bstack_raii::BStackWeakable>::Control>() as u64), + ); + let vec_ty = match kind { + Kind::Owned => quote!(BStackBlockVec), + Kind::Strong => quote!(BStackStrongVec), + Kind::Weak => quote!(BStackWeakVec), + Kind::Ref => quote!(BStackRefVec), + _ => unreachable!(), + }; + + // ---- Accessor: materialize `Vec<[[View; ..]; ..]>` ---- + let view_leaf = if is_weak { + quote!(::core::option::Option<::bstack_raii::BStackRc<'__v, #elem_ty, __A>>) + } else if leaf_nullable { + quote!(::core::option::Option<#elem_ty>) + } else { + quote!(#elem_ty) + }; + let view_ret = nested_ty(&dims, &view_leaf); + let view_read = |k: &Ident| { + if is_weak { + quote!({ + let __o = __grp[#k]; + if __o == 0 { + ::core::option::Option::None + } else { + let __ctrl = unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__o, #ctrl_size)) }; + let __wk = unsafe { + ::bstack_raii::BStackWeak::<#elem_ty, __A>::from_raw( + __ctrl, allocator) }; + let __up = __wk.upgrade()?; + let _ = __wk.into_raw(); + __up + } + }) + } else if leaf_nullable { + quote!({ + let __o = __grp[#k]; + if __o == 0 { + ::core::option::Option::None + } else { + ::core::option::Option::Some( + <#elem_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__o, #size_elem))) + } + }) + } else { + quote!(<#elem_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__grp[#k], #size_elem))) + } + }; + let build_body = nested_build(&dims, &view_leaf, &view_read); + let reshape = quote! { + let mut __out = ::std::vec::Vec::with_capacity(__flat.len() / (#total)); + for __grp in __flat.chunks(#total) { + __out.push(#build_body); + } + __out + }; + let (acc_ret, acc_map): (TokenStream, TokenStream) = if nullable { + ( + quote!(::core::option::Option<::std::vec::Vec<#view_ret>>), + quote!(match unsafe { #store::from_field_opt(#field_loc, allocator) }? { + ::core::option::Option::Some(__v) => { + let __flat = __v.to_vec()?; + ::core::option::Option::Some({ #reshape }) + } + ::core::option::Option::None => ::core::option::Option::None, + }), + ) + } else { + ( + quote!(::std::vec::Vec<#view_ret>), + quote!({ + let __flat = unsafe { #store::from_field(#field_loc, allocator)? }.to_vec()?; + #reshape + }), + ) + }; + 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_map) + } + }); + + // ---- Constructor: flatten `Vec<[[Handle; ..]; ..]>` → flat offsets ---- + let handle_base = match kind { + Kind::Owned => quote!(::bstack_raii::BStackOwned<#elem_ty>), + Kind::Strong => quote!(::bstack_raii::BStackRc<'__ctor, #elem_ty, __A>), + Kind::Weak => quote!(::bstack_raii::BStackWeak<'__ctor, #elem_ty, __A>), + Kind::Ref => quote!(::bstack_raii::BStackRef<#elem_ty>), + _ => unreachable!(), + }; + let handle_leaf = if leaf_nullable { + quote!(::core::option::Option<#handle_base>) + } else { + handle_base.clone() + }; + let param_ty = nested_ty(&dims, &handle_leaf); + 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::Weak => quote!(#h.into_raw().into_range().start()), + Kind::Ref => quote!(#h.into_range().start()), + _ => unreachable!(), + }; + let leaf_write = |_k: &Ident, leaf: &Ident| { + if leaf_nullable { + let hh = format_ident!("__h"); + let off = off_of(&hh); + quote!(__flat.push(match #leaf { + ::core::option::Option::Some(#hh) => #off, + ::core::option::Option::None => 0u64, + });) + } else { + let off = off_of(leaf); + quote!(__flat.push(#off);) + } + }; + let consume_one = nested_consume(&dims, "e!(__a), &leaf_write); + let build_flat = quote! { + let mut __flat: ::std::vec::Vec = ::std::vec::Vec::new(); + for __a in __list { + #consume_one + } + #store::from_slice(allocator, &__flat)?.descriptor() + }; + let (param, prep) = if nullable { + ( + quote!(#fname: ::core::option::Option<::std::vec::Vec<#param_ty>>,), + quote! { + let #fname: ::bstack_raii::VecDesc = match #fname { + ::core::option::Option::Some(__list) => { #build_flat } + ::core::option::Option::None => ::core::default::Default::default(), + }; + }, + ) + } else { + ( + quote!(#fname: ::std::vec::Vec<#param_ty>,), + quote! { + let #fname: ::bstack_raii::VecDesc = { + let __list = #fname; + #build_flat + }; + }, + ) + }; + ctor_params.push(param); + ctor_preps.push(prep); + ctor_inits.push(quote!(#fname: #fname,)); + + // ---- Teardown: free each child per kind, then the offset block ---- + let free_child = match kind { + Kind::Owned => quote!(::bstack_raii::OwnedRef(unsafe { + ::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( + unsafe { ::bstack_raii::BStackRef::<#elem_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) }, + allocator)?;) + } + Kind::Weak => quote!(::bstack_raii::WeakRef::<#elem_ty>(unsafe { + ::bstack_raii::BStackRef::<#ctrl_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #ctrl_size)) + }).bstack_drop(allocator)?;), + Kind::Ref => quote!(), + _ => unreachable!(), + }; + let free_children = if matches!(kind, Kind::Ref) { + quote!() + } else { + quote! { + for __off in #store::from_desc(__desc, allocator).to_vec()? { + if __off != 0 { #free_child } + } + } + }; + drop_stmts.push(quote! { + { + let __desc: ::bstack_raii::VecDesc = __on_disk.#fname; + if __desc.data_off != 0 { + #free_children + #store::from_desc(__desc, allocator).bstack_drop()?; + } + } + }); + + // ---- Clone: owned deep-clones offsets; strong/weak bump + copy; + // ref copies verbatim (all staged into the plan) ---- + let clone_body = match kind { + Kind::Owned => quote! { + let __flat = #store::from_desc(__srcdesc, allocator).to_vec()?; + let mut __new: ::std::vec::Vec = + ::std::vec::Vec::with_capacity(__flat.len()); + for __off in __flat { + if __off != 0 { + __new.push( + <#elem_ty as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + .__bstack_clone_into(allocator, __plan)?.start()); + } else { + __new.push(0u64); + } + } + __od.#fname = __plan.stage_bytevec( + allocator, ::bstack_raii::bytemuck::cast_slice(&__new))?; + }, + Kind::Strong => quote! { + for __off in #store::from_desc(__srcdesc, allocator).to_vec()? { + if __off != 0 { + __plan.bump_strong(unsafe { + ::bstack_raii::BStackRef::<#elem_ty>::from_range( + ::bstack_raii::BStackRange::new(__off, #size_elem)) + }, allocator)?; + } + } + __od.#fname = #store::from_desc(__srcdesc, allocator) + .clone_data_into(__plan)?; + }, + Kind::Weak => quote! { + for __off in #store::from_desc(__srcdesc, allocator).to_vec()? { + if __off != 0 { __plan.bump_weak(__off); } + } + __od.#fname = #store::from_desc(__srcdesc, allocator) + .clone_data_into(__plan)?; + }, + Kind::Ref => quote! { + __od.#fname = #store::from_desc(__srcdesc, allocator) + .clone_data_into(__plan)?; + }, + _ => unreachable!(), + }; + clone_stmts.push(quote! { + { + let __srcdesc: ::bstack_raii::VecDesc = __od.#fname; + if __srcdesc.data_off != 0 { + #clone_body + } + } + }); + + // ---- Move: yield the flat block-vector handle (loses `[T; N]` shape) ---- + let (mvt, mvr) = block_vec_move(&cap, &elem_ts, vec_ty, nullable); + mv_types.push(mvt); + mv_recon.push(mvr); + continue; + } + + // 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::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_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_ty, + quote!(BStackBlockVec), + nullable, + ), + block_vec_ctor( + fname, + elem, + quote!(BStackBlockVec), + quote!(::bstack_raii::BStackOwned<#elem>), + nullable, + ), + block_vec_move(&cap, elem, quote!(BStackBlockVec), nullable), + ), + 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_ctor( + fname, + elem, + quote!(BStackStrongVec), + quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + nullable, + ), + block_vec_move(&cap, elem, quote!(BStackStrongVec), nullable), + ), + 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_ctor( + fname, + elem, + quote!(BStackWeakVec), + quote!(::bstack_raii::BStackWeak<'__ctor, #elem, __A>), + nullable, + ), + block_vec_move(&cap, elem, quote!(BStackWeakVec), nullable), + ), + 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_ctor( + fname, + elem, + quote!(BStackRefVec), + quote!(::bstack_raii::BStackRef<#elem>), + nullable, + ), + block_vec_move(&cap, elem, quote!(BStackRefVec), nullable), + ), + }; + drop_stmts.push(drop_s); + clone_stmts.push(vec_clone_stmt(fname, kind, elem)); + accessors.push(acc); + let (param, prep, init) = ctor; + ctor_params.push(param); + ctor_preps.push(prep); + ctor_inits.push(init); + let (mv_ty, mv_rc) = mv; + mv_types.push(mv_ty); + mv_recon.push(mv_rc); + continue; + } + + // Inline array of vectors `[Vec; N]` — possibly nested `[[Vec;N];M]` + // and/or per-element `[Option>; N]`: N independent inline `VecDesc`s, + // each owning its own data block. Detected as an array whose leaf is a + // `Vec` / `String`. A POD `[Vec; N]` is intercepted here too — the + // `VecDesc`s are Pod bytes, but the data blocks need a real lifecycle, so it + // must NOT fall through to the plain POD path. The element annotation names + // 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), + is_string: true, + }) + } else { + vec_field(leaf) + }; + if let Some(leaf_vinfo) = leaf_vinfo { + // Validate the leaf vector's own element nesting (`Vec>`). + check_container_nesting(leaf)?; + if nullable { + return Err(Error::new_spanned( + &field.ty, + "a whole-array `Option<[Vec; N]>` is not supported; use \ + `[Option>; N]` for per-element nullability", + )); + } + if leaf_vinfo.is_string && kind != Kind::Pod { + return Err(Error::new_spanned( + &field.ty, + "`String` is always POD; remove the ownership annotation", + )); + } + if kind == Kind::Embed { + return Err(Error::new_spanned( + &field.ty, + "cannot #[embed] a `Vec` / `String`; embed a `#[bstack_block]` type", + )); + } + let total = dims_prod(&dims); + let elem = &leaf_vinfo.elem; + let is_string = leaf_vinfo.is_string; + let vec_ty = match kind { + Kind::Pod => quote!(BStackVec), + Kind::Owned => quote!(BStackBlockVec), + Kind::Strong => quote!(BStackStrongVec), + Kind::Weak => quote!(BStackWeakVec), + Kind::Ref => quote!(BStackRefVec), + Kind::Embed => unreachable!(), + }; + on_disk_fields.push(quote!(#fname: [::bstack_raii::VecDesc; #total],)); + + // Accessor: nested `[[VecHandle; ..]; ..]` (or `Option` per slot), + // reading each `VecDesc` from the on-disk field once. + let handle_lt = quote!(::bstack_raii::#vec_ty<'__v, #elem, __A>); + let acc_leaf = if leaf_nullable { + quote!(::core::option::Option<#handle_lt>) + } else { + handle_lt.clone() + }; + let acc_ret = nested_ty(&dims, &acc_leaf); + // Each slot resolves through `from_field` so descriptor updates + // (growth / realloc) persist back to its OWN inline `VecDesc` — like + // a scalar `Vec` field, but at `base + k * size_of::()`. + let acc_read = |k: &Ident| { + let slot = quote!( + __base + (#k as u64) * (::core::mem::size_of::<::bstack_raii::VecDesc>() as u64)); + if leaf_nullable { + quote!(unsafe { ::bstack_raii::#vec_ty::from_field_opt(#slot, allocator) }?) + } else { + quote!(unsafe { ::bstack_raii::#vec_ty::from_field(#slot, allocator) }?) + } + }; + let acc_body = nested_build(&dims, &acc_leaf, &acc_read); + accessors.push(quote! { + #vis fn #getter<'__v, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__v __A, + ) -> ::std::io::Result<#acc_ret> { + let __base = + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; + ::std::result::Result::Ok(#acc_body) + } + }); + + // Constructor: allocate a data block per slot, store its descriptor. + // POD slots take `&[T]` / `&str` (`from_slice`); block slots take + // `Vec` (`from_handles`). + let handle_ctor = match kind { + Kind::Owned => quote!(::bstack_raii::BStackOwned<#elem>), + Kind::Strong => quote!(::bstack_raii::BStackRc<'__ctor, #elem, __A>), + Kind::Weak => quote!(::bstack_raii::BStackWeak<'__ctor, #elem, __A>), + Kind::Ref => quote!(::bstack_raii::BStackRef<#elem>), + _ => quote!(), + }; + let ctor_leaf = match kind { + Kind::Pod if is_string => quote!(&str), + Kind::Pod => quote!(&[#elem]), + _ => quote!(::std::vec::Vec<#handle_ctor>), + }; + let param_leaf = if leaf_nullable { + quote!(::core::option::Option<#ctor_leaf>) + } else { + ctor_leaf.clone() + }; + let ctor_param_ty = nested_ty(&dims, ¶m_leaf); + ctor_params.push(quote!(#fname: #ctor_param_ty,)); + let desc_of = |b: &Ident| -> TokenStream { + match kind { + Kind::Pod => { + let data = if is_string { + quote!(#b.as_bytes()) + } else { + quote!(#b) + }; + quote!(::bstack_raii::BStackVec::<#elem, __A>::from_slice( + allocator, #data)?.descriptor()) + } + _ => quote!(::bstack_raii::#vec_ty::<#elem, __A>::from_handles( + allocator, #b)?.descriptor()), + } + }; + let ctor_write = |k: &Ident, leaf: &Ident| { + if leaf_nullable { + let inner = format_ident!("__vd"); + let d = desc_of(&inner); + quote!({ + __slots[#k] = match #leaf { + ::core::option::Option::Some(#inner) => #d, + ::core::option::Option::None => + ::core::default::Default::default(), + }; + }) + } else { + let d = desc_of(leaf); + quote!(__slots[#k] = #d;) + } + }; + let flatten = nested_consume(&dims, "e!(#fname), &ctor_write); + ctor_preps.push(quote! { + let #fname: [::bstack_raii::VecDesc; #total] = { + let mut __slots = + [<::bstack_raii::VecDesc as ::core::default::Default>::default(); + #total]; + #flatten + __slots + }; + }); + ctor_inits.push(quote!(#fname: #fname,)); + + // Teardown: free each vector's data block. + drop_stmts.push(quote! { + { + let __descs: [::bstack_raii::VecDesc; #total] = __on_disk.#fname; + for __k in 0usize..(#total) { + if __descs[__k].data_off != 0 { + ::bstack_raii::#vec_ty::<#elem, __A>::from_desc( + __descs[__k], allocator).bstack_drop()?; + } + } + } + }); + + // Clone: deep-clone each vector's data block per the element + // relationship, repointing the slot descriptor (a `0` niche is kept). + let clone_expr = match kind { + Kind::Pod => quote!(::bstack_raii::BStackVec::<#elem, __A>::from_desc( + __sd, allocator).clone_data_into(__plan)?), + Kind::Owned => quote!(::bstack_raii::BStackBlockVec::<#elem, __A>::from_desc( + __sd, 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( + __sd, allocator).clone_into(__plan)?), + Kind::Weak => quote!(::bstack_raii::BStackWeakVec::<#elem, __A>::from_desc( + __sd, allocator).clone_into(__plan)?), + Kind::Ref => quote!(::bstack_raii::BStackRefVec::<#elem, __A>::from_desc( + __sd, allocator).clone_into(__plan)?), + Kind::Embed => unreachable!(), + }; + clone_stmts.push(quote! { + { + let mut __descs: [::bstack_raii::VecDesc; #total] = __od.#fname; + for __k in 0usize..(#total) { + let __sd: ::bstack_raii::VecDesc = __descs[__k]; + if __sd.data_off != 0 { + __descs[__k] = #clone_expr; + } + } + __od.#fname = __descs; + } + }); + + // Move: nested `[[VecHandle; ..]; ..]` from the captured descriptors. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + let mv_handle = quote!(::bstack_raii::#vec_ty<'__mv, #elem, __A>); + let mv_leaf = if leaf_nullable { + quote!(::core::option::Option<#mv_handle>) + } else { + mv_handle.clone() + }; + mv_types.push(nested_ty(&dims, &mv_leaf)); + let mv_read = |k: &Ident| { + if leaf_nullable { + quote!({ + let __d = #cap[#k]; + if __d.data_off != 0 { + ::core::option::Option::Some( + ::bstack_raii::#vec_ty::from_desc(__d, __alloc)) + } else { + ::core::option::Option::None + } + }) + } else { + quote!(::bstack_raii::#vec_ty::from_desc(#cap[#k], __alloc)) + } + }; + mv_recon.push(nested_build(&dims, &mv_leaf, &mv_read)); + continue; + } + } + + // `#[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 + // (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)?; + 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 + // inline (`[::OnDisk; TOTAL]`, flat). Construction + // folds each `BStackOwned` in (read OnDisk, copy, free shell). + if kind == Kind::Embed { + if 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; #total],)); + + // Teardown: free each embedded child's children in place. + drop_stmts.push(quote! { + { + let __base = + __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( + __base + (__k as u64) * __step, __step); + <#child>::__bstack_drop_children(__embed, allocator)?; + } + } + }); + + // 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 #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; + #acc_body + } + }); + + // 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); + 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; #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(); #total],), + ); + ctor_post.push(quote! { + { + let __base = + __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]; + allocator.stack().copy( + __src.start(), __base + (__k as u64) * __step, __step)?; + unsafe { ::bstack_raii::dealloc_range(allocator, __src)?; } + } + } + }); + + // 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>), + )); + 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); + } + 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 (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_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) { + let __child = <#child as ::bstack_raii::BStackBlock>::from_range( + ::bstack_raii::BStackRange::new( + __base + (__k as u64) * __step, __step)); + __arr[__k] = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + } + __od.#fname = __arr; + } + }); + continue; + } + + 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 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; #total],)); + + let setter = format_ident!("set_{}", fname); + setters.push(quote! { + #vis fn #setter<'__s, __A: ::bstack_raii::BStackRaiiAllocator>( + &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_ty, #fname) as u64 + + (index as u64) * 8; + ::bstack_raii::set_weak_field(allocator, __field, weak) + } + }); + + 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 #getter<'__u, __A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &'__u __A, + ) -> ::std::io::Result<#acc_ret> { + let __base = + self.0.start() + ::core::mem::offset_of!(#on_disk_ty, #fname) as u64; + ::std::result::Result::Ok(#acc_body) + } + }); + + // Teardown: release each non-null weak reference. + drop_stmts.push(quote! { + { + let __offs: [u64; #total] = __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 kept — a weak clone + // aliases the same control block). + clone_stmts.push(quote! { + { + let __offs: [u64; #total] = __od.#fname; + for __off in __offs { + if __off != 0 { + __plan.bump_weak(__off); + } + } + } + }); + + // Move: nested `[[Option; ..]; ..]` from flat offsets. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + 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 { + 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) + }) + } + }} + }; + mv_recon.push(nested_build(&dims, &mv_leaf_ty, &mv_read)); + continue; + } + + // 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) + }; + 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 #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 __offs: [u64; #total] = __od.#fname; + ::std::result::Result::Ok(#acc_body) + } + }); + + // 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!(), + }; + 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 { + 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). + 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; #total] = __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 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)); + __arr[__k] = + __child.__bstack_clone_into(allocator, __plan)?.start(); + } + } + __od.#fname = __arr; + } + }), + Kind::Strong => clone_stmts.push(quote! { + { + let __offs: [u64; #total] = __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; TOTAL]` is kept verbatim. + _ => {} + } + + // Move: nested `[[Handle; ..]; ..]` from flat offsets. + let cap = format_ident!("__cap_{}", fname); + mv_caps.push(quote!(let #cap = __od.#fname;)); + 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(#build_one) + } + }} + } else { + quote! {{ + let __off = #cap[#k]; + #build_one + }} + } + }; + mv_recon.push(nested_build(&dims, &mv_leaf_ty, &mv_read)); + continue; + } + + // 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 **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. + // `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 #getter( + &self, + stack: &::bstack_raii::BStack, + ) -> ::std::io::Result<#inner_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(( #(__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; + } + + // `#[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_ty, #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 #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, + ::core::mem::size_of::<#child_od>() as u64, + ), + ) + } + }); + + // 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 #src_id = { + let __h = #fname.into_inner(); + ::bstack_raii::BStackBlock::range(&__h) + }; + }); + 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_ty, #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); + 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), + ) + } + } + }); + // 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_ty, #fname) as u64, + ::core::mem::size_of::<#child_od>() as u64, + ), + ); + __od.#fname = + __child.__bstack_clone_children_inplace(allocator, __plan)?; + } + }); + continue; + } + + // On-disk lowering. + match kind { + Kind::Pod => { + 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, + inner_ty, + nullable, + quote!(::bstack_raii::OwnedRef(__child).bstack_drop(allocator)?;), + )), + Kind::Strong => drop_stmts.push(child_range_stmt( + fname, + inner_ty, + nullable, + quote!(<#inner_ty as ::bstack_raii::BStackShared>::drop_strong_ref(__child, allocator)?;), + )), + Kind::Weak => drop_stmts.push(weak_drop_stmt(fname, inner_ty)), + // `#[embed]` is fully handled above (it `continue`s). + 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: the `get_` reader, the unsafe `raw__slice` place, + // 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 { + // POD: overwrite in place. + Kind::Pod => { + accessors.push(set_accessor( + vis, + fname, + inner_ty, + &on_disk_ty, + kind, + 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::Embed => { + return Err(Error::new_spanned( + field, + "#[bstack_mut] is not yet supported on #[embed] fields", + )); + } + } + } + + // 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_ty)); + } else { + let (param, prep, init) = ctor_field(fname, inner_ty, kind, nullable); + 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;)); + 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 + // 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); + // 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() && const_params.is_empty() { + data_eightcc + } else { + // 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())) + } + }); + // 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)]` + // 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) && !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(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!() + }; + + // 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!(), + Mode::Rc => quote! { + impl ::bstack_raii::BStackShared for #name { + 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::BStackRaiiAllocator>( + 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::BStackRaiiAllocator>( + 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::BStackRaiiAllocator>( + 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( + vis, + &on_disk_ty, + &on_disk_ctor, + mode, + &ctrl_eightcc, + &ctor_params, + &ctor_preps, + &ctor_inits, + &ctor_post, + ); + + // 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. + impl #impl_g ::bstack_raii::BStackMove for #name #ty_g #where_g { + type Fields<'__mv, __A: ::bstack_raii::BStackRaiiAllocator> = + ( #(#mv_types,)* ); + fn bstack_move<'__mv, __A: ::bstack_raii::BStackRaiiAllocator>( + owned: ::bstack_raii::BStackOwned, + __alloc: &'__mv __A, + ) -> ::std::io::Result> { + // 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 = ::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)* + // Free the parent shell only; children stay live on disk. + unsafe { ::bstack_raii::dealloc_range(__alloc, __range)?; } + ::std::result::Result::Ok(( #(#mv_recon,)* )) + } + } + } + }; + + // 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)", + )) + }; + (err.clone(), err) + } else { + let children = quote! { + let __stack = allocator.stack(); + let __src = ::bstack_raii::BStackBlock::range(self); + 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)?; + #(#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_ty>() as u64, + )?; + __plan.write( + __dst.start(), + ::bstack_raii::bytemuck::bytes_of(&__od).to_vec(), + ); + ::std::result::Result::Ok(__dst) + }; + (children, into) + }; + // 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::BStackRaiiAllocator>( + &self, + allocator: &__A, + __plan: &mut ::bstack_raii::ClonePlan, + ) -> ::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 _; + #clone_children_body + } + #[doc(hidden)] + #[allow(unused_variables)] + fn __bstack_clone_into<__A: ::bstack_raii::BStackRaiiAllocator>( + &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! { + impl #impl_g ::bstack_raii::TryCloneIn for #name #ty_g #where_g { + fn try_clone_in<__A: ::bstack_raii::BStackRaiiAllocator>( + &self, + allocator: &__A, + ) -> ::std::io::Result<::bstack_raii::BStackOwned> { + use ::bstack_raii::BStackBlock as _; + // 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), + ) + }) + } + } + } + } else { + quote!() + }; + + // 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() && const_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 {} + } + }; + + // 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_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 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, &all_param_idents)) + .copied() + .collect(); + + Ok(quote! { + #handle_def + + // Packed Pod wrappers for any POD tuple fields. + #(#wrapper_defs)* + + #[repr(C, packed)] + #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, 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::<#concrete_pod_types>(); )* + }; + + impl #impl_g ::bstack_raii::BStackCast for #name #ty_g #where_g { + fn eightcc() -> ::bstack_raii::EightCC { + #data_eightcc + } + } + + impl #impl_g ::bstack_raii::BStackBlock for #name #ty_g #where_g { + type OnDisk = #on_disk_ty; + fn from_range(range: ::bstack_raii::BStackRange) -> Self { + #name(range #phantom_ctor) + } + fn range(&self) -> ::bstack_raii::BStackRange { + self.0 + } + + #clone_trait_methods + + /// 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. Overrides the childless + /// `BStackBlock` default. + #[doc(hidden)] + #[allow(unused_imports)] + fn __bstack_drop_children<__A: ::bstack_raii::BStackRaiiAllocator>( + __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 = ::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)* + ::std::result::Result::Ok(()) + } + } + + impl #impl_g ::bstack_raii::BStackDrop for #name #ty_g #where_g { + fn bstack_drop<__A: ::bstack_raii::BStackRaiiAllocator>( + self, + allocator: &__A, + ) -> ::std::io::Result<()> { + ::__bstack_drop_children(self.0, allocator)?; + unsafe { ::bstack_raii::dealloc_range(allocator, self.0) } + } + } + + impl #impl_g #name #ty_g #where_g { + #(#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 + } + + #shared_impl + #weakable_items + #move_impl + #clone_impl + #overlong_warning + #ref_warning + }) +} + +/// A `Vec` / `String` field: its element type (tokens) and whether it's a +/// `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, +} + +/// 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")) +} + +/// 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 { + 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.", + )); + } + Ok(()) +} + +/// 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).", + ) +} + +/// Directed error for a tuple used as a `Vec` element (`Vec<(A, B)>`, +/// `Vec<[(A, B); N]>`, …). +fn err_tuple_in_vec(ty: &Type) -> Error { + Error::new_spanned( + ty, + "a tuple is not supported as a `Vec` element: a `Vec` element must be a single \ + leaf — POD, or a block reference — and a tuple (a POD one has no `Vec` layout, \ + a `(ref, pod)` one cannot be split into offset + inline bytes) is neither. Wrap \ + it in a named `#[bstack_block]` struct and store `Vec` (annotating \ + each reference field inside it, leaving POD fields plain).", + ) +} + +/// 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 an array `[..; N]` of leaves, optionally +/// wrapped in exactly one `Option`. A `Vec` / `String` in leaf position is +/// `Vec` (`Vec<[Vec; N]>` included — arrays are peeled first); an +/// `Option` 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. +/// +/// `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, +} + +/// 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; + +// -- 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/lib.rs b/bstack_raii/src/lib.rs new file mode 100644 index 0000000..b0bcc64 --- /dev/null +++ b/bstack_raii/src/lib.rs @@ -0,0 +1,542 @@ +//! # `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 with C++-style `unique_ptr` / +//! `shared_ptr` / `weak_ptr` conveniences. +//! +//! 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. +//! +//! ## Module map (bottom-up) +//! +//! | Module | Contents | +//! |----------------|-------------------------------------------------------------| +//! | [`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. | +//! | `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. | +//! | [`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 +//! +//! * 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 +//! +//! 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. +extern crate self as bstack_raii; + +mod block; +mod bulk; +mod cast; +mod clone; +mod construct; +mod foreign; +mod handle; +mod layout; +mod owned; +mod refcount; +mod reference; +/// Cross-file `Foreign` support: the process-wide path↔id file registry. +pub mod registry; +mod replace; +mod shared; +mod stdlib; +mod teardown; +mod vec; +mod wal; + +#[cfg(test)] +mod tests; + +pub use block::{ + BStackBlock, BStackCast, BStackMove, BStackMoveExpr, BStackShared, BStackWeakable, +}; +pub use cast::{BStackCastAs, BStackCastInto}; +pub use clone::{ClonePlan, TryClone, TryCloneIn}; +pub use construct::{alloc_block, build_control_payload, 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; +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, + BStackCow, BStackDeque, BStackHashMap, BStackHashSet, BStackLinkedList, BStackString, + BTreeMapIter, BTreeSetIter, BloomOnDisk, BoxOnDisk, DequeIter, DequeOnDisk, HashMapIter, + HashSetIter, HashSetOnDisk, HeapOnDisk, ListIter, ListOnDisk, MapOnDisk, NodeOnDisk, + StringOnDisk, TreeOnDisk, TreeSetOnDisk, +}; +pub use teardown::{AutoDrop, BStackDrop, dealloc_range, wal_teardown}; +pub use vec::{BStackBlockVec, BStackRefVec, BStackStrongVec, BStackVec, BStackWeakVec, VecDesc}; +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 +// 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 + } + + /// 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 + } + + /// 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; + +// Procedural macros, re-exported so downstream depends only on `bstack_raii`. +pub use bstack_raii_derive::{bstack_block, bstack_cast, bstack_enum, bstack_move}; + +/// Runtime support called by `#[bstack_block]` / `#[bstack_enum]`-generated code via +/// fully-qualified `::bstack_raii::__private::…` paths. Not part of the public API — +/// no stability guarantee, use directly at your own risk. +#[doc(hidden)] +pub mod __private { + pub use crate::foreign::{ + foreign_clone_owned, foreign_clone_strong, foreign_clone_weak, foreign_drop_owned, + foreign_drop_strong, foreign_drop_weak, + }; +} + +/// `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() {} +/// ``` +/// +/// 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() {} +/// ``` +/// +/// --- +/// +/// 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() {} +/// ``` +/// +/// `weak` without `rc`: +/// ```compile_fail +/// use bstack_raii::bstack_block; +/// #[bstack_block(weak)] +/// struct X { f: u32 } +/// # 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.) +/// +/// --- +/// +/// 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() {} +/// ``` +/// +/// --- +/// +/// 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() {} +/// ``` +/// +/// **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 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 } +/// #[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/owned.rs b/bstack_raii/src/owned.rs new file mode 100644 index 0000000..c3e1770 --- /dev/null +++ b/bstack_raii/src/owned.rs @@ -0,0 +1,90 @@ +//! [`BStackOwned`]: a without-allocator, uniquely-owned block handle. +//! +//! `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. +//! +//! `X::new(..)` and `bstack_move!`'d owned children hand back a bare +//! `BStackOwned`; the caller decides when (and whether) it dies. + +use core::ops::Deref; +use std::io; + +use crate::BStackRaiiAllocator; +use crate::block::{BStackMove, BStackMoveExpr}; +use crate::teardown::{AutoDrop, BStackDrop, wal_teardown}; + +/// A uniquely-owned handle to a block: an ownership marker over an inner +/// [`BStackDrop`] handle whose teardown recursively frees the block on disk. +/// +/// 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 BStackOwned { + /// Mark `inner` as uniquely owned. + /// + /// # Safety + /// 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) + } + + /// 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 inner handle, e.g. to call generated field accessors: + /// `owned.handle().get_field(stack)`. + pub fn handle(&self) -> &T { + &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 Deref for BStackOwned { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} + +impl BStackDrop for BStackOwned { + 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) + } +} + +/// `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: BStackRaiiAllocator> BStackMoveExpr for AutoDrop<'a, BStackOwned, A> { + type Output = io::Result>; + fn bstack_move(self) -> Self::Output { + let (owned, allocator) = self.into_raw_parts(); + X::bstack_move(owned, allocator) + } +} diff --git a/bstack_raii/src/refcount.rs b/bstack_raii/src/refcount.rs new file mode 100644 index 0000000..20d7bb0 --- /dev/null +++ b/bstack_raii/src/refcount.rs @@ -0,0 +1,116 @@ +//! 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 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 crate::layout::get_u64; +use bstack::BStack; + +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") +} + +/// 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 { + let mut bytes = [0u8; 8]; + stack.get_into(offset, &mut bytes)?; + Ok(u64::from_le_bytes(bytes)) +} + +/// 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 { + let mut prev = 0u64; + let mut overflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = get_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. 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 { + let mut prev = 0u64; + let mut underflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = get_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. 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> { + if load(stack, offset)? == 0 { + return Ok(None); + } + let mut result = None; + let mut overflow = false; + stack.process(offset, offset + 8, |buf| { + let cur = get_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) +} diff --git a/bstack_raii/src/reference.rs b/bstack_raii/src/reference.rs new file mode 100644 index 0000000..0296b11 --- /dev/null +++ b/bstack_raii/src/reference.rs @@ -0,0 +1,87 @@ +//! [`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 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)) + } +} + +impl Clone for BStackRef { + fn clone(&self) -> Self { + *self + } +} +impl Copy for BStackRef {} diff --git a/bstack_raii/src/registry.rs b/bstack_raii/src/registry.rs new file mode 100644 index 0000000..3e1749d --- /dev/null +++ b/bstack_raii/src/registry.rs @@ -0,0 +1,757 @@ +//! 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, BStackAllocError, BStackAllocator, BStackOwnedSlice, BStackRange}; +use parking_lot::RwLock; + +use crate::BStackRaiiAllocator; + +/// 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 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 + } + + /// 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 + } + } +} + +/// 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. +/// +/// 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) + } +} + +/// 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 +/// 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>>, + /// 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, +} + +/// 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 + 1))) // ids are 1-based (0 = SELF) + .collect(); + let live = (0..paths.len()).map(|_| None).collect(); + Ok(FileRegistry { + inner: RwLock::new(RegistryInner { + paths, + by_path, + live, + by_stack: HashMap::new(), + 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 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)?; + 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 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) + } + + /// 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 Some(idx) = table_index(id) else { return }; + let mut g = self.inner.write(); + // 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); + } + } + + /// 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 { + // `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(idx)?.as_ref()?; + 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)?; + self.inner.read().paths.get(idx).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 (always `false` for `SELF` / + /// special ids). + pub fn is_live(&self, id: FileId) -> bool { + let Some(idx) = table_index(id) else { + return false; + }; + 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. +/// 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) +} + +/// [`FileRegistry::id_of_host`] on the process-wide registry. +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()) +} + +// 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/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/shared.rs b/bstack_raii/src/shared.rs new file mode 100644 index 0000000..bd0ce68 --- /dev/null +++ b/bstack_raii/src/shared.rs @@ -0,0 +1,309 @@ +//! [`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 core::ops::Deref; +use std::io; + +use crate::BStackRaiiAllocator; +use bstack::BStackRange; + +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::{AutoDrop, 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 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` +/// 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; a zero-cost representation can replace it later +/// 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: 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> { + /// 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 { + let handle = ::from_range(data.into_range()); + Self { + inner: unsafe { AutoDrop::from_raw(StrongCore { data, ctrl }, allocator) }, + handle, + } + } + + 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().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.handle.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 (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, + Some(ctrl) => ctrl.start() + layout::CTRL_STRONG_OFFSET, + } + } +} + +/// 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 +/// deep-copy-to-owned (`TryCloneIn`) for one. +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 + // accounts for. + Ok(unsafe { Self::from_raw(self.data(), self.ctrl(), self.allocator()) }) + } +} + +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 + /// `(rc)` block's `BStackRc` has no `downgrade` at all — a compile error, not + /// a runtime hazard. + pub fn downgrade(&self) -> io::Result> { + // 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) }; + // SAFETY: the fetch_add above established the weak count this handle holds. + Ok(unsafe { BStackWeak::from_raw(ctrl, self.allocator()) }) + } +} + +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`. + /// + /// 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 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)? { + 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. 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())) }; + let fields = T::bstack_move(owned, allocator)?; + + // `(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(allocator.stack(), weak_off, 1)? == 1 { + unsafe { dealloc_range(allocator, ctrl)? }; + } + } + Ok(Ok(fields)) + } +} + +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() + } +} + +/// 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. 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: BStackRaiiAllocator> { + inner: AutoDrop<'a, WeakRef, A>, +} + +impl<'a, T: BStackWeakable, A: BStackRaiiAllocator> 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 { + 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 (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 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); + } + // 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]; + 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. + Ok(Some(unsafe { + BStackRc::from_raw(data, Some(ctrl_range), allocator) + })) + } +} + +/// 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: 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)?; + // 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/stdlib/bloom.rs b/bstack_raii/src/stdlib/bloom.rs new file mode 100644 index 0000000..83feec7 --- /dev/null +++ b/bstack_raii/src/stdlib/bloom.rs @@ -0,0 +1,413 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, BStackGenOp, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::hash::double_hash; +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}; +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 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, 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)) + } + + /// 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, 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]; + 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, m] = read_fields::<2>(allocator.stack(), handle + DATA_OFF)?; + allocator.stack().set_batched([ + ( + data, + SmallBuf::Heap(vec![0u8; m as usize].into_boxed_slice()), + ), + w8(handle + N_OFF, 0u64), + ]) + } + + /// 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, 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(); + + // 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, 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))? }; + } + 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, 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]; + 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/boxed.rs b/bstack_raii/src/stdlib/boxed.rs new file mode 100644 index 0000000..e8778f6 --- /dev/null +++ b/bstack_raii/src/stdlib/boxed.rs @@ -0,0 +1,184 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, 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: BStackRaiiAllocator> = T; + + 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. + // 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/btreeset.rs b/bstack_raii/src/stdlib/btreeset.rs new file mode 100644 index 0000000..15b8950 --- /dev/null +++ b/bstack_raii/src/stdlib/btreeset.rs @@ -0,0 +1,950 @@ +//! [`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. `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; +use core::mem::size_of; +use std::io; + +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::bloom::{BStackCountingBloomFilter, BloomOnDisk}; +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}; +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: BStackRaiiAllocator> { + allocator: &'a A, + node_size: u64, + ksize: usize, + children_off: usize, + writes: Vec<(u64, SmallBuf)>, + freed: Vec, +} + +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()); + 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, SmallBuf::Heap(b.into_boxed_slice()))); + 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 BStackRaiiAllocator>, + 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, len] = read_fields::<2>(stack, handle + ROOT_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(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 { + // 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 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 BStackRaiiAllocator>, + 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 BStackRaiiAllocator>, + 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, len] = read_fields::<2>(stack, handle + ROOT_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(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 { + 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) + } + + /// 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(()) + } + + /// 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. + 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, _len, bloom_off] = read_fields::<3>(allocator.stack(), handle + ROOT_OFF)?; + Self::drop_subtree(allocator.stack(), root, allocator)?; + 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, 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( + 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)) }) + } +} + +/// 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/cow.rs b/bstack_raii/src/stdlib/cow.rs new file mode 100644 index 0000000..3da9f81 --- /dev/null +++ b/bstack_raii/src/stdlib/cow.rs @@ -0,0 +1,173 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::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().get_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/deque.rs b/bstack_raii/src/stdlib/deque.rs new file mode 100644 index 0000000..cec482e --- /dev/null +++ b/bstack_raii/src/stdlib/deque.rs @@ -0,0 +1,636 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +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}; +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` 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)> { + 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). + 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 { + read_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 { + read_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); + let mut w: WriteBuf<2> = WriteBuf::new(); + 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; + w.push(w8(slot, val_off)); + w.push(w8(handle + LEN_OFF, len + 1)); + } else { + full.set(true); + } + w.as_slice() + }, + )?; + 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); + let mut w: WriteBuf<3> = WriteBuf::new(); + 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; + 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); + } + w.as_slice() + }, + )?; + 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); + let mut w: WriteBuf<1> = WriteBuf::new(); + 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 { + got.set(true); + val.set(v2[0]); + w.push(w8(handle + LEN_OFF, len - 1)); + } + w.as_slice() + }, + )?; + 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); + let mut w: WriteBuf<2> = WriteBuf::new(); + 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 { + got.set(true); + val.set(v2[0]); + w.push(w8(handle + HEAD_OFF, (head + 1) % cap)); + w.push(w8(handle + LEN_OFF, len - 1)); + } + w.as_slice() + }, + )?; + 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 = 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(); + + 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; + let mut w: Vec<(u64, SmallBuf)> = Vec::new(); + + 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) { + 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)); + } + w.as_slice() + }, + )?; + + 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(read_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(read_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(read_u64( + stack, + data + ((head + i) % cap) * 8, + )?)); + } + 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. + 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 = 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)) }; + 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 = read_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)) }) + } +} + +/// 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/hash.rs b/bstack_raii/src/stdlib/hash.rs new file mode 100644 index 0000000..25067cc --- /dev/null +++ b/bstack_raii/src/stdlib/hash.rs @@ -0,0 +1,24 @@ +//! 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 +} + +/// 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/hashset.rs b/bstack_raii/src/stdlib/hashset.rs new file mode 100644 index 0000000..72e1a8b --- /dev/null +++ b/bstack_raii/src/stdlib/hashset.rs @@ -0,0 +1,536 @@ +//! [`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 crate::BStackRaiiAllocator; +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, grow_table, 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}; +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, 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, + SmallBuf::Heap(img.into_boxed_slice()), + ), + w8(handle + LEN_OFF, m.len + 1), + ]; + if slot_was_empty { + w.push(w8(handle + USED_OFF, m.used + 1)); + } + 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)) + } + + /// 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, + 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, _len, used] = read_fields::<3>(allocator.stack(), handle + CAP_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![ + w8(m.table + idx * stride, TOMBSTONE), + w8(handle + LEN_OFF, m.len - 1), + ]) + } 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, cap] = read_fields::<2>(stack, handle + TABLE_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<()> { + grow_table( + allocator, + self.range.start(), + Self::stride(), + Self::ksize(), + OCCUPIED, + MIN_CAP, + ) + } + + /// 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, 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()))? }; + } + 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, 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]; + 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)) }) + } +} + +/// 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/heap.rs b/bstack_raii/src/stdlib/heap.rs new file mode 100644 index 0000000..88f8dd2 --- /dev/null +++ b/bstack_raii/src/stdlib/heap.rs @@ -0,0 +1,454 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +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}; +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]) + } + + /// 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)> { + let [data, cap, len] = read_fields::<3>(stack, handle + DATA_OFF)?; + Ok((data, cap, len)) + } + + /// 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, 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, + SmallBuf::Heap(parent_slot.into_boxed_slice()), + )); + hole = parent; + } else { + break; + } + } + 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(()); + } + } + + /// 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, SmallBuf)> = 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, + SmallBuf::Heap(smaller.into_boxed_slice()), + )); + hole = child; + } else { + break; + } + } + 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. + 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/list.rs b/bstack_raii/src/stdlib/list.rs new file mode 100644 index 0000000..390bb90 --- /dev/null +++ b/bstack_raii/src/stdlib/list.rs @@ -0,0 +1,589 @@ +//! [`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::cell::Cell; +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +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}; +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; +// `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. +/// +/// 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 { + read_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. + /// + /// 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, + value: BStackOwned, + ) -> io::Result<()> { + let list = self.range.start(); + 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, + &[list + TAIL_OFF, list + LEN_OFF], + |_v1| Vec::new(), + |v1, _v2| { + let (old_tail, len) = (v1[0], v1[1]); + // The node's full image (with `prev` wired to the read tail). + 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(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() { + // 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)) }; + } + res + } + + /// 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, + value: BStackOwned, + ) -> io::Result<()> { + 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, + &[list + HEAD_OFF, list + LEN_OFF], + |_v1| Vec::new(), + |v1, _v2| { + let (old_head, len) = (v1[0], v1[1]); + 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(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() { + // SAFETY: freshly allocated, referenced by nobody. + let _ = unsafe { dealloc_range(allocator, BStackRange::new(node, NODE_SIZE)) }; + } + 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 node = Cell::new(0u64); + let val = Cell::new(0u64); + let mut w: WriteBuf<3> = WriteBuf::new(); + + 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 { + 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.as_slice() + }, + )?; + + if node.get() == 0 { + return Ok(None); + } + // 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.get())) + })) + } + + /// 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( + &self, + allocator: &A, + ) -> io::Result>> { + 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, + &[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 { + 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.as_slice() + }, + )?; + + if node.get() == 0 { + return Ok(None); + } + // 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())) + })) + } + + /// 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 = read_u64(stack, self.range.start() + HEAD_OFF)?; + if head == 0 { + return Ok(None); + } + 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 = read_u64(stack, self.range.start() + TAIL_OFF)?; + if tail == 0 { + return Ok(None); + } + Ok(Some(Self::value_at(read_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 = read_u64(stack, self.range.start() + HEAD_OFF)?; + while cur != 0 { + // `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, + 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 = read_u64(allocator.stack(), range.start() + HEAD_OFF)?; + while cur != 0 { + 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. + 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 = read_u64(allocator.stack(), src + HEAD_OFF)?; + while cur != 0 { + vals.push(read_u64(allocator.stack(), cur + NVAL_OFF)?); + cur = read_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)) }) + } +} + +/// 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 new file mode 100644 index 0000000..8e7b6e0 --- /dev/null +++ b/bstack_raii/src/stdlib/map.rs @@ -0,0 +1,618 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use super::hash::fnv1a; +use super::util::{ + 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}; +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 [`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; + +/// 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, +) -> 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, + SmallBuf::Heap(img.into_boxed_slice()), + ), + w8(e.handle + LEN_OFF, m.len + 1), + ]; + if slot_was_empty { + w.push(w8(e.handle + USED_OFF, m.used + 1)); + } + 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 { + read_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); + let entry = NewEntry { + handle, + stride, + ksz, + key_bytes: &key_bytes, + val_ref, + }; + + loop { + // Proactively keep the load factor under 3/4 (also clears tombstones). + let [cap, _len, used] = read_fields::<3>(allocator.stack(), handle + CAP_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 = 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(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])); + is_new.set(false); + let value_off = m.table + idx * stride + 8 + ksz as u64; + ProbeStep::Stop(vec![w8(value_off, val_ref)]) + } 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(&entry, m, t, false) + } 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 = 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(get_u64(&buf[8 + ksz..8 + ksz + 8])); + ProbeStep::Stop(vec![ + w8(m.table + idx * stride, TOMBSTONE), + w8(handle + LEN_OFF, m.len - 1), + ]) + } 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, 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; + // One read per probed bucket (state + key + value in a single get_into). + let mut scratch = Scratch::new(); + for _ in 0..cap { + 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 && buf[8..8 + ksz] == *key_bytes { + return Ok(Some(Self::value_at(get_u64(&buf[8 + ksz..8 + ksz + 8])))); + } + 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()) + } + + /// 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: BStackRaiiAllocator, + 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). + 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. + fn grow(&self, allocator: &A) -> io::Result<()> { + grow_table( + allocator, + self.range.start(), + Self::stride(), + Self::ksize(), + OCCUPIED, + MIN_CAP, + ) + } + + /// 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(); + let handle = range.start(); + 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)) }; + owned.bstack_drop(allocator)?; + } + } + } + // 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, cap, len, used] = read_fields::<4>(allocator.stack(), handle + TABLE_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 get_u64(&image[lo..lo + 8]) != OCCUPIED { + continue; + } + let vref = get_u64(&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)) }) + } +} + +/// 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 new file mode 100644 index 0000000..e5cd186 --- /dev/null +++ b/bstack_raii/src/stdlib/mod.rs @@ -0,0 +1,52 @@ +//! `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. | +//! | [`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. | +//! | [`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`. | +//! | [`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; +mod btreeset; +mod cow; +mod deque; +mod hash; +mod hashset; +mod heap; +mod list; +mod map; +mod string; +mod tree; +mod util; + +pub use bloom::{BStackCountingBloomFilter, BloomOnDisk}; +pub use boxed::{BStackBox, BoxOnDisk}; +pub use btreeset::{BStackBTreeSet, BTreeSetIter, TreeSetOnDisk}; +pub use cow::BStackCow; +pub use deque::{BStackDeque, DequeIter, DequeOnDisk}; +pub use hashset::{BStackHashSet, HashSetIter, HashSetOnDisk}; +pub use heap::{BStackBinaryHeap, HeapOnDisk}; +pub use list::{BStackLinkedList, ListIter, ListOnDisk, NodeOnDisk}; +pub use map::{BStackHashMap, HashMapIter, MapOnDisk}; +pub use string::{BStackString, StringOnDisk}; +pub use tree::{BStackBTreeMap, BTreeMapIter, TreeOnDisk}; diff --git a/bstack_raii/src/stdlib/string.rs b/bstack_raii/src/stdlib/string.rs new file mode 100644 index 0000000..0522ff0 --- /dev/null +++ b/bstack_raii/src/stdlib/string.rs @@ -0,0 +1,317 @@ +//! [`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 crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +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}; +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 { + read_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, 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)?; + } + 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, 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]; + 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) + } + + /// 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. + 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, 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))? }; + } + 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, len] = read_fields::<2>(allocator.stack(), handle + DATA_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/stdlib/tree.rs b/bstack_raii/src/stdlib/tree.rs new file mode 100644 index 0000000..2dfe36b --- /dev/null +++ b/bstack_raii/src/stdlib/tree.rs @@ -0,0 +1,1122 @@ +//! [`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.) +//! +//! `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; +use core::mem::size_of; +use std::io; + +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +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}; +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 + +/// 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: BStackRaiiAllocator> { + allocator: &'a A, + node_size: u64, + ksize: usize, + vals_off: usize, + children_off: usize, + /// New node images `(offset, bytes)`, committed together. + writes: Vec<(u64, SmallBuf)>, + /// Old path nodes, freed after the commit succeeds. + freed: Vec, +} + +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 { + 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, SmallBuf::Heap(b.into_boxed_slice()))); + 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 { + read_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 = 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(); + 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(get_u64(&b[vals_off + i * 8..])); + } + 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, + 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 BStackRaiiAllocator>, + 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, len] = read_fields::<2>(stack, handle + ROOT_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(w8(handle + ROOT_OFF, new_root)); + if added { + writes.push(w8(handle + LEN_OFF, len + 1)); + } + 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 = read_u64(stack, self.range.start() + ROOT_OFF)?; + let ksize = Self::ksize(); + let vals_off = Self::vals_off(); + let children_off = Self::children_off(); + // 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 { + 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; + 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(get_u64(&buf[vals_off + i * 8..])))); + } + if leaf { + return Ok(None); + } + off = get_u64(&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()) + } + + /// 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: BStackRaiiAllocator, + 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(&{ + 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 BStackRaiiAllocator>, + 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 BStackRaiiAllocator>, + 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, 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 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(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 { + // 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) + } + + /// 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 = 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]), + 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 = 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<(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(()) + } + + /// 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. + 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 = 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 = get_u64(&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 = 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 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 = read_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, 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)?; + 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)) }) + } +} + +/// 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 new file mode 100644 index 0000000..ce28f19 --- /dev/null +++ b/bstack_raii/src/stdlib/util.rs @@ -0,0 +1,547 @@ +//! 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 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 { + let mut b = [0u8; 8]; + stack.get_into(off, &mut b)?; + 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())) +} + +/// 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) — +/// 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]; + #[allow(clippy::chunks_exact_to_as_chunks)] + 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 +/// 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( + 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, 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 +/// 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<'w, A, R2, W>( + allocator: &A, + reads1: &[u64], + reads2: R2, + plan: W, +) -> io::Result<()> +where + A: BStackRaiiAllocator, + R2: FnOnce(&[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. + 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: &'w [(u64, SmallBuf)] = &[]; + + 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 + }) +} + +/// 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, SmallBuf)>), +} + +/// 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: BStackRaiiAllocator, + I: FnMut(&Meta, u64, &[u8]) -> ProbeStep, + 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, SmallBuf)> = 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 + }) +} + +/// 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(()) +} diff --git a/bstack_raii/src/teardown.rs b/bstack_raii/src/teardown.rs new file mode 100644 index 0000000..bf48d1e --- /dev/null +++ b/bstack_raii/src/teardown.rs @@ -0,0 +1,246 @@ +//! 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 core::cell::RefCell; +use core::mem::ManuallyDrop; +use core::ops::Deref; +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! { + /// 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. + /// + /// 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, +/// 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 ([`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). +/// +/// 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`](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, +) -> 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); + } + // No anchor → the allocator opts out of reclamation: plain teardown. + 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(); + // 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 +} + +/// 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. +/// +/// 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, + slices: Vec<(FileId, BStackRange)>, +) -> io::Result<()> { + 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 (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 + // 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, &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_locked(allocator)?; + Ok(()) +} + +/// 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 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<()>; +} + +/// 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<()> { + // 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) => { + // 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, + }); + if deferred { + return Ok(()); + } + let owned: BStackOwnedSlice<'_, A> = + 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: BStackRaiiAllocator> { + inner: ManuallyDrop, + allocator: &'a A, +} + +impl<'a, T: BStackDrop, A: BStackRaiiAllocator> 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().get_field(stack)`. + pub fn handle(&self) -> &T { + &self.inner + } +} + +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: 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`. + let _ = inner.bstack_drop(self.allocator); + } +} diff --git a/bstack_raii/src/tests.rs b/bstack_raii/src/tests.rs new file mode 100644 index 0000000..577ad52 --- /dev/null +++ b/bstack_raii/src/tests.rs @@ -0,0 +1,9344 @@ +//! 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, BStackRange, FirstFitBStackAllocator, GhostTreeBstackAllocator, +}; + +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, + BStackRaiiAllocator, BStackRc, BStackRef, BStackShared, BStackString, BStackWeakable, EightCC, + TryClone, TryCloneIn, alloc_block, bstack_block, bstack_cast, bstack_enum, bstack_move, + build_control_payload, dealloc_range, +}; + +// -------------------------------------------------------------------------- +// 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() + } + + /// 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 { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// 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 +// -------------------------------------------------------------------------- + +/// 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 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) { + 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 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; + 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 +} + +// -------------------------------------------------------------------------- +// #[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; + + // 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)) } + }); +} + +// 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 +// -------------------------------------------------------------------------- + +#[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 +// -------------------------------------------------------------------------- + +#[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(); + + // 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)) }; + 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. + MacroStrongChild::drop_strong_ref(unsafe { BStackRef::from_range(child) }, &alloc).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"); +} + +// -------------------------------------------------------------------------- +// 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().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().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().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. + 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().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().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). + assert_ne!( + clone.handle().range().start(), + parent.handle().range().start() + ); + assert_ne!( + 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() + .get_child(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 42 + ); + 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(); + 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().get_val(stack).unwrap(), 5); + drop(rc_keep); +} + +#[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() + .get_child(stack) + .unwrap() + .get_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); +} + +// 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). +// -------------------------------------------------------------------------- + +#[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().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 + // 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().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. + 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. + // 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 + // because bstack_move! frees only the parent shell. + assert_eq!(child.handle().range().start(), leaf_off); + 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. + child.bstack_drop(&alloc).unwrap(); + 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, &alloc).unwrap(); + assert_eq!(n, 5); + + // The strong field came back as a live BStackRc. + 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 + .as_ref() + .unwrap() + .upgrade() + .unwrap() + .expect("wt alive"); + assert_eq!(up.handle().get_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) +} + +// -------------------------------------------------------------------------- +// 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(overlong_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); +} + +// -------------------------------------------------------------------------- +// 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() + .get_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) — 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 (bare) handle. + let owned = bstack_cast!(slice as BStackOwned) + .unwrap() + .ok() + .unwrap(); + assert_eq!(owned.handle().get_val(stack).unwrap(), 9); + owned.bstack_drop(&alloc).unwrap(); // 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().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. + moved_leaf.bstack_drop(&alloc).unwrap(); + 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().get_val(stack).unwrap(), 9); + + // The data block is gone, so the weak can no longer upgrade. + assert!(weak.upgrade().unwrap().is_none()); + + moved_leaf.bstack_drop(&alloc).unwrap(); // 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().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() + .get_val(stack) + .unwrap(), + 42 + ); + 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( + &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().get_n(stack).unwrap(), 9); + assert!(empty.handle().get_child(stack).unwrap().is_none()); + empty.bstack_drop(&alloc).unwrap(); +} + +// -------------------------------------------------------------------------- +// 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 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"); + + // 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!(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 (there is no descriptor block). + 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(); +} + +// -------------------------------------------------------------------------- +// Vec / String fields (POD elements) via BStackVec +// -------------------------------------------------------------------------- + +#[bstack_block] +struct Record { + // POD vectors are un-annotated (an annotation would mean block elements). + name: String, + 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().get_id(stack).unwrap(), 42); + + // Accessors return BStackVec handles (take the allocator). + assert_eq!( + rec.handle().get_name(&alloc).unwrap().to_vec().unwrap(), + b"hello" + ); + assert_eq!( + 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().get_tags(&alloc).unwrap(); + tags.push(4).unwrap(); + assert_eq!( + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3, 4], + ); + + // 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(); + assert_eq!( + rec2.handle().get_name(&alloc).unwrap().to_vec().unwrap(), + b"again" + ); + assert_eq!( + rec2.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), + vec![9u32] + ); + 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().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().get_tags(&alloc).unwrap(); + tags.push(4).unwrap(); + + 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().get_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(); + 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, &alloc).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(); +} + +// -------------------------------------------------------------------------- +// #[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 tree = Tree::new(&alloc, kids, 7).unwrap(); + assert_eq!(tree.handle().get_label(stack).unwrap(), 7); + + // Accessor resolves to a BStackBlockVec; read the children back. + 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.get_val(stack).unwrap()) + .collect(); + assert_eq!(vals, vec![10, 20, 30]); + 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 + // and descriptor — reclaimed with no leak. + tree.bstack_drop(&alloc).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] +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().get_val(stack).unwrap(), 1); + + // The moved-out vector is independently owned; free it (children + arrays). + 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().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] + ); + + // The clone's data blocks are fresh allocations, distinct from the original's. + 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().get_tags(&alloc).unwrap(); + ct.push(99).unwrap(); + assert_eq!( + clone.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3, 99] + ); + assert_eq!( + rec.handle().get_tags(&alloc).unwrap().to_vec().unwrap(), + vec![1u32, 2, 3] + ); + + clone.bstack_drop(&alloc).unwrap(); + 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(); + 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() + .get_kids(&alloc) + .unwrap() + .get(0) + .unwrap() + .unwrap() + .range() + .start(); + + let clone = tree.try_clone_in(&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.get_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() + .get_kids(&alloc) + .unwrap() + .get(0) + .unwrap() + .unwrap() + .get_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().get_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 +// -------------------------------------------------------------------------- + +#[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().get_items(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 2); + 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().get_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().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().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().get_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().get_links(&alloc).unwrap(); + assert_eq!(v.len().unwrap(), 2); + 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().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(); +} + +// -------------------------------------------------------------------------- +// 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().get_id(stack).unwrap(), 7); + assert_eq!( + a.handle() + .get_tags(&alloc) + .unwrap() + .expect("some") + .to_vec() + .unwrap(), + vec![1u32, 2, 3] + ); + assert_eq!( + a.handle() + .get_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().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(); +} + +// -------------------------------------------------------------------------- +// #[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, 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, NodeData::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, NodeData::Child(leaf)).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Child(c) => assert_eq!(c.get_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, NodeData::Link(link)).unwrap(); + match e.handle().read(&alloc).unwrap() { + NodeView::Link(l) => assert_eq!(l.get_val(stack).unwrap(), 9), + _ => panic!("expected Link"), + } + e.bstack_drop(&alloc).unwrap(); + assert_eq!(keep.handle().get_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 = 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().get_tag(stack).unwrap(), 3); + + // Traverse struct -> enum -> owned child. + let node = holder.handle().get_node(stack).unwrap(); + match node.read(&alloc).unwrap() { + NodeView::Child(c) => assert_eq!(c.get_val(stack).unwrap(), 5), + _ => panic!("expected Child"), + } + + // Freeing the struct recursively frees the enum and its owned child — + // reclaimed with no leak. + holder.bstack_drop(&alloc).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() + }); +} + +// -------------------------------------------------------------------------- +// #[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, RcNodeData::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.get_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() }; +} + +#[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, + #[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, RcwNodeData::One(leaf)).unwrap(); // BStackRc, strong = 1 + let weak = rc.downgrade().unwrap(); + + match rc.handle().read(&alloc).unwrap() { + RcwNodeView::One(c) => assert_eq!(c.get_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, CellData::Shared(child)).unwrap(); // consumes child's ref + match cell.handle().read(&alloc).unwrap() { + 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().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().get_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, CellData::Nil).unwrap(); + assert!(matches!(cell.handle().read(&alloc).unwrap(), CellView::Nil)); + 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.get_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.get_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.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().get_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().get_val(stack).unwrap(), 11); + drop(keep); +} + +// -------------------------------------------------------------------------- +// 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().get_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().get_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().get_val(stack).unwrap(), 11); + drop(rc); // releases the moved-out strong ref + } + _ => panic!("expected Shared"), + } + assert_eq!(keep.handle().get_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() + .get_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(); +} + +// -------------------------------------------------------------------------- +// #[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.get_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); +} + +// -------------------------------------------------------------------------- +// #[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"), + } +} + +// -------------------------------------------------------------------------- +// 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().get_maybe(stack).unwrap(), + core::num::NonZeroU32::new(7) + ); + 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. + 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().get_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().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(); + 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(); + + // Struct embed: parent -> embedded 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(); + 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). + holder.bstack_drop(&alloc).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(); + 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() + .get_leaf(stack) + .unwrap() + .get_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.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().get_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() + .get_child() + .get_leaf(stack) + .unwrap() + .range() + .start(); + + let clone = holder.try_clone_in(&alloc).unwrap(); + 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.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. + clone.bstack_drop(&alloc).unwrap(); + assert_eq!( + holder + .handle() + .get_child() + .get_leaf(stack) + .unwrap() + .get_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.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.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.get_leaf(stack).unwrap().get_val(stack).unwrap(), 3), + _ => panic!("expected Wrap"), + } + e.bstack_drop(&alloc).unwrap(); +} + +// -------------------------------------------------------------------------- +// WAL: completing / abandoning a crash-left transaction +// -------------------------------------------------------------------------- + +#[test] +fn wal_finish_rolls_forward_committed() { + use crate::wal::{WalEntry, WalLog, WalStatus}; + use crate::wal::{finish, persist_at}; + + let tmp = TempStack::new(); + 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(); + + // 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, &log, WalStatus::Complete).unwrap(); + + // Completing it rolls both deallocs forward. + 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(&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::{WalEntry, WalLog, WalStatus}; + use crate::wal::{finish, persist_at}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + 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, &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(&alloc).unwrap(), 0); + // Idle after completion: re-running finds nothing staged. + assert_eq!(finish(&alloc).unwrap(), 0); +} + +#[test] +fn wal_anchor_trait_reclaims_via_finish() { + use crate::wal::{WalEntry, WalLog, WalStatus}; + use crate::wal::{finish, persist_at}; + + let tmp = TempStack::new(); + 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, &log, WalStatus::Pending).unwrap(); + + // 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()); +} + +#[test] +fn wal_finish_reclaims_abandoned_allocs() { + use crate::wal::{WalEntry, WalLog, WalStatus}; + use crate::wal::{finish, persist_at}; + + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + // 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, &log, WalStatus::Pending).unwrap(); + + // Reclaiming the abandoned txn frees both orphans. + 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); 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, &log2, WalStatus::Complete).unwrap(); + 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 + // 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::{WalEntry, WalLog, WalStatus}; + use crate::wal::{finish, persist_at}; + + // 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] +// -------------------------------------------------------------------------- + +#[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().get_xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().get_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 l0 = MacroLeaf::new(&alloc, 10).unwrap(); + let l1 = MacroLeaf::new(&alloc, 20).unwrap(); + let l2 = MacroLeaf::new(&alloc, 30).unwrap(); + + let h = ArrHolder::new(&alloc, [l0, l1, l2], 7).unwrap(); + 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(); + 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] +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().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().get_leaves(stack).unwrap()[2] + .get_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().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().get_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().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(); + 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().get_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().get_val(stack).unwrap(), 10); + assert_eq!(leaves[2].handle().get_val(stack).unwrap(), 30); + for l in leaves { + 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().get_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().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().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().get_val(stack).unwrap(), 5); + assert_eq!(c1.handle().get_val(stack).unwrap(), 6); + 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().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().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().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(), + 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().get_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().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().get_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().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)); + 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().get_tag(stack).unwrap(), 99); + + // Accessor: `[EmbChild; 2]` handles into the inline slots (pure offset math). + 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().get_kids(); + assert_eq!( + ckids[1].get_leaf(stack).unwrap().get_val(stack).unwrap(), + 20 + ); + assert_ne!( + 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().get_kids()[1] + .get_leaf(stack) + .unwrap() + .get_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() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 10 + ); + for m in moved { + 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].get_val(stack).unwrap(), 10); + assert_eq!(arr[1].get_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].get_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().get_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].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().get_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].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().get_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().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().get_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(); +} + +// -------------------------------------------------------------------------- +// 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().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().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().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().get_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().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().get_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().get_tag(stack).unwrap(), 5); + + 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().get_kids(); + assert_eq!( + cg[0][1].get_leaf(stack).unwrap().get_val(stack).unwrap(), + 20 + ); + assert_ne!( + cg[0][0].get_leaf(stack).unwrap().range().start(), + g[0][0].get_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() + .get_leaf(stack) + .unwrap() + .get_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.get_val(stack).unwrap()), Some(10)); + assert!(arr[1].is_none()); + assert_eq!(arr[2].map(|h| h.get_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.get_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().get_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].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"), + } + + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + EmbArrEnumView::Kids(arr) => { + assert_eq!(arr[1].get_leaf(stack).unwrap().get_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() + .get_leaf(stack) + .unwrap() + .get_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].get_val(stack).unwrap(), 1); + assert_eq!(g[1][1].get_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().get_val(stack).unwrap(), 3); + for row in g { + for m in row { + m.bstack_drop(&alloc).unwrap(); + } + } + } + _ => panic!("expected Grid"), + } +} + +// -------------------------------------------------------------------------- +// Inline arrays of vectors `[Vec; N]` (N independent inline VecDescs) +// -------------------------------------------------------------------------- + +#[bstack_block] +struct PodVecArr { + rows: [Vec; 2], + tag: u32, +} + +#[test] +fn macro_pod_vec_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let h = PodVecArr::new(&alloc, [&[1u32, 2][..], &[3, 4, 5][..]], 9).unwrap(); + assert_eq!(h.handle().get_tag(stack).unwrap(), 9); + + 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().get_rows(&alloc).unwrap(); + rows_mut[0].push(99).unwrap(); + assert_eq!( + h.handle().get_rows(&alloc).unwrap()[0].to_vec().unwrap(), + vec![1u32, 2, 99] + ); + assert_eq!( + 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().get_rows(&alloc).unwrap(); + assert_eq!(crows[1].to_vec().unwrap(), vec![3u32, 4, 5]); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!( + h.handle().get_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(); + assert_eq!(tag, 9); + assert_eq!(moved[1].to_vec().unwrap(), vec![3u32, 4, 5]); + for v in moved { + v.bstack_drop().unwrap(); + } +} + +#[bstack_block] +struct RefVecArr { + #[bstack_ref] + lists: [Vec; 2], +} + +#[test] +fn macro_ref_vec_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 = RefVecArr::new(&alloc, [vec![r(0), r(1)], vec![r(2), r(3)]]).unwrap(); + + 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().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().get_val(stack).unwrap() < 4); + l.bstack_drop(&alloc).unwrap(); + } +} + +#[bstack_block] +struct OwnedVecArr { + #[bstack_owned] + groups: [Vec; 2], + tag: u32, +} + +#[test] +fn macro_owned_vec_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + 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().get_tag(stack).unwrap(), 7); + + let gs = h.handle().get_groups(&alloc).unwrap(); + assert_eq!(gs[0].len().unwrap(), 2); + 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().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().get_groups(&alloc).unwrap()[1] + .get(0) + .unwrap() + .unwrap() + .get_val(stack) + .unwrap(), + 20 + ); + + h.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct OptVecArr { + slots: [Option>; 3], +} + +#[test] +fn macro_option_vec_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let h = OptVecArr::new(&alloc, [Some(&[1u32, 2][..]), None, Some(&[9][..])]).unwrap(); + 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]); + h.bstack_drop(&alloc).unwrap(); +} + +// -------------------------------------------------------------------------- +// #[bstack_ref] Vec<[T; N]> — a vector of fixed-size arrays of references +// -------------------------------------------------------------------------- + +#[bstack_block] +struct RefVecOfArr { + #[bstack_ref] + rows: Vec<[MacroLeaf; 2]>, + tag: u32, +} + +#[test] +fn macro_ref_vec_of_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 = RefVecOfArr::new(&alloc, vec![[r(0), r(1)], [r(2), r(3)]], 7).unwrap(); + assert_eq!(h.handle().get_tag(stack).unwrap(), 7); + + let rows = h.handle().get_rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> + assert_eq!(rows.len(), 2); + 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().get_rows(&alloc).unwrap(); + assert_eq!( + crows[1][0].range().start(), + rows[1][0].range().start() // same target (aliased) + ); + clone.bstack_drop(&alloc).unwrap(); + // Original + targets still alive after clone teardown. + assert_eq!( + 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().get_val(stack).unwrap() < 4); + l.bstack_drop(&alloc).unwrap(); + } +} + +#[bstack_block] +struct OwnedVecOfArr { + #[bstack_owned] + rows: Vec<[MacroLeaf; 2]>, + tag: u32, +} + +#[test] +fn macro_owned_vec_of_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + 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().get_tag(stack).unwrap(), 7); + + let rows = h.handle().get_rows(&alloc).unwrap(); // Vec<[MacroLeaf; 2]> + assert_eq!(rows.len(), 2); + 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().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().get_rows(&alloc).unwrap()[1][0] + .get_val(stack) + .unwrap(), + 3 + ); + + h.bstack_drop(&alloc).unwrap(); +} + +#[bstack_block] +struct StrongVecOfArr { + #[bstack_strong] + groups: Vec<[MacroStrongChild; 2]>, +} + +#[test] +fn macro_strong_vec_of_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 10).unwrap(); + let b = MacroStrongChild::new(&alloc, 20).unwrap(); + let a_keep = a.try_clone().unwrap(); // a strong = 2 + let a_data = a_keep.handle().range().start(); + + let h = StrongVecOfArr::new(&alloc, vec![[a, b]]).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); // h + a_keep + + 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(); + assert_eq!(strong_of(stack, a_data), 3); // h + a_keep + clone + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); + + h.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 1); // a_keep only + drop(a_keep); +} + +#[bstack_block] +struct WeakVecOfArr { + #[bstack_weak] + groups: Vec<[MacroStrongChild; 2]>, +} + +#[test] +fn macro_weak_vec_of_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 1).unwrap(); + let b = MacroStrongChild::new(&alloc, 2).unwrap(); + let h = WeakVecOfArr::new( + &alloc, + vec![[a.downgrade().unwrap(), b.downgrade().unwrap()]], + ) + .unwrap(); + + 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().get_groups(&alloc).unwrap(); + assert!(g[0][0].is_none()); + assert!(g[0][1].is_some()); + drop(g); + + // Teardown releases each weak count. + h.bstack_drop(&alloc).unwrap(); + drop(b); +} + +// -------------------------------------------------------------------------- +// Vec and Vec<[T; N]> in enum variants +// -------------------------------------------------------------------------- + +#[bstack_enum] +enum OwnedVecEnum { + Empty, + #[bstack_owned] + Items(Vec), +} + +#[test] +fn macro_enum_owned_vec() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let items = BStackBlockVec::from_handles( + &alloc, + vec![ + MacroLeaf::new(&alloc, 10).unwrap(), + MacroLeaf::new(&alloc, 20).unwrap(), + ], + ) + .unwrap(); + let e = OwnedVecEnum::new(&alloc, OwnedVecEnumData::Items(items)).unwrap(); + + match e.handle().read(&alloc).unwrap() { + OwnedVecEnumView::Items(v) => { + assert_eq!(v.len().unwrap(), 2); + 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"), + } + + // 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().get_val(stack).unwrap(), 20) + } + _ => panic!("expected Items"), + } + clone.bstack_drop(&alloc).unwrap(); + + // Move hands back the vector handle. + match bstack_move!(e, &alloc).unwrap() { + OwnedVecEnumData::Items(v) => { + assert_eq!(v.get(0).unwrap().unwrap().get_val(stack).unwrap(), 10); + v.bstack_drop().unwrap(); + } + _ => panic!("expected Items"), + } +} + +#[bstack_enum] +enum RefVecArrEnum { + Empty, + #[bstack_ref] + Rows(Vec<[MacroLeaf; 2]>), +} + +#[test] +fn macro_enum_ref_vec_of_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 e = RefVecArrEnum::new( + &alloc, + RefVecArrEnumData::Rows(vec![[r(0), r(1)], [r(2), r(3)]]), + ) + .unwrap(); + + match e.handle().read(&alloc).unwrap() { + RefVecArrEnumView::Rows(v) => { + // Vec<[MacroLeaf; 2]> + assert_eq!(v.len(), 2); + assert_eq!(v[0][0].get_val(stack).unwrap(), 0); + assert_eq!(v[1][1].get_val(stack).unwrap(), 3); + } + _ => panic!("expected Rows"), + } + + // A ref vec of arrays owns nothing: teardown leaves targets alive. + e.bstack_drop(&alloc).unwrap(); + for l in leaves { + assert!(l.handle().get_val(stack).unwrap() < 4); + l.bstack_drop(&alloc).unwrap(); + } +} + +#[bstack_enum] +enum OwnedVecArrEnum { + Empty, + #[bstack_owned] + Grid(Vec<[MacroLeaf; 2]>), +} + +#[test] +fn macro_enum_owned_vec_of_array() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let mk = |v| MacroLeaf::new(&alloc, v).unwrap(); + let e = OwnedVecArrEnum::new( + &alloc, + OwnedVecArrEnumData::Grid(vec![[mk(1), mk(2)], [mk(3), mk(4)]]), + ) + .unwrap(); + + match e.handle().read(&alloc).unwrap() { + OwnedVecArrEnumView::Grid(v) => { + assert_eq!(v.len(), 2); + assert_eq!(v[0][0].get_val(stack).unwrap(), 1); + assert_eq!(v[1][1].get_val(stack).unwrap(), 4); + } + _ => panic!("expected Grid"), + } + + let clone = e.try_clone_in(&alloc).unwrap(); + clone.bstack_drop(&alloc).unwrap(); + + // 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().get_val(stack).unwrap(), 3); + for row in v { + for m in row { + m.bstack_drop(&alloc).unwrap(); + } + } + } + _ => panic!("expected Grid"), + } +} + +#[bstack_enum] +enum StrongVecEnum { + Empty, + #[bstack_strong] + Items(Vec), +} + +#[test] +fn macro_enum_strong_vec_rc() { + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + let stack = alloc.stack(); + + let a = MacroStrongChild::new(&alloc, 10).unwrap(); + let a_keep = a.try_clone().unwrap(); // a strong = 2 + let a_data = a_keep.handle().range().start(); + + let items = crate::BStackStrongVec::from_handles(&alloc, vec![a]).unwrap(); + let e = StrongVecEnum::new(&alloc, StrongVecEnumData::Items(items)).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); // e + a_keep + + // Clone bumps the strong count; its teardown restores it. + let clone = e.try_clone_in(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 3); + clone.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 2); + + e.bstack_drop(&alloc).unwrap(); + assert_eq!(strong_of(stack, a_data), 1); // a_keep only + drop(a_keep); +} + +#[bstack_enum] +enum PodVecEnum { + Empty, + Nums(Vec), + Text(String), +} + +#[test] +fn macro_enum_pod_vec() { + use crate::BStackVec; + let tmp = TempStack::new(); + let alloc = tmp.allocator(); + + let nums = BStackVec::::from_slice(&alloc, &[1u32, 2, 3]).unwrap(); + let e = PodVecEnum::new(&alloc, PodVecEnumData::Nums(nums)).unwrap(); + match e.handle().read(&alloc).unwrap() { + PodVecEnumView::Nums(v) => assert_eq!(v.to_vec().unwrap(), vec![1u32, 2, 3]), + _ => panic!("expected Nums"), + } + + // Clone deep-copies the data block. + let clone = e.try_clone_in(&alloc).unwrap(); + match clone.handle().read(&alloc).unwrap() { + PodVecEnumView::Nums(v) => assert_eq!(v.to_vec().unwrap(), vec![1u32, 2, 3]), + _ => panic!("expected Nums"), + } + clone.bstack_drop(&alloc).unwrap(); + + // Move hands back the BStackVec. + match bstack_move!(e, &alloc).unwrap() { + PodVecEnumData::Nums(v) => { + assert_eq!(v.to_vec().unwrap(), vec![1u32, 2, 3]); + v.bstack_drop().unwrap(); + } + _ => panic!("expected Nums"), + } + + // String variant round-trips as bytes. + let text = BStackVec::::from_slice(&alloc, b"hello").unwrap(); + let e2 = PodVecEnum::new(&alloc, PodVecEnumData::Text(text)).unwrap(); + match e2.handle().read(&alloc).unwrap() { + PodVecEnumView::Text(v) => assert_eq!(v.to_vec().unwrap(), b"hello"), + _ => panic!("expected Text"), + } + 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().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().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().get_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.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(); + assert_eq!(tag, 3); + 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().get_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().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().get_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 b = OwnedBox::::new(&alloc, leaf, 7).unwrap(); + 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().get_item(stack).unwrap(); + assert_eq!(citem.get_val(stack).unwrap(), 42); + assert_ne!( + citem.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().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(); + assert_teardown_reclaims(&alloc, || { + let leaf = MacroLeaf::new(&alloc, 42).unwrap(); + OwnedBox::::new(&alloc, leaf, 7).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().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(); + 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().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().get_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().get_tag(stack).unwrap(), 99); + // Accessor: an EmbChild handle into the inline slot (pure offset math). + assert_eq!( + b.handle() + .get_item() + .get_leaf(stack) + .unwrap() + .get_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() + .get_item() + .get_leaf(stack) + .unwrap() + .get_val(stack) + .unwrap(), + 10 + ); + assert_ne!( + 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() + .get_leaf(stack) + .unwrap() + .get_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.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.get_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().get_val(stack).unwrap(), 42); + owned.bstack_drop(&alloc).unwrap(); + } + _ => panic!("expected Item"), + } +} + +#[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, + #[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); +} + +// -------------------------------------------------------------------------- +// 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().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!( + 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().get_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().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().get_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().get_xs(stack).unwrap(), [1u16, 2, 3, 4]); + assert_eq!(p.handle().get_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().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().get_val(stack).unwrap(), 7); + owned.bstack_drop(&alloc).unwrap(); + + // The borrowed source is untouched. + assert_eq!(base.handle().get_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().get_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().get_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().get_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().get_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() + .get_boxed(stack) + .unwrap() + .get(stack) + .unwrap(), + 500 + ); + 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().get_boxed(stack).unwrap().range().start(), + holder.handle().get_boxed(stack).unwrap().range().start(), + ); + assert_eq!( + clone.handle().get_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(); +} + +// -------------------------------------------------------------------------- +// 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.get_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().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().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]); + + 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().get_val(stack).unwrap(), 3); + back.bstack_drop(&alloc).unwrap(); + + let front = list.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(front.handle().get_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 (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] +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(); +} + +/// 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.get_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(); +} + +// -------------------------------------------------------------------------- +// 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.get_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().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().get_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().get_val(stack).unwrap(), 3); + back.bstack_drop(&alloc).unwrap(); + + let front = dq.pop_front(&alloc).unwrap().unwrap(); + assert_eq!(front.handle().get_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(); + + // 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] +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(); +} + +// -------------------------------------------------------------------------- +// 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().get_val(stack).unwrap(), + 700 + ); + assert_eq!( + map.get(stack, &9).unwrap().unwrap().get_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().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().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().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()); + 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().get_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().get_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().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() + .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(); + + // 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] +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() + .get_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().get_val(stack).unwrap(), + 103 + ); + + clone.bstack_drop(&alloc).unwrap(); + 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.get_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() + .get_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().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() + .get_val(stack) + .unwrap(), + 9999 + ); + + 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() + .get_val(stack) + .unwrap(), + i as u32 + ); + } + 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(); + + // 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] +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() + .get_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() + .get_val(stack) + .unwrap(), + 110 + ); + assert_eq!( + tree.get(stack, &10) + .unwrap() + .unwrap() + .get_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() + .get_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] +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() + .get_val(alloc.stack()) + .unwrap(), + k + ); + } + + 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(); +} + +// -------------------------------------------------------------------------- +// 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.get_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(); +} + +// -------------------------------------------------------------------------- +// 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(), + ); +} + +#[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().get_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().get_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() + .get_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(); +} + +// -------------------------------------------------------------------------- +// 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().get_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 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(); + + // 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] +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(), + ); +} + +// -------------------------------------------------------------------------- +// 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().get_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().get_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.get_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.get_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(); +} + +// -------------------------------------------------------------------------- +// 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.get_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.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.get_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.get_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.get_val(stack).unwrap(), 70); + + 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 BStackRaiiAllocator + 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))))); + // 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(); + 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() + .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_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() { + 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_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). + 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 + }; + + // 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(); + + // 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), + }))); + // 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"); + + // `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(); +} + +// -------------------------------------------------------------------------- +// 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(); + // 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(), 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. + 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(1)); + assert_eq!(reg.id_of(&ghost).map(FileId::get), Some(2)); + assert_eq!( + 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(1).unwrap())); + } + + 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(); +} + +#[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(); +} + +// 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(); +} + +// 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(); + 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(); +} + +// -------------------------------------------------------------------------- +// 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()) + .unwrap(), + Some(77) + ); + + // 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()) + .unwrap_err() + .kind(), + io::ErrorKind::NotFound + ); + + // 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()) + .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()) + .unwrap(), + Some(88) + ); + assert!(h.handle().get_maybe(stack).unwrap().is_none()); // the `None` niche + + // 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()) + .unwrap(), + Some(88) + ); + + // 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); +} + +#[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. +#[bstack_block] +struct ForeignStrongHolder { + tag: u32, + #[bstack_strong] + 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, +} + +// 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>, +} + +// 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. +#[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>>, +} + +// 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 + // `#[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] +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 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() + .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_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 + // 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_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() + .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() + .unwrap(), + o.with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .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() + .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() + .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() + .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() + .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() + .unwrap(), + 11 + ); + assert_eq!( + got[2] + .unwrap() + .with(&home_alloc, |t, fs| t.get_val(fs).unwrap()) + .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_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() + .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() + .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() + .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() + .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() + .unwrap(), + 11 + ); + let f2 = f2.expect("Some"); + assert_eq!( + f2.with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .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() + .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() + .unwrap(), + 7 + ); + assert_eq!( + a[1].with(&home_alloc, |x, fs| x.get_val(fs).unwrap()) + .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() + .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() + .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() + .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 + // 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}; + 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 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()); + 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)`): 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) }; + 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); +} diff --git a/bstack_raii/src/vec.rs b/bstack_raii/src/vec.rs new file mode 100644 index 0000000..6d20a52 --- /dev/null +++ b/bstack_raii/src/vec.rs @@ -0,0 +1,838 @@ +//! 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 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 +//! struct field: [ data_off, data_size ] ── points to ──▶ data block (may realloc/move) +//! ``` +//! +//! 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. `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. + +use core::marker::PhantomData; +use core::mem::size_of; +use std::io; + +use crate::BStackRaiiAllocator; +use bstack::{BStack, BStackByteVec, BStackOwnedSlice, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use crate::block::{BStackBlock, BStackShared, BStackWeakable}; +use crate::clone::ClonePlan; +use crate::handle::WeakRef; +use crate::layout::{get_u64, put_u64}; +use crate::owned::BStackOwned; +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 `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(&mut img, 0, len); + put_u64(&mut 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. +fn build_offset_desc( + allocator: &A, + offs: &[u64], + plan: &mut ClonePlan, +) -> io::Result { + // 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 +/// 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, +} + +/// 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: get_u64(&buf[0..8]), + data_size: get_u64(&buf[8..16]), + }) +} + +/// 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. 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: BStackRaiiAllocator> { + /// 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: 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. + /// + /// # Safety + /// `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, + }) + } + + /// 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 { + Self { + data: BStackRange::new(desc.data_off, desc.data_size), + writeback: None, + allocator, + _marker: PhantomData, + } + } + + /// 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. + pub fn allocator(&self) -> &'a A { + self.allocator + } + + /// 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 block = unsafe { BStackOwnedSlice::from_raw_range(self.allocator, self.data) }; + Ok(unsafe { BStackByteVec::from_raw_block(block) }) + } + + /// 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<()> { + unsafe { dealloc_range(self.allocator, self.data) } + } +} + +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 { + let data_range = BStackByteVec::from_slice(bytemuck::cast_slice(data), allocator)? + .into_raw_block() + .as_range(); + Ok(Self { + data: data_range, + writeback: None, + allocator, + _marker: PhantomData, + }) + } + + /// 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()? / 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 = size_of::(); + Ok(bytes + .chunks_exact(esz) + .map(bytemuck::pod_read_unaligned::) + .collect()) + } + + /// 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 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: 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 slice = self.allocator.alloc(BYTEVEC_HEADER + new_cap)?; + let new_range = slice.as_range(); + 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); + } + + // 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`]: + /// 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 { + // 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) + } +} + +// --------------------------------------------------------------------------- +// 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**. +/// +/// 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: BStackRaiiAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +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. + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// 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 { + offsets: BStackVec::from_desc(desc, allocator), + _marker: PhantomData, + } + } + + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { + 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 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 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() + .map(|c| c.into_inner().range().start()) + .collect(); + Ok(Self { + offsets: BStackVec::from_slice(allocator, &offs)?, + _marker: PhantomData, + }) + } + + /// 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. + pub fn push_owned(&mut self, child: BStackOwned) -> io::Result<()> { + self.offsets.push(child.into_inner().range().start()) + } + + /// 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()? { + T::from_range(Self::elem_range(off)).bstack_drop(allocator)?; + } + 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 +/// children (`(rc)` / `(rc, weak)` blocks). +/// +/// 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: BStackRaiiAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +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. + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// 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 { + offsets: BStackVec::from_desc(desc, allocator), + _marker: PhantomData, + } + } + + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { + 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 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() + .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. 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() + } + + /// 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 +/// 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. Backs `#[bstack_weak] Vec` +/// fields. +pub struct BStackWeakVec<'a, T: BStackWeakable, A: BStackRaiiAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +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. + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// 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 { + offsets: BStackVec::from_desc(desc, allocator), + _marker: PhantomData, + } + } + + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { + 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 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() + .map(|w| w.into_raw().into_range().start()) + .collect(); + 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<()> { + 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), + /// 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()? { + WeakRef::(Self::ctrl_ref(off)).bstack_drop(allocator)?; + } + 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. +/// +/// 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: BStackRaiiAllocator> { + offsets: BStackVec<'a, u64, A>, + _marker: PhantomData T>, +} + +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). + pub unsafe fn from_field(loc: u64, allocator: &'a A) -> io::Result { + Ok(Self { + offsets: unsafe { BStackVec::from_field(loc, allocator)? }, + _marker: PhantomData, + }) + } + + /// 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 { + offsets: BStackVec::from_desc(desc, allocator), + _marker: PhantomData, + } + } + + /// The current descriptor value — what a field stores inline. + pub fn descriptor(&self) -> VecDesc { + 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 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 { + 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 (elements are not owned). Consumes the handle. + 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) + } +} diff --git a/bstack_raii/src/wal.rs b/bstack_raii/src/wal.rs new file mode 100644 index 0000000..4f8f142 --- /dev/null +++ b/bstack_raii/src/wal.rs @@ -0,0 +1,993 @@ +//! 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 `(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). 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.) +//! +//! 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::collections::HashMap; +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; + +use bstack::{BStackBulkAllocator, BStackOwnedSlice, BStackRange}; +use bytemuck::{Pod, Zeroable}; + +use crate::BStackRaiiAllocator; +use crate::registry::{self, FileId}; +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). +// `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(crate) 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 +/// `{None < Pending < Complete}` plus the recovery sink `Abandon`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum WalStatus { + None = 0, + Pending = 1, + Complete = 2, + Abandon = 3, +} + +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, + 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. + #[cfg(test)] + 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(crate) 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, 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). `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(crate) 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`. + word_b: u64, +} + +impl WalEntry { + /// 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 **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) + } + + /// 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(), + } + } + + pub fn status(&self) -> WalStatus { + WalStatus::from_u8(self.status) + } + + pub fn op(&self) -> WalOp { + 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 + } + + /// 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(BStackRange::new(self.word_a, 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(crate) struct WalLog { + entries: Vec, + #[cfg(test)] + 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), + #[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); + 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 + } + + /// The log's on-disk image (a packed array of [`WalEntry`]). + #[cfg(test)] + 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. +#[cfg(test)] +#[derive(Debug, Default)] +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)>, + /// Requirements still needing a physical `Alloc`. + pub allocs: 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 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. +#[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 { + 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); + } + } + Reduced { + reused, + allocs: rem_allocs, + deallocs, + } +} + +// --------------------------------------------------------------------------- +// On-disk WAL block + completion runtime. +// +// The WAL block is **persistent and reused** (Vec-like), not allocated per +// transaction: `[WalHeader | WalEntry × capacity]`, reached through a stable +// 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: +// +// * `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" + +/// 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(crate) struct WalHeader { + magic: u64, + txn_status: u8, + _pad: [u8; 7], + count: u64, + capacity: u64, +} + +impl WalHeader { + fn txn_status(&self) -> WalStatus { + WalStatus::from_u8(self.txn_status) + } +} + +impl WalLog { + /// 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::()); + img.extend_from_slice(bytemuck::bytes_of(&header)); + img.extend_from_slice(bytemuck::cast_slice(&self.entries)); + img + } +} + +// `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 +/// 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. +/// 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) + } +} +unsafe impl BStackRaiiAllocator for bstack::GhostTreeBstackAllocator { + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) + } + // 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 { + Some(STD_WAL_ANCHOR) + } +} +unsafe impl BStackRaiiAllocator for bstack::CheckedSlabBStackAllocator { + fn wal_anchor(&self) -> Option { + Some(STD_WAL_ANCHOR) + } +} +// `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 +/// 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. +// +// 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() +} + +/// 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(slot, &mut buf)?; + let off = u64::from_le_bytes(buf); + Ok((off != 0).then_some(off)) +} + +/// 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. 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)? { + 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(slot, off.to_le_bytes())?; + Ok((off, capacity)) +} + +/// 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(crate) fn persist_at( + allocator: &A, + log: &WalLog, + txn_status: WalStatus, +) -> io::Result { + 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; + let esz = size_of::() as u64; + Ok(BStackRange::new(off, hsz + capacity * esz)) +} + +/// 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, +) -> io::Result)>> { + let stack = allocator.stack(); + let wal_off = match read_anchor(allocator)? { + 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); + 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 + header.capacity * size_of::() as u64; + Ok(Some(( + BStackRange::new(wal_off, block_size), + header, + entries, + ))) +} + +/// 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]) +} + +/// 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 +/// `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 +/// 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 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. 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) -> io::Result { + let (wal_range, header, entries) = match load_at(allocator)? { + Some(x) => x, + None => return Ok(0), + }; + let stack = allocator.stack(); + 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 esz = size_of::() as u64; + let mut completed = 0usize; + + // 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; + } + } + 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; + } + } + } + + // 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 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) +} + +#[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, BStackRange::new(0x1000, 256)); + assert_eq!(a.op(), WalOp::Alloc); + 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_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_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] + 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, + 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, + 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, + 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, + 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); + assert_eq!(r.deallocs.len(), 1); + } +}