# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.4] - 2026-09-04
### Added
- **`BStackChunk` mutation APIs (`alloc` + `set`, `atomic` additionally for the permutation ops), Rust only.** `swap(i, j)` exchanges two chunks via one crash-atomic `BStack::cross_exchange`. `reverse()` reverses chunk order and `rotate_left(k)`/`rotate_right(k)` rotate by `k` chunk positions, both chunk-granularity (the bytes within each chunk are untouched) via one crash-atomic `BStack::process`, same shape as `sort_by`. `fill(chunk)` fills every chunk with a copy of `chunk` via one crash-atomic `BStack::repeat` (O(1) journal staging regardless of region size); panics if `chunk.len() != chunk_len()`. `set(index, bytes)` overwrites one chunk, delegating to `get(index)` + `BStackSlice::copy_from_slice`.
- **`BStackChunk` read-only companions: `first`/`last`, `split_at`, `partition_point`, `is_sorted_by` (`alloc`, read-only), Rust only.** `first()`/`last()` are O(1) `get(0)`/`get(chunk_count() - 1)`; `split_at(mid)` splits a view into two chunk-granularity sub-views with no I/O, mirroring `with_stride`. `partition_point(pred)` mirrors `[T]::partition_point` and `is_sorted_by(cmp)` checks every chunk compares `<=` the next, each atomic against concurrent mutation. With the `atomic` feature, `partition_point` is O(log n) probes under one `BStack::get_batched_gen` lock — except below 512 bytes of total region size, where it instead takes the same one whole-region `BStack::get` bulk read the `not(atomic)` build always uses. `is_sorted_by` always via one whole-region `BStack::get` plus an in-memory scan, with no bounded-memory counterpart (unlike `sort_partial_by`) for regions too large for one buffer.
- **`BStackSlice::cas_on`/`cas_on_ne`/`cas_on_masked`/`process` (Rust, `alloc` + `set` + `atomic`) / `bstack_slice_cas_on`/`cas_on_ne`/`cas_on_masked`/`process` (C, `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`).** `cas_on(guard, expected, new_bytes)` is one crash-atomic `BStack::eq_crds`/`bstack_eq_crds` call: if `guard`'s bytes equal `expected`, the slice is overwritten with `new_bytes` and the prior contents returned (Rust: `Option<Vec<u8>>`; C: an `old_buf` buffer plus `int *ok` flag, matching the existing CRDS convention); `cas_on_ne`/`cas_on_masked` wrap `ne_crds`/`masked_eq_crds` the same way. Each rejects a `guard` backed by a different `BStack`/`bstack_t`, or a length mismatch against `guard`/self, with `io::ErrorKind::InvalidInput`/`errno = EINVAL`. `process(f)`/`bstack_slice_process` is one crash-atomic `BStack::process`/`bstack_process` call exposing the length-preserving transform now available for arbitrary transforms.
- **`BStackGenOp::Abort` (Rust) / `BSTACK_GEN_ABORT` (C), accepted by `process_gen` and `inplace_gen` (`set` + `atomic` / `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`).** Ends a generator sequence **without applying anything it accumulated** — the counterpart of ending with `None`/`0`, which commits everything. It closes the one gap in `inplace_gen`, whose `Write`s accumulate in memory and whose per-op failures are *reported* through the closure's argument (C: `prev_status`) rather than returned: a generator that saw an op rejected could not unwind since ending the sequence commit writes. An abort always leaves the payload byte-for-byte as it was. `source: Option<io::Error>` (C: `u.abort.status`) sets the outcome independently of the discard — `Some(e)`/non-zero fails the call with that error, `None`/`0` succeeds.
- **`BStackBulkAllocator` for `SlabBStackAllocator` and `CheckedSlabBStackAllocator` (`alloc` + `set` + `atomic`; Rust and C).** The two fixed-stride slab allocators now provide `alloc_bulk`/`dealloc_bulk` — in C via `bstack_allocator_alloc_bulk`/`dealloc_bulk` once `slab_bstack_allocator_t`/`checked_slab_bstack_allocator_t` populate `bulk_vtbl` (NULL without `atomic`) — returning per-request handles (each slab block stays independently deallocatable, unlike `GhostTreeBstackAllocator`'s single sliced block). `alloc_bulk` serves single-block requests from the free list first (one `process_gen` chase) and everything else from one `extend`, then claims the blocks in a single streamed multi-write. `dealloc_bulk` frees the whole batch as one chain committed with a single multi-write plus one atomic `cross_exchange` splice; the checked variant clears each freed overhead before the splice so a crash leaves only `recover`-reclaimable leaks, and rejects a double-free (including a block repeated within the batch) before any write. Free-list chases carry cycle detection. Both require `atomic`; the single-item `alloc`/`dealloc` remain the non-`atomic` API. Freed runs always go to the free list — a tail run is not discarded, unlike the single-item `dealloc`.
- **`BStackBulkAllocator` for `SegregatedBStackAllocator` (`alloc` + `set` + `atomic`; Rust and C).** Adds `alloc_bulk`/`dealloc_bulk` — in C via `bstack_allocator_alloc_bulk`/`dealloc_bulk` once `segregated_bstack_allocator_t` populates `bulk_vtbl` (NULL without `atomic`) — work bounded by the distinct size classes touched (≤ 33). `alloc_bulk` drains every touched class's free list in one atomic multi-class pop, matches oversized requests largest-first (unlinking only the matched blocks, carving slack at or above `SPLIT_MIN`), serves the misses from one `extend_sparse_batched`, and claims everything in one `set_batched`, relinking the carve remainders afterwards. `dealloc_bulk` reads all overheads under one lock (`get_batched_gen` in Rust, `bstack_get_batched` in C) and splices each class's freed chain onto its head with one `cross_exchange`, rejecting a double-free (including a repeat within the batch) first. Free-list chases carry cycle detection; a crash leaves detached blocks reclaimable by `recover`.
- **Read-side fault injection inside the generator methods (`fault-injection` feature, dev/test only, Rust only).** `get_batched_gen`, `process_gen` and `inplace_gen` now consult the armed `FaultPolicy` once per `Read` step under a `"<method>:read"` op name, on top of the existing whole-call consult, so a test can fail the *n*-th read of a multi-step chase rather than only the call as a whole. Policies matching on the plain method name are unaffected. a rate-based or `seq`-indexed **schedule will differ** from 0.4.3 wherever a generator runs, which may affect reproducibility.
- **`BStack::recover` (Rust, base API): run a pending deferred replay on demand.** After a write fails midway, the repair is deferred to the next write and reads are refused until it happens (see *Fixed* below); `recover` performs that replay without a write, returning `true` if one was pending and `false` if the stack was already intact, so a caller whose write failed can go back to reading without issuing another write or reopening the file. It is never required for correctness since every write already runs the same repair and propagates the replay's I/O error, leaving the stack flagged for a later retry.
- **C port of the `BStackChunk` chunked-view operations, as `bstack_slice_*` functions over a `bstack_slice_t` + `chunk_len` (no `bstack_chunk_t`; `s.len` must be a whole multiple of `chunk_len`).** Brings the mostly already-released Rust `BStackChunk` surface to C: geometry `bstack_slice_same_chunk_phase`, `adjacent_to`, `overlaps`, `merge`, `merge_adjacent` (the last four take `chunk_len == 0` for the plain-slice form); structural `split_at` (byte), `split_chunk_at` (chunk index), and the `bstack_slice_nth_chunk` macro (`get`, no bounds check); read-only `search` (bsearch-style), `select` (partition point), and `is_sorted` (no feature flag — O(log n) probing via `bstack_get_batched_gen` under `BSTACK_FEATURE_ATOMIC`, one whole-region `bstack_get` otherwise); and mutating `reverse_chunks`, `rotate_left`/`rotate_right`, `sort`/`sort_partial`, `partition`/`partition_partial` (select-nth), each one crash-atomic `bstack_process` call under `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`. Callbacks follow the ctx-free `qsort`/`bsearch` convention; `sort` uses `qsort` (not guaranteed stable, unlike Rust's `sort_by`) and `partition` is an in-place quickselect; the `_partial` pair is the bounded-memory out-of-core form for regions too large for one buffer, per-step crash-atomic and re-runnable rather than atomic as a whole.
- **`SegregatedBStackAllocator::coalesce` (Rust) / `segregated_bstack_allocator_coalesce` (C), `alloc` + `set` + `atomic` / `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`: merge physically-adjacent free blocks.** Freed blocks only ever return to their own class list, so adjacent free blocks never combine and no oversized request can reuse a contiguous free run; `coalesce` fuses them and rebuilds the free lists, reporting the number of blocks merged into a neighbour. Unlike the quiescence-required `recover`, it takes no allocator-level lock and the whole scan-and-rewrite runs as one `inplace_gen` batch (in Rust it is therefore a **safe** method, where `recover` is `unsafe`).
- **`BStackGuardedSlice` gains a full I/O surface derived from whole-block `decode`/`encode` (`guarded` feature, Rust only; `set` for writes, `set` + `atomic` for in-place updates).** The transform hooks are now whole-block: `decode` maps the raw stored bytes to the apparent (decoded) bytes and `encode` the reverse — both default to identity (`Cow::Borrowed`, no allocation) — so a transforming guard (encryption, compression) implements only `len`, `raw_block`, `decode`, and `encode`. Reads: `read`/`read_into`/`read_range`/`read_range_into` plus the scan family `get`/`contains`/`starts_with`/`ends_with`/`find`/`rfind`/`position`/`rposition` (each reads and decodes the whole block). Whole-block writes (`set`): `write`/`copy_from_slice`/`zero`/`fill`/`fill_with`, which `encode` a full-block replacement. In-place read-modify-writes (`set` + `atomic`): `write_range`/`zero_range`/`process`/`copy_within`, each one crash-atomic `BStack::process_gen` that decodes, mutates, re-encodes, and writes under a single held lock, aborting with no write on a `decode`/`encode`/bounds error or an `encode` that changes the block length. Coordinate and `BStack` accessors `start`/`end`/`range`/`as_range`/`stack`, plus the `BStackGuardedSliceSubview` conveniences `head`/`tail`/`split_at`, round out parity with `BStackSlice`. `to_owned_in`/`to_owned_uninit_in` (`set`) copy the apparent bytes into a fresh allocation from any `BStackOwnedSliceAllocator`, leaving a plain region with no guard attached; unlike `BStackSlice::to_owned_in` they route through memory — decoding cannot happen on disk — so they need no `atomic` and may target a different `BStack`.
- **`BStackAtomicGuardedSlice` raw-level atomics: `swap`, `cas_on`, `cas_on_ne`, `cas_on_masked` (`guarded` + `set` + `atomic`, Rust only).** Each is a single crash-atomic operation on the raw block, delegating to the underlying `BStackSlice::swap`/`cas_on`/`cas_on_ne`/`cas_on_masked`. They operate on the **raw** stored bytes and bypass `decode`/`encode` (so `expected`/`new_bytes` are in raw form); for a pass-through guard the raw and apparent bytes coincide.
- **`Default` for `BStackRange` (`alloc`, Rust only).** Returns the zero-length range at offset 0 — the same sentinel `BStackRange::empty` returns, and the value the C port already documents as the pre-allocation placeholder. Lets a `BStackRange` field sit in a `#[derive(Default)]` struct or a `[BStackRange; N]` array init without naming `empty()` at every site.
- **`Debug` for `BStackReader` (base API, Rust only).** The one public type in the crate that did not implement it, so a struct holding a `BStackReader` could not itself derive `Debug`. Prints `position` and the stack's `len` (as `Option`, `None` when the length cannot be read), matching `BStack`'s own `Debug` and `BStackSliceReader`'s.
- **`Display` for the coordinate types: `BStackRange`, `BStackSlice`, `BStackOwnedSlice`, `BStackChunk`, `BStackReader`, `BStackSliceReader`, `BStackSliceWriter` (`alloc` for all but `BStackReader`; `set` for `BStackSliceWriter`; Rust only).** A compact one-line form for log and error messages, where `Debug`'s struct rendering is too noisy: `start..end` for the three range-shaped handles, `start..end/chunk_len` for `BStackChunk`, `start..end@cursor` for the slice reader/writer (cursor relative to the slice, as `position` reports it), and `@position` for `BStackReader`. `Display` is deliberately not provided for `BStack`, the allocators, or the iterators, which have no canonical text form.
- **`Borrow<BStackRange>` and `AsRef<BStackRange>` for `BStackSlice` and `BStackOwnedSlice` (`alloc`, Rust only).** Both types already key `PartialEq`/`Eq`/`Hash`/`Ord` on their `(offset, len)` alone, so the `Borrow` consistency contract holds exactly and a `HashMap<BStackOwnedSlice<A>, V>` or `BTreeSet<BStackSlice>` can now be probed with a bare `BStackRange` — previously impossible without fabricating a handle, which needs both an `unsafe` constructor and an allocator reference the lookup site may not have. `AsRef` is the same borrow under the weaker contract, for code generic over `impl AsRef<BStackRange>`. Deliberately **not** implemented for `BStackChunk`, whose `Hash` includes `chunk_len` and so would violate the contract.
- **`Deref<Target = BStackSlice<'a>>` for `BStackSliceReader` and `BStackSliceWriter` (`alloc` / `alloc` + `set`, Rust only).** Puts the slice's shared-reference API (`len`, `start`, `end`, `read`, `find`, `subslice`, …) directly on the cursor types, so `reader.len()` reads as well as `reader.slice().len()`. Grants no new capability: `slice()` already returned `&BStackSlice` and `From<BStackSliceWriter> for BStackSlice` already existed. Neither implements `DerefMut` — on the writer it would hand out the exclusive slice the writer holds, and it would put `BStackSlice::write` (overwrites from the start of the region) one shadow away from `io::Write::write` (writes at the cursor), which have the same arity and both take `&[u8]`.
- **`Clone`, `DoubleEndedIterator`, `ExactSizeIterator` and `FusedIterator` for `BStackByteVecIter` (`alloc` + `set`, Rust only).** Brings the byte-vec iterator, which implemented only `Iterator`, to parity with `BStackChunkIter`. `Clone` forks an iteration at its position; `next_back` shrinks the snapshotted `len` and reads there, so `.rev()`/`.last()` cost one read rather than a scan; that same snapshot is what makes the iterator soundly fused. `size_hint` was already exact and is unchanged.
- **`BStackChunk` conversions, and `From<[u8; 16]> for BStackRange` (`alloc`, Rust only).** `BStackChunk` previously had no conversion impls, unlike every other handle type; it now has `AsRef<BStackSlice<'a>>` (borrowing the aligned region) and `From<BStackChunk<'a>>` for `BStackSlice<'a>`, `BStackRange`, and `[u8; 16]` — the standard trait spellings of `as_slice`/`into_slice`, plus the serialization edges `BStackSlice` and `BStackOwnedSlice` already had. Each discards the stride explicitly; `Deref` stays absent, since autoderef would drop it implicitly and pull the slice's byte-unit methods into a chunk-unit API. Separately, `From<[u8; 16]> for BStackRange` fills in the inverse of the existing `From<BStackRange> for [u8; 16]`, wrapping the `from_bytes` constructor — the crate's docs already described it as present.
- **`RefUnwindSafe` for the six built-in allocators in non-`atomic` builds (`alloc`, Rust only).** Each carries a `PhantomData<Cell<()>>` marker to remove `Sync` where the allocator is not thread-shareable; `Cell` removed `RefUnwindSafe` along with it, so `catch_unwind` over an `&allocator` compiled with `atomic` and not without. This explicit impl restores it as the only interior mutability is the `BStack`'s own poisoning lock.
### Changed
- **`len`, `is_empty`, and every other read now fail while an interrupted write is pending replay — with the new `InterruptedWrite` error (Rust) or `errno = BSTACK_EREPLAY` (C).** The signatures are unchanged, but `len`/`is_empty` previously could not fail outside an armed fault policy, so a `len().unwrap()` issued after a failed write on the same stack now panics where it did not before, and `bstack_len`/`bstack_is_empty` gain a failure mode their callers may not check. `InterruptedWrite` is a public unit struct carried as the payload of an `io::ErrorKind::Other` error.
- **`FirstFitBStackAllocator` (Rust and C, `atomic` feature): each free-list mutation now commits as a single crash-atomic unit instead of a sequence of individually-synced writes.** Previously every free-list write was a separate `BStack::set` / `bstack_set` with its own `durable_sync`, so a crash could land after any prefix, leaving a partial state only the reopen recovery scan resolved. `add_to_free_list` now runs its probe-coalesce-prepend inside one `BStack::inplace_gen` (C: `bstack_inplace_gen`), and the alloc/realloc carve commits through one `inplace_gen` or `BStack::set_batched` (C: `bstack_set_batched`), so a crash leaves the operation wholly applied or wholly absent — and `add_to_free_list`'s six syncs collapse to one four-sync journal arm. The atomic paths also fully validate untrusted on-disk sizes and free-list pointers before use, refusing (rather than chasing) a corrupt arena. The `recovery_needed` bracket, the recovery scan, and the on-disk format are unchanged, and the non-`atomic` build is byte-for-byte as before.
- **`SlabBStackAllocator` and `CheckedSlabBStackAllocator` versions bumped (`alloc` + `set`; Rust and C): magic `ALSL\x00\x01\x01\x00` → `ALSL\x00\x01\x02\x00`, `ALCK\x00\x01\x02\x00` → `ALCK\x00\x01\x03\x00`.** No layout change; the patch byte records the writer, so a file can be attributed to a build carrying this cycle's `BStackBulkAllocator` paths — `alloc_bulk`/`dealloc_bulk`, including the checked variant's clearing of each freed block's overhead before the splice.
- **`SegregatedBStackAllocator` version bumped (`alloc` + `set`, experimental; Rust and C): magic `ALSG\x00\x02\x00\x00` → `ALSG\x00\x02\x01\x00`.** No layout change; the patch byte records the writer, so a file can be attributed to a build carrying this cycle's `alloc_bulk`/`dealloc_bulk` and `coalesce`, the latter merging physically-adjacent free blocks and rebuilding every class list in place.
- **`GhostTreeBstackAllocator` version bumped (`alloc` + `set`; Rust and C): magic `ALGT\x00\x01\x03\x00` → `ALGT\x00\x01\x04\x00`.** Marks the `align_up_len` / `algt_align_up_len` overflow fix below, so a file can be attributed to a writer that rejects an unalignable length rather than one that wraps it.
- **`BStack`'s `Hash` now hashes the raw fd (Unix) / handle (Windows) instead of the instance address (Rust only).** Same per-live-instance uniqueness, still consistent with the pointer-identity `PartialEq`, but stable when the value moves. Platforms that are neither Unix nor Windows keep the address hash. See `algos/EQUALITY.md`.
- **`FirstFitBStackAllocator` version bumped (`alloc` + `set`; Rust and C): magic `ALFF\x00\x01\x03\x00` → `ALFF\x00\x01\x04\x00`.** No layout change; the patch byte records the writer, so a file can be attributed to a build carrying this cycle's batched free-list writes: `add_to_free_list`'s probe-coalesce-prepend and the alloc/realloc carve each committing as one crash-atomic unit, with the on-disk sizes and free-list pointers validated before use.
- **`BStackGuardedSlice::write` now overwrites the whole apparent block instead of writing a `min(len, data.len())` prefix (`guarded` + `set`, Rust only).** It passes all of `data` through `encode` and writes the result — the whole-block replacement the codec model requires — then fires `on_write(0, data.len())`. A guard that relied on the old prefix-truncation now writes all of `data`, and errors if the encoded result exceeds the raw block.
### Deprecated
- **`BStackGuardedSlice::pre_read`, `post_read`, `pre_write`, `post_write` renamed; removal in 0.5.0 (`guarded` feature, Rust only).** `post_read` → `decode` and `pre_write` → `encode` (the whole-block transform pair); `pre_read` → `on_read` and `post_write` → `on_write` (the observe/deny hooks). The hook offset convention is unified to **relative to the start of the slice**: `post_write` was already relative, but `pre_read`'s offset was absolute within the `BStack`, so `on_read` receives a relative offset where `pre_read` received an absolute one. The new hooks bridge to the deprecated ones by default — the `on_read` bridge re-adds the slice start, so an implementor overriding only `pre_read` still receives the absolute offset it expects — so a guard overriding only the old hooks keeps working unchanged until removal.
### Fixed
- **`BStackChunk::binary_search_by`/`binary_search_by_key` were not atomic: each probe took and released its own lock, so a concurrent `swap`/`sort_by`/`set`/etc. could land between probes and the search could observe a composite of several different points in time.** With the `atomic` feature, the probe sequence now runs inside one `BStack::get_batched_gen` call under a single lock for the whole search — except below 512 bytes of total region size, where it instead takes the same one whole-region `BStack::get` bulk read the `not(atomic)` build always uses, avoiding `get_batched_gen`'s per-probe overhead when there's barely anything to search. Without `atomic`, these methods always read the whole region in one `BStack::get` call and search in memory instead, trading their O(log n) memory bound for atomicity. Their runtime is still O(log n), unchanged.
- **`GhostTreeBstackAllocator` (Rust and C): a length too large to round up to a 32-byte multiple was accepted rather than rejected — `alloc` returned an oversized handle, and `realloc` poisoned the stack's lock (Rust; C has no unwind, so its damage stops at the oversized handle).** `align_up_len` / `algt_align_up_len` computed `(len + 31) & !31` unchecked, so any `len > u64::MAX - 31` wrapped and a near-`u64::MAX` request rounded *down* to a 32-byte block. `alloc` then handed back a handle claiming the requested length. `realloc` saw `aligned_new < aligned_old`, took the shrink path into the tail-shrink `process_gen`, and underflowed `aligned_new - new_len` into an out-of-range slice index — panicking inside the generator closure while holding the write lock and poisoned the `BStack`'s `RwLock` and every subsequent call on that stack panicked. Debug builds trapped earlier on the overflowing add, making the reachable damage release-only. `align_up_len` / `algt_align_up_len` is now checked against a new `MAX_ALLOC` / `ALGT_MAX_ALLOC` — `(u64::MAX - ARENA_START) & !31`, bounded by the arena rather than by `u64` alone, since a block at the very first arena offset already overflows `ARENA_START + aligned` above that — and `alloc`, `alloc_bulk`, `realloc`, `realloc_inplace` (Rust only), `dealloc`, and `dealloc_bulk` reject an unalignable length with `io::ErrorKind::InvalidInput` / `errno = EINVAL`. On-disk format unchanged.
- **A write issued after a failed write ran against a file the failure had left inconsistent.** A write that failed after its first mutating I/O — a full disk, an I/O error, a failed `durable_sync`, or a failed rollback `ftruncate` — could leave an armed journal or a stale tail past the committed length, which only reopening the file repaired. Nothing stopped the caller from immediately issuing another write on the same handle, and that write derived the payload size from the file end, which a stale tail inflates: a range the bounds check should have rejected could be accepted and written past the committed payload, and a still-armed journal could then be replayed over it on the next `open`. No documented contract was broken — none promised a stack is usable after a failed write — but the behaviour was silent and counterintuitive. There is one now: the failure sets an in-memory `replay_needed` flag under the `BStack`/`bstack_t` rwlock, and the next write silently runs the ordinary recovery dispatch before validating its own arguments, then proceeds normally, clearing the flag only once the replay succeeds. On-disk format unchanged.
## [0.4.3] - 2026-08-27
### Added
- **`to_owned_in`/`try_clone` — copy a borrowed view into a fresh owned allocation (`alloc` + `set` + `atomic`, Rust only).** `BStackSlice::to_owned_in(allocator)` and `BStackChunk::to_owned_in(allocator)` allocate `len` bytes and fill them with one crash-atomic `copy_from_bstack_slice` (no bytes read into process memory, source untouched), returning a plain `BStackOwnedSlice`; `BStackOwnedSlice::try_clone()` does the same against the handle's own allocator, duplicating a deliberately non-`Clone` handle through an explicit fallible call (cf. `std::fs::File::try_clone`). Each has a `_uninit` companion gated on `BStackUninitAllocator` that allocates via `alloc_uninit`, skipping a zero-fill the copy immediately overwrites. A cross-stack `allocator` propagates `copy_from_bstack_slice`'s `InvalidInput`.
- **`debug-no-sync` Cargo feature (Rust, debug builds only): skips `durable_sync` for faster fault-injection test iteration.** Not for production use. Mirrors the C library's existing unconditional `BSTACK_TEST_NO_DURABLE_SYNC` `#define`, which needed no change.
- **`BStackUninitAllocator` implemented for every built-in allocator that reuses freed regions** (`alloc` + `set`; the `DebugCheckingAllocator` forwarding needs only `alloc`). `SlabBStackAllocator`, `GhostTreeBstackAllocator`, `FirstFitBStackAllocator`, `CheckedSlabBStackAllocator`, and `SegregatedBStackAllocator` (experimental) now provide `alloc_uninit`/`realloc_uninit`, which skip the zero-fill of newly allocated or newly grown bytes; `DebugCheckingAllocator<A>` forwards both when `A` implements them. `LinearBStackAllocator` does not implement the trait: its zero-fill is already free. Rust only.
- **`bstack_unsafe_reborrow!` / `bstack_unsafe_reborrow_mut!` and the `reborrow` module (base API): standardised lifetime-extending reborrows for `process_gen` / `inplace_gen` generators.** Replaces the `core::mem::transmute` that call sites previously open-coded to hand an op a scratch buffer captured by the closure (`E0521: borrowed data escapes outside of closure`). The macros change the borrow's lifetime and nothing else — the referent type is fixed by the underlying helper's signature, where an inferred-target `transmute` could silently reinterpret the pointee — and need no `unsafe` block at the call site. The safety obligations, including `inplace_gen`'s stricter one (staged `Write` payloads are retained, so their buffers stay frozen until the batch commits), are documented on the module.
- **`BStackOwnedSlice::is_from(allocator)` (Rust, `alloc`) / `bstack_slice_is_from(s, a)` (C, macro): reports whether a handle was issued by a given allocator instance.** A handle records its allocator, but neither language can enforce that it is only ever handed back to *that* instance at compile time — every allocator of a given kind has the same type, and `BStackOwnedSlice<'a, A>` is additionally covariant in `'a`, so `a2.dealloc(h1)` / `bstack_allocator_dealloc(a2, s1)` compiles. This is the run-time check custom allocators need to reject a foreign handle.
- **`BStackInPlaceResizeAllocator` trait (`alloc`) — front/back in-place resize without relocating retained bytes.** `realloc_inplace(handle, prepend, append)` moves the front edge, back edge, or both in one call; on success the returned range is exactly `(start − prepend, end + append)`, else `io::ErrorKind::Unsupported`. An empty handle, negative resulting length, or foreign handle is `io::ErrorKind::InvalidInput`. Every failure carries the handle back, never panics. `LinearBStackAllocator` is tail-only; `FirstFitBStackAllocator` does front/back shrink and grow (mixed cross-edge grow/shrink `Unsupported`); `GhostTreeBstackAllocator` does front and/or back shrink (any grow `Unsupported`); `SegregatedBStackAllocator` (experimental) is back-edge only — a back grow within the recorded physical block costs no metadata write, a back grow past it extends the stack tail in place (else `Unsupported`, no move), a back shrink retains its excess (reclaimed above `SPLIT_MIN` under `atomic`), and any front move is `Unsupported`. Torn-write recovery for the new structural paths is fault-tested. Rust only.
- **`BStackOwnedSlice::try_subslice[_inplace]` and `try_join[_inplace]`, built on `BStackInPlaceResizeAllocator`.** `try_subslice_inplace(start, end)` narrows an allocation and `try_join_inplace(other)` concatenates two, without copying the retained/extended payload; both propagate `Unsupported`. The non-`_inplace` variants add an `alloc` + copy + `dealloc` fallback that never surfaces `Unsupported`, carrying the intact inputs back on any pre-commit failure. `try_subslice_inplace` copies nothing and is available under `alloc` alone. Failures use dedicated types — `BStackSliceError` (one handle) for subslice, `BStackJoinError` (two inline `Option` survivors) for join. Rust only.
- **`BStackChunk::sort_partial_by`/`_key` and `select_nth_partial_by`/`_key` — sort and select for regions too large to fit in memory (`alloc` + `set` + `atomic`, Rust only).** Bounded-memory counterparts to `sort_by`/`select_nth_by`, which need the whole region loaded at once; these hold only a fixed budget resident regardless of region size, so out-of-core data can be sorted or selected in place. A crash or I/O error mid-run always leaves a valid (never lost or duplicated) permutation, and re-running the call finishes where it left off. Sort isn't guaranteed stable; select panics if `n >= chunk_count`.
- **`DebugCheckingAllocator<A>` forwards `BStackInPlaceResizeAllocator::realloc_inplace` when `A` implements it (`alloc`).** Tracks the move like a relocating `realloc` and debug-asserts the trait's exact-position guarantee.
- **`io::Write` for `BStackByteVec` (`alloc` + `set`, Rust only).** `write(buf)` forwards to `extend_from_slice(buf)` and returns `buf.len()`; `flush()` is a no-op. Each `write` re-reads the 16-byte header and may `realloc` to grow capacity, so `write_all` over many small chunks is materially worse than one `extend_from_slice` call.
- **`BStackByteVec::split_off`/`drain` (`alloc` + `set` + `atomic`, Rust only).** `split_off(at)` splits the vec at `at`, keeping `[0, at)` in place and returning a new vec holding `[at, len)`, moving the tail directly between the two on-disk blocks with a single crash-atomic `BStack::copy` and never passing through process memory. `drain(range)` removes an interior byte range and returns it, shifting the tail down with one crash-atomic `BStack::copy` before committing the shorter `len`. Both return `Ok(None)` for an out-of-range request, matching the vec's existing convention.
- **C `bstack_bytevec` byte-mover parity — the whole mutation surface beyond append/tail-shrink, previously Rust-only.** `bstack_bytevec_set`/`fill` (`BSTACK_FEATURE_SET`) join the existing accessors; the atomic movers `extend_from_within`, `extend_from_bstack_slice`, `append_from_owned`, `insert`, `remove`, `swap_remove`, `copy_into_bstack_slice`, `move_tail_into`, `split_off`, and `drain` are gated on `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC` (built on `bstack_copy` / `bstack_cross_exchange`, so bytevec gets its first atomic build variant). Each mirrors its Rust method's crash model — append-only movers commit `len` last (benign, like `push`); in-place movers are torn-but-valid on crash. An out-of-range request sets `*out_ok = 0` (mirroring `Option::None`); a foreign-`BStack` slice or owned block passed to a cross-slice function is `-1` / `errno = EINVAL`, with `append_from_owned` still freeing its argument on that path. New `libbstack-bytevec-set-atomic.a` and `test-bytevec-atomic` build targets.
### Changed
- **`#[must_use]` (Rust) / `BSTACK_WARN_UNUSED_RESULT` (C) added across the public API.** Functions whose return value reports success/failure or hands back a result that shouldn't be silently discarded now warn at compile time if the caller ignores them; `Result`-returning Rust functions are untouched, since `Result` is already `#[must_use]` at the type level. No behaviour change.
- **`#[track_caller]` added to `BStackSlice`/`BStackOwnedSlice`/`BStackChunk`/`BStackByteVec` methods with a documented panic precondition** A panic from one of these (or a wrapper that forwards to one) now reports the caller's source location instead of the internal `assert!`/`panic!` line. No behaviour change beyond the reported panic location.
- **`LinearBStackAllocator::realloc` grows the tail with `BStack::try_extend_zeros` under `atomic`.** The grow branch previously staged a `vec![0u8; delta]` and appended it with `try_extend`, writing `delta` bytes for a region that is guaranteed zero anyway; `try_extend_zeros` applies the identical tail guard and realises the growth with a single `set_len` on a sparse file, so the zeroes cost no write I/O and no heap staging. The documented `BStack` op for that path changes accordingly (`try_extend` → `try_extend_zeros`). No behaviour change: same guard semantics, same crash consistency, same zero-filled result.
- **`CheckedSlabBStackAllocator` version bumped to 0.1.2** (`alloc` + `set`): magic `ALCK\x00\x01\x01\x00` → `ALCK\x00\x01\x02\x00`, reflecting the non-tail-shrink recovery fix below. Only the first 6 bytes are checked on open, so existing 0.1.x files stay compatible.
- **Every built-in allocator now rejects a handle issued by a different allocator instance** (Rust `alloc`; C `bstack_alloc`). Rust `realloc`/`realloc_uninit`/`dealloc`/`dealloc_bulk` and C `realloc`/`dealloc`/`dealloc_bulk` check handle ownership before touching any metadata and fail with `io::ErrorKind::InvalidInput` / `errno = EINVAL`. The handle is never lost: Rust carries it back in the error and C reports the survivable `-1` with `realloc` writing the untouched slice to `*out` and `dealloc_bulk` freeing nothing. This was never a soundness issue — handles are `(offset, len)` coordinates into a file, not pointers — and neither language can catch it at compile time, so it is the allocator's job at run time; the reasoning is documented under the crate's lifetime model and under "Foreign slices" in `bstack_alloc.h`. Correct programs are unaffected.
- **`SegregatedBStackAllocator` (Rust) / `segregated_bstack_allocator_*` (C), experimental, `alloc` + `set` / `BSTACK_FEATURE_SET`: the in-use overhead word now records the block's physical size, not the caller's length; magic `ALSG\x00\x01` → `ALSG\x00\x02` *(breaking on-disk — a `\x01` file fails `new` / `segregated_bstack_allocator_new` with `InvalidData` / `EINVAL`, under the allocator's standing "format not yet stable" caveat; no API change).** The visible length lives in the handle, so a live block may be retained physically larger than its request needs. This drops an in-block resize to zero `BStack` writes, lets oversized reuse claim the whole popped block when its excess is small, and lets a shrink retain its excess in place — reclaimed (tail `Atrunc` / `BSTACK_GEN_SPLICE` or in-place carve) only under `atomic` / `BSTACK_FEATURE_ATOMIC` and only above the new `SPLIT_MIN` threshold, so the non-`atomic` build no longer *moves* on a shrink. `SPLIT_MIN` (256 B) is a tuning dial, not a format constraint.
### Fixed
- **`CheckedSlabBStackAllocator` (Rust) / `checked_slab_bstack_allocator_realloc` (C): an interrupted non-tail-shrink `realloc` could make recovery corrupt an *unrelated* live allocation.** The shrink committed the block's smaller count *before* scrubbing the excess blocks into the free list, so a fault in between left the excess holding stale payload while the header already claimed the smaller span. `recover`'s linear scan then read those orphaned bytes as a valid multi-block in-use marker, strode past a neighbouring live allocation's header, and reclaimed *its* interior as leaked blocks — writing free-list links over live data. The excess is now scrubbed to a zero-overhead free run *before* the count is committed, so every crash window leaves either the intact original, zero-overhead leaked blocks `recover` reclaims cleanly, or a region reported lost (`handle: None` / `-2`) — never corruption. On-disk format and the lock-free `atomic` model unchanged. Surfaced by the allocator fault-injection fuzz.
- **`SegregatedBStackAllocator` (Rust) / `segregated_bstack_allocator_realloc` (C), experimental: an interrupted tail-shrink `realloc` could make recovery destroy live allocations.** The shrink committed the block's new (shorter) `len` into its overhead word and dropped the excess tail as a *second* call, so a failure in between left a block whose committed length implies a smaller class stride than the block physically spans. The recovery scan then walked off the block's true end and read the caller's live payload as an overhead word; a run of zeros there — ordinary data — looked like a crashed `extend`, and recovery discarded the entire arena from that point on, silently taking every later allocation with it. The two calls are now one crash-atomic transaction that confirms the tail and replaces the block in a single locked step (`Len` + `Atrunc` under `atomic`; `BSTACK_GEN_LEN` + `BSTACK_GEN_SPLICE` with a `NULL` `removed` under `BSTACK_FEATURE_ATOMIC`), so a failure leaves the block wholly un-shrunk. Without `atomic` / `BSTACK_FEATURE_ATOMIC` there is no way to fuse them and either ordering leaves a window that desyncs recovery, so the in-place tail shrink is unavailable in that build and the `realloc` takes a move instead — as the non-tail shrink already did, and as `realloc`'s contract already allows (the returned handle is the one to use; its offset was never guaranteed to match the old one).
## [0.4.2] - 2026-08-13
### Added
- **`BStackSlice`, `BStackOwnedSlice`, and `BStackRange` — cross-type `PartialEq` and `PartialOrd` (`alloc`).** Every pairing among the three, both directions, compares/orders on `(offset, len)` coordinates only — location, not content — and performs no I/O. `PartialOrd` matches each type's own `Ord` (by `offset`, then `len`). `BStackByteVec` deliberately does not participate in either trait: a meaningful comparison would require reading its header to resolve `len`, and `==`/`<` should not perform I/O silently.
- **`BStackSlice` — `std`-slice-style ergonomic methods (`alloc`).** Read-only, no extra feature: `get(index)`, `head(n)`/`tail(n)`, `contains(byte)`, `starts_with`/`ends_with`, `find`/`rfind`, `position`/`rposition`, `split_at`/`split_at_mut`. Write methods (`set`): `fill(value)` (single `BStack::repeat` call), `fill_with(f)`, `copy_from_slice(src)`. Atomic compound writes (`set` + `atomic`, each a single crash-atomic `BStack` call): `copy_from_bstack_slice`, `copy_within`, `swap` (via `cross_exchange`), `reverse`, `rotate_left`/`rotate_right` (via `process`). `BStackOwnedSlice` mirrors the full set, delegating through `as_slice()`/`as_slice_mut()`.
- **`BStackChunk<'a>`/`BStackChunkIter<'a>` — fixed-stride view over `BStackSlice` (`alloc`).** `BStackSlice::chunks`/`rchunks` (mirrored on `BStackOwnedSlice`) return `(BStackChunk, BStackSlice)`: aligned view + remainder, pure offset arithmetic, no I/O. `as_slice`/`into_slice`/`with_stride` recover or re-chunk the aligned region. `PartialEq`/`Eq`/`Hash`/`PartialOrd`/`Ord` on `(chunk_len, region)`; no cross-type comparison with `BStackSlice`. Not an iterator itself: `iter()`/`IntoIterator` yield a `BStackChunkIter` (`DoubleEndedIterator` + `ExactSizeIterator` + `FusedIterator`), zero I/O per step.
- **`BStackChunk` search/sort/select.** `binary_search_by`/`binary_search_by_key` (`alloc`): O(log n) chunk reads. `sort_by`/`sort_by_key`/`select_nth_by`/`select_nth_by_key` (`set` + `atomic`): one crash-atomic `BStack::process` call, in-place cycle-following permutation (O(1) scratch chunks, stack-allocated ≤128 B); `select_nth_*` mirrors `[T]::select_nth_unstable_by`.
- **`BStackRange`/`BStackSlice`/`BStackChunk` — overlap, adjacency, and merge queries (`alloc`).** `BStackRange::overlaps`/`adjacent_to` are pure `(offset, len)` arithmetic, no I/O. `merge`/`merge_adjacent` combine two ranges into one covering union: `merge` succeeds on overlap or when either range is empty (empty acts as an identity element, returned unchanged); `merge_adjacent` is stricter, requiring the ranges to touch end-to-end with both non-empty. `BStackSlice` mirrors all four, delegating to the underlying `BStackRange`, and additionally returns `None` from `merge`/`merge_adjacent` if the two slices are backed by different `BStack`s. `BStackChunk` adds `same_stride`/`same_phase` plus stride-aware `adjacent_to`/`overlaps`/`merge`/`merge_adjacent`, requiring `same_phase`. An exception is that `merge` treats an empty, same-stride chunk as a phase-agnostic identity element. Not provided on `BStackOwnedSlice` — ownership/allocation semantics leave "adjacent" and "mergeable" without a meaningful definition for an owned handle.
- **`SegregatedBStackAllocator` (Rust, `alloc` + `set`) / `segregated_bstack_allocator_*` (C, `BSTACK_FEATURE_SET`), experimental: segregated (binned) free-list allocator.** Generalises `CheckedSlabBStackAllocator` / `checked_slab_bstack_allocator_*` from one block size to 33 size classes sharing one arena — 16 linear (16‥256 B, step 16), 16 geometric (320‥4096 B, 4 per octave), and one shared oversized bucket — with the class derived from the request by register arithmetic (no tables) for O(1) classed alloc/dealloc. Each block carries an 8-byte overhead tag (in-use length / free physical size, the latter doubling as the class tag), so leaked blocks are reclaimable by a linear scan and double-frees are caught. A single `new(stack)` / `segregated_bstack_allocator_new` constructor initialises a fresh stack or reopens one, running recovery automatically; `recover()` / `segregated_bstack_allocator_recover` is `unsafe` / requires a quiescent allocator (C has no `unsafe`). `Send` in all configurations; `Send + Sync` with `atomic` / `BSTACK_FEATURE_ATOMIC`, no allocator-level lock — free-list pops/pushes ride `BStack::process_gen`/`inplace_gen` (`bstack_process_gen`/`bstack_inplace_gen`) and tail grow/shrink ride `try_extend_zeros`/`try_discard` (`bstack_try_extend_zeros`/`bstack_try_discard`). Without `atomic` the in-place non-tail-shrink carve is unavailable (that `realloc` takes a move instead). Experimental: the on-disk format (`ALSG` magic) and API are not yet stable, and the background coalescer and deep in-use-leak GC are unimplemented.
- **`SegregatedBStackAllocator` (`alloc` + `set`, experimental): segregated (binned) free-list allocator.** Generalises `CheckedSlabBStackAllocator` from one block size to 33 size classes sharing one arena — 16 linear (16‥256 B, step 16), 16 geometric (320‥4096 B, 4 per octave), and one shared oversized bucket — with the class derived from the request by register arithmetic (no tables) for O(1) classed alloc/dealloc. Each block carries an 8-byte overhead tag (in-use length / free physical size, the latter doubling as the class tag), so leaked blocks are reclaimable by a linear scan and double-frees are caught. A single `new(stack)` constructor initialises a fresh stack or reopens one, running recovery automatically; `recover()` is `unsafe` (requires a quiescent allocator). `Send` in all configurations; `Send + Sync` with `atomic`, no allocator-level lock — free-list pops/pushes ride `BStack::process_gen`/`inplace_gen`. Without `atomic` the in-place non-tail-shrink carve is unavailable (that `realloc` takes a move instead). Experimental: the on-disk format (`ALSG` magic) and API are not yet stable, and the background coalescer and deep in-use-leak GC are unimplemented.
- **`BStack::resize`/`ensure` (Rust, base API) / `bstack_resize`/`bstack_ensure` (C, base API) and `ensure_with` (Rust, `atomic`) / `bstack_ensure_with` (C, `BSTACK_FEATURE_ATOMIC`): grow-or-shrink and grow-to-at-least helpers.** `resize(target)` grows (zero-filled) or shrinks the payload to exactly `target` bytes; `ensure(target)` is the grow-only, no-op-if-already-long-enough counterpart. Both return the size before the call. `ensure_with(target, f)` additionally hands the freshly grown tail to `f` (`FnOnce(&mut [u8])` in Rust; `int cb(uint8_t *buf, size_t len, void *ctx)` in C, aborting the call on a nonzero return) for initialization before it commits — no `set` dependency, since it only touches bytes beyond the previously committed length.
### Changed
- **`GhostTreeBstackAllocator` — smaller AVL critical section (Rust + C, `alloc`).** `alloc`/`dealloc`/`realloc` of non-tail blocks do less work while holding the allocator mutex. The rebalance up-pass no longer re-reads and re-writes each ancestor through a redundant balance-factor pass — the balance factor and height computed by the node write are threaded into `avl_rebalance` — and each node now caches its two child heights, so the up-pass and rotations write one node per level and read no children in the common in-balance case (down from ~2 writes plus several reads per level). Rust also swaps the per-op heap `Vec` path buffer for a stack array of the fixed `MAX_AVL_DEPTH` bound. Purely internal — no API or observable-behavior change beyond throughput (~25–33% lower per-op latency under real `F_FULLFSYNC`, `benches/alloc.rs`).
- **`GhostTreeBstackAllocator` version bumped to 0.1.3** (`alloc` + `set` features): Magic number updated from `ALGT\x00\x01\x02\x00` to `ALGT\x00\x01\x03\x00`. Reflects the new per-node child-height cache stored in the AVL node header's previously-reserved bytes. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
## [0.4.1] - 2026-08-03
### Added
- **`DebugCheckingAllocator<A>` (`alloc`): debug-only allocator wrapper reintroduced.** Wraps any allocator whose `Allocated` type is `BStackOwnedSlice` and whose `Error` is `io::Error`. Tracks allocated and freed regions in memory and panics on overlapping allocations, double-frees, partial-frees, and multi-span frees. Updated for the `BStackAllocError`/`BStackBulkAllocError` return types introduced in 0.4.0: on inner failure the surviving handle (if any) is re-wrapped and returned; lost handles (`handle: None`) are removed from tracking without being marked as freed. Implements `BStackAllocator` and (when the inner does) `BStackBulkAllocator`. Gated on `alloc` only — no `set` dependency.
- **`BStack::extend_sparse` / `extend_sparse_batched` (Rust, base API) and `try_extend_sparse` / `try_extend_sparse_batched` (Rust, `atomic`) / `bstack_extend_sparse` / `bstack_extend_sparse_batched` (C, base API) / `bstack_try_extend_sparse` / `bstack_try_extend_sparse_batched` (C, `BSTACK_FEATURE_ATOMIC`): efficient sparse tail growth.** Grow the payload by `length` while writing only a little real data into the new region, leaving the rest zero. `extend_sparse(buf, length)` writes `buf` at the start; `extend_sparse_batched(writes, length)` scatters `(relative_offset, data)` buffers (relative to the current tail) across it (in C the batch reuses `bstack_iovec_t`, its `offset` read as the tail-relative position). The whole `length` is realised with one `set_len`/`ftruncate`, so the zero gaps cost no write I/O and only the supplied bytes plus the header commit are synced — cheaper than a `push` of a large mostly-zero buffer. No journal is needed (the grown region sits beyond `clen`, so a crash rolls back by truncation, like `push`/`extend`). The `try_` variants add a `try_extend`-style size guard `s` (apply only if the current size equals `s`, else `Ok(false)` / `*ok = 0`). Batched writes must be pairwise non-overlapping and fit within `[0, length)`; `length = 0` is a no-op; a malformed request is rejected as invalid input (for the `try_` forms, regardless of the size match).
- **`BStackGenOp::Sparse { writes, length }` (Rust) / `BSTACK_GEN_SPARSE` (C, `u.sparse`) — in-sequence sparse tail growth for `process_gen`** (`set` + `atomic` / `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`). The generator-driven equivalent of `extend_sparse_batched` (a single write at relative offset `0` covers `extend_sparse`): sparsely grow the payload by `length`, scattering the `(relative_offset, data)` `writes` into the new region and leaving the gaps zero, ending the sequence like the other mutating variants. Useful when the growth size or block offsets are only known once earlier `Read`/`Len` steps have resolved. As a size-changing op it is **not** permitted in `inplace_gen` (rejected with `InvalidInput`/`EINVAL` reported to the callback, like `Push`/`Pop`/`Atrunc`/`Splice`). `BStackGenOp` is `#[non_exhaustive]` and the C `bstack_gen_op_kind_t` gains a new enumerator, so this is not a breaking change.
- **`BStackByteVec::extend_from_slice` (`alloc` + `set`): bulk byte append.** Appends an entire `&[u8]` in one shot — reserving the required capacity in a single reallocation (if any) and writing all bytes with one durable `set` before committing the new `len`. Empty input is a no-op. Crash consistency matches the other multi-step methods.
- **`BStackByteVec` — in-place and capacity methods (`alloc` + `set`).** `set(index, value)` overwrites a single existing slot (crash-atomic single write), returning `Ok(None)` if `index` is out of range like [`get`]; `fill(value)` overwrites the whole populated region via one `BStack::repeat` (fixed-size journal regardless of `len`); `reserve_exact(additional)` grows to exactly `len + additional` without the amortising over-allocation of `reserve`; `shrink_to(min_capacity)` and `shrink_to_fit()` reallocate the block down to `max(len, min_capacity)` / `len`, releasing spare capacity (the internal reallocation helper now handles shrink as well as growth).
- **C allocator vtable `realloc`/`dealloc` now signal whether the original survived a failure** (`alloc`, C only; API-only break, ABI-preserving). Mirrors Rust's `BStackAllocError { handle: Option<A::Allocated> }`: on failure, `-1` means the original survived (`realloc` writes its handle to `*out`; for `dealloc` it's just the slice the caller passed in) and `-2` means it was genuinely lost, recoverable only via crash recovery. Existing callers testing `ret != 0`/`ret < 0` are unaffected; only callers that care check for `-2`. Implemented for all five built-in allocators, mirroring each one's Rust `lost`/`recovered` tracking. Also fixes `GhostTreeBstackAllocator::realloc`'s C non-`atomic` tail-shrink to discard before zeroing (matching Rust's already-fixed order), so a post-commit fault reports lost rather than a partially-zeroed original.
- **`BStackByteVec` — crash-atomic byte movers (`alloc` + `set` + `atomic`).** New methods built on `BStack::copy` and `BStack::cross_exchange` so the vec never shifts bytes one at a time; gated on `atomic` rather than widening the type's base requirement. Append-only movers keep `push`'s benign crash model (bytes land in spare capacity, `len` commits last): `extend_from_within(start, count)` appends a copy of an existing range; `extend_from_bstack_slice(&src)` appends an on-disk `BStackSlice` from the same `BStack`; `append_from_owned(owned)` appends a `BStackOwnedSlice`'s bytes and then frees it (never leaking it, even on error). In-place movers are crash-atomic per step but leave a logically torn (yet structurally valid) vec if interrupted mid-operation: `insert(index, value)` and `remove(index)` shift the tail via `copy`; `swap_remove(index)` swaps the hole with the last byte via `cross_exchange`; `move_tail_into(&mut dest)` swaps the vec's tail into a `BStackOwnedSlice` and shrinks. `copy_into_bstack_slice(start, &mut dst)` copies vec bytes out into a same-`BStack` slice with a single atomic `copy`. Following the `get`-style convention, an out-of-bounds index/range or a `u64` overflow returns `Ok(None)` (the vec is untouched) rather than an error — the index-taking methods (`set`, `insert`, `remove`, `swap_remove`, `extend_from_within`, `copy_into_bstack_slice`, `move_tail_into`) return `io::Result<Option<_>>`; `Err` is reserved for I/O failures, and passing a slice/handle from a *different* `BStack` to a cross-slice method is still an `Err` (a misuse, not an out-of-range request).
### Changed
- **`#[inline]` on small public APIs.** Added `#[inline]` to all short public functions across `bstack`. No behaviour change.
### Fixed
- **`GhostTreeBstackAllocator::realloc` tail shrink was not crash-atomic.** Shrinking a tail allocation to a sub-block-unaligned length truncated the freed tail and zeroed the retained block's padding as two separate operations, so a fault (or crash) between them left the stack shrunk with un-zeroed padding — violating the zeroed-memory invariant and yielding a `realloc`-failure handle claiming a length past the now-shorter stack. The `atomic` path now fuses both into one crash-atomic tail-replace, so a fault leaves the block fully intact; the non-`atomic` path discards first and commits the shrink before zeroing, so a fault after the commit reports the allocation as lost (`handle: None`) rather than handing back a partially-zeroed "original". Surfaced by the new allocator fault-injection fuzz.
- **`FirstFitBStackAllocator::realloc` in-place tail shrink was not crash-atomic** (Rust + C). Reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault (or crash) mid-sequence left the header, footer, and physical size disagreeing — a state first_fit's block-walking recovery cannot repair (it would truncate the whole block, losing live data). A tail shrink now narrows only the user-visible length and keeps the block at its current size — a valid "oversized" allocation, exactly as a non-tail shrink already does — and the space is reclaimed when the block is freed. Behavior change: a tail `realloc` shrink no longer returns space to the file immediately. Surfaced by the allocator fault-injection fuzz.
- **`FirstFitBStackAllocator::realloc` in-place tail *grow* left an unrecoverable file after a crash** (Rust + C). Growing the tail block `extend`s (zero-filling) the payload before rewriting the block header/footer to cover the new bytes; a fault (or crash) in that window left the still-valid block followed by a zero-filled region with no block header, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. The recovery scan in both implementations now recognises an all-zero trailing region (a real block is never all-zero) as the interrupted extension and rolls it back by truncation, restoring the pre-grow tail the failed `realloc` already handed back to the caller; genuine mid-arena corruption still fails loudly. Surfaced by the allocator fault-injection fuzz.
- **`FirstFitBStackAllocator` recovery left a coalescing free's block half-updated after a crash** (Rust + C). Freeing a block wedged between two free blocks coalesces all three: `add_to_free_list` commits the merged size to the block header and then, as a separate write, to the footer. A crash between the two left the header correct but the footer stale. The recovery scan follows headers, so the merged block spanned correctly and recovery cleared the flag — but the stale footer survived. Because it still equalled the old right sub-block's size, it pointed back at that sub-block's untouched interior header, so a later neighbour's left-coalesce walked into the merged block's interior, matched the ghost header, and coalesced onto it — overlapping two blocks and eventually desyncing the recovery walk into a hard `open` failure. Recovery now normalizes every block's footer to its (authoritative, written-first) header as it walks, so any header/footer split left by an interrupted resize is healed. Surfaced by the allocator fault-injection fuzz.
## [0.4.0] - 2026-07-10
> Upgrading from 0.2.x? See [docs/MIGRATION_0.4.0.md](docs/MIGRATION_0.4.0.md) for a step-by-step migration guide.
### Added
- **`BStackOwnedSlice<'a, A: BStackAllocator>`** (`alloc` feature): New ownership handle for one allocator-managed region. Non-`Copy`, non-`Clone` — an allocation has exactly one owner. `realloc` and `dealloc` consume it by value, so use-after-free and use-after-realloc are compile errors. I/O is performed by borrowing a view via `as_slice(&self)` (shared) or `as_slice_mut(&mut self)` (exclusive); `BStackOwnedSlice` also provides convenience `read*`/`write*`/`zero*` methods that delegate through those views. Constructors: `from_raw_parts`, `from_raw_range`, `empty`. Accessors: `start`, `end`, `len`, `is_empty`, `range`, `allocator`. Drop is a no-op; the allocation persists until explicitly freed.
- **`BStackOwnedSlice` — delegate I/O methods** (`alloc` feature): `read`, `read_into`, `read_range`, `read_range_into` (borrow via `as_slice`); `write`, `write_range`, `zero`, `zero_range` (borrow via `as_slice_mut`, `set` feature); `reader`, `reader_at`, `writer`, `writer_at` — all delegate to the corresponding `BStackSlice` method to avoid requiring callers to borrow manually.
- **`BStackAllocError<'a, A: BStackAllocator>`** (`alloc` feature): New error type returned by `realloc` and `dealloc`. Carries the failing `source: A::Error` plus `handle: Option<A::Allocated<'a>>` — the surviving allocation handed back to the caller so a failed resize/free is not a silent leak (recall `BStackOwnedSlice`'s `Drop` is a no-op). `handle` is `Some` whenever the region survives (the common case: original untouched, or a fully-committed new region whose old block could not be freed) and `None` only when the allocation was genuinely lost mid-operation (recoverable through crash recovery). Implements `Debug` (delegating to `source`, plus a `handle_recovered` bit), `Display` (delegates to `source`), and `std::error::Error`, so `?` works in functions that return it. Constructors: `with_handle`, `lost`; accessor `into_handle`.
- **`BStackBulkAllocError<'a, A: BStackAllocator>`** (`alloc` feature): Bulk analogue of `BStackAllocError` returned by `BStackBulkAllocator::dealloc_bulk`. Carries `source: A::Error` plus `handles: Vec<A::Allocated<'a>>` — the handles that were **not** freed. For an atomic implementation that is every handle passed in, so a failed bulk free never silently leaks. Constructors: `with_handles`; accessor `into_handles`.
- **`BStackUninitAllocator`** (`alloc` feature): New opt-in extension trait for `BStackAllocator` adding `alloc_uninit` and `realloc_uninit`, which skip the zero-fill of newly allocated or newly grown bytes. Those bytes hold **unspecified** contents (possibly leftover from a previous allocation) that are always valid to read but must not be relied on until written; existing bytes are preserved exactly as `realloc`. Implementing the trait is optional and signals a genuinely cheaper uninitialised path. No built-in allocator implements it yet.
- **`BStack::repeat` (Rust, `set`) / `bstack_repeat` (C, `BSTACK_FEATURE_SET`): crash-atomic in-place repeating fill.** `repeat(offset, pattern, count)` overwrites `[offset, offset + count * pattern.len())` with `count` back-to-back copies of `pattern`. An empty `pattern` or `count == 0` is a no-op. It is the general form of `zero` (which is now `repeat` of the single byte `0x00`), and only the pattern and count are journaled, so a crash-safe fill of a large region uses a fixed-size write-in-progress journal (`8 + pattern.len()` bytes) rather than one proportional to the region.
- **`BStack::migrate` (Rust) / `bstack_migrate` (C): upgrade a legacy 0.1.x file to the 0.4.0 layout in place.** Rewrites the file into a sibling `"<path>.migrating"` — a fresh 32-byte 0.4.0 header followed by the old payload shifted from offset 16 to offset 32 — then atomically renames it onto the original (a crash leaves either the intact original or the finished new file, never neither). The committed length is preserved (clamped to the bytes actually present). Errors if the file is not a legacy 0.1.x file (wrong magic or shorter than the 16-byte legacy header).
- **`BStackGenOp::Atrunc { n, data }` and `BStackGenOp::Splice { old, new }` (Rust, `set` + `atomic`) / `BSTACK_GEN_SPLICE` (C, `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`): in-sequence tail-replace for `process_gen`.** `Atrunc` cuts `n` bytes off the tail then appends `data` without reading the removed bytes (the in-sequence equivalent of `atrunc`, and the buffer-free counterpart of `Splice`); `Splice` pops `old.len()` bytes off the tail into `old` then appends `new` (the in-sequence equivalent of `splice_into`). Both change the payload length by the difference of the two lengths, both end the `process_gen` sequence like the other mutating variants, and both are crash-atomic through the shared tail-replace commit path (truncate / append / `Set` journal / splice journal). Adds the length-changing tail operations to the set of in-sequence mutations, completing the `Discard`/`Pop` pairing with `Atrunc`/`Splice`. `BStackGenOp` is `#[non_exhaustive]`, so this is not a breaking change. In C — where slices cannot be null — the tagged union gains a single `BSTACK_GEN_SPLICE` (`u.splice`): a non-NULL `removed` pops the tail bytes into it (in-sequence `bstack_splice`), a NULL `removed` discards them (in-sequence `bstack_atrunc`), mirroring how a NULL `u.pop.buf` turns `BSTACK_GEN_POP` into a discard.
- **`BStack::set_batched` / `BStack::inplace_gen` (Rust, `set` + `atomic`) / `bstack_set_batched` / `bstack_inplace_gen` (C, `BSTACK_FEATURE_SET` + `BSTACK_FEATURE_ATOMIC`): commit several non-overlapping in-place writes as one crash-atomic unit.** Backed by a new **multi-write journal** mode (`wip_aux = MultiWrite` / `WIP_MULTI` with `wip_ptr` left `0`, staging the writes past `clen` as self-delimiting `[s | e | data]` blocks): after a crash either every write applies or none does, replayed all-or-nothing on `open`. `set_batched` takes a batch of `(offset, data)` writes, ignores empty entries, and rejects any overlapping pair. `inplace_gen` is its generator counterpart (like `process_gen`): its callback yields `Read`/`Write`/`Len` ops — `Write`s accumulate (they do not end the sequence) and commit together at the end, later writes override earlier overlapping ones, and `Read`s see the batch-so-far content; size-changing ops and `Swap` are rejected. Ships under the existing 0.4.0 on-disk format — no magic bump. See `algos/WIP.md`.
- **`fault` module: deterministic API-level I/O-fault injection for testing** (`fault-injection` feature, dev/test only). A user-supplied `FaultPolicy` (`next_fault(op, seq) -> Option<io::Error>`) is armed via `BStack::with_fault_policy` / `set_fault_policy`, and every I/O method consults it once — *after* validating its arguments, so validation errors always win over an injected fault. Faults are per public-API call, not per syscall; under `atomic`, operations share one per-stack sequence counter. Gated on `all(debug_assertions, feature = "fault-injection")` (off by default), so a `--release` build carries none of the machinery. Exercises allocator/downstream error paths that successful `alloc`/`realloc`/`dealloc` sequences never reach.
### Fixed
- **`atrunc`, `splice`, `splice_into`, `replace` (Rust, `atomic`) and `bstack_atrunc`, `bstack_splice`, `bstack_replace`, `BSTACK_GEN_SPLICE` (C, `BSTACK_FEATURE_ATOMIC`) — length-changing tail replace was not crash-atomic.** When the new tail length differed from the old (a net grow or shrink that is neither a pure append nor a pure truncation), the old code overwrote committed payload bytes in place before committing the new `clen`, so a crash mid-write left a torn tail with no journal to repair it. These operations now route length-changing replaces through a **splice journal** (`wip_aux = SpliceGrow`/`SpliceShrink`): the new tail is staged past the live payload, the direction is armed, the tail is replayed into place, and the new length is committed and disarmed in a single atomic header write. Recovery derives the new committed length from the file size and the armed direction and rolls a crash forward (or, if the arm never landed, rolls it back). Pure appends, pure truncations, and same-length overwrites are unchanged (they need no length-change journal).
### Removed
- **`BStackSlice::new`** (`alloc` feature): removed; use `unsafe { BStackSlice::from_raw_parts(allocator, offset, len) }` instead.
- **`ManualAllocator`** (`alloc` feature): Removed entirely. Use `BStackRange` directly for typed `(offset, len)` coordinates that are managed outside of an allocator.
- **`DebugCheckingAllocator`** (`alloc` feature): Temporarily removed in 0.4.0 pending a rework; it is not part of this release's public API. Expected to return in a later version.
### Changed
- **Three-type region handle design** (`alloc` feature) *(breaking)*: The allocator module now exposes three distinct handle types with a clean separation of concerns: `BStackRange` (raw `Copy` coordinate, no I/O, no alloc ops), `BStackOwnedSlice<'a, A>` (ownership handle, non-`Copy`/`Clone`, consumed by `realloc`/`dealloc`), and `BStackSlice<'a>` (borrowed I/O view, `Clone`, no alloc ops). This replaces the previous single `BStackSlice<'a, A>` type that served all three roles.
- **`BStackAllocator::Allocated<'a>` constraint changed** (`alloc` feature) *(breaking)*: Was `Copy + TryInto<BStackSlice<'a, Self>>`. Now `Into<BStackOwnedSlice<'a, Self>>`. The `Copy` requirement is gone; ownership semantics are enforced instead. **Migration:** Set `type Allocated<'a> = BStackOwnedSlice<'a, Self>` in all `impl BStackAllocator` blocks.
- **`BStackSlice<'a>` redesigned** (`alloc` feature) *(breaking)*: No longer `Copy`. No longer parameterised on allocator — signature is now `BStackSlice<'a>`, not `BStackSlice<'a, A>`, because it carries `&'a BStack` directly. Write methods (`write`, `write_range`, `zero`, `zero_range`, `writer`, `writer_at`) take `&mut self` for single-writer exclusivity. `Clone` is retained for explicit second views. **Migration:** Remove the `A` type parameter from every `BStackSlice<'a, A>` annotation; update code that relied on `Copy` to use `.clone()` or the `as_slice`/`as_slice_mut` pattern on `BStackOwnedSlice`.
- **`BStackSliceAllocator` renamed to `BStackOwnedSliceAllocator`** (`alloc` feature) *(breaking)*: Renamed to match the new handle type. **Migration:** Replace `A: BStackSliceAllocator` with `A: BStackOwnedSliceAllocator` at every bound.
- **`algos/ALLOCATOR.md`**: Detailed per-allocator documentation (on-disk layouts, allocation/deallocation policies, coalescing rules, crash-consistency guarantees, thread-safety analysis) moved here from README. README now links to it with one-paragraph summaries per allocator.
- **All built-in allocators ported to the three-type API** (`alloc` feature): `LinearBStackAllocator`, `FirstFitBStackAllocator`, `GhostTreeBstackAllocator`, `SlabBStackAllocator`, and `CheckedSlabBStackAllocator` all set `type Allocated<'a> = BStackOwnedSlice<'a, Self>`. Internal I/O now routes through the `as_slice`/`as_slice_mut` borrow pattern or the `BStackOwnedSlice` delegate methods.
- **Non-tail `realloc` growth uses `BStack::copy` on the fast path** (`alloc` + `set` + `atomic`): `GhostTreeBstackAllocator` and `CheckedSlabBStackAllocator` move the old payload into the freshly-allocated region with a single crash-atomic `BStack::copy` instead of reading it into an intermediate `Vec` and writing it back. Without `atomic` the previous read-then-write path is retained.
- **`SlabBStackAllocator` and `CheckedSlabBStackAllocator` promoted out of experimental** (`alloc` + `set` features): The "Experimental" label has been removed from all documentation.
- **`realloc` / `dealloc` return the surviving handle on failure** (`alloc` feature) *(breaking)*: Both now return `Result<_, BStackAllocError<'a, Self>>` instead of `Result<_, Self::Error>`. Previously a failed `realloc`/`dealloc` consumed the handle by value and returned only the error, silently leaking the still-valid allocation with no way to retry, fall back, or free it. The `BStackAllocError` carries that allocation back in its `handle` field. **Migration:** where you previously wrote `alloc.realloc(h, n)?` / `alloc.dealloc(h)?` in a function returning `io::Result`, use `.map_err(|e| e.source)?` to surface just the error (dropping the recovered handle), or match on the error to recover `e.handle` and retry/fall back. `BStackByteVec::grow_to` now adopts the returned handle automatically.
- **`BStackBulkAllocator::dealloc_bulk` returns un-freed handles on failure** (`alloc` feature) *(breaking)*: Now returns `Result<(), BStackBulkAllocError<'a, Self>>` instead of `Result<(), Self::Error>`, carrying back the handles it did not free rather than dropping (leaking) them. **Migration:** use `.map_err(|e| e.source)?` to surface just the error, or inspect `e.handles`.
- **`BStackByteVec` can now become backing-less after a failed growth** (`alloc` + `set` features): If a growth's `realloc` reports the block was genuinely lost (`BStackAllocError::handle == None` — not produced by any built-in allocator, only reachable via a custom one), the vec now detaches from the freed region instead of retaining a stale handle into it. Retaining the old coordinates was unsafe: the freed region can be reused by a later allocation, so a subsequent `push` could corrupt unrelated data. A detached vec has lost its contents along with the allocation, and every subsequent operation fails cleanly with an error rather than risking corruption.
- **`BStackByteVec<'a, A>` now requires `A: BStackOwnedSliceAllocator`** (`alloc` + `set` features) *(breaking)*: The internal backing field is `BStackOwnedSlice<'a, A>` instead of `BStackSlice<'a, A>`. `from_raw_block` takes `BStackOwnedSlice`; `into_raw_block` returns `BStackOwnedSlice`; `raw_block` returns `BStackSlice<'a>` (no allocator parameter); `as_slice` returns `io::Result<BStackSlice<'_>>` (lifetime shortened to the borrow of `self`). Write-helper methods now take `&mut self`. **Migration:** Update `from_raw_block`/`into_raw_block` call sites; narrow any lifetime annotations on `as_slice` return types.
- **README — Writing and Reading sections condensed**: Step-by-step walkthroughs removed; sections now provide a concise API reference.
- **README — Allocator implementations section**: Replaced ~430-line inline descriptions with one-paragraph summaries per allocator and a link to `algos/ALLOCATOR.md`.
- **`BStack::set`, `BStack::zero`, `BStack::swap`, `BStack::cas`, `BStack::copy`, `BStack::cross_exchange`, `BStack::process`, the `crds` family, and the tail-replace family (Rust, `set`) / `bstack_set`, `bstack_zero`, `bstack_swap`, `bstack_cas`, `bstack_copy`, `bstack_cross_exchange`, `bstack_process`, the `crds` family, and the tail-replace family (`bstack_atrunc` / `bstack_splice` / `bstack_replace`) (C, `BSTACK_FEATURE_SET`) — in-place mutations are now crash-atomic via the write-in-progress journal.** Previously the 16-byte header (magic `BSTK\x00\x01\x0f\x00`) offered no journal, so a crash mid-write could leave a torn region. Both implementations now use the full 0.4.0 format — 32-byte header (`clen | wip_ptr | wip_aux`), the derived-atomicity single-block fast path, and the `Set` / `Repeat` / `Copy` / `SpliceGrow` / `SpliceShrink` journal modes with recovery on `open` / `bstack_open`. See `algos/WIP.md`.
- **`BStack::copy` (Rust) / `bstack_copy` (C) (`set` + `atomic`) — disjoint copies journal in O(1) instead of O(n).** A copy whose source and destination do not overlap now uses a dedicated copy journal (`wip_aux = Copy`) that stages only the source coordinate `[src | n]` (16 bytes) rather than a full backup of the `n` copied bytes: because the disjoint source is untouched during the copy, recovery replays it directly from the still-intact source. Overlapping copies are unchanged (they still route source→tail→dest through the verbatim journal, since a replay must not read clobbered source bytes), a destination within one aligned block still takes the single-block atomic path, and a copy onto its own location is now a no-op. Behaviour and signature are unchanged.
- **On-disk format version bumped to `0.4.0` (magic `BSTK\x00\x04\x00\x00`), in both the Rust and C implementations.** Incompatible with `0.1.x` files, which `open` now rejects (use `migrate` to upgrade).
## [0.2.5] - 2026-06-15
### Added
- `BStack::process_gen` (Rust) / `bstack_process_gen` (C) (`set` + `atomic`): generator/callback-driven primitive that acquires the write lock once and holds it across a sequence of dependent reads ending in at most one mutating operation (`Write`, `Swap`, `Push`, or `Pop`), which always ends the sequence. Closes the ABA window that a `get_batched_gen` (read, release lock) + `cas` (re-acquire, compare, write) pairing would otherwise leave open for allocator-mutex-free pop-style algorithms — see `examples/atomic_linked_list.rs` / `examples/atomic_linked_list.c` for a worked free-list push/pop demonstration.
- `BStackGenOp<'a>` (Rust) / `bstack_gen_op_t` (C) (`set` + `atomic`): non-exhaustive enum (Rust) / tagged union (C) of operations yielded by `process_gen`'s closure/callback — `Read { offset, buf }`, `Len { out }`, `Write { offset, data }`, `Swap { a_offset, b_offset, len }`, `Push { data }`, `Pop { buf }`, and `Discard { len }` (Rust; in C, a `Pop` with a `NULL` destination buffer). `Write`, `Swap`, `Push`, `Pop`, and `Discard` are the only mutating variants — exactly one is permitted per call, and any one of them ends the sequence immediately; `Read`/`Len` do not end the sequence. The Rust enum derives `Debug` (intentionally not `PartialEq`/`Eq`/`Hash` — see the type's doc comment).
- `BStackGenOp::Push { data }` / `BSTACK_GEN_PUSH` and `BStackGenOp::Pop { buf }` / `BSTACK_GEN_POP` (Rust + C, `set` + `atomic`): in-sequence equivalents of `push`/`pop` for `process_gen` — `Push` appends `data` and `Pop` removes the last `buf.len()` bytes into `buf`, growing/shrinking the payload. Like `Write` and `Swap`, exactly one of `Write`/`Swap`/`Push`/`Pop` is permitted per call and any one of them ends the sequence immediately. `Pop` errors if it would remove more than the current payload or shrink it below the locked length.
- `BStackGenOp::Len { out }` / `BSTACK_GEN_LEN` (Rust + C, `set` + `atomic`): writes the current logical payload size into `out` and, unlike the mutating variants, does not end the sequence — the in-sequence equivalent of `len`, useful when a later step's offset or length depends on the current payload size.
- `BStackGenOp::Discard { len }` (Rust) / `BSTACK_GEN_POP` with a `NULL` `u.pop.buf` (C) (`set` + `atomic`): removes the last `len` bytes from the end of the file without reading them back, shrinking the payload and ending the sequence — the in-sequence, buffer-free equivalent of `discard` and the counterpart of `Pop`. Useful for truncating a tail whose size is only known once earlier `Read`s/`Len` have resolved, without allocating a throwaway buffer. In Rust this is a dedicated variant (slices cannot be null); in C it is expressed idiomatically as a `Pop` whose destination pointer is `NULL`. Errors on the same conditions as `Pop`.
### Fixed
- **`atrunc`, `splice`, `splice_into`, `replace` (Rust, `atomic`) and `bstack_atrunc`, `bstack_splice` (C, `BSTACK_FEATURE_ATOMIC`) — committed-length write not durably synced**: The header `clen` write that commits the new payload length was the last step of these operations and was never followed by `durable_sync`/`plat_durable_sync`, so a crash could leave the on-disk `clen` update only in the OS page cache. Every commit of a new `clen` — including best-effort rollback writes after a failed commit — is now followed by a sync. Crate-level durability table updated to reflect the additional sync.
### Changed
- **`BStack::len` (Rust) / `bstack_len` (C) and `BStack::is_empty` / `bstack_is_empty` no longer make a syscall**: The committed payload length is now cached in memory and kept in sync by every write-lock-held operation that commits a new length to the header. `len`/`is_empty` read this cache under the read lock instead of calling `File::metadata` (Rust) or `fstat`/`GetFileSizeEx` (C). Behaviour and signatures are unchanged.
- **`SlabBStackAllocator` and `CheckedSlabBStackAllocator` — `alloc` / `dealloc` / `realloc` are now lock-free under the `atomic` feature** (`alloc` + `set` features): The allocator-level `Mutex` that previously serialised free-list push/pop is gone from these paths. Free-list pop now drives a single `BStack::process_gen` sequence (read `free_head`, read the popped block's `next`, advance `free_head` — all under one held `BStack` write lock, closing the ABA window a `get`/`cas` pair would leave open); free-list push splices a single block or a whole freed run onto the head with one `BStack::cross_exchange`; tail grow/shrink use `BStack::try_extend_zeros` / `BStack::try_discard` (atomic check-and-act under `BStack`'s own write lock). `SlabBStackAllocator` drops its allocator-level `Mutex` entirely and is `Sync` purely through `BStack`'s interior mutability. `CheckedSlabBStackAllocator` retains a `Mutex` solely for `recover` (see below); none of `alloc` / `dealloc` / `realloc` take it. The on-disk format is unchanged — no magic-number bump.
- **`CheckedSlabBStackAllocator::recover` runs under its own mutex** (`alloc` + `set` features, `atomic`): the `Mutex` is held for the full call solely to keep recovery single-flight, preventing two concurrent runs from reclaiming the same leaked block twice. The scan itself (free-list walk, arena classification, and its one optional tail discard) runs as a single `BStack::process_gen` sequence, so the `BStack` write lock — not the `Mutex` — serialises it against the lock-free `alloc` / `dealloc` / `realloc`. Ordinary `alloc` / `dealloc` / `realloc` never take the `Mutex`.
## [0.2.4] - 2026-06-07
### Fixed
- **`GhostTreeBStackAllocator::dealloc_bulk` (C) — arena not reclaimed after bulk free** (`alloc` + `set` features): Freeing all slices returned by a single `alloc_bulk` call now shrinks the stack back to its pre-allocation size, matching the Rust implementation.
- **`FirstFitBStackAllocator::dealloc` / `realloc` — coalesced tail blocks never reclaimed** (`alloc` + `set` features): `cascade_discard_free_tail` was only called from the explicit tail-discard path in `dealloc`. When `add_to_free_list` coalesced a freed block with its neighbours and the result ended at the stack tail, that merged free block was never discarded — leaving the arena larger than necessary after all allocations were freed. No data corruption occurs; the free list remains structurally valid throughout. Fixed by calling `cascade_discard_free_tail` after every `add_to_free_list` call in both `dealloc` and `realloc`. The cascade is a no-op when the tail is still allocated.
### Changed
- **`GhostTreeBstackAllocator` version bumped to 0.1.2** (`alloc` + `set` features): Magic number updated from `ALGT\x00\x01\x01\x00` to `ALGT\x00\x01\x02\x00`. Reflects the addition of `atomic` / `Sync` support. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
- **`SlabBStackAllocator` version bumped to 0.1.1** (`alloc` + `set` features): Magic number updated from `ALSL\x00\x01\x00\x00` to `ALSL\x00\x01\x01\x00`. Reflects the addition of `atomic` / `Sync` support. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
- **`CheckedSlabBStackAllocator` version bumped to 0.1.1** (`alloc` + `set` features): Magic number updated from `ALCK\x00\x01\x00\x00` to `ALCK\x00\x01\x01\x00`. Reflects the addition of `atomic` / `Sync` support. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
- **`SlabBStackAllocator` is `Send + Sync` with the `atomic` feature** (`alloc` + `set` features): Without `atomic`, free-list mutations read then write `free_head` as separate `BStack` calls — a TOCTOU race that can hand the same block to two callers. With `atomic`, an internal mutex serialises free-list pop/push; tail operations use `BStack::try_discard` / `BStack::try_extend_zeros` (atomic check-and-act under `BStack`'s own write lock, no allocator lock needed). Non-tail paths lock only around `push_free_blocks`.
- **`CheckedSlabBStackAllocator` is `Send + Sync` with the `atomic` feature** (`alloc` + `set` features): Same mutex model. Free-list pop in `alloc` is lock-scoped; tail extend runs lock-free. `dealloc` uses `try_discard` for the tail path without the lock; free-list push is locked. In `realloc`, the grow path uses `try_extend_zeros` lock-free; the shrink path holds the lock across tail-check + overhead-write + discard (overhead must be committed before truncation for crash safety). `recover` holds the lock for its full duration.
- **`GhostTreeBstackAllocator` is `Send + Sync` with the `atomic` feature** (`alloc` + `set` features): Without `atomic`, all allocator operations take `&self` and mutate the on-disk AVL tree — concurrent shared access from multiple threads would race on that state. With `atomic`, an internal `Mutex` serialises all AVL tree mutations (`avl_insert`, `avl_find_best_fit_and_remove`, `write_root`); tail operations use `BStack::try_discard` / `BStack::try_extend_zeros` (check-and-act atomically under `BStack`'s own write lock, no allocator lock needed). The `PhantomData<Cell<()>>` field that previously opted out of `Sync` is replaced by the `Mutex`, which confers `Sync` without an `unsafe impl`. Documentation updated across type-level docs, module overview, crate overview, and README.
---
## [0.2.3] - 2026-06-01
### Added
- `BStack::try_extend_zeros` (`atomic`): appends zeros only if current size matches; conditional atomic extend.
- `BStack::get_batched` (`atomic`): reads multiple byte ranges under one lock.
- `BStack::get_batched_into` (`atomic`): like `get_batched` but into caller-provided buffers.
- `BStack::get_batched_gen` (`atomic`): like `get_batched_into` but with a generator closure for dependent reads.
- `BStack::cross_exchange` (`set` + `atomic`): atomically swaps two non-overlapping byte regions.
- `BStack::copy` (`set` + `atomic`): copies a byte region to another offset under one lock.
- `BStack::eq_crds` (`set` + `atomic`): writes region B only if region A equals an expected value (compare-and-swap across two regions).
- `BStack::ne_crds` (`set` + `atomic`): like `eq_crds` but writes when region A does not match.
- `BStack::masked_eq_crds` (`set` + `atomic`): like `eq_crds` with a bitmask applied to the comparison.
- `BStack::masked_ne_crds` (`set` + `atomic`): like `ne_crds` with a bitmask applied to the comparison.
- **`CheckedSlabBStackAllocator`** (`alloc` + `set` features) *(Experimental)*: New crash-recoverable fixed-block slab allocator. Every block carries an 8-byte overhead prefix: zero when free (`data[0..8]` holds the next-free block offset, sentinel `0`), high bit set with the block count in the low bits when in use. The double-free guard reads the overhead and returns `InvalidInput` before touching the free list. Leaked blocks are recoverable by a linear scan. Constructor takes `data_size` (usable bytes per block, ≥ 8); the on-disk `block_size` is `data_size + 8`. Magic: `ALCK\x00\x01\x00\x00`. Multi-block allocations always extend the tail (the free list holds single blocks only). Crash-consistency model: block payloads are written before `free_head` is updated, and the in-use high bit is flipped last, so a crash at any step leaks at most the current block without corrupting the rest of the list.
### Changed
- **`FirstFitBStackAllocator` version bumped to 0.1.3`** (`alloc` + `set` features): Magic number updated from `ALFF\x00\x01\x02\x00` to `ALFF\x00\x01\x03\x00`. This reflects the crash-recovery fix by adding the missing recovery guard around `realloc` tail-grow. This version new version of `FirstFitBStackAllocator` is also thread-safe when used with the `atomic` feature. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
- **`FirstFitBStackAllocator` is `Send + Sync` with the `atomic` feature** (`alloc` + `set` features): Without `atomic`, `FirstFitBStackAllocator` remains `Send` but not `Sync` — operations mutate the on-disk free list in several steps, a data race under concurrent `&self` access. With the `atomic` feature it gains `Sync`: an internal `std::sync::Mutex` serializes the two compound operations not already made atomic by `BStack`'s per-call write lock — free-list mutation and stack extension/discard — while in-place writes within an already-allocated block (in-bucket grow, same-block zeroing) stay lock-free. The `recovery_needed` flag is now updated with a compare-and-swap (no extra cost over the disk write it performs regardless), which also rejects operating on a stack left in a needs-recovery state. Unlike `LinearBStackAllocator`'s optimistic `try_extend`/`try_discard` (which reports a lost tail race as `Unsupported`), a contended `FirstFit` operation blocks on the mutex. Structurally, the `#[cfg(not(feature = "atomic"))] PhantomData<Cell<()>>` field opts out of `Sync`; under `atomic` that field is replaced by the `Mutex`, which makes the type `Sync` without an `unsafe impl`. Documentation updated across type-level docs, module overview, crate overview, and README.
- **`FirstFitBStackAllocator::alloc` and `realloc` — optimizes heap memory allocation out of the if statement and lock scope** (`alloc` + `set` features): Both methods now compute the aligned length and allocate the return buffer before acquiring the write lock, so the critical section only covers the actual stack mutation. This reduces contention and latency under concurrent access.
- **`FirstFitBStackAllocator::cascade_discard_free_tail` — remove unneeded recovery needed flag operations** (`alloc` + `set` features): This helper function is only called from `dealloc` when the freed block is at the tail, so the caller is already responsible for setting and clearing `recovery_needed` around the `discard` call. The helper no longer touches the flag, eliminating redundant writes and reentry into non-reentrant lock in atomic builds that is created by cas of `recovery_needed` in the loop.
### Fixed
- **`FirstFitBStackAllocator::new` — recovery triggered with `recovery_needed == 0` could fail spuriously under the `atomic` feature** (`alloc` + `set` features): When `new` decides to run recovery from an out-of-range `free_head` rather than from the flag itself (so the on-disk flag is still `0`), the recovery routine's final clear under the `atomic` feature performed `BStack::cas(1 → 0)` and surfaced a "recovery_needed was not set when clearing" error, making `new` return `Err` on a stack it could otherwise have repaired. Recovery now resets the flag with a direct `BStack::set` of zeros: it is authoritative and must run regardless of the prior flag value. Non-atomic behaviour is unchanged (the helper already did a plain set).
- **`FirstFitBStackAllocator::realloc` — tail-grow missing `recovery_needed` guard** (`alloc` + `set` features): A multi-step tail grow (`BStack::extend`, then zero, header, footer) without the recovery flag could leave an unrecoverable mid-arena layout on crash: for a grow delta of ≥ 24 bytes, a crash after `extend` but before the header write left the old block's valid header followed by zero bytes that recovery would read as a corrupt block (`"recovery: corrupted block header ... manual repair required"`), refusing to proceed. Tail-grow now sets `recovery_needed` before `extend` and clears it after the footer write in both atomic and non-atomic builds.
## [0.2.2] - 2026-05-26
### Added
- **In-memory caching of the locked region (`open_cached`, `open_locked_up_to_cached`)** *(unstable)*: Opening a `BStack` with [`open_cached`](BStack::open_cached) enables an opt-in in-memory mirror of the locked region. Each subsequent `lock_up_to(n)` call reads the newly locked bytes from disk into a heap-allocated `Vec<u8>` with power-of-two capacity growth. Reads whose range falls entirely within the locked region bypass the `RwLock` and are served by copying from the buffer with no syscall (on non-cached stacks the `RwLock` is bypassed too, but the read still issues a `pread(2)` or equivalent). The trade-off is that `lock_up_to` becomes significantly more expensive on cached stacks (it must read up to `n` bytes from disk before returning). [`open_locked_up_to_cached`](BStack::open_locked_up_to_cached) combines `open_cached` and `lock_up_to` into a single call for the common pattern of locking a known prefix at open time. Stacks opened with [`open`](BStack::open) or [`open_locked_up_to`](BStack::open_locked_up_to) are unaffected.
- **`BStackByteVec<'a, A: BStackSliceAllocator>`** (`alloc` + `set` features): A growable byte (`u8`) vector backed by a `BStack` allocation, mirroring the core `Vec<u8>` API. The 16-byte block header stores `len` and `capacity` as little-endian `u64` values; bytes follow immediately. The header is re-read from disk on every call, so the metadata is recoverable after a crash by reconstructing the handle via `BStackByteVec::from_raw_block`. A general typed vector (`BStackVec<T>`) is planned; the general case requires a sound POD/byte-castable bound and will be added in a future release. Key design points:
- **Growth**: when `push` would exceed capacity, the block is reallocated to `max(cap * 2, 4)` bytes via the allocator's `realloc`. New space is zero-initialised by `BStack::extend`.
- **Zeroing on removal**: `pop` decrements `len` before zeroing the vacated slot; `truncate` writes the new `len` before zeroing removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator.
- **API**: `new`, `with_capacity`, `from_slice`, `unsafe from_raw_block`, `len`, `capacity`, `is_empty`, `get`, `read_bytes`, `as_slice`, `push`, `pop`, `truncate`, `clear`, `reserve`, `resize`, `iter`, `unsafe raw_block`, `into_raw_block`, `dealloc`.
- **Iterator**: `BStackByteVecIter<'b, 'a, A>` borrows the vec immutably for its lifetime (preventing concurrent mutation), snapshots `len` at construction, and yields `io::Result<u8>` per byte read from disk on demand.
- **`BStackSlice::empty(allocator)` / `bstack_slice_empty`** (`alloc` feature): Constructs a zero-length slice anchored at offset 0.
- **`SlabBStackAllocator`** (`alloc` + `set` features) *(Experimental)*: New fixed-block slab allocator. All blocks in the arena are exactly `block_size` bytes (≥ 8) with no per-block header or footer. Freed blocks form an intrusive singly-linked free list; live bytes carry zero metadata overhead. Allocation is O(1) — either a free-list pop or a single tail extension. Deallocation of an oversized tail block shrinks the stack; all other blocks are returned to the free list in O(n_blocks). Reallocation within the same block count is O(1) with no I/O; tail resize is O(1); non-tail shrink recycles excess blocks; non-tail grow allocates and copies. Crash consistency: each free-list update is two `BStack` calls (write next-pointer, then update `free_head`); a crash between them leaks the affected block but leaves the rest of the list intact. `block_size < 8` returns `InvalidInput`.
- **`Debug` impl for `GhostTreeBstackAllocator` and `FirstFitBStackAllocator`** (`alloc` / `alloc` + `set` features): Both allocator types now implement `fmt::Debug`.
### Changed
- **`LinearBStackAllocator` is `Send + Sync` with the `atomic` feature** (`alloc` feature): Without `atomic`, `LinearBStackAllocator` is `Send` but not `Sync` — `realloc` and `dealloc` read the tail length and modify it in two separate steps, which is a TOCTOU race under concurrent `&self` access. With the `atomic` feature, `LinearBStackAllocator` gains `Sync`: `realloc` is reimplemented with `BStack::try_extend`/`try_discard` (fusing the check and the modify into one locked step), `dealloc` and `dealloc_bulk` use `BStack::try_discard` (a `false` return silently skips the discard, matching existing non-tail no-op semantics). `alloc` and `alloc_bulk` are unchanged — a single `extend` is already serialized by `BStack`'s write lock. The `PhantomData<Cell<()>>` field opts out of `Sync` structurally; `#[cfg(feature = "atomic")] unsafe impl Sync` re-enables it when the atomic implementations are in effect. Documentation updated across type-level docs, module overview, crate overview, and README.
- **`FirstFitBStackAllocator` and `GhostTreeBstackAllocator` are now explicitly `!Sync`** (`alloc` feature): Both types now carry a `PhantomData<Cell<()>>` field, making the compiler statically reject sharing a `&Self` reference across threads. Both remain `Send` — transferring exclusive ownership to another thread is safe. Previously, `Sync` was auto-derived from `BStack: Sync`, giving a false promise that concurrent `&self` calls were safe; in reality, concurrent access races on the free-list (FirstFit) or AVL tree (GhostTree) state with no allocator-level lock. Documentation updated across type-level docs, module overview, crate overview, and README.
- **`FirstFitBStackAllocator` promoted out of experimental** (`alloc` + `set` features): The "Experimental" label has been removed from all documentation.
- **`FirstFitBStackAllocator` internal reads converted from `get` to `get_into` with stack-allocated buffers** (`alloc` + `set` features): Three internal reads — the 32-byte allocator header on `new`, the 8-byte `free_head` field at the start of `find_large_enough_block`, and the 8-byte block size during `realloc`'s same-block fast path — now use fixed-size stack arrays and `get_into` instead of `get`. This eliminates three small heap allocations per call to those paths, with no change to observable behaviour.
## [0.2.1] - 2026-05-20
### Fixed
- **`FirstFitBStackAllocator::dealloc` — double-free** (`alloc` + `set` features): `dealloc` now returns `InvalidInput` if the block header's `is_free` flag is already set. Previously a double-free wrote a self-referential free-list entry; because `recovery_needed` was cleared normally, the corruption survived reopen and made every subsequent allocation fail.
- **`FirstFitBStackAllocator::dealloc` — tail fast path missing `recovery_needed` guard** (`alloc` + `set` features): `dealloc` now sets `recovery_needed` before `BStack::discard` in the tail-block path and clears it after `cascade_discard_free_tail`. A crash between the two steps could leave a free block at the new tail, violating the invariant that the tail is always allocated, with no recovery triggered on reopen.
- **`FirstFitBStackAllocator` recovery — mid-arena corrupt block truncated all following data** (`alloc` + `set` features): Recovery now returns `InvalidData` when a block header contains an invalid size (below minimum or not 8-aligned), instead of discarding everything from that offset to the tail. Truncation is still performed for a valid-size block that merely extends past the stack end (the intended partial-tail-write case).
- **`FirstFitBStackAllocator::find_large_enough_block` — free-list cycle hangs indefinitely** (`alloc` + `set` features): The walk now returns `InvalidData` if iteration count exceeds `arena_size / min_block_size + 1`, bounding the loop against self-referential entries introduced by corruption.
- **`GhostTreeBstackAllocator::alloc_bulk` — found-block remainder silently discarded** (`alloc` feature): When best-fit returned a block larger than the requested total, the surplus was consumed without being re-inserted into the AVL tree. `alloc_bulk` now mirrors `alloc`'s split logic: if the remainder is ≥ 32 bytes it is recycled as a new free block.
- **`GhostTreeBstackAllocator::coalesce_and_rebalance` — duplicate node visits widened corruption** (`alloc` feature): A partial rotation crash can leave one node reachable from two parents; the in-order walk visited it twice, and the rebuild pass clobbered the child pointers written by the first visit. The collected block list is now deduplicated by address before coalescing.
- **`GhostTreeBstackAllocator` — AVL recursion unbounded on cyclic corrupted tree** (`alloc` feature): All five recursive AVL functions (`walk_inorder`, `insert_rec`, `remove_rec`, `find_best_fit_and_remove_rec`, `min`) now accept a depth counter capped at 128 and return `InvalidData` when it reaches zero, preventing a stack overflow when a stale pointer forms a cycle.
- **`GhostTreeBstackAllocator::dealloc` — double-free undetectable by design** (`alloc` feature): GhostTree stores no per-block `is_free` flag and no block headers for live allocations, so there is no reliable way to distinguish a free block's AVL size field from user data that happens to hold the same value. Double-free is not detected; callers are responsible for not freeing a handle twice.
### Changed
- **`FirstFitBStackAllocator` version bumped to 0.1.2** (`alloc` + `set` features): Magic number updated from `ALFF\x00\x01\x01\x00` to `ALFF\x00\x01\x02\x00`. This is to reflect the bug fixes in 0.1.6, which are critical for data integrity. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open).
- **`GhostTreeBstackAllocator` version bumped to 0.1.1** (`alloc` feature): Magic number updated from `ALGT\x00\x01\x00\x00` to `ALGT\x00\x01\x01\x00`. This is to reflect the bug fixes in 0.1.6, which are critical for data integrity. Existing 0.1.x files remain fully compatible.
- **`LinearBStackAllocator::realloc` error message clarified** (`alloc` feature): The `Unsupported` error returned for non-tail slice reallocation now explicitly states that deallocating the old slice is also a no-op and will leak the region, so callers understand both steps of the copy-and-dealloc workaround are their own responsibility.
- **`LinearBStackAllocator::alloc_bulk` documents zero-length entry aliasing** (`alloc` feature): Zero-length entries produce slices with the same offset as the immediately following non-zero slice (adding zero to the running offset leaves it unchanged). Multiple consecutive zero-length entries therefore compare equal by `(offset, len)`. This was always true; it is now documented so callers are not surprised.
- **`LinearBStackAllocator::dealloc_bulk` documents duplicate/overlap coalescing** (`alloc` feature): Duplicate or overlapping slice handles are silently coalesced rather than causing an error — only the bytes collectively covered are discarded once. This matches single-item `dealloc` semantics and is now documented explicitly.
- **`BStackReader`, `BStackSliceReader`, and `BStackSliceWriter` now implement `Copy`**: All cursor types can now be copied by value without an explicit `.clone()` call, making them more ergonomic to use in iterator-like patterns and when passing to functions that don't need to move the original cursor. (`BStackSliceReader` and `BStackSliceWriter` require the `alloc` feature.)
### Added
- **`DebugCheckingAllocator`** (`alloc` feature): A wrapper allocator that tracks allocated regions in memory and validates all operations against them, providing strong guarantees that all allocations and deallocations are well-formed and correctly paired. Intended for testing and debugging; the tracking data structure is not optimised for performance or memory usage. See `debug_checking.rs` for details.
## [0.2.0] - 2026-05-15
### Changed
- **`BStackSlice::from_bytes` is now `unsafe`** (`alloc` feature): The method signature has changed to `pub unsafe fn from_bytes(allocator: &'a A, bytes: [u8; 16]) -> Self`. **Migration:** Callers must ensure the encoded offset and length lie within the bounds of the underlying allocator's payload. Each existing call site must be wrapped in an `unsafe` block.
- **`BStackAllocator` gains an associated error type** (`alloc` feature): The trait now requires `type Error: fmt::Debug + fmt::Display;`, and `alloc`, `realloc`, `dealloc`, `alloc_bulk`, and `dealloc_bulk` return `Result<_, Self::Error>` instead of `io::Result<_>`. All allocators provided by this library (`LinearBStackAllocator`, `FirstFitBStackAllocator`, `GhostTreeBstackAllocator`) set `type Error = io::Error`, preserving existing behaviour. **Migration:** Third-party `impl BStackAllocator` blocks must add `type Error = io::Error;` (or a custom type implementing `Debug + Display`) to compile.
- **`BStackAllocator` gains a `type Allocated<'a>` GAT** (`alloc` feature): The trait now requires `type Allocated<'a>: Copy + TryInto<BStackSlice<'a, Self>> where Self: 'a;`, and `alloc`, `realloc`, and `dealloc` traffic in `Self::Allocated<'_>` instead of `BStackSlice` directly. `BStackBulkAllocator` is updated in the same way: `alloc_bulk` returns `Vec<Self::Allocated<'_>>` and `dealloc_bulk` accepts `impl AsRef<[Self::Allocated<'a>]>`. All allocators provided by this library set `type Allocated<'a> = BStackSlice<'a, Self>`, so existing call sites are unaffected. **Migration:** Third-party `impl BStackAllocator` blocks must add `type Allocated<'a> = BStackSlice<'a, Self>;` to compile.
### Deprecated
- **`BStackSlice::new`** (`alloc` feature): Replaced by the explicitly-unsafe [`BStackSlice::from_raw_parts`] (see *Added* below). The old name hid that constructing an arbitrary `(offset, len)` slice can corrupt allocator metadata when the slice is later passed to `realloc` or `dealloc`. **Migration:** replace every call `BStackSlice::new(allocator, offset, len)` with `unsafe { BStackSlice::from_raw_parts(allocator, offset, len) }` and ensure the safety contract of `from_raw_parts` is upheld at that call site.
### Added
- **`BStackSlice::from_raw_parts(allocator, offset, len)` — `unsafe` constructor** (`alloc` feature): Explicit-unsafe replacement for the deprecated `BStackSlice::new`. The body is identical but the `unsafe fn` signature makes the caller's responsibility visible. The safety contract requires that `offset + len` does not overflow `u64`, and — critically — that **if the slice will be passed to `realloc` or `dealloc`** the `(offset, len)` pair must describe an allocation that was returned by `alloc` or a prior `realloc` on the same allocator instance; passing a sub-slice (from `subslice`/`subslice_range`) or a manually-forged offset may silently corrupt the allocator's persistent metadata.
- **`BStack::lock_up_to(n)` / `locked_len()` / `open_locked_up_to(path, n)`**: Declares the first `n` payload bytes permanently immutable for the lifetime of this open file. The boundary is monotonically growing, kept in memory only (not persisted — resets to `0` on every reopen), and never changes the on-disk format. Reads of ranges entirely within the locked region (`get`, `get_into`, `peek_into`) bypass the internal `RwLock` and serve the read directly with `pread(2)` (Unix) or `ReadFile` + `OVERLAPPED` (Windows). Writes (`set`, `zero`, `swap`, `swap_into`, `cas`, `process`, `atrunc`, `splice`, `splice_into`, `replace`) and shrink operations (`pop`, `pop_into`, `discard`, `try_discard`) return `InvalidInput` when their target overlaps the locked region. Callers that never call `lock_up_to` see only an uncontended atomic load and comparison added to each path.
- **`ManualAllocator`** (`alloc` feature): A singleton allocator with no backing [`BStack`]. `alloc` and `realloc` always return `Err(Unsupported)`; `dealloc` is a no-op. Intended for code that wants the `BStackSlice` type as a typed `(offset, len)` coordinate — serialised, compared, stored — while managing positions on the [`BStack`] directly rather than through an allocator. Obtain via `ManualAllocator::get()` (direct construction is prevented). Calling `stack()` or `into_stack()` panics; calling `read`/`write` on slices backed by this allocator will also panic. Implements `BStackSliceAllocator` and benefits from the same migration path.
- **`BStackSliceAllocator` convenience supertrait** (`alloc` feature): A supertrait that bundles `BStackAllocator<Error = io::Error>` and `for<'a> BStackAllocator<Allocated<'a> = BStackSlice<'a, Self>>` into a single bound, covering the common case where no custom handle or error type is needed. All allocators provided by this library implement it automatically via a blanket `impl`. Requires `'static` (implied by the `for<'a>` HRTB) — all provided allocators satisfy this. **Migration:** replacing `A: BStackAllocator` with `A: BStackSliceAllocator` in generic bounds is sufficient to satisfy all of the breaking `BStackAllocator` changes above in one step.
## [0.1.9] - 2026-05-07
### Changed
- **Restructured allocators into multiple files**: `alloc` now contain three files — `mod.rs` for the public API, `linear.rs` for `LinearBStackAllocator`, and `first_fit.rs` for `FirstFitBStackAllocator` — to improve organization and readability as the allocator codebase grows. The main `lib.rs` re-exports the public API from `alloc/mod.rs` when the `alloc` feature is enabled, so no API changes are required for users of the allocators.
- **`FirstFitBStackAllocator` allocation methods now optimize for zero-length slices**: `alloc(0)` returns a valid zero-length slice at offset 0; `realloc` can grow or shrink to/from zero length; `dealloc` of a zero-length slice is a no-op. This allows users to treat zero-length allocations as normal slices without actually allocating any space in the file, which is a common pattern for representing empty values or optional data. The change is backward-compatible.
### Added
- **`BStackSlice::read_range`** (`alloc` feature): Reads the relative range `[start, end)` into a freshly allocated `Vec<u8>`.
- **`BStackBulkAllocator` trait** (`alloc` feature): Extension trait for [`BStackAllocator`] that adds two required, atomic bulk methods — alloc_bulk` and `dealloc_bulk`. Both methods must either succeed completely or leave the backing store unchanged; partial state is never permitted.
- **Implement `BStackBulkAllocator` for `LinearBStackAllocator`**. `alloc_bulk` reduces all allocations to a single `BStack::extend` call (one durable
sync for the whole batch) and `dealloc_bulk` reclaims the largest contiguous tail region covered by the supplied slices with a single `BStack::discard` call.
- **`BStackGuardedSlice` trait** (`guarded` feature): Lifecycle-hook slice abstraction for transparent I/O interception. Provides four hook methods (`pre_read`, `post_read`, `pre_write`, `post_write`) to intercept and transform read/write operations. Includes support for narrowed subviews and atomic marker traits (`BStackAtomicGuardedSlice`, `BStackAtomicGuardedSliceSubview`) with crash-safety guarantees.
- **`GhostTreeBstackAllocator`** (`alloc` feature): A pure-AVL general-purpose allocator with zero-overhead live allocations. Free blocks store their AVL node inline, and the tree is keyed on `(size, address)` for best-fit allocation. Provides O(log n) allocation and deallocation with crash recovery through tree rebalancing on mount.
- **`BStackBulkAllocator` for `GhostTreeBstackAllocator`**: `alloc_bulk` rounds each requested length up to 32 bytes individually, sums them into a single contiguous block allocation (one AVL remove or one `extend` — crash-safe by construction), then slices it into per-request regions. `dealloc_bulk` sorts slices by address, merges contiguous ones (so slices from the same `alloc_bulk` call collapse into one block), and frees each merged group via tail-truncate (`discard`) when at the tail, or zero + AVL insert otherwise.
- **Tail truncation optimisation for `GhostTreeBstackAllocator`**: `dealloc` and `realloc` shrink now detect when the freed region is at the BStack tail and call `BStack::discard` directly instead of zeroing and inserting into the AVL tree. `realloc` grow at the tail calls `BStack::extend` in-place — no copy, no free, O(1).
## [0.1.8] - 2026-05-01
### Changed
- **Immutable buffer parameters widened from `&[u8]` to `impl AsRef<[u8]>`**: `push`, `set`, `atrunc`, `splice`, `try_extend`, `swap`, and `cas` (`old` and `new`) now accept any type that implements `AsRef<[u8]>` — including `Vec<u8>`, `Box<[u8]>`, `[u8; N]`, and `String` — without requiring an explicit `as_slice()` or `&buf[..]` conversion. The `new` parameter of `splice_into` is also widened. All existing callers passing `&[u8]` or byte-string literals continue to compile unchanged.
- **`BStackSlice::write` and `BStackSlice::write_range` widened from `&[u8]` to `impl AsRef<[u8]>`** (`alloc + set` features): same widening applied to the slice write methods for consistency.
- **FirstFitBStackAllocator version bumped to 0.1.1**: Updated magic number from `ALFF\x00\x01\x00\x00` to `ALFF\x00\x01\x01\x00`. This is still compatible with the previous version, so existing files can be read and written by both versions, but the new magic allows the allocator to detect whether the fixes in 0.1.6 are present.
### Fixed
- **`FirstFitBStackAllocator::dealloc` and `realloc` — validation rejected valid slices with user-visible length < 16**: both methods validated the input slice using the raw user-visible `slice.len()`, passing it directly to `is_impossible_block_size` and `is_impossible_block_end`. Because `align_len` guarantees the underlying block is always at least 16 bytes, a slice allocated with `len < 16` (e.g. `alloc(5)`) is perfectly valid, but the check `5 < MIN_BLOCK_PAYLOAD_SIZE` returned "impossible" and the call returned `InvalidInput`. Fixed by computing `aligned_len = align_len(slice.len())` first and using that for all size and end-offset checks.
- **`FirstFitBStackAllocator::dealloc` and `realloc` — wrong block boundary for small slices**: the tail-block detection condition used `slice.end().next_multiple_of(8)`, where `slice.end() = slice.start() + slice.len()`. For a 5-byte slice the rounded end (8) does not equal the actual block boundary (`slice.start() + 16`), so the fast tail-discard path was never taken and the block was incorrectly routed through `add_to_free_list` instead. The `discard` call on that path also used `slice.len().next_multiple_of(8)` (8 bytes) instead of `align_len(slice.len())` (16 bytes), leaving 8 bytes of garbage at the stack tail. Fixed to use `slice.start() + aligned_len` for both the condition and the discard amount.
- **`FirstFitBStackAllocator::find_large_enough_block` — end-of-list treated as corrupt pointer**: after advancing `head = next_free`, the function immediately called `is_impossible_block_start(head)` before the `while head != 0` loop guard could re-check. A `next_free` value of `0` (normal end-of-list sentinel) was therefore flagged as an invalid offset and the function returned `InvalidData`, causing any allocation attempt to fail when the free list contained at least one block that was too small. Fixed by guarding the check with `if head != 0 && is_impossible_block_start(head)`.
## [0.1.7] - 2026-04-28
### Added
- **`atomic` feature**: Enables compound read-modify-write operations that hold the write lock across what would otherwise be separate calls, providing thread-level atomicity and crash-safe sequencing.
- **`BStack::atrunc(n, buf)`**: Cut `n` bytes off the tail then append `buf` as a single locked operation. Operation ordering is chosen based on the net file-size change: for a net extension the file is extended before the write (so a crash before the committed-length update cleanly rolls back to the original state); for a net truncation the new bytes are written first then the file is truncated (so a crash after truncation is correctly committed by recovery).
- **`BStack::splice(n, buf) -> Vec<u8>`**: Pop `n` bytes from the tail (returning them) then append `buf`. The removed bytes are read before any mutation. Uses the same two-path ordering strategy as `atrunc`.
- **`BStack::splice_into(old, new)`**: Buffer-reuse counterpart of `splice`: reads the removed bytes into the caller-supplied `old` slice (`n = old.len()`) then appends `new`, avoiding a heap allocation.
- **`BStack::try_extend(s, buf) -> bool`**: Append `buf` only if the current logical payload size equals `s`; returns `true` if the append was performed, `false` (no-op) otherwise. Enables optimistic check-then-append patterns.
- **`BStack::try_discard(s, n) -> bool`**: Discard `n` bytes only if the current logical payload size equals `s`; returns `true` if the discard was performed. When `n = 0` only the read lock is taken.
- **`BStack::swap(offset, buf) -> Vec<u8>`** *(requires `set` + `atomic`)*: Atomically read `buf.len()` bytes at `offset` and overwrite them with `buf`; returns the old contents. File size is never changed.
- **`BStack::swap_into(offset, buf)`** *(requires `set` + `atomic`)*: Same atomic swap but exchanges in-place through a caller-supplied buffer: on entry `buf` holds the new bytes; on return `buf` holds the old bytes.
- **`BStack::cas(offset, old, new) -> bool`** *(requires `set` + `atomic`)*: Compare-and-exchange. Reads `old.len()` bytes at `offset`, compares them to `old`, and if equal writes `new` in their place. Returns `true` if the exchange was performed. Returns `false` (no-op) if the byte comparison fails or if `old.len() != new.len()`.
- **`BStack::replace(n, f)`** *(requires `atomic`)*: Pop `n` bytes off the tail, pass them read-only to a callback `f: FnOnce(&[u8]) -> Vec<u8>`, then write whatever `f` returns as the new tail. The entire read-callback-write sequence holds the write lock, so no other thread can observe the intermediate state. The file grows or shrinks according to the returned `Vec` length, using the same crash-safe two-path ordering as `atrunc`. `n = 0` is valid: the callback receives an empty slice.
- **`BStack::process(start, end, f)`** *(requires `set` + `atomic`)*: Read bytes in the half-open logical range `[start, end)`, pass them to a callback `f: FnOnce(&mut [u8])` that mutates them in place, then write the modified bytes back. The entire read-callback-write sequence holds the write lock, so no other thread can observe the intermediate state. The file size is never changed. `start == end` is a valid no-op.
## [0.1.6] - 2026-04-26
### Added
- **`FirstFitBStackAllocator::realloc` — in-place grow by merging the next free block**: when the block immediately following the current allocation is free and large enough, `realloc` now absorbs it without copying any data. The merged region is then split if the result is significantly larger than the requested size, with the surplus returned to the free list as a new free block. This avoids the copy-and-move path for the common case of growing an allocation that has adjacent free space.
- **Recovery: partial-split detection and repair**: after a crash between the block-data write and the header-size update of a split operation, the recovered header still reports the pre-split (oversized) length while the inner footer and the second sub-block's header form a consistent signature. Recovery now detects this three-point mismatch — outer footer value `F`, inner footer at `H − F − OVERHEAD` equal to `H − F − OVERHEAD`, second sub-block header equal to `F` — and rewrites the corrupted header to its correct value so both sub-blocks are visible and navigated correctly.
### Fixed
- **`FirstFitBStackAllocator::realloc` — incorrect merged block size**: the in-place merge computed `merged_size = block_size + next_block_size`, omitting the 24-byte `BLOCK_OVERHEAD_SIZE` that sits between the two original blocks. This caused the header to advertise a smaller extent than where the footer was actually written, making any subsequent free-and-coalesce operation navigate to the wrong position. Fixed to `block_size + BLOCK_OVERHEAD_SIZE + next_block_size`.
- **`FirstFitBStackAllocator::realloc` — split threshold too loose**: the split condition used `merged_size > aligned_new_len + BLOCK_FOOTER_SIZE + MIN_BLOCK_PAYLOAD_SIZE` (strict `>`). Because `BLOCK_FOOTER_SIZE + MIN_BLOCK_PAYLOAD_SIZE = BLOCK_OVERHEAD_SIZE = 24` and all sizes are multiples of 8, the minimum triggering case was `merged_size = aligned_new_len + 32`, producing a remainder of 8 bytes — below `MIN_BLOCK_PAYLOAD_SIZE` (16) and too small to hold the free block's `next_free`/`prev_free` pointers. Fixed to `merged_size >= aligned_new_len + BLOCK_OVERHEAD_SIZE + MIN_BLOCK_PAYLOAD_SIZE`, guaranteeing remainder ≥ 16 bytes.
- **`FirstFitBStackAllocator::alloc` and `realloc` — split/no-split detection inconsistent with `unlink_block`**: `alloc` and `realloc` computed the new payload location using `found_size > aligned_len + BLOCK_FOOTER_SIZE + MIN_BLOCK_PAYLOAD_SIZE` to decide whether `unlink_block` would split, but `unlink_block` itself uses `found_size >= aligned_len + BLOCK_OVERHEAD_SIZE + MIN_BLOCK_PAYLOAD_SIZE`. When `found_size` fell in the gap between those two thresholds (i.e., `aligned_len + 32 ≤ found_size < aligned_len + 40`), the caller assumed a split occurred and returned a slice pointing to the back of the found block, while `unlink_block` had in fact written the user data to the front. Every read and write via the returned slice then accessed the wrong memory region, silently corrupting or discarding user data. Fixed by aligning both conditions to `>= aligned_len + BLOCK_OVERHEAD_SIZE + MIN_BLOCK_PAYLOAD_SIZE`.
- **`FirstFitBStackAllocator::realloc` — stale bytes exposed on in-place grow**: when `realloc` grew a slice without moving it (block already large enough, tail-extend, or in-place merge), bytes between the old `slice.len()` and the new `len` could contain stale data from a previous larger allocation. The affected paths now zero exactly `[slice.len(), new_len)` before returning, matching the zero-initialisation contract of `alloc` and `LinearBStackAllocator::realloc`. The copy-and-move paths are fixed by limiting the copy to `slice.len()` bytes (not `aligned_current_len`), leaving the rest of the destination buffer zero-initialised.
## [0.1.5] - 2026-04-26 [YANKED]
* Yanked due to critical bugs in the new `FirstFitBStackAllocator` implementation. See fixes in [0.1.6].
### Added
- **`BStack::Debug`**: Shows `version` (semver string derived from the magic header, e.g. `"0.1.x"`) and `len` (current payload size as `Option<u64>`, `None` on I/O failure).
- **`BStack` equality and hashing**: `PartialEq`/`Eq` use pointer identity — two distinct instances are never equal. Because `open` holds an exclusive advisory lock, no two `BStack` values in one process can refer to the same file simultaneously, making pointer identity the only meaningful equality. `Hash` hashes the instance address, consistent with `PartialEq`.
- **`BStackReader` equality, hashing, and ordering**: `PartialEq`/`Eq` compare `(BStack pointer, offset)`; `Hash` is consistent; `PartialOrd`/`Ord` order by `BStack` instance address then by cursor offset.
- **`alloc` feature**: Adds region-based allocation over a `BStack` payload.
- **`BStackAllocator` trait**: Standard interface for types that own a `BStack` and manage contiguous byte regions within its payload. Requires `stack()`, `into_stack()`, `alloc()`, and `realloc()`; provides a default no-op `dealloc()`, and delegation helpers `len()` / `is_empty()`. Includes `Debug`, `From<BStack>`, and `From<LinearBStackAllocator> for BStack` on `LinearBStackAllocator`.
- **`BStackSlice<'a, A>`**: Lightweight `Copy` handle (allocator reference + `offset` + `len`) to a contiguous region. Exposes `read`, `read_into`, `read_range_into`, `subslice`, `subslice_range`, `reader`, `reader_at`, `to_bytes`, `from_bytes`; and (with `set`) `write`, `write_range`, `zero`, `zero_range`, `writer`, `writer_at`. Trait impls: `PartialEq`/`Eq`/`Hash` by `(offset, len)`; `PartialOrd`/`Ord` by `(offset, len)`; `From<BStackSlice> for [u8; 16]`.
- **`BStackSliceReader<'a, A>`**: Cursor-based reader over a `BStackSlice`, implementing `io::Read` and `io::Seek` in the slice's coordinate space. Trait impls: `PartialEq`/`Eq`/`Hash` by `(slice, cursor)`; `PartialOrd`/`Ord` by absolute payload position `slice.start() + cursor`, then `slice.len()`.
- **`BStackSliceWriter<'a, A>`** (requires `alloc` + `set`): Cursor-based writer over a `BStackSlice`, implementing `io::Write` and `io::Seek`. Every `write` call delegates to `BStack::set` and is durably synced. Same trait impls as `BStackSliceReader`.
- **Cross-type comparisons**: `PartialEq` and `PartialOrd` are defined between `BStackSliceReader` and `BStackSliceWriter` using the same `(abs_pos, len)` key (requires `set`). Both cursor types also implement `PartialEq<BStackSlice>` (cursor position ignored).
- **`From` conversions**: `BStackSlice` ↔ `BStackSliceReader`, `BStackSlice` ↔ `BStackSliceWriter`, `BStackSliceReader` ↔ `BStackSliceWriter`.
- **`LinearBStackAllocator`**: Reference bump allocator that appends regions sequentially. `realloc` is O(1) for the tail allocation and returns `Unsupported` for non-tail slices. `dealloc` reclaims the tail via `BStack::discard`; non-tail deallocations are a no-op. Every operation maps to exactly one `BStack` call and is crash-safe by inheritance.
- **`FirstFitBStackAllocator`** (requires `alloc` + `set`): Persistent first-fit free-list allocator. Freed regions are tracked on disk in a doubly-linked intrusive free list and reused for future allocations so the file does not grow without bound.
- **On-disk layout**: the first 48 payload bytes are an allocator header (`ALFF` magic + flags + `free_head`); the arena follows immediately. Each block is `[BlockHeader 16 B | payload | BlockFooter 8 B]`; free blocks store `next_free`/`prev_free` in the first 16 bytes of their payload. Minimum payload size is 16 bytes; all sizes are 8-byte aligned.
- **Allocation**: first-fit walk of the free list; splits found blocks from the back when the remainder would be ≥ 16 bytes; extends the stack when no free block fits.
- **Coalescing**: `dealloc` merges adjacent free neighbours (right then left). Merged blocks that reach the stack tail are discarded. A cascade check removes any further free blocks newly exposed at the tail, maintaining the invariant that the tail block is always allocated.
- **Crash consistency**: multi-step operations bracket free-list mutations with a `recovery_needed` flag. On `new`, if `recovery_needed` is set, a linear O(n) scan rebuilds the free list from `is_free` header flags (stored pointers are not trusted) and truncates any partial tail block.
- **`realloc`**: O(1) in-place grow/shrink for the tail block; copy-and-move for non-tail blocks using an existing free block or a new stack extension; same-block optimisation when the existing block already fits.
## [0.1.4] - 2026-04-25
### Added
- **`extend` method (Rust) / `bstack_extend` (C)**: Append `n` zero bytes to the tail and durable-sync. Returns the starting logical offset. `n = 0` is a no-op. Useful for reserving space in the payload without a caller-supplied buffer.
- **`zero` method (Rust, `set` feature) / `bstack_zero` (C, `BSTACK_FEATURE_SET`)**: Overwrite `n` bytes with zeros in place at a logical offset and durable-sync, without changing the file size. `n = 0` is a no-op; errors if `offset + n` exceeds the payload size.
- **`discard` method (Rust) / `bstack_discard` (C)**: Remove the last `n` bytes from the tail and durable-sync, without reading or returning the removed bytes. Equivalent to `pop`/`bstack_pop` but skips the buffer read, avoiding any allocation or copy. `n = 0` is a no-op; exceeding the payload size returns an error.
## [0.1.3] - 2026-04-20
### Added
- **`peek_into` method**: Fill a caller-supplied `&mut [u8]` from a logical offset, avoiding the `Vec` allocation of `peek`
- **`get_into` method**: Fill a caller-supplied `&mut [u8]` from a half-open logical range, avoiding the `Vec` allocation of `get`
- **`pop_into` method**: Pop bytes from the tail directly into a caller-supplied `&mut [u8]`, avoiding the `Vec` allocation of `pop`
- **`impl std::io::Write for BStack`**: Each `write` call forwards to `push` — atomically appended and durably synced; `flush` is a no-op
- **`impl std::io::Write for &BStack`**: Shared-reference counterpart, mirroring `impl Write for &File`; enables `BufWriter<&BStack>` for batched writes
- **`BStackReader` type**: Cursor-based reader over `&BStack` implementing `std::io::Read`, `std::io::Seek`, and `From<&BStack>`; multiple readers can coexist and run concurrently
- **`BStack::reader()`**: Construct a `BStackReader` positioned at the start of the payload
- **`BStack::reader_at(offset)`**: Construct a `BStackReader` at an arbitrary logical offset
### Changed
- Moved tests to `src/test.rs` for better organization and to avoid cluttering the main library file
## [0.1.2] - 2026-04-18
### Added
- **Windows support**: Full first-class Windows support with `LockFileEx` for exclusive file locking and `ReadFile` with `OVERLAPPED` for cursor-safe positional reads
- **Concurrent reads on Windows**: `peek` and `get` operations now use the read lock on Windows, enabling concurrent readers just like on Unix
- **Cross-platform durability**: `FlushFileBuffers` on Windows provides equivalent durability guarantees to `fdatasync` on Unix
### Changed
- Updated thread-safety documentation to reflect Windows support alongside Unix
- Updated multi-process safety documentation to cover both `flock` (Unix) and `LockFileEx` (Windows)
- Extended concurrent reads test to run on both Unix and Windows platforms
### Dependencies
- Added `windows-sys` crate for Windows platform support
## [0.1.1] - 2026-04-17
### Added
- **`get` method**: Read arbitrary half-open byte ranges `[start, end)` from logical offsets
- **Concurrent reads on Unix**: `peek` and `get` operations now use `pread(2)` and take only the read lock, allowing multiple concurrent readers
- **Enhanced durability on macOS**: `durable_sync` now uses `F_FULLFSYNC` to flush the drive's hardware write cache, providing stronger guarantees than plain `fdatasync`
### Changed
- Updated thread-safety model documentation to reflect read-lock usage for `peek`/`get` on Unix
## [0.1.0] - 2026-04-16
### Added
- Initial release of `bstack`: A persistent, fsync-durable binary stack backed by a single file
- Core operations: `push`, `pop`, `peek`, `len`
- Crash recovery with committed-length sentinel
- Multi-process safety via advisory `flock` on Unix
- File format with 16-byte header containing magic number and committed length
- Durability guarantees with `durable_sync` (fdatasync on Unix)
- Optional `set` feature for in-place payload mutation