diff --git a/.github/workflows/alloc_fuzz.yml b/.github/workflows/alloc_fuzz.yml index 25a4037e..7eeebab4 100644 --- a/.github/workflows/alloc_fuzz.yml +++ b/.github/workflows/alloc_fuzz.yml @@ -2,10 +2,10 @@ name: Alloc Fuzz on: push: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [ src/alloc/**, src/alloc_fuzz_tests.rs ] pull_request: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [ src/alloc/**, src/alloc_fuzz_tests.rs ] permissions: diff --git a/.github/workflows/cci.yml b/.github/workflows/cci.yml index 0be8afbb..ebf2ce90 100644 --- a/.github/workflows/cci.yml +++ b/.github/workflows/cci.yml @@ -2,10 +2,10 @@ name: CCI on: push: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [c/**, .github/workflows/cci.yml] pull_request: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [c/**, .github/workflows/cci.yml] permissions: @@ -61,5 +61,8 @@ jobs: run: cd c && make test-checked-slab-atomic - name: Build and test bytevec run: cd c && make test-bytevec + - name: Build and test bytevec (atomic) + if: runner.os != 'Windows' + run: cd c && make test-bytevec-atomic - name: Build alloc libraries (all feature variants) run: cd c && make libbstack-alloc.a libbstack-alloc-set.a diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b627f06e..d1435326 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,9 +2,9 @@ name: Check on: push: - branches: [ master ] + branches: [ master, "0.2-backports" ] pull_request: - branches: [ master ] + branches: [ master, "0.2-backports" ] permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51c53d6f..153584cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,10 @@ name: CI on: push: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [src/**] pull_request: - branches: [ master ] + branches: [ master, "0.2-backports" ] paths: [src/**] permissions: diff --git a/.github/workflows/devskim.yml b/.github/workflows/devskim.yml index 976ab8e8..7b72eef1 100644 --- a/.github/workflows/devskim.yml +++ b/.github/workflows/devskim.yml @@ -7,10 +7,10 @@ name: DevSkim on: push: - branches: [ "master" ] + branches: [ "master", "0.2-backports" ] paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock'] pull_request: - branches: [ "master" ] + branches: [ "master", "0.2-backports" ] paths-ignore: ['**.md', '**/Makefile', '.gitignore', '.gitattributes', '**.toml', '**.lock'] jobs: diff --git a/.gitignore b/.gitignore index d2cc6130..d4035b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,12 @@ test_* # VSCode .vscode/ + +# GitHub +.github/ + +# Data +.data/ + +# Claude +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a69df5a..09601307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`Debug` for `BStackReader` (base API, Rust only).** `BStackSliceReader` and `BStackSliceWriter` already implemented it, so a struct holding a plain `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`. Backported from the 0.4.x line. +- **`Display` for `BStackSlice`, `BStackReader`, `BStackSliceReader` and `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 a slice, `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. Backported from the 0.4.x line. +- **`Clone`, `DoubleEndedIterator`, `ExactSizeIterator` and `FusedIterator` for `BStackByteVecIter` (`alloc` + `set`, Rust only).** The byte-vec iterator previously implemented only `Iterator`. `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. Backported from the 0.4.x line. +- **`RefUnwindSafe` for the five built-in allocators in non-`atomic` builds (`alloc`; `set` for the three that need it; Rust only).** Each carries a `PhantomData>` 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. An explicit impl restores it — the only interior mutability is the `BStack`'s own poisoning lock. `Sync` is still absent without `atomic`, as before. Backported from the 0.4.x line. + +## [0.2.7] - 2026-09-02 + +### Added + +- **`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>`; 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. `guard` may be any view into the same `BStack`/`bstack_t`, including the slice itself. 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 for arbitrary transforms the length-preserving primitive `reverse`/`rotate_left`/`rotate_right` already use. Backported from the 0.4.x line. +- **`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. Backported from the 0.4.x line. +- **`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. Backported from the 0.4.x line. +- **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. Backported from the 0.4.x line. +- **`BStackSlice::is_from(allocator)` (Rust, `alloc`) / `bstack_slice_is_from(s, a)` (C, macro): reports whether a slice was issued by a given allocator instance.** A slice records its allocator, but neither language can enforce at compile time that it is only ever handed back to *that* instance — every allocator of a given kind has the same type, so `a2.dealloc(s1)` / `bstack_allocator_dealloc(a2, s1)` compiles. This is the run-time check custom allocators need to reject a foreign slice. Backported from the 0.4.x line. + +### Fixed + +- **`GhostTreeBstackAllocator` (Rust and C): a length too large to round up to a 32-byte multiple was accepted rather than rejected.** `align_up_len` / `algt_align_up_len` computed `(len + 31) & !31` unchecked, so any `len > u64::MAX - 31` wrapped and rounded *down* to a 32-byte block: `alloc` returned a handle claiming the requested length, and `realloc` shrank the block to 32 bytes and freed the remainder while the handle still claimed the huge length. Debug builds trapped on the overflowing add, so the damage was release-only. Both helpers now check against a new `MAX_ALLOC` / `ALGT_MAX_ALLOC` (`(u64::MAX - ARENA_START) & !31`, bounded by the arena rather than by `u64` alone), and `alloc`, `alloc_bulk`, `realloc`, `dealloc`, and `dealloc_bulk` reject an unalignable length with `io::ErrorKind::InvalidInput` / `errno = EINVAL`. On-disk format unchanged. Backported from the 0.4.x line. + +### Changed + +- **Every built-in allocator now rejects a slice issued by a different allocator instance** (Rust `alloc`; C `bstack_alloc`). `realloc`, `dealloc` and `dealloc_bulk` check slice ownership before touching any metadata and fail with `io::ErrorKind::InvalidInput` / `errno = EINVAL`; `dealloc_bulk` rejects the whole batch and frees nothing rather than stopping part-way. The slice is `Copy` / passed by value, so a refused caller still holds it. This was never a soundness issue — slices 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 on `BStackSlice::is_from` and under "Foreign slices" in `bstack_alloc.h`. Correct programs are unaffected. Backported from the 0.4.x line. +- **`LinearBStackAllocator::realloc` grows the tail with `BStack::try_extend_zeros` under `atomic` (Rust only).** 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. The C `linear_vt_realloc` never staged a buffer (it grows with `bstack_extend`) and is unchanged. Backported from the 0.4.x line. +- **`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. Backported from the 0.4.x line. +- **`GhostTreeBstackAllocator` version bumped to 0.1.4** (`alloc` + `set`; Rust and C): magic `ALGT\x00\x01\x03\x00` → `ALGT\x00\x01\x04\x00`. No layout change; the patch byte attributes a file to a build that rejects an unalignable length rather than wrapping it. Existing 0.1.x files remain fully compatible (only the first 6 bytes are checked on open). + +## [0.2.6] - 2026-08-23 + +### Added + +- **`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. Growth follows `extend`'s crash-consistency, shrinkage follows `discard`'s. Ported from the 0.4.x line. +- **`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). Ported from the 0.4.x line. +- **`BStack::repeat` (Rust, `set`) / `bstack_repeat` (C, `BSTACK_FEATURE_SET`): 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, and it is the general form of `zero`. Unlike the 0.4.x line — which journals only the pattern and count into a fixed-size write-in-progress journal — this version has no such journal and writes the full `count * pattern.len()` bytes directly, so a large crash-safe fill is slower and stages the expanded buffer in memory. Ported from the 0.4.x line. +- **`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`). Ported from the 0.4.x line. +- **`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`; `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). Ported from the 0.4.x line. +- **`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`, rather than a grow/write/len cycle per byte. Empty input is a no-op. Crash consistency matches the other multi-step methods (a crash before the `len` commit leaves the bytes beyond the committed length, invisible). Ported from the 0.4.x line. +- **`BStackByteVec` — crash-atomic byte movers (`alloc` + `set` + `atomic`).** Built on `BStack::copy` and `BStack::cross_exchange` so the vec never shifts bytes one at a time; gated on `atomic`. 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(other)` appends another `BStackSlice`'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: `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 `BStackSlice` and shrinks. `copy_into_bstack_slice(start, &mut dst)` copies vec bytes out into a same-`BStack` slice. Following the `get`-style convention, an out-of-bounds index/range or `u64` overflow returns `Ok(None)` (the vec is untouched); passing a slice/handle from a *different* `BStack` to a cross-slice method is an `Err`. (On this line, unlike 0.4.x's `BStackOwnedSlice`, these operate on `BStackSlice`.) Ported from the 0.4.x line. + +### 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 left with zeroed tail bytes (never a corrupted neighbour). On-disk format unchanged; allocator magic bumped `ALCK\x00\x01\x01\x00` → `ALCK\x00\x01\x02\x00` (patch byte only, so existing 0.1.x files stay compatible). Backported from the 0.4.x line. Surfaced by the allocator fault-injection fuzz. +- **`FirstFitBStackAllocator::realloc` (Rust) / `ff_vt_realloc` (C): an in-place tail-shrink was not crash-atomic.** Reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault mid-sequence left the header, footer, and physical size disagreeing — a state the 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. Behaviour change: a tail `realloc` shrink no longer returns space to the file immediately. Backported from the 0.4.x line. Surfaced by the allocator fault-injection fuzz. +- **`FirstFitBStackAllocator` (Rust) / `alff_recovery` (C): two recovery bugs surfaced by the allocator fault-injection fuzz.** (1) An interrupted in-place tail *grow* `extend`s (zero-filling) the payload before rewriting the header/footer, so a crash in that window left a valid block followed by a headerless all-zero region, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. Recovery now recognises an all-zero trailing region (a real block is never all-zero) as the interrupted extension and rolls it back by truncation; genuine mid-arena corruption still fails loudly. (2) A coalescing free commits the merged size to the block header before the footer, so a crash between left the header correct and the footer stale; because the recovery walk follows headers, the stale footer slipped through and later let a neighbour's coalesce walk into the merged block's interior, overlapping two blocks and eventually desyncing the walk into a hard `open` failure. Recovery now normalizes every block's footer to its authoritative header as it walks. Backported from the 0.4.x line. +- **`GhostTreeBstackAllocator::realloc` (Rust) / `gt_vt_realloc` (C) — atomic tail-shrink could strand stale sub-block padding after a crash.** Under `atomic` / `BSTACK_FEATURE_ATOMIC`, an in-place tail shrink discarded the freed tail (`try_discard`) *before* zeroing the retained block's sub-block padding `[new_len, aligned_new)`, so a crash between the two left that padding holding the caller's stale bytes. A later same-block grow does not re-zero the newly-exposed region (it trusts the zeroed-memory invariant), so it would hand those stale bytes back. The padding is now zeroed *before* the tail is discarded — matching the non-`atomic` path, which was already correct — so a crash leaves at worst a zeroed retained block plus an unreclaimed tail (a benign leak), never stale padding. On-disk format unchanged (operation ordering only; no magic bump). + +### 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`). Ported from the 0.4.x line. +- **`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; the cache is rebuilt from scratch by the coalesce-and-rebalance pass every open). +- **`#[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. Ported from the 0.4.x line. +- **`#[track_caller]` added to `BStackSlice`/`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. Ported from the 0.4.x line. +- **`#[inline]` on small public APIs.** Added `#[inline]` to short public functions across `bstack` to enable cross-crate inlining. No behaviour change. Ported from the 0.4.x line. + ## [0.2.5] - 2026-06-15 ### Added diff --git a/Cargo.lock b/Cargo.lock index cb98e6f7..4cbcf07e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -23,27 +23,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - [[package]] name = "bstack" -version = "0.2.5" +version = "0.2.7" dependencies = [ "criterion", "libc", @@ -71,9 +59,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", @@ -109,18 +97,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstyle", "clap_lex", @@ -134,9 +122,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -179,9 +167,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -189,18 +177,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -210,39 +198,27 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -252,16 +228,14 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", "rand_core", - "wasip2", - "wasip3", ] [[package]] @@ -275,50 +249,11 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "is-terminal" @@ -348,39 +283,26 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "log" -version = "0.4.32" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "num-traits" @@ -437,30 +359,20 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -473,9 +385,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom", @@ -510,9 +422,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -522,9 +434,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -533,15 +445,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -552,17 +464,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -570,29 +476,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -609,9 +515,20 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -634,12 +551,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "walkdir" version = "2.5.0" @@ -650,29 +561,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -683,9 +576,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -693,65 +586,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -854,122 +713,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index aa8f9dec..69f96699 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bstack" -version = "0.2.5" +version = "0.2.7" edition = "2024" authors = ["William Wu ", "Claude "] license = "MIT" diff --git a/README.md b/README.md index b214a659..469c96d8 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ assert!(stack.pop(stack.len()? - 60).is_err()); // would shrink below locked | Trait | Semantics | |--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| | `PartialEq` / `Eq` | **Pointer identity.** Two values are equal iff they are the same instance. No two distinct `BStack` values in one process can refer to the same file. | -| `Hash` | Hashes the instance address — consistent with pointer-identity equality. | +| `Hash` | Hashes the raw fd (Unix) / handle (Windows) — unique per live instance, consistent with pointer-identity equality, and stable across moves. | ### `BStackReader` @@ -532,7 +532,7 @@ file offset 0 offset 16 16+n0 EOF ``` * **`magic`** — 8 bytes: `BSTK` + major(1 B) + minor(1 B) + patch(1 B) + reserved(1 B). - This version writes `BSTK\x00\x01\x0f\x00` (0.1.15). `open` accepts any + This version writes `BSTK\x00\x01\x11\x00` (0.1.17). `open` accepts any 0.1.x file (first 6 bytes `BSTK\x00\x01`) and rejects a different major or minor as incompatible. * **`clen`** — little-endian `u64` recording the last successfully committed @@ -750,23 +750,51 @@ Produced by `BStackAllocator::alloc`; consumed by `realloc` and `dealloc`. > raw `(start, len)` fields and reconstruct via > `unsafe { BStackSlice::from_raw_parts(...) }` for read/write I/O only — > never pass a reconstructed slice to `realloc` or `dealloc`. +> +> **Foreign slices.** A slice records the allocator that issued it, but nothing +> stops it being handed to a *different* instance of the same type — the +> language cannot catch it. Every built-in allocator therefore checks ownership +> at the top of `realloc`, `dealloc` and `dealloc_bulk`, before touching any +> metadata, and fails with `io::ErrorKind::InvalidInput` (C: `-1` / `errno = +> EINVAL`); `dealloc_bulk` rejects the whole batch and frees nothing. The slice +> is `Copy`, so a refused caller still holds it. `is_from` is the check. Key methods: -| Method | Description | -|----------------------------------------------|------------------------------------------------| -| `read()` | Read the entire region into a new `Vec` | -| `read_into(buf)` | Read into a caller-supplied buffer | -| `read_range(start, end)` | Read a sub-range into a new `Vec` | -| `read_range_into(start, buf)` | Read a sub-range into a caller-supplied buffer | -| `subslice(start, end)` | Narrow to a sub-range (relative offsets) | -| `subslice_range(range)` | Narrow to a sub-range using a `Range` | -| `reader()` | Cursor-based `BStackSliceReader` at position 0 | -| `reader_at(offset)` | Cursor-based `BStackSliceReader` at `offset` | -| `write(data)` *(feature `set`)* | Overwrite the beginning of the region in place | -| `write_range(start, data)` *(feature `set`)* | Overwrite a sub-range in place | -| `zero()` *(feature `set`)* | Zero the entire region in place | -| `zero_range(start, n)` *(feature `set`)* | Zero a sub-range in place | +| Method | Description | +|---------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| +| `read()` | Read the entire region into a new `Vec` | +| `read_into(buf)` | Read into a caller-supplied buffer | +| `read_range(start, end)` | Read a sub-range into a new `Vec` | +| `read_range_into(start, buf)` | Read a sub-range into a caller-supplied buffer | +| `subslice(start, end)` | Narrow to a sub-range (relative offsets) | +| `subslice_range(range)` | Narrow to a sub-range using a `Range` | +| `is_from(allocator)` | Whether this slice was issued by `allocator` (the foreign-slice check) | +| `reader()` | Cursor-based `BStackSliceReader` at position 0 | +| `reader_at(offset)` | Cursor-based `BStackSliceReader` at `offset` | +| `write(data)` *(feature `set`)* | Overwrite the beginning of the region in place | +| `write_range(start, data)` *(feature `set`)* | Overwrite a sub-range in place | +| `zero()` *(feature `set`)* | Zero the entire region in place | +| `zero_range(start, n)` *(feature `set`)* | Zero a sub-range in place | +| `get(index)` | Read a single byte, or `None` if out of bounds | +| `head(n)` / `tail(n)` | Sub-view of the first/last `n` bytes (capped to length) | +| `split_at(mid)` / `split_at_mut(mid)` | Split into two independent sub-views | +| `contains(byte)` | Whether the slice contains a byte | +| `starts_with(prefix)` / `ends_with(suffix)` | Whether the slice starts/ends with a byte pattern | +| `find(byte)` / `rfind(byte)` | Index of the first/last occurrence of a byte | +| `position(pred)` / `rposition(pred)` | Index of the first/last byte matching a predicate | +| `fill(value)` *(feature `set`)* | Overwrite the entire slice with one byte value | +| `fill_with(f)` *(feature `set`)* | Overwrite the entire slice, generating each byte | +| `copy_from_slice(src)` *(feature `set`)* | Overwrite from a matching-length `&[u8]` | +| `copy_from_bstack_slice(src)` *(features `set` + `atomic`)* | Overwrite from a matching-length `BStackSlice` | +| `copy_within(range, dest)` *(features `set` + `atomic`)* | Copy a sub-range to another offset, in place | +| `swap(other)` *(features `set` + `atomic`)* | Exchange contents with another same-length slice | +| `reverse()` *(features `set` + `atomic`)* | Reverse the byte order in place | +| `rotate_left(mid)` / `rotate_right(k)` *(features `set` + `atomic`)* | Rotate the slice in place | +| `process(f)` *(features `set` + `atomic`)* | Run an arbitrary length-preserving in-place transform — the primitive `reverse`/`rotate_left`/`rotate_right` are built on | +| `cas_on(guard, expected, new_bytes)` *(features `set` + `atomic`)* | Overwrite `self` with `new_bytes`, returning the prior contents, if `guard`'s bytes equal `expected` | +| `cas_on_ne(guard, expected, new_bytes)` *(features `set` + `atomic`)* | Like `cas_on`, but swaps when `guard`'s bytes do **not** equal `expected` | +| `cas_on_masked(guard, mask, expected, new_bytes)` *(features `set` + `atomic`)* | Like `cas_on`, comparing `guard`'s bytes to `expected` under a bitwise `mask` | ### `BStackSliceReader<'a, A>` @@ -965,7 +993,7 @@ bstack = { version = "0.2", features = ["alloc"] } ┌─────────────────────────────┐ payload offset 0 │ User-reserved (32 bytes) │ ├─────────────────────────────┤ offset 32 -│ Magic number (8 bytes) │ "ALGT\x00\x01\x02\x00" +│ Magic number (8 bytes) │ "ALGT\x00\x01\x04\x00" ├─────────────────────────────┤ offset 40 │ AVL root pointer (8 B) │ absolute payload offset of the root node ├─────────────────────────────┤ offset 48 ← arena start (32-byte aligned) @@ -1267,7 +1295,12 @@ recoverable after a crash by reconstructing the handle from the raw block via - **Growth**: `push` reallocates to `max(cap × 2, 4)` bytes when `len == cap`. New space is zero-initialised by `BStack::extend`. - **Readback helper**: `read_bytes` loads all logical bytes into a Rust `Vec`. - **Zeroing on removal**: `pop` decrements `len` before zeroing the vacated slot; `truncate` writes the new `len` before zeroing removed slots in a single `BStackSlice::zero_range` call. Deallocation zeroing is delegated to the allocator. -- **Iterator**: `BStackByteVecIter` borrows the vec immutably for its lifetime (preventing concurrent mutation) and yields `io::Result` per byte, reading from disk on demand. +- **Out-of-bounds convention**: the index/range-taking methods (`set`, `insert`, `remove`, `swap_remove`, `extend_from_within`, `copy_into_bstack_slice`, `move_tail_into`, `split_off`, `drain`) return `io::Result>` and yield `Ok(None)` for an out-of-range index/length or a `u64` overflow (like `get`), leaving the vec untouched. `Err` is reserved for I/O failures; passing a slice from a *different* `BStack` to a cross-slice method is an `Err` (a misuse, not an out-of-range request). +- **Atomic byte movers** (`atomic` feature), in two crash-consistency classes: + - Append-only, benign on crash (bytes land in spare capacity, `len` commits last): `extend_from_within`, `extend_from_bstack_slice`, `append_from_owned`. + - In-place, torn-but-valid on crash: `insert(index, value)` / `remove(index)` shift the tail via `copy`; `swap_remove(index)` swaps the hole with the last byte via `cross_exchange`; `drain(range)` removes and returns an interior range, shifting the tail down via `copy`; `split_off(at)` splits the vec at `at` into a new vec holding the tail, moving the bytes directly between the two on-disk blocks via `copy`; `move_tail_into(&mut dest)` swaps the vec's tail into a `BStackSlice` and shrinks. `copy_into_bstack_slice(start, &mut dst)` copies vec bytes out into a same-`BStack` slice (a single atomic `copy`). +- **`io::Write`**: `write(buf)` forwards to `extend_from_slice(buf)` and returns `buf.len()`; `flush()` is a no-op. Each `write` re-reads the header and may reallocate, so `write_all` over many small chunks costs more than one `extend_from_slice` call — `reserve` beforehand avoids the repeated regrowth. +- **Iterator**: `BStackByteVecIter` borrows the vec immutably for its lifetime (preventing concurrent mutation) and yields `io::Result` per byte, reading from disk on demand. It is `Clone` (forking an iteration at its current position), `DoubleEndedIterator` (so `.rev()` and `.last()` cost one read, not a full scan), `ExactSizeIterator`, and `FusedIterator` — `len` is snapshotted at construction, so an exhausted iterator stays exhausted. The count is tracked as `u64` and `size_hint()`/`len()` are exact on 64-bit targets; on targets where `usize` is narrower, a count that overflows `usize` clamps to `usize::MAX` rather than wrapping. ### Example diff --git a/c/Makefile b/c/Makefile index 72c24046..6a665ab6 100644 --- a/c/Makefile +++ b/c/Makefile @@ -82,6 +82,14 @@ LIB_BYTEVEC_SET = libbstack-bytevec-set.a TEST_BV_BIN = test_bstack_bytevec$(EXE_EXT) TEST_BV_OBJ = test_bstack_bytevec.o +# ByteVec compiled with SET+ATOMIC — adds the atomic byte-mover surface +# (extend_from_within, insert, remove, swap_remove, split_off, drain, ...) built +# on bstack_copy / bstack_cross_exchange. +OBJ_BYTEVEC_ATOMIC = bstack_bytevec-atomic.o +LIB_BYTEVEC_SET_ATOMIC = libbstack-bytevec-set-atomic.a +TEST_BV_ATOMIC_OBJ = test_bstack_bytevec-atomic.o +TEST_BV_ATOMIC_BIN = test_bstack_bytevec-atomic$(EXE_EXT) + EXAMPLE_NAMES = basic buffer_reuse journal reading vec_store EXAMPLE_BINS = $(addprefix ../examples/,$(addsuffix $(EXE_EXT),$(EXAMPLE_NAMES))) HASHMAP_BIN = ../examples/hashmap$(EXE_EXT) @@ -91,7 +99,7 @@ LINKED_LIST_BIN = ../examples/linked_list$(EXE_EXT) CHECKSUMMED_CACHE_BIN = ../examples/checksummed_cache$(EXE_EXT) ATOMIC_LINKED_LIST_BIN = ../examples/atomic_linked_list$(EXE_EXT) -.PHONY: all test test-set test-atomic test-set-atomic test-first-fit test-first-fit-atomic test-ghost-tree test-ghost-tree-atomic test-slab test-slab-atomic test-checked-slab test-checked-slab-atomic test-bytevec leaks clean alloc \ +.PHONY: all test test-set test-atomic test-set-atomic test-first-fit test-first-fit-atomic test-ghost-tree test-ghost-tree-atomic test-slab test-slab-atomic test-checked-slab test-checked-slab-atomic test-bytevec test-bytevec-atomic leaks clean alloc \ example example-basic example-buffer_reuse example-journal \ example-reading example-vec_store example-hashmap example-atomic_ops example-move_and_cow \ example-linked_list example-checksummed_cache example-atomic_linked_list @@ -231,6 +239,19 @@ $(TEST_BV_BIN): $(TEST_BV_OBJ) $(LIB_BYTEVEC_SET) test-bytevec: $(TEST_BV_BIN) $(RUN)$(TEST_BV_BIN) +$(OBJ_BYTEVEC_ATOMIC): bstack_bytevec.c bstack_bytevec.h bstack_alloc.h bstack.h + $(CC) $(CFLAGS) -DBSTACK_FEATURE_SET -DBSTACK_FEATURE_ATOMIC -c -o $@ $< + +$(LIB_BYTEVEC_SET_ATOMIC): $(OBJ_SET_ATOMIC) $(OBJ_ALLOC_SET_ATOMIC) $(OBJ_BYTEVEC_ATOMIC) + ar rcs $@ $^ + +# Same bytevec test compiled against the SET+ATOMIC bytevec lib, exercising the +# atomic byte-mover surface guarded by BSTACK_FEATURE_ATOMIC. +test-bytevec-atomic: $(LIB_BYTEVEC_SET_ATOMIC) + $(CC) $(CFLAGS) -DBSTACK_FEATURE_SET -DBSTACK_FEATURE_ATOMIC -c -o $(TEST_BV_ATOMIC_OBJ) test_bstack_bytevec.c + $(CC) $(CFLAGS) -DBSTACK_FEATURE_SET -DBSTACK_FEATURE_ATOMIC -o $(TEST_BV_ATOMIC_BIN) $(TEST_BV_ATOMIC_OBJ) -L. -lbstack-bytevec-set-atomic -lpthread + $(RUN)$(TEST_BV_ATOMIC_BIN) + # Run tests with the set feature enabled. test-set: $(LIB_SET) $(CC) $(CFLAGS) -DBSTACK_FEATURE_SET -c -o $(TEST_OBJ) test_bstack.c @@ -359,4 +380,6 @@ clean: $(TEST_CSL_OBJ) $(TEST_CSL_BIN) \ $(TEST_CSL_ATOMIC_OBJ) $(TEST_CSL_ATOMIC_BIN) \ $(OBJ_BYTEVEC) $(LIB_BYTEVEC_SET) $(TEST_BV_OBJ) $(TEST_BV_BIN) \ + $(OBJ_BYTEVEC_ATOMIC) $(LIB_BYTEVEC_SET_ATOMIC) \ + $(TEST_BV_ATOMIC_OBJ) $(TEST_BV_ATOMIC_BIN) \ $(EXAMPLE_BINS) $(HASHMAP_BIN) $(ATOMIC_BIN) $(MOVE_COW_BIN) $(LINKED_LIST_BIN) $(CHECKSUMMED_CACHE_BIN) $(ATOMIC_LINKED_LIST_BIN) diff --git a/c/bstack.c b/c/bstack.c index 400f2ce5..f7fa3b8f 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -55,7 +55,7 @@ * Constants * ---------------------------------------------------------------------- */ -static const uint8_t MAGIC[8] = {'B','S','T','K', 0, 1, 15, 0}; +static const uint8_t MAGIC[8] = {'B','S','T','K', 0, 1, 17, 0}; static const uint8_t MAGIC_PREFIX[6] = {'B','S','T','K', 0, 1}; static const uint64_t HEADER_SIZE = 16; @@ -125,13 +125,25 @@ static void win_set_errno(void) } } -static int plat_durable_sync(bstack_fd_t h) +/* When BSTACK_TEST_NO_DURABLE_SYNC is defined at compile time, plat_durable_sync + * becomes a no-op. An in-process test or fuzz run tears the store down logically + * and reopens it in-process rather than surviving a real power loss, so skipping + * the physical sync changes neither the exercised logic nor the on-disk bytes, + * yet on macOS F_FULLFSYNC otherwise dominates runtime (minutes -> seconds). + * The define is never set by the default build; opt in only for test/fuzz runs. + * UNSAFE for any build that must survive a real crash — never production. */ +static inline int plat_durable_sync(bstack_fd_t h) { +#ifdef BSTACK_TEST_NO_DURABLE_SYNC + (void)h; + return 0; +#else if (!FlushFileBuffers(h)) { win_set_errno(); return -1; } return 0; +#endif } -static int plat_file_size(bstack_fd_t h, uint64_t *out) +static inline int plat_file_size(bstack_fd_t h, uint64_t *out) { LARGE_INTEGER li; if (!GetFileSizeEx(h, &li)) { win_set_errno(); return -1; } @@ -143,7 +155,7 @@ static int plat_file_size(bstack_fd_t h, uint64_t *out) * Positional write via OVERLAPPED — does not advance the file pointer. * The file is extended automatically if offset + count exceeds its size. */ -static int plat_pwrite(bstack_fd_t h, const void *buf, size_t count, +static inline int plat_pwrite(bstack_fd_t h, const void *buf, size_t count, uint64_t offset) { if (count == 0) return 0; @@ -159,7 +171,7 @@ static int plat_pwrite(bstack_fd_t h, const void *buf, size_t count, } /* Positional read via OVERLAPPED — does not advance the file pointer. */ -static int plat_pread(bstack_fd_t h, void *buf, size_t count, +static inline int plat_pread(bstack_fd_t h, void *buf, size_t count, uint64_t offset) { if (count == 0) return 0; @@ -175,7 +187,7 @@ static int plat_pread(bstack_fd_t h, void *buf, size_t count, } /* Truncate (or extend) the file to exactly `size` bytes. */ -static int plat_ftruncate(bstack_fd_t h, uint64_t size) +static inline int plat_ftruncate(bstack_fd_t h, uint64_t size) { LARGE_INTEGER li; li.QuadPart = (LONGLONG)size; @@ -190,17 +202,24 @@ static int plat_ftruncate(bstack_fd_t h, uint64_t size) #else /* !_WIN32 */ -static int plat_durable_sync(bstack_fd_t fd) +/* No-op under BSTACK_TEST_NO_DURABLE_SYNC — see the note on the Windows + * definition above. Test/fuzz builds only, never production. */ +static inline int plat_durable_sync(bstack_fd_t fd) { +#ifdef BSTACK_TEST_NO_DURABLE_SYNC + (void)fd; + return 0; +#else # ifdef __APPLE__ if (fcntl(fd, F_FULLFSYNC) == 0) return 0; /* Device does not support F_FULLFSYNC — fall back to fdatasync. */ # endif return fdatasync(fd); +#endif } -static int plat_file_size(bstack_fd_t fd, uint64_t *out) +static inline int plat_file_size(bstack_fd_t fd, uint64_t *out) { struct stat st; if (fstat(fd, &st) != 0) return -1; @@ -208,7 +227,7 @@ static int plat_file_size(bstack_fd_t fd, uint64_t *out) return 0; } -static int plat_pwrite(bstack_fd_t fd, const void *buf, size_t count, +static inline int plat_pwrite(bstack_fd_t fd, const void *buf, size_t count, uint64_t offset) { if (count == 0) return 0; @@ -218,7 +237,7 @@ static int plat_pwrite(bstack_fd_t fd, const void *buf, size_t count, return 0; } -static int plat_pread(bstack_fd_t fd, void *buf, size_t count, +static inline int plat_pread(bstack_fd_t fd, void *buf, size_t count, uint64_t offset) { if (count == 0) return 0; @@ -228,7 +247,7 @@ static int plat_pread(bstack_fd_t fd, void *buf, size_t count, return 0; } -static int plat_ftruncate(bstack_fd_t fd, uint64_t size) +static inline int plat_ftruncate(bstack_fd_t fd, uint64_t size) { return ftruncate(fd, (off_t)size); } @@ -239,7 +258,7 @@ static int plat_ftruncate(bstack_fd_t fd, uint64_t size) * Close helper (releases advisory lock on both platforms) * ---------------------------------------------------------------------- */ -static void close_fd(bstack_fd_t fd) +static inline void close_fd(bstack_fd_t fd) { #ifdef _WIN32 CloseHandle(fd); @@ -276,7 +295,7 @@ static void close_fd(bstack_fd_t fd) * Returns 1 for n == 0. * Returns 0 on overflow (input > 2^63; next power of two would be 2^64). * Callers must treat a 0 return as an error (ENOMEM / EINVAL). */ -static uint64_t next_pow2_u64(uint64_t n) +static inline uint64_t next_pow2_u64(uint64_t n) { if (n == 0) return 1; n--; @@ -295,7 +314,7 @@ static uint64_t next_pow2_u64(uint64_t n) * Little-endian helpers (positional — no cursor side-effects) * ---------------------------------------------------------------------- */ -static int write_le64(bstack_fd_t fd, uint64_t file_offset, uint64_t val) +static inline int write_le64(bstack_fd_t fd, uint64_t file_offset, uint64_t val) { uint8_t buf[8]; for (int i = 0; i < 8; i++) @@ -309,7 +328,7 @@ static int write_le64(bstack_fd_t fd, uint64_t file_offset, uint64_t val) /* Overwrite the committed-length field at file offset 8 and update the * in-memory cache (*clen) to match. */ -static int write_committed_len(bstack_fd_t fd, uint64_t *clen, uint64_t len) +static inline int write_committed_len(bstack_fd_t fd, uint64_t *clen, uint64_t len) { if (write_le64(fd, 8, len) != 0) return -1; @@ -317,7 +336,7 @@ static int write_committed_len(bstack_fd_t fd, uint64_t *clen, uint64_t len) return 0; } -static int init_header(bstack_fd_t fd) +static inline int init_header(bstack_fd_t fd) { uint8_t hdr[16]; memcpy(hdr, MAGIC, 8); @@ -349,7 +368,7 @@ static int read_header(bstack_fd_t fd, uint64_t *out_clen) * File size helper * ---------------------------------------------------------------------- */ -static int file_size(bstack_fd_t fd, uint64_t *out) +static inline int file_size(bstack_fd_t fd, uint64_t *out) { return plat_file_size(fd, out); } @@ -833,6 +852,327 @@ int bstack_discard(bstack_t *bs, size_t n) return -1; } +/* ------------------------------------------------------------------------- + * bstack_resize + * ---------------------------------------------------------------------- */ + +int bstack_resize(bstack_t *bs, uint64_t target, uint64_t *out_initial_len) +{ + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t data_size = raw_size - HEADER_SIZE; + + if (target == data_size) { + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + } + + if (target < data_size) { + uint64_t locked = ATOMIC_LOAD_ACQUIRE(&bs->locked); + if (target < locked) { + BS_WRUNLOCK(bs); + errno = EINVAL; + return -1; + } + + if (plat_ftruncate(bs->fd, HEADER_SIZE + target) != 0) + goto fail_unlock; + /* Truncation is the commit point: update the cache now, before the + * header write, which can fail and skip it (matching bstack_discard). */ + bs->clen = target; + if (write_committed_len(bs->fd, &bs->clen, target) != 0 || + plat_durable_sync(bs->fd) != 0) + goto fail_unlock; + + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + } + + /* Grow: the OS zero-fills the new space. */ + if (plat_ftruncate(bs->fd, HEADER_SIZE + target) != 0) + goto fail_unlock; + + if (write_committed_len(bs->fd, &bs->clen, target) != 0 || + plat_durable_sync(bs->fd) != 0) + { + /* Best-effort rollback. The cache is reset up front so it reflects the + * rolled-back file even if the header rewrite below fails. */ + plat_ftruncate(bs->fd, raw_size); + bs->clen = data_size; + write_committed_len(bs->fd, &bs->clen, data_size); + plat_durable_sync(bs->fd); + goto fail_unlock; + } + + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + +fail_unlock: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + return -1; +} + +/* ------------------------------------------------------------------------- + * bstack_ensure + * ---------------------------------------------------------------------- */ + +int bstack_ensure(bstack_t *bs, uint64_t target, uint64_t *out_initial_len) +{ + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t data_size = raw_size - HEADER_SIZE; + + if (target <= data_size) { + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + } + + if (plat_ftruncate(bs->fd, HEADER_SIZE + target) != 0) + goto fail_unlock; + + if (write_committed_len(bs->fd, &bs->clen, target) != 0 || + plat_durable_sync(bs->fd) != 0) + { + plat_ftruncate(bs->fd, raw_size); + bs->clen = data_size; + write_committed_len(bs->fd, &bs->clen, data_size); + plat_durable_sync(bs->fd); + goto fail_unlock; + } + + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + +fail_unlock: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + return -1; +} + +/* ------------------------------------------------------------------------- + * Sparse-extend helpers + * + * Shared by bstack_extend_sparse[_batched] and their try_ variants. + * ---------------------------------------------------------------------- */ + +/* qsort comparator: order iovec descriptors by ascending offset. */ +static int cmp_iovec_offset(const void *pa, const void *pb) +{ + uint64_t a = ((const bstack_iovec_t *)pa)->offset; + uint64_t b = ((const bstack_iovec_t *)pb)->offset; + return (a < b) ? -1 : (a > b) ? 1 : 0; +} + +/* Validate and compact a batch of sparse-extend blocks against a declared + * extension of length bytes. Drops empty (len == 0) entries in place, then + * rejects any block whose [offset, offset + len) overflows uint64_t or runs past + * length, sorts the survivors by offset, and rejects any overlapping pair. The + * compacted, sorted count is written to *out_n on success. Returns 0, or -1 with + * errno = EINVAL on a validation failure. w is modified in place. */ +static int validate_sparse_blocks(bstack_iovec_t *w, size_t count, + uint64_t length, size_t *out_n) +{ + size_t n = 0; + for (size_t i = 0; i < count; i++) { + if (w[i].len != 0) + w[n++] = w[i]; + } + for (size_t i = 0; i < n; i++) { + if ((uint64_t)w[i].len > UINT64_MAX - w[i].offset) { errno = EINVAL; return -1; } + uint64_t end = w[i].offset + (uint64_t)w[i].len; + if (end > length) { errno = EINVAL; return -1; } + } + qsort(w, n, sizeof(bstack_iovec_t), cmp_iovec_offset); + for (size_t i = 0; i + 1 < n; i++) { + uint64_t a_end = w[i].offset + (uint64_t)w[i].len; + if (a_end > w[i + 1].offset) { errno = EINVAL; return -1; } + } + *out_n = n; + return 0; +} + +/* Commit a sparse payload growth to new_len (== logical_offset + length): extend + * the file with one ftruncate (the OS zero-fills the new space), write each block + * into the grown region, then commit the header length and durable-sync. blocks[] + * holds (relative offset, source buf, len) with offsets measured from + * logical_offset; callers guarantee each block fits within [logical_offset, + * new_len) and blocks do not overlap. raw_size (== HEADER_SIZE + logical_offset) + * is the pre-op file size, the rollback anchor. + * + * No journal is needed: the whole grown region sits beyond clen, so a crash + * before the header commit rolls back by truncation, exactly like bstack_push. On + * failure the file is rolled back (best effort) to raw_size and the cache/header + * reset to logical_offset before returning -1 (the triggering errno preserved). */ +static int commit_sparse_extend(bstack_t *bs, uint64_t logical_offset, + uint64_t raw_size, uint64_t new_len, + const bstack_iovec_t *blocks, size_t n_blocks) +{ + if (plat_ftruncate(bs->fd, HEADER_SIZE + new_len) != 0) + return -1; + for (size_t i = 0; i < n_blocks; i++) { + if (plat_pwrite(bs->fd, blocks[i].buf, blocks[i].len, + HEADER_SIZE + logical_offset + blocks[i].offset) != 0) { + int saved = errno; + plat_ftruncate(bs->fd, raw_size); + errno = saved; + return -1; + } + } + if (write_committed_len(bs->fd, &bs->clen, new_len) != 0 || + plat_durable_sync(bs->fd) != 0) + { + /* Rollback: truncate away the growth and reset the committed length. The + * cache is reset up front so it reflects the rolled-back file even if the + * best-effort header rewrite below fails. */ + int saved = errno; + plat_ftruncate(bs->fd, raw_size); + bs->clen = logical_offset; + write_committed_len(bs->fd, &bs->clen, logical_offset); + plat_durable_sync(bs->fd); + errno = saved; + return -1; + } + return 0; +} + +/* ------------------------------------------------------------------------- + * bstack_extend_sparse + * ---------------------------------------------------------------------- */ + +int bstack_extend_sparse(bstack_t *bs, const uint8_t *buf, size_t buf_len, + uint64_t length, uint64_t *out_offset) +{ + if ((uint64_t)buf_len > length) { errno = EINVAL; return -1; } + + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t logical_offset = raw_size - HEADER_SIZE; + + if (length == 0) { + BS_WRUNLOCK(bs); + if (out_offset) + *out_offset = logical_offset; + return 0; + } + if (length > UINT64_MAX - logical_offset) { + BS_WRUNLOCK(bs); errno = EINVAL; return -1; + } + uint64_t new_len = logical_offset + length; + + /* Single block at the start; empty buf means a pure sparse extend. */ + bstack_iovec_t one; + size_t n_blocks = 0; + if (buf_len != 0) { + one.offset = 0; + one.buf = (uint8_t *)(uintptr_t)buf; + one.len = buf_len; + n_blocks = 1; + } + if (commit_sparse_extend(bs, logical_offset, raw_size, new_len, + n_blocks ? &one : NULL, n_blocks) != 0) + goto fail_unlock; + + BS_WRUNLOCK(bs); + if (out_offset) + *out_offset = logical_offset; + return 0; + +fail_unlock: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + return -1; +} + +/* ------------------------------------------------------------------------- + * bstack_extend_sparse_batched + * ---------------------------------------------------------------------- */ + +int bstack_extend_sparse_batched(bstack_t *bs, + const bstack_iovec_t *writes, size_t count, + uint64_t length, uint64_t *out_offset) +{ + /* Materialise into a working array so it can be compacted, sorted, and + * validated (before the lock). */ + bstack_iovec_t *w = NULL; + size_t n = 0; + if (count != 0) { + if (writes == NULL) { errno = EINVAL; return -1; } + if (count > SIZE_MAX / sizeof(bstack_iovec_t)) { errno = EINVAL; return -1; } + w = malloc(count * sizeof(bstack_iovec_t)); + if (!w) return -1; + memcpy(w, writes, count * sizeof(bstack_iovec_t)); + if (validate_sparse_blocks(w, count, length, &n) != 0) { + int saved = errno; free(w); errno = saved; return -1; + } + } + + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail; + + uint64_t logical_offset = raw_size - HEADER_SIZE; + + if (length == 0) { + /* Every block was validated to fit within [0, 0), so n == 0. */ + BS_WRUNLOCK(bs); + free(w); + if (out_offset) + *out_offset = logical_offset; + return 0; + } + if (length > UINT64_MAX - logical_offset) { + BS_WRUNLOCK(bs); free(w); errno = EINVAL; return -1; + } + uint64_t new_len = logical_offset + length; + + if (commit_sparse_extend(bs, logical_offset, raw_size, new_len, w, n) != 0) + goto fail; + + BS_WRUNLOCK(bs); + free(w); + if (out_offset) + *out_offset = logical_offset; + return 0; + +fail: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + free(w); + return -1; +} + /* ------------------------------------------------------------------------- * bstack_len * ---------------------------------------------------------------------- */ @@ -1127,6 +1467,87 @@ int bstack_zero(bstack_t *bs, uint64_t offset, size_t n) return -1; } +/* ------------------------------------------------------------------------- + * bstack_repeat + * ---------------------------------------------------------------------- */ + +int bstack_repeat(bstack_t *bs, uint64_t offset, + const uint8_t *pattern, size_t pattern_len, uint64_t count) +{ + if (pattern_len == 0 || count == 0) + return 0; + + /* total = pattern_len * count, guarded against overflow. */ + if ((uint64_t)pattern_len > UINT64_MAX / count) { + errno = EINVAL; + return -1; + } + uint64_t total = (uint64_t)pattern_len * count; + if (total > UINT64_MAX - offset) { + errno = EINVAL; + return -1; + } + uint64_t end = offset + total; +#if UINT64_MAX > SIZE_MAX + if (total > (uint64_t)SIZE_MAX) { + errno = EINVAL; + return -1; + } +#endif + size_t total_sz = (size_t)total; + + BS_WRLOCK(bs); + + /* Load locked under the write lock (see bstack_set for rationale). */ + uint64_t locked = ATOMIC_LOAD_ACQUIRE(&bs->locked); + if (offset < locked) { + BS_WRUNLOCK(bs); + errno = EINVAL; + return -1; + } + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t data_size = raw_size - HEADER_SIZE; + if (end > data_size) { + BS_WRUNLOCK(bs); + errno = EINVAL; + return -1; + } + + /* Stage the whole expanded region (no journal) and write it in one pass. */ + uint8_t *buf = (uint8_t *)malloc(total_sz); + if (!buf) { + BS_WRUNLOCK(bs); + errno = ENOMEM; + return -1; + } + for (size_t i = 0; i < total_sz; i += pattern_len) + memcpy(buf + i, pattern, pattern_len); + + if (plat_pwrite(bs->fd, buf, total_sz, HEADER_SIZE + offset) != 0) { + free(buf); + goto fail_unlock; + } + free(buf); + + if (plat_durable_sync(bs->fd) != 0) + goto fail_unlock; + + BS_WRUNLOCK(bs); + return 0; + +fail_unlock: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + return -1; +} + #endif /* BSTACK_FEATURE_SET */ /* ------------------------------------------------------------------------- @@ -1502,6 +1923,110 @@ int bstack_try_extend_zeros(bstack_t *bs, uint64_t s, size_t n, int *ok) return -1; } +int bstack_try_extend_sparse(bstack_t *bs, uint64_t s, + const uint8_t *buf, size_t buf_len, + uint64_t length, int *ok) +{ + /* Reject a malformed request before the lock, regardless of the size guard. */ + if ((uint64_t)buf_len > length) { errno = EINVAL; return -1; } + + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t data_size = raw_size - HEADER_SIZE; + if (data_size != s) { + BS_WRUNLOCK(bs); + if (ok) *ok = 0; + return 0; + } + if (length == 0) { + BS_WRUNLOCK(bs); + if (ok) *ok = 1; + return 0; + } + if (length > UINT64_MAX - data_size) { + BS_WRUNLOCK(bs); errno = EINVAL; return -1; + } + uint64_t new_len = data_size + length; + + bstack_iovec_t one; + size_t n_blocks = 0; + if (buf_len != 0) { + one.offset = 0; + one.buf = (uint8_t *)(uintptr_t)buf; + one.len = buf_len; + n_blocks = 1; + } + if (commit_sparse_extend(bs, data_size, raw_size, new_len, + n_blocks ? &one : NULL, n_blocks) != 0) + goto fail_unlock; + + BS_WRUNLOCK(bs); + if (ok) *ok = 1; + return 0; + +fail_unlock: + { int sv = errno; BS_WRUNLOCK(bs); errno = sv; } + return -1; +} + +int bstack_try_extend_sparse_batched(bstack_t *bs, uint64_t s, + const bstack_iovec_t *writes, size_t count, + uint64_t length, int *ok) +{ + /* Validate the batch up front (before the lock and the size guard), so a + * malformed request always surfaces rather than being masked by a mismatch. */ + bstack_iovec_t *w = NULL; + size_t n = 0; + if (count != 0) { + if (writes == NULL) { errno = EINVAL; return -1; } + if (count > SIZE_MAX / sizeof(bstack_iovec_t)) { errno = EINVAL; return -1; } + w = malloc(count * sizeof(bstack_iovec_t)); + if (!w) return -1; + memcpy(w, writes, count * sizeof(bstack_iovec_t)); + if (validate_sparse_blocks(w, count, length, &n) != 0) { + int saved = errno; free(w); errno = saved; return -1; + } + } + + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail; + + uint64_t data_size = raw_size - HEADER_SIZE; + if (data_size != s) { + BS_WRUNLOCK(bs); free(w); + if (ok) *ok = 0; + return 0; + } + if (length == 0) { + BS_WRUNLOCK(bs); free(w); + if (ok) *ok = 1; + return 0; + } + if (length > UINT64_MAX - data_size) { + BS_WRUNLOCK(bs); free(w); errno = EINVAL; return -1; + } + uint64_t new_len = data_size + length; + + if (commit_sparse_extend(bs, data_size, raw_size, new_len, w, n) != 0) + goto fail; + + BS_WRUNLOCK(bs); free(w); + if (ok) *ok = 1; + return 0; + +fail: + { int sv = errno; BS_WRUNLOCK(bs); errno = sv; } + free(w); + return -1; +} + int bstack_get_batched(bstack_t *bs, const bstack_iovec_t *entries, size_t n_entries) { @@ -1590,6 +2115,81 @@ int bstack_get_batched_gen(bstack_t *bs, return -1; } +/* ------------------------------------------------------------------------- + * bstack_ensure_with + * ---------------------------------------------------------------------- */ + +int bstack_ensure_with(bstack_t *bs, uint64_t target, + int (*cb)(uint8_t *buf, size_t len, void *ctx), + void *ctx, uint64_t *out_initial_len) +{ + BS_WRLOCK(bs); + + uint64_t raw_size; + if (file_size(bs->fd, &raw_size) != 0) + goto fail_unlock; + + uint64_t data_size = raw_size - HEADER_SIZE; + + if (target <= data_size) { + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + } + + uint64_t growth64 = target - data_size; +#if UINT64_MAX > SIZE_MAX + if (growth64 > (uint64_t)SIZE_MAX) { + BS_WRUNLOCK(bs); + errno = ENOMEM; + return -1; + } +#endif + size_t growth = (size_t)growth64; + + uint8_t *buf = (uint8_t *)calloc(1, growth); + if (buf == NULL) + goto fail_unlock; + + if (cb(buf, growth, ctx) != 0) { + free(buf); + goto fail_unlock; + } + + /* The grown bytes sit beyond the committed length until the header write + * below, so a crash rolls back by truncation. */ + if (plat_pwrite(bs->fd, buf, growth, raw_size) != 0) { + free(buf); + plat_ftruncate(bs->fd, raw_size); + goto fail_unlock; + } + free(buf); + + if (write_committed_len(bs->fd, &bs->clen, target) != 0 || + plat_durable_sync(bs->fd) != 0) + { + /* Best-effort rollback. The cache is reset up front so it reflects the + * rolled-back file even if the header rewrite below fails. */ + plat_ftruncate(bs->fd, raw_size); + bs->clen = data_size; + write_committed_len(bs->fd, &bs->clen, data_size); + plat_durable_sync(bs->fd); + goto fail_unlock; + } + + BS_WRUNLOCK(bs); + if (out_initial_len) *out_initial_len = data_size; + return 0; + +fail_unlock: + { + int saved = errno; + BS_WRUNLOCK(bs); + errno = saved; + } + return -1; +} + #endif /* BSTACK_FEATURE_ATOMIC */ /* ------------------------------------------------------------------------- @@ -2153,7 +2753,7 @@ static int crds_compare_a_masked(bstack_fd_t fd, * When matched == 1, reads b_len bytes at b_offset into b_old_buf, writes * b_new_buf, and syncs. Caller holds the write lock. * Returns 0 on success, -1 on I/O error. */ -static int crds_do_swap(bstack_fd_t fd, +static inline int crds_do_swap(bstack_fd_t fd, uint64_t b_offset, uint8_t *b_old_buf, const uint8_t *b_new_buf, size_t b_len) { diff --git a/c/bstack.h b/c/bstack.h index 5aca11ad..f7a6e462 100644 --- a/c/bstack.h +++ b/c/bstack.h @@ -55,6 +55,22 @@ * bstack_masked_ne_crds. */ +/* + * BSTACK_WARN_UNUSED_RESULT — marks a function whose return value reports + * success/failure (0/-1, an error code, or NULL) so the compiler warns if a + * caller discards it without checking. Prefixed before the declaration so + * it works identically under MSVC's SAL annotations and GCC/Clang's + * attribute syntax. + */ +#if defined(_MSC_VER) +#include +#define BSTACK_WARN_UNUSED_RESULT _Check_return_ +#elif defined(__GNUC__) || defined(__clang__) +#define BSTACK_WARN_UNUSED_RESULT __attribute__((warn_unused_result)) +#else +#define BSTACK_WARN_UNUSED_RESULT +#endif + typedef struct bstack bstack_t; #ifdef __cplusplus @@ -62,6 +78,7 @@ extern "C" { #endif /* Open or create a stack file at path. Returns NULL on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT bstack_t *bstack_open(const char *path); /* Close the handle and release all resources (flock, rwlock, fd, memory). */ @@ -73,6 +90,7 @@ void bstack_close(bstack_t *bs); * begins (i.e. the payload size before the write). * An empty slice (len == 0) is valid and returns the current end offset. */ +BSTACK_WARN_UNUSED_RESULT int bstack_push(bstack_t *bs, const uint8_t *data, size_t len, uint64_t *out_offset); @@ -82,6 +100,7 @@ int bstack_push(bstack_t *bs, const uint8_t *data, size_t len, * zeros begin (i.e. the payload size before the write). * n = 0 is valid and returns the current end offset. */ +BSTACK_WARN_UNUSED_RESULT int bstack_extend(bstack_t *bs, size_t n, uint64_t *out_offset); /* @@ -90,6 +109,7 @@ int bstack_extend(bstack_t *bs, size_t n, uint64_t *out_offset); * If written is non-NULL it receives n on success. * Returns EINVAL if n exceeds the current payload size. */ +BSTACK_WARN_UNUSED_RESULT int bstack_pop(bstack_t *bs, size_t n, uint8_t *buf, size_t *written); @@ -100,6 +120,7 @@ int bstack_pop(bstack_t *bs, size_t n, * offset == bstack_len is valid and copies 0 bytes. * Returns EINVAL if offset exceeds the payload size. */ +BSTACK_WARN_UNUSED_RESULT int bstack_peek(bstack_t *bs, uint64_t offset, uint8_t *buf, size_t *written); @@ -108,6 +129,7 @@ int bstack_peek(bstack_t *bs, uint64_t offset, * The caller must ensure buf has room for (end - start) bytes. * Returns EINVAL if end < start or end exceeds the payload size. */ +BSTACK_WARN_UNUSED_RESULT int bstack_get(bstack_t *bs, uint64_t start, uint64_t end, uint8_t *buf); @@ -116,14 +138,95 @@ int bstack_get(bstack_t *bs, uint64_t start, uint64_t end, * Equivalent to bstack_pop but skips the read; n = 0 is a no-op. * Returns EINVAL if n exceeds the current payload size. */ +BSTACK_WARN_UNUSED_RESULT int bstack_discard(bstack_t *bs, size_t n); +/* + * Grow or shrink the payload to exactly `target` bytes and durable-sync; any + * newly grown region is zero-filled. If out_initial_len is non-NULL it + * receives the payload size before the call. target == current size is a + * valid no-op. Growth follows bstack_extend's guarantees, shrinkage + * bstack_discard's. Returns EINVAL if shrinking would cut into the locked + * region [0, bstack_locked_len); otherwise -1 (errno set) on I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_resize(bstack_t *bs, uint64_t target, uint64_t *out_initial_len); + +/* + * Grow the payload to at least `target` bytes (zero-filling the new region) + * and durable-sync; a no-op if it is already that long. If out_initial_len is + * non-NULL it receives the payload size before the call. The grow-only, + * unconditional counterpart of bstack_resize. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_ensure(bstack_t *bs, uint64_t target, uint64_t *out_initial_len); + +/* + * Descriptor for one entry in a batched operation: a logical byte offset, a + * buffer pointer, and a byte count. Used as a read destination by + * bstack_get_batched, and as a write source by bstack_extend_sparse_batched and + * bstack_try_extend_sparse_batched (where offset is interpreted relative to the + * current tail). + */ +typedef struct { + uint64_t offset; + uint8_t *buf; + size_t len; +} bstack_iovec_t; + +/* + * Sparsely grow the payload by length bytes, writing the buf_len bytes at buf at + * the start of the freshly grown region and leaving the remaining + * length - buf_len bytes zero. + * + * The whole length is realised with a single ftruncate (the OS zero-fills), so + * the tail past buf costs no write I/O — a cheaper alternative to a bstack_push + * of a large mostly-zero buffer when only a small prefix carries real data. + * If out_offset is non-NULL it receives the logical byte offset where the growth + * begins (the payload size before the call, the anchor buf is written at). + * + * length = 0 is valid only when buf_len == 0; it writes nothing and returns the + * current end offset. A NULL buf is permitted only when buf_len == 0. No + * journal is needed: the grown region sits beyond the committed length, so a + * crash before the commit rolls back by truncation (like bstack_push). + * Returns EINVAL if buf_len exceeds length or if the payload size plus length + * overflows uint64_t; otherwise -1 (errno set) on I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_extend_sparse(bstack_t *bs, const uint8_t *buf, size_t buf_len, + uint64_t length, uint64_t *out_offset); + +/* + * Sparsely grow the payload by length bytes, scattering count buffers into the + * freshly grown region and leaving the gaps between them zero. + * + * writes is an array of count bstack_iovec_t descriptors; each (offset, buf, len) + * writes its len bytes at logical offset tail + offset, where tail is the payload + * size before the growth (the returned offset). The bytes not covered by any + * buffer read back as zero. Empty (len == 0) entries are ignored. As with + * bstack_extend_sparse, the whole length is realised with a single ftruncate, so + * the zero gaps cost no write I/O. + * + * The writes must be pairwise non-overlapping and each must fit within + * [0, length); a violation is rejected. count = 0 (or writes = NULL with + * count = 0) extends by length with no data (equivalent to bstack_extend). + * length = 0 is valid only when every buffer is empty. + * Returns EINVAL if any offset + len overflows uint64_t or exceeds length, if two + * writes overlap, or if the payload size plus length overflows uint64_t; + * otherwise -1 (errno set) on I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_extend_sparse_batched(bstack_t *bs, + const bstack_iovec_t *writes, size_t count, + uint64_t length, uint64_t *out_offset); + /* * Write the current logical payload size (excluding the 16-byte header) * into *out_len. This value is cached in memory, so no syscall is made; * it takes the read lock, so it can run concurrently with other readers * but blocks while a writer is in progress. */ +BSTACK_WARN_UNUSED_RESULT int bstack_len(bstack_t *bs, uint64_t *out_len); /* @@ -131,6 +234,7 @@ int bstack_len(bstack_t *bs, uint64_t *out_len); * Like bstack_len, this is a cached read under the read lock and makes * no syscall. */ +BSTACK_WARN_UNUSED_RESULT int bstack_is_empty(bstack_t *bs, int *out_empty); /* @@ -154,6 +258,7 @@ uint64_t bstack_locked_len(bstack_t *bs); * Returns EINVAL if n is less than the current locked length (partition can * only grow) or if n exceeds the current payload length. */ +BSTACK_WARN_UNUSED_RESULT int bstack_lock_up_to(bstack_t *bs, uint64_t n); /* @@ -165,6 +270,7 @@ int bstack_lock_up_to(bstack_t *bs, uint64_t n); * Returns NULL on failure (errno set); EINVAL if n exceeds the payload * length of the opened file. */ +BSTACK_WARN_UNUSED_RESULT bstack_t *bstack_open_locked_up_to(const char *path, uint64_t n); /* @@ -181,6 +287,7 @@ bstack_t *bstack_open_locked_up_to(const char *path, uint64_t n); * * Returns NULL on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT bstack_t *bstack_open_cached(const char *path); /* @@ -189,6 +296,7 @@ bstack_t *bstack_open_cached(const char *path); * Returns NULL on failure (errno set); EINVAL if n exceeds the payload * length of the opened file. */ +BSTACK_WARN_UNUSED_RESULT bstack_t *bstack_open_locked_up_to_cached(const char *path, uint64_t n); #ifdef BSTACK_FEATURE_SET @@ -200,6 +308,7 @@ bstack_t *bstack_open_locked_up_to_cached(const char *path, uint64_t n); * * Only available when compiled with -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_set(bstack_t *bs, uint64_t offset, const uint8_t *data, size_t len); @@ -211,18 +320,24 @@ int bstack_set(bstack_t *bs, uint64_t offset, * * Only available when compiled with -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_zero(bstack_t *bs, uint64_t offset, size_t n); -#endif /* BSTACK_FEATURE_SET */ /* - * Descriptor for one entry in a batched read: logical byte offset, destination - * buffer pointer, and number of bytes to read. + * Overwrite [offset, offset + count*pattern_len) in place with count back-to-back + * copies of the pattern_len bytes at pattern. An empty pattern (pattern_len == 0) + * or count == 0 is a no-op. The file size is never changed; the write is rejected + * if it would exceed the current payload size or overlap the locked region. The + * general form of bstack_zero (which is a repeat of the single byte 0x00). This + * version writes the full count*pattern_len bytes directly (no journal), so a + * large fill is slower and stages the expanded buffer in memory. + * + * Only available when compiled with -DBSTACK_FEATURE_SET. */ -typedef struct { - uint64_t offset; - uint8_t *buf; - size_t len; -} bstack_iovec_t; +BSTACK_WARN_UNUSED_RESULT +int bstack_repeat(bstack_t *bs, uint64_t offset, + const uint8_t *pattern, size_t pattern_len, uint64_t count); +#endif /* BSTACK_FEATURE_SET */ #ifdef BSTACK_FEATURE_ATOMIC /* @@ -239,6 +354,7 @@ typedef struct { * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_atrunc(bstack_t *bs, size_t n, const uint8_t *buf, size_t buf_len); @@ -254,6 +370,7 @@ int bstack_atrunc(bstack_t *bs, size_t n, * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_splice(bstack_t *bs, uint8_t *removed, size_t n, const uint8_t *new_buf, size_t new_len); @@ -268,6 +385,7 @@ int bstack_splice(bstack_t *bs, * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_try_extend(bstack_t *bs, uint64_t s, const uint8_t *buf, size_t buf_len, int *ok); @@ -282,8 +400,28 @@ int bstack_try_extend(bstack_t *bs, uint64_t s, * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_try_discard(bstack_t *bs, uint64_t s, size_t n, int *ok); +/* + * Grow the payload to at least `target` bytes, only if it is currently shorter, + * handing the freshly allocated tail to `cb` for initialization before it is + * committed. `cb` receives a zero-filled buffer of `target - old_len` bytes + * (exactly the region bstack_ensure would have appended) plus the caller's + * `ctx`; whatever it leaves in the buffer is what lands on disk. A nonzero + * return from `cb` aborts the call (nothing is changed). If out_initial_len is + * non-NULL it receives the payload size before the call. Crash-atomic on the + * same terms as bstack_extend: the grown region sits beyond the committed + * length until the final header write. Returns ENOMEM if the growth exceeds + * SIZE_MAX; otherwise -1 (errno set) on I/O error. + * + * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_ensure_with(bstack_t *bs, uint64_t target, + int (*cb)(uint8_t *buf, size_t len, void *ctx), + void *ctx, uint64_t *out_initial_len); + /* * Pop n bytes from the tail, pass them read-only to the callback, then write * whatever the callback produces as the new tail. @@ -304,6 +442,7 @@ int bstack_try_discard(bstack_t *bs, uint64_t s, size_t n, int *ok); * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_replace(bstack_t *bs, size_t n, int (*cb)(const uint8_t *old, size_t old_len, uint8_t **new_buf, size_t *new_len, @@ -320,8 +459,49 @@ int bstack_replace(bstack_t *bs, size_t n, * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_try_extend_zeros(bstack_t *bs, uint64_t s, size_t n, int *ok); +/* + * Sparsely grow the payload by length bytes with buf at the start, only if the + * current logical payload size equals s. Size-guarded counterpart of + * bstack_extend_sparse. + * + * *ok (if non-NULL) is set to 1 when the condition matched and the growth was + * applied (or length == 0 and no I/O was needed), or 0 when the size did not + * match (no-op). A malformed request (buf_len exceeding length) is rejected with + * EINVAL regardless of whether the size matches, so it always surfaces rather + * than being masked by a size mismatch. + * Returns EINVAL if buf_len exceeds length or if the payload size plus length + * overflows uint64_t; otherwise -1 (errno set) on I/O error. + * + * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_try_extend_sparse(bstack_t *bs, uint64_t s, + const uint8_t *buf, size_t buf_len, + uint64_t length, int *ok); + +/* + * Sparsely grow the payload by length bytes, scattering count buffers into the + * grown region, only if the current logical payload size equals s. Size-guarded + * counterpart of bstack_extend_sparse_batched. + * + * *ok (if non-NULL) is set to 1 when the condition matched and the growth was + * applied (or length == 0 and no I/O was needed), or 0 when the size did not + * match (no-op). A malformed batch (overlapping writes, or a write past length) + * is rejected with EINVAL regardless of whether the size matches. + * Returns EINVAL if any offset + len overflows uint64_t or exceeds length, if two + * writes overlap, or if the payload size plus length overflows uint64_t; + * otherwise -1 (errno set) on I/O error. + * + * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_try_extend_sparse_batched(bstack_t *bs, uint64_t s, + const bstack_iovec_t *writes, size_t count, + uint64_t length, int *ok); + /* * Read multiple logical ranges into caller-provided buffers in a single * lock acquisition. @@ -338,6 +518,7 @@ int bstack_try_extend_zeros(bstack_t *bs, uint64_t s, size_t n, int *ok); * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_get_batched(bstack_t *bs, const bstack_iovec_t *entries, size_t n_entries); @@ -361,6 +542,7 @@ int bstack_get_batched(bstack_t *bs, * * Only available when compiled with -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_get_batched_gen(bstack_t *bs, int (*gen)(uint64_t *out_offset, uint8_t **out_buf, size_t *out_len, void *ctx), @@ -473,6 +655,7 @@ typedef struct { * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_swap(bstack_t *bs, uint64_t offset, uint8_t *old_buf, const uint8_t *new_buf, size_t len); @@ -489,6 +672,7 @@ int bstack_swap(bstack_t *bs, uint64_t offset, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_cas(bstack_t *bs, uint64_t offset, const uint8_t *old_buf, const uint8_t *new_buf, size_t len, int *ok); @@ -509,6 +693,7 @@ int bstack_cas(bstack_t *bs, uint64_t offset, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_process(bstack_t *bs, uint64_t start, uint64_t end, int (*cb)(uint8_t *buf, size_t len, void *ctx), void *ctx); @@ -573,6 +758,7 @@ int bstack_process(bstack_t *bs, uint64_t start, uint64_t end, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_process_gen(bstack_t *bs, int (*gen)(bstack_gen_op_t *out_op, void *ctx), void *ctx); @@ -591,6 +777,7 @@ int bstack_process_gen(bstack_t *bs, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_cross_exchange(bstack_t *bs, uint64_t a, uint64_t b, uint64_t n); /* @@ -606,6 +793,7 @@ int bstack_cross_exchange(bstack_t *bs, uint64_t a, uint64_t b, uint64_t n); * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_copy(bstack_t *bs, uint64_t from, uint64_t to, uint64_t n); /* @@ -625,6 +813,7 @@ int bstack_copy(bstack_t *bs, uint64_t from, uint64_t to, uint64_t n); * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_eq_crds(bstack_t *bs, uint64_t a_offset, const uint8_t *a_expected, size_t a_len, uint64_t b_offset, uint8_t *b_old_buf, @@ -641,6 +830,7 @@ int bstack_eq_crds(bstack_t *bs, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_ne_crds(bstack_t *bs, uint64_t a_offset, const uint8_t *a_expected, size_t a_len, uint64_t b_offset, uint8_t *b_old_buf, @@ -659,6 +849,7 @@ int bstack_ne_crds(bstack_t *bs, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_masked_eq_crds(bstack_t *bs, uint64_t a_offset, const uint8_t *mask, const uint8_t *a_expected, size_t a_len, @@ -676,6 +867,7 @@ int bstack_masked_eq_crds(bstack_t *bs, * Only available when compiled with both -DBSTACK_FEATURE_SET and * -DBSTACK_FEATURE_ATOMIC. */ +BSTACK_WARN_UNUSED_RESULT int bstack_masked_ne_crds(bstack_t *bs, uint64_t a_offset, const uint8_t *mask, const uint8_t *a_expected, size_t a_len, diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 7dd4b3f1..20c5799b 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -53,6 +53,41 @@ static inline bstack_t *slice_stack(bstack_slice_t s) return s.allocator->vtbl->stack(s.allocator); } +/* ------------------------------------------------------------------------- + * Internal helper — reject a slice this allocator did not issue. + * + * A slice handed to an allocator that did not issue it is a caller error the + * language cannot catch (see "Foreign slices" in bstack_alloc.h); acting on it + * would make the receiving allocator record a free block it never owned. + * + * Returns 0 when s belongs to self; otherwise sets errno = EINVAL and returns + * -1, having modified nothing. + * ---------------------------------------------------------------------- */ + +static inline int check_own_slice(const bstack_allocator_t *self, + bstack_slice_t s) +{ + if (bstack_slice_is_from(s, self)) + return 0; + errno = EINVAL; + return -1; +} + +/* Bulk analogue: rejects the whole batch if any slice is foreign, so a bad + * batch frees nothing rather than stopping part-way through. */ +static inline int check_own_slices(const bstack_allocator_t *self, + const bstack_slice_t *slices, size_t n) +{ + size_t i; + for (i = 0; i < n; i++) { + if (!bstack_slice_is_from(slices[i], self)) { + errno = EINVAL; + return -1; + } + } + return 0; +} + /* ========================================================================= * bstack_slice_t — serialization * ====================================================================== */ @@ -211,6 +246,79 @@ int bstack_slice_zero_range(bstack_slice_t s, uint64_t start, uint64_t n) #endif /* BSTACK_FEATURE_SET */ +/* ========================================================================= + * bstack_slice_t — cross-region CAS and in-place transform + * (BSTACK_FEATURE_SET + BSTACK_FEATURE_ATOMIC) + * ====================================================================== */ + +#if defined(BSTACK_FEATURE_SET) && defined(BSTACK_FEATURE_ATOMIC) + +/* Shared argument validation for the cas_on family: same bstack_t, expected + * sized to guard, new_bytes sized to s. Sets errno on rejection. */ +static int slice_cas_check(bstack_slice_t s, bstack_slice_t guard, + size_t expected_len, size_t new_bytes_len) +{ + if (slice_stack(s) != slice_stack(guard)) { + errno = EINVAL; + return -1; + } + if ((uint64_t)expected_len != guard.len || + (uint64_t)new_bytes_len != s.len) { + errno = EINVAL; + return -1; + } + return 0; +} + +int bstack_slice_cas_on(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok) +{ + if (slice_cas_check(s, guard, expected_len, new_bytes_len) != 0) + return -1; + return bstack_eq_crds(slice_stack(s), + guard.offset, expected, expected_len, + s.offset, old_buf, new_bytes, new_bytes_len, + ok); +} + +int bstack_slice_cas_on_ne(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok) +{ + if (slice_cas_check(s, guard, expected_len, new_bytes_len) != 0) + return -1; + return bstack_ne_crds(slice_stack(s), + guard.offset, expected, expected_len, + s.offset, old_buf, new_bytes, new_bytes_len, + ok); +} + +int bstack_slice_cas_on_masked(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *mask, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok) +{ + if (slice_cas_check(s, guard, expected_len, new_bytes_len) != 0) + return -1; + return bstack_masked_eq_crds(slice_stack(s), + guard.offset, mask, expected, expected_len, + s.offset, old_buf, new_bytes, new_bytes_len, + ok); +} + +int bstack_slice_process(bstack_slice_t s, + int (*cb)(uint8_t *buf, size_t len, void *ctx), + void *ctx) +{ + return bstack_process(slice_stack(s), s.offset, s.offset + s.len, cb, ctx); +} + +#endif /* BSTACK_FEATURE_SET && BSTACK_FEATURE_ATOMIC */ + /* ========================================================================= * bstack_guarded_slice_t — I/O * ====================================================================== */ @@ -327,7 +435,7 @@ int bstack_guarded_slice_subslice(bstack_guarded_slice_t gs, #ifdef BSTACK_FEATURE_SET /* Clip n to cap after a pre_write hook may have changed it. */ -static size_t guard_clip(size_t n, uint64_t cap) +static inline size_t guard_clip(size_t n, uint64_t cap) { if (cap > (uint64_t)SIZE_MAX) cap = (uint64_t)SIZE_MAX; @@ -590,6 +698,8 @@ static int linear_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, { linear_bstack_allocator_t *a = (linear_bstack_allocator_t *)self; uint64_t cur_tail, extra, shrink, dummy; + if (check_own_slice(self, slice) != 0) + return -1; if (bstack_len(a->bs, &cur_tail) != 0) return -1; if (slice.offset + slice.len != cur_tail) { @@ -631,6 +741,8 @@ static int linear_vt_dealloc(bstack_allocator_t *self, bstack_slice_t slice) { linear_bstack_allocator_t *a = (linear_bstack_allocator_t *)self; uint64_t cur_tail; + if (check_own_slice(self, slice) != 0) + return -1; if (bstack_len(a->bs, &cur_tail) != 0) return -1; if (slice.offset + slice.len == cur_tail) { @@ -668,6 +780,8 @@ linear_vt_dealloc_bulk(bstack_allocator_t *self, const bstack_slice_t *slices, size_t n) { size_t i; + if (check_own_slices(self, slices, n) != 0) + return -1; for (i = 0; i < n; i++) { if (linear_vt_dealloc(self, slices[i]) != 0) return -1; @@ -1213,11 +1327,38 @@ static int alff_recovery(bstack_t *bs) size = read_le64(hdr_buf); is_free = hdr_buf[8] & 1; - /* Invalid size (below minimum, unaligned, or overflows u64) means - * mid-arena corruption rather than a partial tail write — refuse to - * silently discard all data that follows. */ + /* The header does not describe a valid block (size below minimum, + * unaligned, or overflowing u64). Two cases: + * * All-zero trailing region -> an interrupted tail-grow realloc, + * which extends (zero-filling) the payload before rewriting the + * header/footer to cover it. The valid block ends at pos and the + * zeros beyond it carry no header (size reads 0). A real block is + * never all-zero (size >= ALFF_MIN_PAYLOAD), so roll the extension + * back by truncating to pos — restoring the pre-grow tail the failed + * realloc already handed back to the caller. + * * Anything else -> genuine mid-arena corruption; fail loudly rather + * than silently discard the data that follows. */ if (size < ALFF_MIN_PAYLOAD || size % 8 != 0 || size > UINT64_MAX - ALFF_BLOCK_OVERHEAD) { + uint8_t *trailing; + int all_zero = 1; + uint64_t k; +#if UINT64_MAX > SIZE_MAX + if (remaining > (uint64_t)SIZE_MAX) { errno = EINVAL; ret = -1; goto done; } +#endif + trailing = malloc((size_t)remaining); + if (!trailing) { ret = -1; goto done; } + if (bstack_get(bs, pos, pos + remaining, trailing) != 0) { + free(trailing); ret = -1; goto done; + } + for (k = 0; k < remaining; k++) { + if (trailing[k] != 0) { all_zero = 0; break; } + } + free(trailing); + if (all_zero) { + if (bstack_discard(bs, (size_t)remaining) != 0) { ret = -1; goto done; } + break; + } errno = EINVAL; ret = -1; goto done; @@ -1269,6 +1410,30 @@ static int alff_recovery(bstack_t *bs) } } + /* Normalize the footer to the (authoritative) header size. Every + * block-resizing operation commits its new size to the header before the + * matching footer (a coalescing free writes header then footer, a tail + * grow writes header then footer, a split's header is fixed above), so a + * crash between those two writes leaves the header correct and the footer + * stale. The walk follows headers, so a stale footer slips through here + * yet corrupts a later neighbour's coalesce (which reads this footer) and + * eventually desyncs the walk. Rewriting the footer to match makes the + * block whole; healthy blocks already agree, so this is a no-op. */ + { + uint64_t footer_pos = pos + ALFF_BLOCK_HDR_SIZE + size; + uint8_t cur_ftr[8]; + if (bstack_get(bs, footer_pos, footer_pos + 8, cur_ftr) != 0) { + ret = -1; goto done; + } + if (read_le64(cur_ftr) != size) { + uint8_t size_le[8]; + write_le64(size_le, size); + if (bstack_set(bs, footer_pos, size_le, 8) != 0) { + ret = -1; goto done; + } + } + } + if (is_free) { if (free_cnt == free_cap) { size_t nc = free_cap ? free_cap * 2 : 16; @@ -1420,6 +1585,9 @@ static int ff_vt_dealloc(bstack_allocator_t *self, bstack_slice_t slice) uint64_t stack_len; int r; + if (check_own_slice(self, slice) != 0) + return -1; + /* Hold the lock across the tail check and the free-list mutation / tail * discard, so the read of the tail and the write that follows are atomic * w.r.t. other threads. The validation reads below are harmless to do @@ -1492,6 +1660,9 @@ static int ff_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, uint64_t aligned_new_len; uint64_t stack_len; + if (check_own_slice(self, slice) != 0) + return -1; + /* Validation reads stack_len once. No lock yet — only caller-owned bytes * are touched by the lock-free fast paths (cases 1 and 3). */ if (bstack_len(a->bs, &stack_len) != 0) return -1; @@ -1552,19 +1723,14 @@ static int ff_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, size_le, 8) != 0) { MUTEX_UNLOCK(a); return -1; } if (alff_clear_recovery_needed(a->bs) != 0) { MUTEX_UNLOCK(a); return -1; } } else { - uint64_t delta = aligned_current_len - aligned_new_len; -#if UINT64_MAX > SIZE_MAX - if (delta > (uint64_t)SIZE_MAX) { MUTEX_UNLOCK(a); errno = EINVAL; return -1; } -#endif - /* Tail-shrink touches only this block's header/footer and the tail. - * The lock above excludes concurrent tail modification; within that, - * the discard is a single atomic bstack call, so no recovery flag. */ - write_le64(size_le, aligned_new_len); - if (bstack_set(a->bs, slice.offset + aligned_new_len, - size_le, 8) != 0) { MUTEX_UNLOCK(a); return -1; } - if (bstack_set(a->bs, slice.offset - ALFF_BLOCK_HDR_SIZE, - size_le, 8) != 0) { MUTEX_UNLOCK(a); return -1; } - if (bstack_discard(a->bs, (size_t)delta) != 0) { MUTEX_UNLOCK(a); return -1; } + /* Tail shrink: keep the block; don't reclaim the tail in place. A + * physical shrink needs a header write plus a discard (metadata + + * size change) that cannot be one crash-atomic call, and the + * block-walking recovery cannot parse the torn intermediate (a fault + * between the header rewrite and the discard leaves header, footer, + * and physical size disagreeing). Narrowing only the user-visible + * length (an oversized block, as a non-tail shrink does) needs no + * writes; the tail is reclaimed when the block is freed. */ } out->allocator = self; out->offset = slice.offset; @@ -1860,6 +2026,8 @@ ff_vt_dealloc_bulk(bstack_allocator_t *self, const bstack_slice_t *slices, size_t n) { size_t i; + if (check_own_slices(self, slices, n) != 0) + return -1; for (i = 0; i < n; i++) { if (ff_vt_dealloc(self, slices[i]) != 0) return -1; @@ -2052,13 +2220,21 @@ static void bstack_alloc_lock_destroy(void *lock) #define ALGT_ROOT_OFFSET UINT64_C(40) #define ALGT_ARENA_START UINT64_C(48) #define ALGT_MIN_ALLOC UINT64_C(32) +/* Largest length algt_align_up_len will round up; anything larger is rejected + * with EINVAL rather than wrapped. Unchecked, len + 31 wraps a near-UINT64_MAX + * request down to a 32-byte block, which alloc would hand back as a slice + * claiming the original length and realloc would carry into the tail-shrink + * path as an underflowed padding size. Bounded by the arena rather than by + * UINT64_MAX alone: above this a block at the first arena offset already + * overflows ALGT_ARENA_START + aligned. */ +#define ALGT_MAX_ALLOC ((UINT64_MAX - ALGT_ARENA_START) & ~UINT64_C(31)) #define ALGT_NULL_PTR UINT64_C(0) /* Maximum recursion depth for AVL operations. A balanced AVL tree never * exceeds ~60 levels; 128 gives headroom for post-crash imbalance while * reliably detecting cycles created by a partial rotation crash. */ #define ALGT_MAX_AVL_DEPTH 128u -static const uint8_t algt_magic[8] = {'A','L','G','T',0,1,2,0}; +static const uint8_t algt_magic[8] = {'A','L','G','T',0,1,4,0}; static const uint8_t algt_magic_prefix[6] = {'A','L','G','T',0,1}; typedef struct { @@ -2067,6 +2243,8 @@ typedef struct { uint64_t left; uint64_t right; int went_left; + uint8_t lh; /* this node's cached child heights, read on the way */ + uint8_t rh; /* down; the sibling's is stable for the up-pass write */ } algt_path_entry_t; /* ---- alignment helpers ------------------------------------------------- */ @@ -2074,7 +2252,9 @@ typedef struct { /* Round len up to a multiple of 32, minimum 32. */ static inline uint64_t algt_align_up_len(uint64_t len) { - uint64_t a = (len + UINT64_C(31)) & ~UINT64_C(31); + uint64_t a; + if (len > ALGT_MAX_ALLOC) return UINT64_MAX; /* signal overflow */ + a = (len + UINT64_C(31)) & ~UINT64_C(31); return a < ALGT_MIN_ALLOC ? ALGT_MIN_ALLOC : a; } @@ -2087,7 +2267,7 @@ static inline uint64_t algt_align_up_ptr(uint64_t ptr) /* ---- root I/O ---------------------------------------------------------- */ -static int algt_read_root(bstack_t *bs, uint64_t *out) +static inline int algt_read_root(bstack_t *bs, uint64_t *out) { uint8_t buf[8]; if (bstack_get(bs, ALGT_ROOT_OFFSET, ALGT_ROOT_OFFSET + 8, buf) != 0) @@ -2096,7 +2276,7 @@ static int algt_read_root(bstack_t *bs, uint64_t *out) return 0; } -static int algt_write_root(bstack_t *bs, uint64_t root) +static inline int algt_write_root(bstack_t *bs, uint64_t root) { uint8_t buf[8]; write_le64(buf, root); @@ -2109,9 +2289,14 @@ static int algt_write_root(bstack_t *bs, uint64_t root) * [0..8) size (u64 LE) * [8] balance_factor (i8) * [9] height (u8) - * [10..16) reserved / zero + * [10] cached height of left child (u8) ─┐ denormalized so a parent read + * [11] cached height of right child (u8) ─┘ on the way down yields its + * [12..16) reserved / zero untouched sibling's height * [16..24) left child ptr (u64 LE) - * [24..32) right child ptr (u64 LE) */ + * [24..32) right child ptr (u64 LE) + * + * The cache is rebuilt from scratch by algt_coalesce_and_rebalance on open, so + * old-format arenas (whose [10..16) were zero) self-upgrade transparently. */ static int algt_read_node(bstack_t *bs, uint64_t ptr, uint64_t *out_size, int8_t *out_bf, uint8_t *out_height, @@ -2127,18 +2312,45 @@ static int algt_read_node(bstack_t *bs, uint64_t ptr, return 0; } -/* Write (size, left, right) to ptr, computing bf and height from children's - * stored heights in one pass. Sets *out_bf if non-NULL. Returns 0/-1. */ -static int algt_avl_write_and_update(bstack_t *bs, uint64_t ptr, - uint64_t size, uint64_t left, uint64_t right, int8_t *out_bf) +/* Read the node at ptr for a down-pass: size, left, right, and the node's cached + * child heights (*out_lh, *out_rh). Same single bstack_get as algt_read_node; + * the cache lets the up-pass skip re-reading the untouched sibling child. */ +static int algt_read_node_hc(bstack_t *bs, uint64_t ptr, + uint64_t *out_size, uint64_t *out_left, uint64_t *out_right, + uint8_t *out_lh, uint8_t *out_rh) +{ + uint8_t buf[32]; + if (bstack_get(bs, ptr, ptr + 32, buf) != 0) return -1; + *out_size = read_le64(buf); + *out_left = read_le64(buf + 16); + *out_right = read_le64(buf + 24); + *out_lh = buf[10]; + *out_rh = buf[11]; + return 0; +} + +/* Write (size, left, right) to ptr, computing bf and height in one pass. + * + * A child height passed as >= 0 (known_lh / known_rh) is used directly — the + * caller already knows it, e.g. from the node written in the previous up-pass + * step or from a sibling untouched by a rotation — which avoids a bstack_get + * (lock + syscall) to re-read that child. A negative value reads the height + * from the child. Sets *out_bf and *out_height when non-NULL. Returns 0/-1. */ +static int algt_avl_write_h(bstack_t *bs, uint64_t ptr, uint64_t size, + uint64_t left, uint64_t right, int known_lh, int known_rh, + int8_t *out_bf, uint8_t *out_height) { uint8_t lh = 0, rh = 0; - if (left != ALGT_NULL_PTR) { + if (known_lh >= 0) { + lh = (uint8_t)known_lh; + } else if (left != ALGT_NULL_PTR) { uint8_t buf[32]; if (bstack_get(bs, left, left + 32, buf) != 0) return -1; lh = buf[9]; } - if (right != ALGT_NULL_PTR) { + if (known_rh >= 0) { + rh = (uint8_t)known_rh; + } else if (right != ALGT_NULL_PTR) { uint8_t buf[32]; if (bstack_get(bs, right, right + 32, buf) != 0) return -1; rh = buf[9]; @@ -2150,81 +2362,113 @@ static int algt_avl_write_and_update(bstack_t *bs, uint64_t ptr, uint8_t buf[32]; memset(buf, 0, 32); write_le64(buf, size); - buf[8] = (uint8_t)bf; - buf[9] = height; + buf[8] = (uint8_t)bf; + buf[9] = height; + buf[10] = lh; /* cached left-child height */ + buf[11] = rh; /* cached right-child height */ write_le64(buf + 16, left); write_le64(buf + 24, right); if (bstack_set(bs, ptr, buf, 32) != 0) return -1; - if (out_bf) *out_bf = bf; + if (out_bf) *out_bf = bf; + if (out_height) *out_height = height; } return 0; } +/* Write (size, left, right) to ptr, reading both child heights. Thin wrapper + * over algt_avl_write_h. Sets *out_bf if non-NULL. Returns 0/-1. */ +static inline int algt_avl_write_and_update(bstack_t *bs, uint64_t ptr, + uint64_t size, uint64_t left, uint64_t right, int8_t *out_bf) +{ + return algt_avl_write_h(bs, ptr, size, left, right, -1, -1, out_bf, NULL); +} + /* ---- AVL helpers ------------------------------------------------------- */ -/* Right-rotate around node; return the new subtree root. */ -static int algt_avl_rotate_right(bstack_t *bs, uint64_t node, uint64_t *out_root) +/* Right-rotate around node; return the new subtree root and (if out_height is + * non-NULL) its height. */ +static int algt_avl_rotate_right(bstack_t *bs, uint64_t node, + uint64_t *out_root, uint8_t *out_height) { uint64_t node_sz, node_r, pivot, pivot_sz, pivot_l, pivot_r; - int8_t bf; uint8_t height; - if (algt_read_node(bs, node, &node_sz, &bf, &height, &pivot, &node_r) != 0) return -1; - if (algt_read_node(bs, pivot, &pivot_sz, &bf, &height, &pivot_l, &pivot_r) != 0) return -1; - if (algt_avl_write_and_update(bs, node, node_sz, pivot_r, node_r, NULL) != 0) return -1; - if (algt_avl_write_and_update(bs, pivot, pivot_sz, pivot_l, node, NULL) != 0) return -1; + uint8_t node_lh, node_rh, pivot_lh, pivot_rh, node_h; + /* Read both nodes' cached child heights so neither rewrite re-reads a child. + * (node's left child is pivot, whose height node_lh is not needed.) */ + if (algt_read_node_hc(bs, node, &node_sz, &pivot, &node_r, &node_lh, &node_rh) != 0) return -1; + if (algt_read_node_hc(bs, pivot, &pivot_sz, &pivot_l, &pivot_r, &pivot_lh, &pivot_rh) != 0) return -1; + (void)node_lh; + /* node's new children (pivot_r, node_r) → heights (pivot_rh, node_rh). */ + if (algt_avl_write_h(bs, node, node_sz, pivot_r, node_r, + (int)pivot_rh, (int)node_rh, NULL, &node_h) != 0) return -1; + /* pivot's new children (pivot_l, node) → heights (pivot_lh, node_h). */ + if (algt_avl_write_h(bs, pivot, pivot_sz, pivot_l, node, + (int)pivot_lh, (int)node_h, NULL, out_height) != 0) return -1; *out_root = pivot; return 0; } -/* Left-rotate around node; return the new subtree root. */ -static int algt_avl_rotate_left(bstack_t *bs, uint64_t node, uint64_t *out_root) +/* Left-rotate around node; return the new subtree root and (if out_height is + * non-NULL) its height. */ +static int algt_avl_rotate_left(bstack_t *bs, uint64_t node, + uint64_t *out_root, uint8_t *out_height) { uint64_t node_sz, node_l, pivot, pivot_sz, pivot_l, pivot_r; - int8_t bf; uint8_t height; - if (algt_read_node(bs, node, &node_sz, &bf, &height, &node_l, &pivot) != 0) return -1; - if (algt_read_node(bs, pivot, &pivot_sz, &bf, &height, &pivot_l, &pivot_r) != 0) return -1; - if (algt_avl_write_and_update(bs, node, node_sz, node_l, pivot_l, NULL) != 0) return -1; - if (algt_avl_write_and_update(bs, pivot, pivot_sz, node, pivot_r, NULL) != 0) return -1; + uint8_t node_lh, node_rh, pivot_lh, pivot_rh, node_h; + /* Read both nodes' cached child heights so neither rewrite re-reads a child. + * (node's right child is pivot, whose height node_rh is not needed.) */ + if (algt_read_node_hc(bs, node, &node_sz, &node_l, &pivot, &node_lh, &node_rh) != 0) return -1; + if (algt_read_node_hc(bs, pivot, &pivot_sz, &pivot_l, &pivot_r, &pivot_lh, &pivot_rh) != 0) return -1; + (void)node_rh; + /* node's new children (node_l, pivot_l) → heights (node_lh, pivot_lh). */ + if (algt_avl_write_h(bs, node, node_sz, node_l, pivot_l, + (int)node_lh, (int)pivot_lh, NULL, &node_h) != 0) return -1; + /* pivot's new children (node, pivot_r) → heights (node_h, pivot_rh). */ + if (algt_avl_write_h(bs, pivot, pivot_sz, node, pivot_r, + (int)node_h, (int)pivot_rh, NULL, out_height) != 0) return -1; *out_root = pivot; return 0; } -/* Fix imbalance at node (uses < -1 / > 1 to handle post-crash excess). */ -static int algt_avl_rebalance(bstack_t *bs, uint64_t node, uint64_t *out_root) +/* Fix imbalance at node (uses < -1 / > 1 to handle post-crash excess). + * + * The caller passes the bf and height already computed by the algt_avl_write_h + * that installed node's current children, so the common in-balance case needs + * no further I/O. Returns the (possibly new) subtree root and its height. */ +static int algt_avl_rebalance(bstack_t *bs, uint64_t node, int8_t bf, uint8_t height, + uint64_t *out_root, uint8_t *out_height) { - uint64_t size, left, right; - int8_t bf; uint8_t height; - if (algt_read_node(bs, node, &size, &bf, &height, &left, &right) != 0) return -1; - if (algt_avl_write_and_update(bs, node, size, left, right, &bf) != 0) return -1; - if (bf < -1) { - uint64_t left_sz, left_l, left_r; - int8_t left_bf; uint8_t left_h; + uint64_t size, left, right, left_sz, left_l, left_r; + int8_t nbf, left_bf; uint8_t nh, left_h; + if (algt_read_node(bs, node, &size, &nbf, &nh, &left, &right) != 0) return -1; if (algt_read_node(bs, left, &left_sz, &left_bf, &left_h, &left_l, &left_r) != 0) return -1; if (left_bf > 0) { /* Left-right: rotate left child left first */ uint64_t new_left; - if (algt_avl_rotate_left(bs, left, &new_left) != 0) return -1; + if (algt_avl_rotate_left(bs, left, &new_left, NULL) != 0) return -1; if (algt_avl_write_and_update(bs, node, size, new_left, right, NULL) != 0) return -1; } - return algt_avl_rotate_right(bs, node, out_root); + return algt_avl_rotate_right(bs, node, out_root, out_height); } if (bf > 1) { - uint64_t right_sz, right_l, right_r; - int8_t right_bf; uint8_t right_h; + uint64_t size, left, right, right_sz, right_l, right_r; + int8_t nbf, right_bf; uint8_t nh, right_h; + if (algt_read_node(bs, node, &size, &nbf, &nh, &left, &right) != 0) return -1; if (algt_read_node(bs, right, &right_sz, &right_bf, &right_h, &right_l, &right_r) != 0) return -1; if (right_bf < 0) { /* Right-left: rotate right child right first */ uint64_t new_right; - if (algt_avl_rotate_right(bs, right, &new_right) != 0) return -1; + if (algt_avl_rotate_right(bs, right, &new_right, NULL) != 0) return -1; if (algt_avl_write_and_update(bs, node, size, left, new_right, NULL) != 0) return -1; } - return algt_avl_rotate_left(bs, node, out_root); + return algt_avl_rotate_left(bs, node, out_root, out_height); } - *out_root = node; + *out_root = node; + *out_height = height; return 0; } @@ -2242,10 +2486,10 @@ static int algt_avl_insert(bstack_t *bs, uint64_t ptr, uint64_t size) current = root; while (current != ALGT_NULL_PTR) { uint64_t root_sz, left, right; - int8_t bf; uint8_t height; + uint8_t lh, rh; int went_left; if (path_len >= ALGT_MAX_AVL_DEPTH) { errno = EINVAL; return -1; } - if (algt_read_node(bs, current, &root_sz, &bf, &height, &left, &right) != 0) + if (algt_read_node_hc(bs, current, &root_sz, &left, &right, &lh, &rh) != 0) return -1; went_left = (size < root_sz || (size == root_sz && ptr < current)); path[path_len].ptr = current; @@ -2253,32 +2497,47 @@ static int algt_avl_insert(bstack_t *bs, uint64_t ptr, uint64_t size) path[path_len].left = left; path[path_len].right = right; path[path_len].went_left = went_left; + path[path_len].lh = lh; + path[path_len].rh = rh; path_len++; current = went_left ? left : right; } { uint8_t buf[32]; - memset(buf, 0, 32); + memset(buf, 0, 32); /* null children → cached child heights [10],[11] = 0 */ write_le64(buf, size); buf[9] = 1; if (bstack_set(bs, ptr, buf, 32) != 0) return -1; } + /* Up-pass: install the new child pointer in each ancestor and rebalance. + * Both child heights are known — the modified child from the previous + * iteration, the untouched sibling from this node's cache read on the way + * down — so algt_avl_write_h reads neither. The (bf, height) it returns is + * handed to algt_avl_rebalance, so the in-balance case does no further I/O: + * one write per level, zero reads. */ child = ptr; - for (i = (int)path_len - 1; i >= 0; i--) { - uint64_t new_left, new_right, new_child; - if (path[i].went_left) { - new_left = child; - new_right = path[i].right; - } else { - new_left = path[i].left; - new_right = child; + { + uint8_t child_h = 1; /* leaf height */ + for (i = (int)path_len - 1; i >= 0; i--) { + uint64_t new_left, new_right, new_child; + int known_lh, known_rh; + int8_t bf; uint8_t h, new_h; + if (path[i].went_left) { + new_left = child; new_right = path[i].right; + known_lh = (int)child_h; known_rh = (int)path[i].rh; + } else { + new_left = path[i].left; new_right = child; + known_lh = (int)path[i].lh; known_rh = (int)child_h; + } + if (algt_avl_write_h(bs, path[i].ptr, path[i].size, + new_left, new_right, known_lh, known_rh, &bf, &h) != 0) + return -1; + if (algt_avl_rebalance(bs, path[i].ptr, bf, h, &new_child, &new_h) != 0) return -1; + child = new_child; + child_h = new_h; } - if (algt_avl_write_and_update(bs, path[i].ptr, path[i].size, - new_left, new_right, NULL) != 0) return -1; - if (algt_avl_rebalance(bs, path[i].ptr, &new_child) != 0) return -1; - child = new_child; } return algt_write_root(bs, child); } @@ -2291,27 +2550,36 @@ static int algt_avl_insert(bstack_t *bs, uint64_t ptr, uint64_t size) static int algt_avl_remove_min(bstack_t *bs, uint64_t root, uint64_t *out_min_ptr, uint64_t *out_min_size, uint64_t *out_new_root) { - uint64_t stk_ptr [ALGT_MAX_AVL_DEPTH]; - uint64_t stk_size [ALGT_MAX_AVL_DEPTH]; - uint64_t stk_right[ALGT_MAX_AVL_DEPTH]; + uint64_t stk_ptr [ALGT_MAX_AVL_DEPTH]; + uint64_t stk_size [ALGT_MAX_AVL_DEPTH]; + uint64_t stk_right [ALGT_MAX_AVL_DEPTH]; + uint8_t stk_right_h[ALGT_MAX_AVL_DEPTH]; /* cached right-child heights */ size_t stk_len = 0; uint64_t current = root; for (;;) { uint64_t size, left, right; - int8_t bf; uint8_t height; - if (algt_read_node(bs, current, &size, &bf, &height, &left, &right) != 0) + uint8_t lh, rh; + if (algt_read_node_hc(bs, current, &size, &left, &right, &lh, &rh) != 0) return -1; + (void)lh; if (left == ALGT_NULL_PTR) { + /* Replace current with its right child, whose height is current's + * cached right height. `child` is always the left side going up; + * both child heights are known each step, so write_h reads neither. */ uint64_t child = right; + uint8_t child_h = rh; int i; for (i = (int)stk_len - 1; i >= 0; i--) { uint64_t new_child; - if (algt_avl_write_and_update(bs, stk_ptr[i], stk_size[i], - child, stk_right[i], NULL) != 0) + int8_t bf; uint8_t h, new_h; + if (algt_avl_write_h(bs, stk_ptr[i], stk_size[i], + child, stk_right[i], (int)child_h, (int)stk_right_h[i], + &bf, &h) != 0) return -1; - if (algt_avl_rebalance(bs, stk_ptr[i], &new_child) != 0) return -1; - child = new_child; + if (algt_avl_rebalance(bs, stk_ptr[i], bf, h, &new_child, &new_h) != 0) return -1; + child = new_child; + child_h = new_h; } *out_min_ptr = current; *out_min_size = size; @@ -2319,9 +2587,10 @@ static int algt_avl_remove_min(bstack_t *bs, uint64_t root, return 0; } if (stk_len >= ALGT_MAX_AVL_DEPTH) { errno = EINVAL; return -1; } - stk_ptr [stk_len] = current; - stk_size [stk_len] = size; - stk_right[stk_len] = right; + stk_ptr [stk_len] = current; + stk_size [stk_len] = size; + stk_right [stk_len] = right; + stk_right_h[stk_len] = rh; stk_len++; current = left; } @@ -2340,8 +2609,9 @@ static int algt_avl_find_best_fit_and_remove(bstack_t *bs, uint64_t min_size, size_t last_fit_idx = 0; uint64_t root, current; uint64_t found_ptr, found_size, found_left, found_right; - uint64_t replacement, child; - int i; + uint8_t found_lh, found_rh; + uint64_t child; + int i, child_h; if (algt_read_root(bs, &root) != 0) return -1; if (root == ALGT_NULL_PTR) { @@ -2353,10 +2623,10 @@ static int algt_avl_find_best_fit_and_remove(bstack_t *bs, uint64_t min_size, current = root; while (current != ALGT_NULL_PTR) { uint64_t root_sz, left, right; - int8_t bf; uint8_t height; + uint8_t lh, rh; int went_left; if (path_len >= ALGT_MAX_AVL_DEPTH) { errno = EINVAL; return -1; } - if (algt_read_node(bs, current, &root_sz, &bf, &height, &left, &right) != 0) + if (algt_read_node_hc(bs, current, &root_sz, &left, &right, &lh, &rh) != 0) return -1; if (root_sz >= min_size) { last_fit_idx = path_len; @@ -2370,6 +2640,8 @@ static int algt_avl_find_best_fit_and_remove(bstack_t *bs, uint64_t min_size, path[path_len].left = left; path[path_len].right = right; path[path_len].went_left = went_left; + path[path_len].lh = lh; + path[path_len].rh = rh; path_len++; current = went_left ? left : right; } @@ -2384,34 +2656,48 @@ static int algt_avl_find_best_fit_and_remove(bstack_t *bs, uint64_t min_size, found_size = path[last_fit_idx].size; found_left = path[last_fit_idx].left; found_right = path[last_fit_idx].right; + found_lh = path[last_fit_idx].lh; /* cached found_left height */ + found_rh = path[last_fit_idx].rh; /* cached found_right height */ + /* Seed the up-pass directly with the best-fit node's replacement. child_h + * is the replacement subtree's height — known in every case now (single + * child from the cache, successor from its rebalance). */ if (found_left == ALGT_NULL_PTR) { - replacement = found_right; + child = found_right; + child_h = (int)found_rh; } else if (found_right == ALGT_NULL_PTR) { - replacement = found_left; + child = found_left; + child_h = (int)found_lh; } else { uint64_t succ, succ_sz, new_right; + int8_t bf; uint8_t h, rh; if (algt_avl_remove_min(bs, found_right, &succ, &succ_sz, &new_right) != 0) return -1; - if (algt_avl_write_and_update(bs, succ, succ_sz, found_left, new_right, NULL) != 0) + if (algt_avl_write_h(bs, succ, succ_sz, found_left, new_right, -1, -1, &bf, &h) != 0) return -1; - if (algt_avl_rebalance(bs, succ, &replacement) != 0) return -1; + if (algt_avl_rebalance(bs, succ, bf, h, &child, &rh) != 0) return -1; + child_h = (int)rh; } - child = replacement; + /* Up-pass: both child heights are known each step — the modified child + * threaded from below, the untouched sibling from this node's cache — so + * algt_avl_write_h reads neither and the in-balance case does no further I/O. */ for (i = (int)last_fit_idx - 1; i >= 0; i--) { uint64_t new_left, new_right, new_child; + int known_lh, known_rh; + int8_t bf; uint8_t h, new_h; if (path[i].went_left) { - new_left = child; - new_right = path[i].right; + new_left = child; new_right = path[i].right; + known_lh = child_h; known_rh = (int)path[i].rh; } else { - new_left = path[i].left; - new_right = child; + new_left = path[i].left; new_right = child; + known_lh = (int)path[i].lh; known_rh = child_h; } - if (algt_avl_write_and_update(bs, path[i].ptr, path[i].size, - new_left, new_right, NULL) != 0) return -1; - if (algt_avl_rebalance(bs, path[i].ptr, &new_child) != 0) return -1; - child = new_child; + if (algt_avl_write_h(bs, path[i].ptr, path[i].size, + new_left, new_right, known_lh, known_rh, &bf, &h) != 0) return -1; + if (algt_avl_rebalance(bs, path[i].ptr, bf, h, &new_child, &new_h) != 0) return -1; + child = new_child; + child_h = (int)new_h; } if (algt_write_root(bs, child) != 0) return -1; @@ -2659,6 +2945,7 @@ static int gt_vt_alloc(bstack_allocator_t *self, uint64_t len, } aligned = algt_align_up_len(len); + if (aligned == UINT64_MAX) { errno = EINVAL; return -1; } /* Lock covers AVL search and conditional insert (split case). * Released before bstack_zero (no-split) and before bstack_extend. */ @@ -2714,6 +3001,8 @@ static int gt_vt_dealloc(bstack_allocator_t *self, bstack_slice_t slice) ghost_tree_bstack_allocator_t *a = (ghost_tree_bstack_allocator_t *)self; uint64_t true_len; + if (check_own_slice(self, slice) != 0) + return -1; if (slice.len == 0) return 0; if (slice.offset < ALGT_ARENA_START || @@ -2723,6 +3012,7 @@ static int gt_vt_dealloc(bstack_allocator_t *self, bstack_slice_t slice) } true_len = algt_align_up_len(slice.len); + if (true_len == UINT64_MAX) { errno = EINVAL; return -1; } #if UINT64_MAX > SIZE_MAX if (true_len > (uint64_t)SIZE_MAX) { errno = EINVAL; return -1; } #endif @@ -2769,6 +3059,8 @@ static int gt_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, int is_tail; #endif + if (check_own_slice(self, slice) != 0) + return -1; if (slice.len == 0) return gt_vt_alloc(self, new_len, out); @@ -2787,6 +3079,10 @@ static int gt_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, old_len = slice.len; aligned_old = algt_align_up_len(old_len); aligned_new = algt_align_up_len(new_len); + if (aligned_old == UINT64_MAX || aligned_new == UINT64_MAX) { + errno = EINVAL; + return -1; + } if (aligned_new == aligned_old) { /* Same underlying block: just zero the gap on shrink. */ @@ -2815,23 +3111,32 @@ static int gt_vt_realloc(bstack_allocator_t *self, bstack_slice_t slice, uint64_t tail_ptr = slice.offset + aligned_new; #ifdef BSTACK_FEATURE_ATOMIC - /* Atomic tail path: try_discard atomically checks and discards. */ + /* Atomic tail path: zero the sub-block padding BEFORE discarding the + * tail. The two steps cannot be fused into one crash-atomic call here, + * so their order decides what a crash between them leaves: zeroing first + * keeps the retained block's [new_len, aligned_new) padding zeroed (the + * zeroed-memory invariant a later same-block grow relies on) and leaves + * an as-yet-unreclaimed tail (a benign leak); discarding first would + * leave stale bytes there for a later grow to hand back. try_discard + * then atomically checks tail == sentinel and removes the freed tail; on + * failure the block is not the tail and we fall through to the non-tail + * path (which re-zeros the region, harmlessly, before the AVL insert). */ { int ok = 0; #if UINT64_MAX > SIZE_MAX if (freed_tail > (uint64_t)SIZE_MAX) { errno = EINVAL; return -1; } #endif + if (new_len < aligned_new) { + uint64_t gap = aligned_new - new_len; +#if UINT64_MAX > SIZE_MAX + if (gap > (uint64_t)SIZE_MAX) { errno = EINVAL; return -1; } +#endif + if (bstack_zero(a->bs, slice.offset + new_len, (size_t)gap) != 0) + return -1; + } if (bstack_try_discard(a->bs, slice.offset + aligned_old, (size_t)freed_tail, &ok) != 0) return -1; if (ok) { - if (new_len < aligned_new) { - uint64_t gap = aligned_new - new_len; -#if UINT64_MAX > SIZE_MAX - if (gap > (uint64_t)SIZE_MAX) { errno = EINVAL; return -1; } -#endif - if (bstack_zero(a->bs, slice.offset + new_len, (size_t)gap) != 0) - return -1; - } out->allocator = self; out->offset = slice.offset; out->len = new_len; return 0; } @@ -2947,6 +3252,11 @@ gt_vt_alloc_bulk(bstack_allocator_t *self, const uint64_t *lens, size_t n, for (i = 0; i < n; i++) { uint64_t al = (lens[i] == 0) ? 0 : algt_align_up_len(lens[i]); aligned[i] = al; + if (al == UINT64_MAX) { + free(aligned); + errno = EINVAL; + return -1; + } if (al > UINT64_MAX - total) { free(aligned); errno = EINVAL; @@ -3047,6 +3357,8 @@ gt_vt_dealloc_bulk(bstack_allocator_t *self, const bstack_slice_t *slices, size_t pairs_n, i; if (n == 0) return 0; + if (check_own_slices(self, slices, n) != 0) + return -1; pairs = (algt_block_t *)malloc(n * sizeof *pairs); if (!pairs) return -1; @@ -3061,6 +3373,9 @@ gt_vt_dealloc_bulk(bstack_allocator_t *self, const bstack_slice_t *slices, } pairs[pairs_n].ptr = slices[i].offset; pairs[pairs_n].size = algt_align_up_len(slices[i].len); + if (pairs[pairs_n].size == UINT64_MAX) { + free(pairs); errno = EINVAL; return -1; + } pairs_n++; } @@ -3290,7 +3605,7 @@ static const uint8_t alsl_magic_prefix[6] = {'A','L','S','L',0,1}; /* ---- helpers ----------------------------------------------------------- */ -static uint64_t slab_blocks_needed(uint64_t len, uint64_t block_size) +static inline uint64_t slab_blocks_needed(uint64_t len, uint64_t block_size) { if (len == 0) return 0; /* (len - 1) / block_size + 1 avoids the (len + block_size - 1) overflow. @@ -3302,7 +3617,7 @@ static uint64_t slab_blocks_needed(uint64_t len, uint64_t block_size) /* free_head read/write helpers: only the non-atomic free-list paths use them; * the atomic paths drive free_head through process_gen / cross_exchange. */ #ifndef BSTACK_FEATURE_ATOMIC -static int slab_read_free_head(bstack_t *bs, uint64_t *out) +static inline int slab_read_free_head(bstack_t *bs, uint64_t *out) { uint8_t buf[8]; if (bstack_get(bs, SLAB_FREE_HEAD_OFFSET, @@ -3311,7 +3626,7 @@ static int slab_read_free_head(bstack_t *bs, uint64_t *out) return 0; } -static int slab_write_free_head(bstack_t *bs, uint64_t val) +static inline int slab_write_free_head(bstack_t *bs, uint64_t val) { uint8_t buf[8]; write_le64(buf, val); @@ -3432,7 +3747,7 @@ static int slab_pop_free_block(bstack_t *bs, uint64_t block_size, * becomes the old head, in a single indivisible step. A crash between the two * calls leaks block_start rather than corrupting the list. */ -static int slab_push_free_block(bstack_t *bs, uint64_t block_start) +static inline int slab_push_free_block(bstack_t *bs, uint64_t block_start) { uint8_t buf[8]; write_le64(buf, block_start); /* placeholder: replaced by old head below */ @@ -3445,7 +3760,7 @@ static int slab_push_free_block(bstack_t *bs, uint64_t block_start) * a crash after the first write but before the second leaks the block rather * than corrupting the list. */ -static int slab_push_free_block(bstack_t *bs, uint64_t block_start) +static inline int slab_push_free_block(bstack_t *bs, uint64_t block_start) { uint8_t buf[8]; uint64_t head; @@ -3576,6 +3891,8 @@ static int slab_vtbl_dealloc(bstack_allocator_t *base, bstack_slice_t s) slab_bstack_allocator_t *a = (slab_bstack_allocator_t *)base; uint64_t n_blocks, backing_size; + if (check_own_slice(base, s) != 0) + return -1; if (s.len == 0 && s.offset == SLAB_SENTINEL) return 0; n_blocks = slab_blocks_needed(s.len, a->block_size); @@ -3618,6 +3935,8 @@ static int slab_vtbl_realloc(bstack_allocator_t *base, bstack_slice_t s, slab_bstack_allocator_t *a = (slab_bstack_allocator_t *)base; uint64_t old_n, new_n, old_backing, new_backing; + if (check_own_slice(base, s) != 0) + return -1; if (s.len == 0 && s.offset == SLAB_SENTINEL) return slab_vtbl_alloc(base, new_len, out); @@ -3909,14 +4228,14 @@ uint64_t slab_bstack_allocator_block_size(const slab_bstack_allocator_t *alloc) /* Maximum number of suspect blocks analysed in resync_tail before giving up. */ #define ALCK_MAX_RECOVER_REGION ((size_t)(1u << 26)) -static const uint8_t alck_magic[8] = {'A','L','C','K',0,1,1,0}; +static const uint8_t alck_magic[8] = {'A','L','C','K',0,1,2,0}; static const uint8_t alck_magic_prefix[6] = {'A','L','C','K',0,1}; /* ---- LE helpers (reuse read_le64 / write_le64 already defined above) --- */ /* ---- overhead I/O ------------------------------------------------------ */ -static int alck_read_overhead(bstack_t *bs, uint64_t block_start, uint64_t *out) +static inline int alck_read_overhead(bstack_t *bs, uint64_t block_start, uint64_t *out) { uint8_t buf[8]; if (bstack_get(bs, block_start, block_start + 8, buf) != 0) return -1; @@ -3924,7 +4243,7 @@ static int alck_read_overhead(bstack_t *bs, uint64_t block_start, uint64_t *out) return 0; } -static int alck_write_overhead(bstack_t *bs, uint64_t block_start, uint64_t value) +static inline int alck_write_overhead(bstack_t *bs, uint64_t block_start, uint64_t value) { uint8_t buf[8]; write_le64(buf, value); @@ -3936,7 +4255,7 @@ static int alck_write_overhead(bstack_t *bs, uint64_t block_start, uint64_t valu /* Only the non-atomic free-list paths read/write free_head directly; the * atomic paths drive it through process_gen / cross_exchange. */ #ifndef BSTACK_FEATURE_ATOMIC -static int alck_read_free_head(bstack_t *bs, uint64_t *out) +static inline int alck_read_free_head(bstack_t *bs, uint64_t *out) { uint8_t buf[8]; if (bstack_get(bs, ALCK_FREE_HEAD_OFFSET, @@ -3945,7 +4264,7 @@ static int alck_read_free_head(bstack_t *bs, uint64_t *out) return 0; } -static int alck_write_free_head(bstack_t *bs, uint64_t val) +static inline int alck_write_free_head(bstack_t *bs, uint64_t val) { uint8_t buf[8]; write_le64(buf, val); @@ -3959,7 +4278,7 @@ static int alck_write_free_head(bstack_t *bs, uint64_t val) * Number of block_size blocks required to hold len usable bytes plus the * 8-byte overhead prefix. */ -static uint64_t alck_blocks_needed(uint64_t len, uint64_t block_size) +static inline uint64_t alck_blocks_needed(uint64_t len, uint64_t block_size) { uint64_t total; if (len == 0) return 0; @@ -4983,6 +5302,8 @@ static int alck_vt_dealloc(bstack_allocator_t *base, bstack_slice_t s) checked_slab_bstack_allocator_t *a = (checked_slab_bstack_allocator_t *)base; uint64_t block_start, overhead, num_blocks, backing, slice_end; + if (check_own_slice(base, s) != 0) + return -1; if (s.len == 0 && s.offset == 0) return 0; if (s.offset < ALCK_OVERHEAD) { errno = EINVAL; return -1; } @@ -5036,6 +5357,8 @@ static int alck_vt_realloc(bstack_allocator_t *base, bstack_slice_t s, uint64_t block_start, overhead, old_n, new_n, old_backing, new_backing; /* tail and is_tail are now computed per-path in inner scopes */ + if (check_own_slice(base, s) != 0) + return -1; if (s.len == 0 && s.offset == 0) return alck_vt_alloc(base, new_len, out); @@ -5162,44 +5485,39 @@ static int alck_vt_realloc(bstack_allocator_t *base, bstack_slice_t s, } } - /* Shrink path (new_n < old_n). Lock-free under atomic: the overhead - * write is the commit point, try_discard is atomic, and the non-tail - * push splices via cross_exchange. Single-threaded otherwise. */ + /* Shrink path (new_n < old_n). + * + * Crash-order matters: the excess blocks must never be left holding the + * allocation's stale payload once the header records the smaller count. + * Such bytes can mimic in-use overhead words and desync recover()'s + * linear scan, which would then stride over and reclaim an unrelated + * live allocation, corrupting it. So the excess is scrubbed to a + * clean, zero-overhead free run BEFORE the header is shrunk. + * + * Lock-free under atomic: try_discard is atomic and the non-tail splice + * rides cross_exchange. Single-threaded otherwise. */ { uint64_t delta = old_backing - new_backing; - uint64_t excess_start; + uint64_t excess_start, excess_count = old_n - new_n; #if UINT64_MAX > SIZE_MAX if (delta > (uint64_t)SIZE_MAX) { errno = EINVAL; return -1; } #endif + if (new_n > UINT64_MAX / a->block_size) { errno = EINVAL; return -1; } + excess_start = block_start + new_n * a->block_size; - /* Overhead is the commit point for both tail and non-tail paths: - * write it first so a crash after this point leaves an orphaned - * (but safely unreferenced) tail region or leaked blocks that - * recover() can reclaim, rather than an overhead that claims more - * blocks than the file contains. */ - if (alck_write_overhead(a->bs, block_start, - ALCK_IN_USE_BIT | new_n) != 0) return -1; - - /* Tail shrink: try_discard atomically checks tail == sentinel and - * removes the excess under bstack's write lock, so no other thread - * can race between the check and the truncation. On failure the - * slice is not at the tail; fall through to recycle excess blocks. */ -#ifdef BSTACK_FEATURE_ATOMIC - { - int ok = 0; - if (bstack_try_discard(a->bs, sentinel, (size_t)delta, - &ok) != 0) return -1; - if (ok) { - out->allocator = base; out->offset = s.offset; - out->len = new_len; - return 0; - } - } -#else + /* Non-atomic tail fast path: commit the smaller count, then discard + * the tail. Commit-first is safe here — a crash before the discard + * leaves an orphaned tail past new_n that recover() reclaims, and + * nothing live follows the arena tail — and it avoids scrubbing + * bytes that are about to be truncated away. */ +#ifndef BSTACK_FEATURE_ATOMIC { uint64_t cur_tail; if (bstack_len(a->bs, &cur_tail) != 0) return -1; if (sentinel == cur_tail) { + if (alck_write_overhead(a->bs, block_start, + ALCK_IN_USE_BIT | new_n) != 0) + return -1; if (bstack_discard(a->bs, (size_t)delta) != 0) return -1; out->allocator = base; out->offset = s.offset; out->len = new_len; @@ -5208,13 +5526,47 @@ static int alck_vt_realloc(bstack_allocator_t *base, bstack_slice_t s, } #endif - /* Shrink non-tail: overhead already written (commit point); push the - * excess blocks onto the free list (lock-free under atomic). */ - if (new_n > UINT64_MAX / a->block_size) { errno = EINVAL; return -1; } - excess_start = block_start + new_n * a->block_size; - if (alck_push_free_blocks(a->bs, excess_start, - old_n - new_n, a->block_size) != 0) - return -1; + /* General shrink (non-tail, and every atomic shrink). Scrub the + * excess to a clean, zero-overhead free run before committing the + * smaller count: + * 1. write_free_run clears every excess overhead + payload byte + * and links the run internally (free_head untouched). A fault + * here leaves the excess untouched and the original whole. + * 2. write_overhead commits the smaller count — the shrunk view + * is now in force. A fault before the splice leaves the + * scrubbed excess as zero-overhead leaked blocks that recover() + * reclaims one by one, staying aligned. + * 3. Publish the run onto free_head (or discard it when it is the + * arena tail, on the atomic path). + * + * (The previous order — commit first, scrub second — left a window + * where the header claimed new_n while the excess still held the + * caller's stale payload, which is what desynced recover().) */ + if (alck_write_free_run(a->bs, excess_start, excess_count, + a->block_size) != 0) return -1; + if (alck_write_overhead(a->bs, block_start, + ALCK_IN_USE_BIT | new_n) != 0) return -1; + +#ifdef BSTACK_FEATURE_ATOMIC + { + /* Discard when the scrubbed excess is the tail; otherwise splice + * the pre-built run onto free_head with one cross_exchange. */ + int ok = 0; + if (bstack_try_discard(a->bs, sentinel, (size_t)delta, &ok) != 0) + return -1; + if (!ok) { + uint64_t last_block = + excess_start + (excess_count - 1) * a->block_size; + if (bstack_cross_exchange(a->bs, last_block + ALCK_OVERHEAD, + ALCK_FREE_HEAD_OFFSET, 8) != 0) + return -1; + } + } +#else + /* Non-tail (tail handled above): the run already points at the old + * head, so publishing its first block as the new head splices it. */ + if (alck_write_free_head(a->bs, excess_start) != 0) return -1; +#endif out->allocator = base; out->offset = s.offset; out->len = new_len; return 0; } diff --git a/c/bstack_alloc.h b/c/bstack_alloc.h index e57feeb3..9f1455f7 100644 --- a/c/bstack_alloc.h +++ b/c/bstack_alloc.h @@ -23,6 +23,9 @@ * linear_bstack_allocator_t — bump allocator; every operation maps to one call. * * Compile with -DBSTACK_FEATURE_SET to enable bstack_slice_write and friends. + * Both -DBSTACK_FEATURE_SET and -DBSTACK_FEATURE_ATOMIC together additionally + * enable bstack_slice_cas_on, bstack_slice_cas_on_ne, bstack_slice_cas_on_masked, + * and bstack_slice_process. */ /* ------------------------------------------------------------------------- @@ -53,6 +56,36 @@ typedef struct { #define bstack_slice_len(s) ((s).len) #define bstack_slice_is_empty(s) ((s).len == 0) +/* + * bstack_slice_is_from(s, a) → non-zero if s was issued by allocator a + * + * A slice records the allocator that produced it, but nothing checks at + * compile time that it is only ever handed back to *that* instance: every + * allocator of a given kind has the same type, so passing a1's slice to a2's + * realloc/dealloc compiles. See "Foreign slices" below; allocators use this + * to reject a foreign slice at run time. `a` is a bstack_allocator_t * — for + * a concrete allocator, pass &alloc->base. + */ +#define bstack_slice_is_from(s, a) ((s).allocator == (a)) + +/* + * Foreign slices + * -------------- + * Handing a slice to an allocator that did not issue it is a caller error the + * language cannot catch. It is not a memory-safety problem: a slice is an + * (offset, len) coordinate pair into a file, not a pointer, and every access + * through it goes via bstack's bounds-checked I/O. The damage would be to + * on-disk bookkeeping — the receiving allocator recording a free block it + * never owned. + * + * Rejecting it is therefore the allocator's job, at run time. Every allocator + * in this library checks slice ownership at the top of realloc, dealloc, and + * dealloc_bulk, before touching any metadata, and fails with -1 and + * errno = EINVAL; dealloc_bulk rejects the whole batch and frees nothing. + * The slice is passed by value, so a refused caller still holds it. Custom + * allocators should do the same; bstack_slice_is_from is the check. + */ + #ifdef __cplusplus extern "C" { #endif @@ -82,12 +115,14 @@ bstack_slice_t bstack_slice_empty(bstack_allocator_t *a); * buf must have room for at least s.len bytes; no overflow check is done. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_read(bstack_slice_t s, uint8_t *buf); /* * Read min(buf_len, s.len) bytes from the start of the slice into buf. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_read_into(bstack_slice_t s, uint8_t *buf, size_t buf_len); /* @@ -95,6 +130,7 @@ int bstack_slice_read_into(bstack_slice_t s, uint8_t *buf, size_t buf_len); * Returns -1 with errno = EINVAL if start + buf_len exceeds s.len or would * overflow uint64_t. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_read_range_into(bstack_slice_t s, uint64_t start, uint8_t *buf, size_t buf_len); @@ -103,6 +139,7 @@ int bstack_slice_read_range_into(bstack_slice_t s, uint64_t start, * buf must have room for (end - start) bytes; no overflow check is done. * Returns -1 with errno = EINVAL if start > end or end exceeds s.len. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_read_range(bstack_slice_t s, uint64_t start, uint64_t end, uint8_t *buf); @@ -111,6 +148,7 @@ int bstack_slice_read_range(bstack_slice_t s, uint64_t start, uint64_t end, * start and end are 0-based within the slice (not the payload). * Returns -1 with errno = EINVAL if start > end or end > s.len. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_subslice(bstack_slice_t s, uint64_t start, uint64_t end, bstack_slice_t *out); @@ -119,6 +157,7 @@ int bstack_slice_subslice(bstack_slice_t s, uint64_t start, uint64_t end, * Overwrite the first min(data_len, s.len) bytes of the slice in place. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_write(bstack_slice_t s, const uint8_t *data, size_t data_len); @@ -128,6 +167,7 @@ int bstack_slice_write(bstack_slice_t s, * Returns -1 with errno = EINVAL if start + data_len exceeds s.len. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_write_range(bstack_slice_t s, uint64_t start, const uint8_t *data, size_t data_len); @@ -135,6 +175,7 @@ int bstack_slice_write_range(bstack_slice_t s, uint64_t start, * Zero the entire slice in place. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_zero(bstack_slice_t s); /* @@ -143,9 +184,84 @@ int bstack_slice_zero(bstack_slice_t s); * Returns -1 with errno = EINVAL if start + n exceeds s.len. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_zero_range(bstack_slice_t s, uint64_t start, uint64_t n); #endif /* BSTACK_FEATURE_SET */ +#if defined(BSTACK_FEATURE_SET) && defined(BSTACK_FEATURE_ATOMIC) +/* + * Overwrite s with new_bytes if guard's current contents equal expected. + * + * One crash-atomic bstack_eq_crds call: guard's expected_len bytes are read + * and compared to expected, and if they match, s is overwritten with + * new_bytes_len bytes from new_bytes and its prior contents are written to + * old_buf, all under the same write lock. *ok (if non-NULL) is set to 1 if + * the swap ran, 0 if the comparison failed (s left untouched). old_buf must + * have room for s.len bytes unless s.len == 0, in which case it may be NULL. + * + * guard may be a view into the same or a different region of s's bstack, + * including s itself, but must be backed by the same bstack_t. + * + * Returns -1 with errno = EINVAL if guard and s are backed by different + * bstack_t instances, if expected_len != guard.len, or if new_bytes_len != + * s.len. + * + * Requires -DBSTACK_FEATURE_SET and -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_slice_cas_on(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok); + +/* + * Overwrite s with new_bytes if guard's current contents do NOT equal + * expected. + * + * Like bstack_slice_cas_on but wraps bstack_ne_crds: the swap runs when the + * comparison fails rather than when it succeeds. + * + * Requires -DBSTACK_FEATURE_SET and -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_slice_cas_on_ne(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok); + +/* + * Overwrite s with new_bytes if guard's current contents equal expected + * under a bitwise mask. + * + * Like bstack_slice_cas_on but wraps bstack_masked_eq_crds: the condition is + * (guard[i] & mask[i]) == (expected[i] & mask[i]) for every byte i. mask + * must have expected_len bytes. + * + * Requires -DBSTACK_FEATURE_SET and -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_slice_cas_on_masked(bstack_slice_t s, bstack_slice_t guard, + const uint8_t *mask, + const uint8_t *expected, size_t expected_len, + const uint8_t *new_bytes, size_t new_bytes_len, + uint8_t *old_buf, int *ok); + +/* + * Run a length-preserving transform over the slice's bytes in place. + * + * One crash-atomic bstack_process call: the slice's bytes are read, handed + * to cb for in-place modification, then written back, all under the same + * write lock. cb must not change the buffer's length — see bstack_process + * for the callback contract. + * + * Requires -DBSTACK_FEATURE_SET and -DBSTACK_FEATURE_ATOMIC. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_slice_process(bstack_slice_t s, + int (*cb)(uint8_t *buf, size_t len, void *ctx), + void *ctx); +#endif /* BSTACK_FEATURE_SET && BSTACK_FEATURE_ATOMIC */ + /* ========================================================================= * bstack_guard_vtbl_t / bstack_guarded_slice_t * @@ -239,12 +355,14 @@ bstack_guarded_slice(bstack_slice_t slice, * buf must have room for at least gs.slice.len bytes. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_read(bstack_guarded_slice_t gs, uint8_t *buf); /* * Read min(buf_len, gs.slice.len) bytes from the start of the slice. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_read_into(bstack_guarded_slice_t gs, uint8_t *buf, size_t buf_len); @@ -252,6 +370,7 @@ int bstack_guarded_slice_read_into(bstack_guarded_slice_t gs, * Read buf_len bytes starting at slice-relative offset start into buf. * Returns -1 with errno = EINVAL if start + buf_len exceeds gs.slice.len. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_read_range_into(bstack_guarded_slice_t gs, uint64_t start, uint8_t *buf, size_t buf_len); @@ -261,6 +380,7 @@ int bstack_guarded_slice_read_range_into(bstack_guarded_slice_t gs, * buf must have room for (end - start) bytes. * Returns -1 with errno = EINVAL if start > end or end > gs.slice.len. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_read_range(bstack_guarded_slice_t gs, uint64_t start, uint64_t end, uint8_t *buf); @@ -270,6 +390,7 @@ int bstack_guarded_slice_read_range(bstack_guarded_slice_t gs, * same vtbl/ctx. start and end are 0-based within the slice. * Returns -1 with errno = EINVAL if start > end or end > gs.slice.len. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_subslice(bstack_guarded_slice_t gs, uint64_t start, uint64_t end, bstack_guarded_slice_t *out); @@ -279,6 +400,7 @@ int bstack_guarded_slice_subslice(bstack_guarded_slice_t gs, * Write min(data_len, gs.slice.len) bytes into the slice via the guard. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_write(bstack_guarded_slice_t gs, const uint8_t *data, size_t data_len); @@ -287,6 +409,7 @@ int bstack_guarded_slice_write(bstack_guarded_slice_t gs, * Returns -1 with errno = EINVAL if start + data_len exceeds gs.slice.len. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_write_range(bstack_guarded_slice_t gs, uint64_t start, const uint8_t *data, size_t data_len); @@ -295,6 +418,7 @@ int bstack_guarded_slice_write_range(bstack_guarded_slice_t gs, * Zero the entire slice via the guard. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_zero(bstack_guarded_slice_t gs); /* @@ -302,6 +426,7 @@ int bstack_guarded_slice_zero(bstack_guarded_slice_t gs); * Returns -1 with errno = EINVAL if start + n exceeds gs.slice.len. * Requires -DBSTACK_FEATURE_SET. */ +BSTACK_WARN_UNUSED_RESULT int bstack_guarded_slice_zero_range(bstack_guarded_slice_t gs, uint64_t start, uint64_t n); #endif /* BSTACK_FEATURE_SET */ @@ -336,6 +461,7 @@ bstack_slice_reader_t bstack_slice_reader_at(bstack_slice_t s, uint64_t offset); * Returns 0 on success (including end-of-slice where *n_read = 0). * Returns -1 on I/O failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_reader_read(bstack_slice_reader_t *r, uint8_t *buf, size_t buf_len, size_t *n_read); @@ -352,6 +478,7 @@ int bstack_slice_reader_seek_start(bstack_slice_reader_t *r, uint64_t offset, * Returns -1 with errno = EINVAL if the resulting position would be negative. * If out_pos is non-NULL it receives the new cursor position. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_reader_seek_cur(bstack_slice_reader_t *r, int64_t delta, uint64_t *out_pos); @@ -360,6 +487,7 @@ int bstack_slice_reader_seek_cur(bstack_slice_reader_t *r, int64_t delta, * Returns -1 with errno = EINVAL if the resulting position would be negative. * If out_pos is non-NULL it receives the new cursor position. */ +BSTACK_WARN_UNUSED_RESULT int bstack_slice_reader_seek_end(bstack_slice_reader_t *r, int64_t delta, uint64_t *out_pos); @@ -448,12 +576,14 @@ bstack_allocator_stack(bstack_allocator_t *a) return a->vtbl->stack(a); } +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_alloc(bstack_allocator_t *a, uint64_t len, bstack_slice_t *out) { return a->vtbl->alloc(a, len, out); } +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_realloc(bstack_allocator_t *a, bstack_slice_t s, uint64_t new_len, bstack_slice_t *out) @@ -465,6 +595,7 @@ bstack_allocator_realloc(bstack_allocator_t *a, bstack_slice_t s, * Dispatch dealloc through the vtable. If the vtable entry is NULL the call * is a no-op and returns 0 (equivalent to a default no-op dealloc). */ +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_dealloc(bstack_allocator_t *a, bstack_slice_t s) { @@ -477,6 +608,7 @@ bstack_allocator_dealloc(bstack_allocator_t *a, bstack_slice_t s) * Return the current logical payload size via the allocator's stack. * Delegates to bstack_len; returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_len(bstack_allocator_t *a, uint64_t *out_len) { @@ -487,6 +619,7 @@ bstack_allocator_len(bstack_allocator_t *a, uint64_t *out_len) * Set *out_empty to 1 if the backing stack is empty, 0 otherwise. * Delegates to bstack_len; returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_is_empty(bstack_allocator_t *a, int *out_empty) { @@ -508,6 +641,7 @@ bstack_allocator_bulk_vtbl(bstack_allocator_t *a) * Allocate n slices in bulk. Returns -1 with errno = ENOTSUP when the * allocator has no bulk vtable. */ +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_alloc_bulk(bstack_allocator_t *a, const uint64_t *lens, size_t n, bstack_slice_t *out_slices) @@ -520,6 +654,7 @@ bstack_allocator_alloc_bulk(bstack_allocator_t *a, const uint64_t *lens, * Free n slices in bulk. Returns -1 with errno = ENOTSUP when the allocator * has no bulk vtable. */ +BSTACK_WARN_UNUSED_RESULT static inline int bstack_allocator_dealloc_bulk(bstack_allocator_t *a, const bstack_slice_t *slices, size_t n) @@ -553,6 +688,7 @@ typedef struct { * Returns NULL on allocation failure (errno = ENOMEM). * Cast the result to bstack_allocator_t * to use the generic allocator interface. */ +BSTACK_WARN_UNUSED_RESULT linear_bstack_allocator_t *linear_bstack_allocator_new(bstack_t *bs); /* @@ -626,6 +762,7 @@ typedef struct { * allocation failure, or the errno from any failing bstack operation). * Cast the result to bstack_allocator_t * to use the generic interface. */ +BSTACK_WARN_UNUSED_RESULT first_fit_bstack_allocator_t *first_fit_bstack_allocator_new(bstack_t *bs); /* @@ -648,7 +785,7 @@ bstack_t *first_fit_bstack_allocator_into_stack(first_fit_bstack_allocator_t *al * * On-disk layout (all within the bstack payload): * [0..32) — reserved (user area) - * [32..40) — magic: "ALGT\x00\x01\x02\x00" + * [32..40) — magic: "ALGT\x00\x01\x04\x00" * [40..48) — AVL root pointer (8 B LE) — absolute payload offset of root * [48..) — block arena (32-byte aligned) * @@ -686,6 +823,7 @@ typedef struct { * * Returns NULL on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT ghost_tree_bstack_allocator_t *ghost_tree_bstack_allocator_new(bstack_t *bs); /* @@ -764,6 +902,7 @@ typedef struct { * ENOMEM on allocation failure, or the errno from any failing bstack operation). * Cast the result to bstack_allocator_t * to use the generic interface. */ +BSTACK_WARN_UNUSED_RESULT slab_bstack_allocator_t *slab_bstack_allocator_new(bstack_t *bs, uint64_t block_size); @@ -778,6 +917,7 @@ slab_bstack_allocator_t *slab_bstack_allocator_new(bstack_t *bs, * allocation failure; or the errno from any failing bstack operation). * Cast the result to bstack_allocator_t * to use the generic interface. */ +BSTACK_WARN_UNUSED_RESULT slab_bstack_allocator_t *slab_bstack_allocator_open(bstack_t *bs); /* @@ -874,6 +1014,7 @@ typedef struct { * Returns NULL on failure (errno = EINVAL if bs non-empty or data_size < 8, * ENOMEM on allocation failure, or the errno from any failing bstack op). */ +BSTACK_WARN_UNUSED_RESULT checked_slab_bstack_allocator_t *checked_slab_bstack_allocator_new( bstack_t *bs, uint64_t data_size); @@ -889,6 +1030,7 @@ checked_slab_bstack_allocator_t *checked_slab_bstack_allocator_new( * bad magic, invalid stored values, or misaligned tail; ENOMEM on allocation * failure; or the errno from any failing bstack op). */ +BSTACK_WARN_UNUSED_RESULT checked_slab_bstack_allocator_t *checked_slab_bstack_allocator_open( bstack_t *bs); @@ -903,6 +1045,7 @@ checked_slab_bstack_allocator_t *checked_slab_bstack_allocator_open( * Returns 0 on success, -1 on I/O error (errno set). * checked_slab_bstack_allocator_open calls this automatically. */ +BSTACK_WARN_UNUSED_RESULT int checked_slab_bstack_allocator_recover(checked_slab_bstack_allocator_t *alloc, uint64_t *out_unsure); diff --git a/c/bstack_bytevec.c b/c/bstack_bytevec.c index e3abdd61..4099db3b 100644 --- a/c/bstack_bytevec.c +++ b/c/bstack_bytevec.c @@ -29,7 +29,7 @@ extern "C" { * ====================================================================== */ /* Decode 8 bytes from buf[0..8) as a little-endian uint64_t. */ -static uint64_t le64_read(const uint8_t b[8]) +static inline uint64_t le64_read(const uint8_t b[8]) { int i; uint64_t v = 0; @@ -39,7 +39,7 @@ static uint64_t le64_read(const uint8_t b[8]) } /* Encode val as 8 little-endian bytes into buf[0..8). */ -static void le64_write(uint8_t b[8], uint64_t val) +static inline void le64_write(uint8_t b[8], uint64_t val) { int i; for (i = 0; i < 8; i++) @@ -47,7 +47,7 @@ static void le64_write(uint8_t b[8], uint64_t val) } /* Re-read (len, cap) from the 16-byte block header. */ -static int bytevec_read_header(const bstack_bytevec_t *v, +static inline int bytevec_read_header(const bstack_bytevec_t *v, uint64_t *out_len, uint64_t *out_cap) { uint8_t hdr[16]; @@ -59,7 +59,7 @@ static int bytevec_read_header(const bstack_bytevec_t *v, } /* Write the len field (bytes 0..8) of the header. */ -static int bytevec_write_len(const bstack_bytevec_t *v, uint64_t len) +static inline int bytevec_write_len(const bstack_bytevec_t *v, uint64_t len) { uint8_t b[8]; le64_write(b, len); @@ -67,7 +67,7 @@ static int bytevec_write_len(const bstack_bytevec_t *v, uint64_t len) } /* Write the cap field (bytes 8..16) of the header. */ -static int bytevec_write_cap(const bstack_bytevec_t *v, uint64_t cap) +static inline int bytevec_write_cap(const bstack_bytevec_t *v, uint64_t cap) { uint8_t b[8]; le64_write(b, cap); @@ -75,7 +75,7 @@ static int bytevec_write_cap(const bstack_bytevec_t *v, uint64_t cap) } /* Write both len and cap in a single 16-byte write. */ -static int bytevec_write_header(const bstack_bytevec_t *v, +static inline int bytevec_write_header(const bstack_bytevec_t *v, uint64_t len, uint64_t cap) { uint8_t hdr[16]; @@ -85,11 +85,23 @@ static int bytevec_write_header(const bstack_bytevec_t *v, } /* Saturating double of cap, used for growth decisions. */ -static uint64_t sat_double(uint64_t cap) +static inline uint64_t sat_double(uint64_t cap) { return (cap >= UINT64_MAX / 2) ? UINT64_MAX : cap * 2; } +/* + * Absolute payload offset of logical byte index within the backing bstack, + * i.e. the coordinate accepted by bstack_copy, bstack_cross_exchange and + * bstack_repeat. Equal to the block start plus the 16-byte header plus + * index. Must be recomputed after any reallocation since the block's start + * may move. + */ +static inline uint64_t bytevec_abs_offset(const bstack_bytevec_t *v, uint64_t index) +{ + return bstack_slice_start(v->slice) + BYTEVEC_HEADER_LEN + index; +} + /* * Reallocate the block to hold new_cap bytes of data (total block size is * BYTEVEC_HEADER_LEN + new_cap). Updates v->slice on success. @@ -135,7 +147,7 @@ int bstack_bytevec_with_capacity(bstack_allocator_t *a, uint64_t capacity, return -1; /* len is already 0 (zeroed by alloc); write the non-zero cap field. */ if (bytevec_write_cap(out, capacity) != 0) { - bstack_allocator_dealloc(a, out->slice); + (void)bstack_allocator_dealloc(a, out->slice); return -1; } return 0; @@ -159,7 +171,7 @@ int bstack_bytevec_from_data(bstack_allocator_t *a, if (bytevec_write_header(out, len, len) != 0 || bstack_slice_write_range(out->slice, BYTEVEC_HEADER_LEN, data, data_len) != 0) { - bstack_allocator_dealloc(a, out->slice); + (void)bstack_allocator_dealloc(a, out->slice); return -1; } } @@ -400,6 +412,383 @@ int bstack_bytevec_resize(bstack_bytevec_t *v, uint64_t new_len, uint8_t value) return bytevec_write_len(v, new_len); } +int bstack_bytevec_set(bstack_bytevec_t *v, uint64_t index, uint8_t value, + int *out_ok) +{ + uint64_t len, cap; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (index >= len) { + *out_ok = 0; + return 0; + } + if (bstack_slice_write_range(v->slice, bytevec_elem_offset(index), + &value, 1) != 0) + return -1; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_fill(bstack_bytevec_t *v, uint8_t value) +{ + uint64_t len, cap; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (len == 0) + return 0; + return bstack_repeat(bstack_allocator_stack(v->slice.allocator), + bytevec_abs_offset(v, 0), &value, 1, len); +} + +/* ========================================================================= + * Atomic byte-mover operations + * ====================================================================== */ + +#ifdef BSTACK_FEATURE_ATOMIC + +int bstack_bytevec_extend_from_within(bstack_bytevec_t *v, uint64_t start, + uint64_t count, int *out_ok) +{ + uint64_t len, cap; + bstack_t *stack; + + if (count == 0) { + *out_ok = 1; + return 0; + } + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + /* Out of bounds (overflow or past len) → not ok, per the get()-style contract. */ + if (count > UINT64_MAX - start || start + count > len) { + *out_ok = 0; + return 0; + } + if (bstack_bytevec_reserve(v, count) != 0) + return -1; + /* Recompute offsets after reserve: a realloc may have moved the block. */ + stack = bstack_allocator_stack(v->slice.allocator); + if (bstack_copy(stack, bytevec_abs_offset(v, start), + bytevec_abs_offset(v, len), count) != 0) + return -1; + if (bytevec_write_len(v, len + count) != 0) + return -1; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_extend_from_bstack_slice(bstack_bytevec_t *v, + bstack_slice_t src) +{ + uint64_t len, cap, n; + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + + if (bstack_allocator_stack(src.allocator) != stack) { + errno = EINVAL; + return -1; + } + n = bstack_slice_len(src); + if (n == 0) + return 0; + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (bstack_bytevec_reserve(v, n) != 0) + return -1; + if (bstack_copy(stack, bstack_slice_start(src), + bytevec_abs_offset(v, len), n) != 0) + return -1; + return bytevec_write_len(v, len + n); +} + +int bstack_bytevec_append_from_owned(bstack_bytevec_t *v, bstack_slice_t other) +{ + uint64_t len, cap, n; + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + int appended = 0; + int saved_errno = 0; + int freed; + + if (bstack_allocator_stack(other.allocator) != stack) { + /* Foreign BStack (a misuse). Free other through its own allocator so + * the call is not a leak; if that free itself fails, surface its I/O + * error, otherwise report EINVAL. Either way other is consumed. */ + if (bstack_allocator_dealloc(other.allocator, other) != 0) + return -1; + errno = EINVAL; + return -1; + } + /* Append first, capturing any error, but always fall through to the free so + * other is never leaked on an append failure. */ + n = bstack_slice_len(other); + if (n > 0) { + if (bytevec_read_header(v, &len, &cap) != 0 || + bstack_bytevec_reserve(v, n) != 0 || + bstack_copy(stack, bstack_slice_start(other), + bytevec_abs_offset(v, len), n) != 0 || + bytevec_write_len(v, len + n) != 0) { + appended = -1; + saved_errno = errno; + } + } + freed = bstack_allocator_dealloc(other.allocator, other); + if (appended != 0) { + errno = saved_errno; /* prefer the append error over any dealloc error */ + return -1; + } + if (freed != 0) + return -1; + return 0; +} + +int bstack_bytevec_insert(bstack_bytevec_t *v, uint64_t index, uint8_t value, + int *out_ok) +{ + uint64_t len, cap; + bstack_t *stack; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (index > len) { + *out_ok = 0; + return 0; + } + if (bstack_bytevec_reserve(v, 1) != 0) + return -1; + stack = bstack_allocator_stack(v->slice.allocator); + if (index < len) { + uint64_t n = len - index; + if (bstack_copy(stack, bytevec_abs_offset(v, index), + bytevec_abs_offset(v, index + 1), n) != 0) + return -1; + } + if (bstack_slice_write_range(v->slice, bytevec_elem_offset(index), + &value, 1) != 0) + return -1; + if (bytevec_write_len(v, len + 1) != 0) + return -1; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_remove(bstack_bytevec_t *v, uint64_t index, + uint8_t *out_byte, int *out_ok) +{ + uint64_t len, cap, tail; + uint8_t value; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (index >= len) { + *out_ok = 0; + return 0; + } + if (bstack_slice_read_range_into(v->slice, bytevec_elem_offset(index), + &value, 1) != 0) + return -1; + tail = len - index - 1; + if (tail > 0) { + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + if (bstack_copy(stack, bytevec_abs_offset(v, index + 1), + bytevec_abs_offset(v, index), tail) != 0) + return -1; + } + /* Commit the shorter len first, then zero the vacated tail slot, as in pop. */ + if (bytevec_write_len(v, len - 1) != 0) + return -1; + if (bstack_slice_zero_range(v->slice, bytevec_elem_offset(len - 1), 1) != 0) + return -1; + if (out_byte) *out_byte = value; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_swap_remove(bstack_bytevec_t *v, uint64_t index, + uint8_t *out_byte, int *out_ok) +{ + uint64_t len, cap, last; + uint8_t value; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (index >= len) { + *out_ok = 0; + return 0; + } + if (bstack_slice_read_range_into(v->slice, bytevec_elem_offset(index), + &value, 1) != 0) + return -1; + last = len - 1; + if (index != last) { + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + if (bstack_cross_exchange(stack, bytevec_abs_offset(v, index), + bytevec_abs_offset(v, last), 1) != 0) + return -1; + } + if (bytevec_write_len(v, last) != 0) + return -1; + if (bstack_slice_zero_range(v->slice, bytevec_elem_offset(last), 1) != 0) + return -1; + if (out_byte) *out_byte = value; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_copy_into_bstack_slice(const bstack_bytevec_t *v, + uint64_t start, bstack_slice_t dst, + int *out_ok) +{ + uint64_t len, cap, n; + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + + if (bstack_allocator_stack(dst.allocator) != stack) { + errno = EINVAL; + return -1; + } + n = bstack_slice_len(dst); + if (n == 0) { + *out_ok = 1; + return 0; + } + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + /* Out of bounds (overflow or past len) → not ok. */ + if (n > UINT64_MAX - start || start + n > len) { + *out_ok = 0; + return 0; + } + if (bstack_copy(stack, bytevec_abs_offset(v, start), + bstack_slice_start(dst), n) != 0) + return -1; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_move_tail_into(bstack_bytevec_t *v, bstack_slice_t dest, + int *out_ok) +{ + uint64_t len, cap, n, start; + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + + if (bstack_allocator_stack(dest.allocator) != stack) { + errno = EINVAL; + return -1; + } + n = bstack_slice_len(dest); + if (n == 0) { + *out_ok = 1; + return 0; + } + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (n > len) { + *out_ok = 0; + return 0; + } + start = len - n; + if (bstack_cross_exchange(stack, bytevec_abs_offset(v, start), + bstack_slice_start(dest), n) != 0) + return -1; + if (bstack_bytevec_truncate(v, start) != 0) + return -1; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_split_off(bstack_bytevec_t *v, uint64_t at, + bstack_bytevec_t *out, int *out_ok) +{ + uint64_t len, cap, tail_len; + bstack_bytevec_t tail; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (at > len) { + *out_ok = 0; + return 0; + } + tail_len = len - at; + /* Even when tail_len is zero, allocate a real header-only tail vec. */ + if (bstack_bytevec_with_capacity(v->slice.allocator, tail_len, &tail) != 0) + return -1; + if (tail_len > 0) { + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + if (bstack_copy(stack, bytevec_abs_offset(v, at), + bytevec_abs_offset(&tail, 0), tail_len) != 0) { + (void)bstack_bytevec_dealloc(tail); + return -1; + } + if (bytevec_write_len(&tail, tail_len) != 0) { + (void)bstack_bytevec_dealloc(tail); + return -1; + } + } + if (bstack_bytevec_truncate(v, at) != 0) { + (void)bstack_bytevec_dealloc(tail); + return -1; + } + *out = tail; + *out_ok = 1; + return 0; +} + +int bstack_bytevec_drain(bstack_bytevec_t *v, uint64_t start, uint64_t end, + uint8_t **out_buf, uint64_t *out_len, int *out_ok) +{ + uint64_t len, cap, count, tail; + uint8_t *buf; + + if (bytevec_read_header(v, &len, &cap) != 0) + return -1; + if (start > end || end > len) { + *out_ok = 0; + return 0; + } + count = end - start; + if (count == 0) { + *out_buf = NULL; + *out_len = 0; + *out_ok = 1; + return 0; + } +#if UINT64_MAX > SIZE_MAX + if (count > (uint64_t)SIZE_MAX) { + errno = EINVAL; + return -1; + } +#endif + buf = (uint8_t *)malloc((size_t)count); + if (!buf) { + errno = ENOMEM; + return -1; + } + /* Read the removed bytes out before compacting. */ + if (bstack_slice_read_range(v->slice, bytevec_elem_offset(start), + bytevec_elem_offset(end), buf) != 0) { + free(buf); + return -1; + } + tail = len - end; + if (tail > 0) { + bstack_t *stack = bstack_allocator_stack(v->slice.allocator); + if (bstack_copy(stack, bytevec_abs_offset(v, end), + bytevec_abs_offset(v, start), tail) != 0) { + free(buf); + return -1; + } + } + if (bstack_bytevec_truncate(v, len - count) != 0) { + free(buf); + return -1; + } + *out_buf = buf; + *out_len = count; + *out_ok = 1; + return 0; +} + +#endif /* BSTACK_FEATURE_ATOMIC */ + /* ========================================================================= * Deallocation * ====================================================================== */ diff --git a/c/bstack_bytevec.h b/c/bstack_bytevec.h index 49816643..51829ebf 100644 --- a/c/bstack_bytevec.h +++ b/c/bstack_bytevec.h @@ -97,6 +97,7 @@ typedef struct { * Returns 0 on success, -1 on failure (errno set). * Writes the initialised vec into *out. */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_new(bstack_allocator_t *a, bstack_bytevec_t *out); /* @@ -108,6 +109,7 @@ int bstack_bytevec_new(bstack_allocator_t *a, bstack_bytevec_t *out); * Returns 0 on success, -1 on failure (errno set). Sets errno=EINVAL if * capacity would overflow uint64_t when adding the 16-byte header. */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_with_capacity(bstack_allocator_t *a, uint64_t capacity, bstack_bytevec_t *out); @@ -119,6 +121,7 @@ int bstack_bytevec_with_capacity(bstack_allocator_t *a, uint64_t capacity, * Returns 0 on success, -1 on failure (errno set). Sets errno=EINVAL if * data_len would overflow uint64_t when adding the 16-byte header. */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_from_data(bstack_allocator_t *a, const uint8_t *data, size_t data_len, bstack_bytevec_t *out); @@ -145,6 +148,7 @@ bstack_bytevec_t bstack_bytevec_from_raw_block(bstack_slice_t slice); * Re-reads len from the block header on every call. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_len(const bstack_bytevec_t *v, uint64_t *out_len); /* @@ -154,12 +158,14 @@ int bstack_bytevec_len(const bstack_bytevec_t *v, uint64_t *out_len); * Re-reads cap from the block header on every call. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_capacity(const bstack_bytevec_t *v, uint64_t *out_cap); /* * Set *out_empty to 1 if the vec contains no bytes, 0 otherwise. * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_is_empty(const bstack_bytevec_t *v, int *out_empty); /* @@ -171,6 +177,7 @@ int bstack_bytevec_is_empty(const bstack_bytevec_t *v, int *out_empty); * * Returns -1 on I/O failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_get(const bstack_bytevec_t *v, uint64_t index, uint8_t *out_byte, int *out_found); @@ -185,6 +192,7 @@ int bstack_bytevec_get(const bstack_bytevec_t *v, uint64_t index, * Returns 0 on success, -1 on failure (errno set; errno=ENOMEM on allocation * failure, errno=EINVAL if len exceeds SIZE_MAX). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_read_bytes(const bstack_bytevec_t *v, uint8_t **out_buf, uint64_t *out_len); @@ -228,6 +236,7 @@ bstack_slice_t bstack_bytevec_into_raw_block(bstack_bytevec_t v); * * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_push(bstack_bytevec_t *v, uint8_t value); /* @@ -240,6 +249,7 @@ int bstack_bytevec_push(bstack_bytevec_t *v, uint8_t value); * * Returns -1 on I/O failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_pop(bstack_bytevec_t *v, uint8_t *out_byte, int *out_popped); /* @@ -250,6 +260,7 @@ int bstack_bytevec_pop(bstack_bytevec_t *v, uint8_t *out_byte, int *out_popped); * * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_truncate(bstack_bytevec_t *v, uint64_t new_len); /* @@ -258,6 +269,7 @@ int bstack_bytevec_truncate(bstack_bytevec_t *v, uint64_t new_len); * Equivalent to bstack_bytevec_truncate(v, 0). * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_clear(bstack_bytevec_t *v); /* @@ -270,6 +282,7 @@ int bstack_bytevec_clear(bstack_bytevec_t *v); * len + additional overflows uint64_t. Returns -1 with errno from the * failing bstack call on I/O error. */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_reserve(bstack_bytevec_t *v, uint64_t additional); /* @@ -283,8 +296,257 @@ int bstack_bytevec_reserve(bstack_bytevec_t *v, uint64_t additional); * exceeds SIZE_MAX. Returns -1 with errno=ENOMEM on allocation failure. * Returns -1 with errno from the failing bstack call on I/O error. */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_resize(bstack_bytevec_t *v, uint64_t new_len, uint8_t value); +/* + * Overwrite the byte at index with value. + * + * A single in-place, crash-atomic write to an existing slot; capacity and len + * are unchanged. If index < len the byte is written and *out_ok is set to 1; + * if index >= len nothing is written and *out_ok is set to 0 (mirroring + * bstack_bytevec_get). + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_set(bstack_bytevec_t *v, uint64_t index, uint8_t value, + int *out_ok); + +/* + * Overwrite every logical byte with value. + * + * Backed by a single bstack_repeat, so the whole populated region is filled + * crash-atomically with a fixed-size journal regardless of len. A no-op on an + * empty vec; capacity and len are unchanged. + * + * Returns 0 on success, -1 on I/O failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_fill(bstack_bytevec_t *v, uint8_t value); + +/* ========================================================================= + * Atomic byte-mover operations + * ====================================================================== */ + +#ifdef BSTACK_FEATURE_ATOMIC +/* + * The operations in this block are built on the crash-atomic in-file byte + * movers bstack_copy and bstack_cross_exchange, and so additionally require + * -DBSTACK_FEATURE_ATOMIC. When the library is compiled without it, none of + * these functions are declared or defined; the base API above is unaffected. + * + * Crash-consistency classes + * -------------------------- + * The append-only movers (extend_from_within, extend_from_bstack_slice, + * append_from_owned) copy into spare capacity and commit len last, so a crash + * before the commit leaves the extra bytes invisible — the same benign, + * re-runnable model as push. + * + * The in-place movers (insert, remove, swap_remove, drain, split_off, + * move_tail_into) mutate the live region before committing the new len. Every + * individual bstack call is still crash-atomic, so the on-disk (len, cap) + * header is never left invalid, but the multi-step method is not atomic: a + * crash between the byte move and the len commit leaves a logically torn (but + * structurally valid) vec that is not automatically recovered. + * + * Cross-BStack misuse + * ------------------- + * The cross-slice functions (extend_from_bstack_slice, copy_into_bstack_slice, + * append_from_owned, move_tail_into) copy bytes within a single backing BStack. + * Passing a slice or owned block backed by a different BStack is rejected with + * errno = EINVAL, matching the overflow-check convention of + * bstack_bytevec_with_capacity; append_from_owned still consumes (frees) its + * argument on that path so the call is never a leak. + */ + +/* + * Append a copy of the existing bytes [start, start + count) to the end of the + * vec. + * + * Backed by a single crash-atomic bstack_copy into spare capacity; benign + * crash model identical to push. If start + count is in bounds (does not + * overflow uint64_t and is <= len) the bytes are appended and *out_ok is set + * to 1; otherwise nothing is appended and *out_ok is set to 0. An empty range + * (count == 0) is a successful no-op with *out_ok = 1. + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_extend_from_within(bstack_bytevec_t *v, uint64_t start, + uint64_t count, int *out_ok); + +/* + * Append the bytes of an on-disk slice to the end of the vec. + * + * src must be backed by the same BStack as this vec (the bytes are copied + * within one file); its issuing allocator need not be the same. Backed by a + * single crash-atomic bstack_copy into spare capacity; benign crash model + * identical to push. An empty src is a successful no-op. + * + * Returns 0 on success. Returns -1 with errno = EINVAL if src is backed by a + * different BStack; -1 with errno from the failing bstack call on I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_extend_from_bstack_slice(bstack_bytevec_t *v, + bstack_slice_t src); + +/* + * Append the bytes of the owned raw block other to the vec, then deallocate + * other — a move that consumes the handle. + * + * other's bytes are copied into spare capacity with a single crash-atomic + * bstack_copy, len is committed, and other is freed through its allocator. + * other must be backed by the same BStack as this vec. The copy targets + * invisible spare capacity and len is committed before the free, so a crash + * before the free leaves the vec correct with other merely still allocated + * (recoverable), never data loss. + * + * other is consumed — and, wherever possible, freed — on every path, so it is + * never leaked silently. + * + * Returns 0 on success. Returns -1 with errno = EINVAL if other is backed by + * a different BStack (other is still freed); otherwise -1 propagates the + * append or dealloc I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_append_from_owned(bstack_bytevec_t *v, bstack_slice_t other); + +/* + * Insert value at index, shifting every byte at or after index one slot to the + * right. + * + * The shift is a single crash-atomic bstack_copy. In-place mover (see the + * block note). If index <= len the byte is inserted and *out_ok is set to 1; + * if index > len nothing is inserted and *out_ok is set to 0. + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_insert(bstack_bytevec_t *v, uint64_t index, uint8_t value, + int *out_ok); + +/* + * Remove and return the byte at index, shifting every later byte one slot to + * the left (preserves order). + * + * The shift is a single crash-atomic bstack_copy; the vacated tail slot is then + * zeroed as in pop. In-place mover (see the block note). If index < len the + * byte is removed, written into *out_byte (when non-NULL), and *out_ok is set + * to 1; if index >= len nothing is removed and *out_ok is set to 0. + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_remove(bstack_bytevec_t *v, uint64_t index, + uint8_t *out_byte, int *out_ok); + +/* + * Remove the byte at index and return it, replacing the hole with the last + * byte (O(1), does NOT preserve order). + * + * Uses a single crash-atomic bstack_cross_exchange to swap the element into the + * tail slot, which is then dropped as in pop. In-place mover (see the block + * note). If index < len the byte is removed, written into *out_byte (when + * non-NULL), and *out_ok is set to 1; if index >= len nothing is removed and + * *out_ok is set to 0. + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_swap_remove(bstack_bytevec_t *v, uint64_t index, + uint8_t *out_byte, int *out_ok); + +/* + * Copy bstack_slice_len(dst) bytes from the vec, starting at logical start, + * into dst (overwriting it). + * + * dst must be backed by the same BStack as this vec. A single crash-atomic + * bstack_copy; the vec itself is not modified. If start + dst.len is in bounds + * (does not overflow uint64_t and is <= len) the copy is performed and *out_ok + * is set to 1; otherwise nothing is copied and *out_ok is set to 0. An empty + * dst is a successful no-op with *out_ok = 1. + * + * Returns 0 on success — including the out-of-range no-op. Returns -1 with + * errno = EINVAL if dst is backed by a different BStack (a misuse, distinct + * from an out-of-range request); otherwise -1 on I/O failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_copy_into_bstack_slice(const bstack_bytevec_t *v, + uint64_t start, bstack_slice_t dst, + int *out_ok); + +/* + * Move the last bstack_slice_len(dest) bytes of the vec into dest, shrinking + * the vec by that many bytes. + * + * The tail is swapped into dest with a single crash-atomic + * bstack_cross_exchange, and the vacated tail — now holding dest's former + * contents — is dropped and zeroed by shrinking len via truncate. dest must be + * backed by the same BStack and sized to exactly the tail being moved. + * In-place mover (see the block note). If dest.len <= len the move is + * performed and *out_ok is set to 1; if dest.len > len the vec is unchanged and + * *out_ok is set to 0. A zero-length dest is a successful no-op. + * + * Returns 0 on success — including the out-of-range no-op. Returns -1 with + * errno = EINVAL if dest is backed by a different BStack; otherwise -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_move_tail_into(bstack_bytevec_t *v, bstack_slice_t dest, + int *out_ok); + +/* + * Split the vec in two at at: v keeps [0, at) and a new vec holding [at, len) + * is written into *out. + * + * The new vec is allocated with exactly len - at bytes of capacity and the tail + * is transferred with a single crash-atomic bstack_copy straight between the + * two blocks. In-place mover (see the block note). If at <= len the split is + * performed, the tail vec is written into *out, and *out_ok is set to 1 + * (at == len yields an empty tail); if at > len the vec is unchanged, *out is + * left untouched, and *out_ok is set to 0. + * + * The caller owns the tail vec written to *out and must eventually + * bstack_bytevec_dealloc it. + * + * Returns 0 on success — including the out-of-range no-op — and -1 on I/O + * failure (errno set). + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_split_off(bstack_bytevec_t *v, uint64_t at, + bstack_bytevec_t *out, int *out_ok); + +/* + * Remove the bytes in [start, end), shifting every later byte down to close the + * gap, and return the removed bytes. + * + * The removed bytes are read out first, the tail (if any) is shifted down with + * a single crash-atomic bstack_copy, and the shorter len is committed last. + * In-place mover (see the block note). If start <= end and end <= len the + * bytes are removed and *out_ok is set to 1; otherwise the vec is unchanged and + * *out_ok is set to 0. + * + * On a successful removal of a non-empty range, *out_buf receives a + * malloc-allocated buffer of *out_len bytes that the caller must free(). An + * empty range (start == end) is a successful no-op that sets *out_buf to NULL + * and *out_len to 0 (no allocation). + * + * Returns 0 on success — including the out-of-range no-op. Returns -1 with + * errno = ENOMEM on allocation failure, errno = EINVAL if the removed count + * exceeds SIZE_MAX, or errno from the failing bstack call on I/O error. + */ +BSTACK_WARN_UNUSED_RESULT +int bstack_bytevec_drain(bstack_bytevec_t *v, uint64_t start, uint64_t end, + uint8_t **out_buf, uint64_t *out_len, int *out_ok); + +#endif /* BSTACK_FEATURE_ATOMIC */ + /* ========================================================================= * Deallocation * ====================================================================== */ @@ -297,6 +559,7 @@ int bstack_bytevec_resize(bstack_bytevec_t *v, uint64_t new_len, uint8_t value); * * Returns 0 on success, -1 on failure (errno set). */ +BSTACK_WARN_UNUSED_RESULT int bstack_bytevec_dealloc(bstack_bytevec_t v); #ifdef __cplusplus diff --git a/c/test_bstack.c b/c/test_bstack.c index d13a6dcf..2a4630da 100644 --- a/c/test_bstack.c +++ b/c/test_bstack.c @@ -590,7 +590,7 @@ static int test_large_payload_roundtrip(void) * Header / magic * ====================================================================== */ -static const uint8_t MAGIC[8] = {'B','S','T','K', 0, 1, 15, 0}; +static const uint8_t MAGIC[8] = {'B','S','T','K', 0, 1, 17, 0}; static const uint8_t MAGIC_PREFIX[6] = {'B','S','T','K', 0, 1}; static int test_new_file_has_valid_header(void) @@ -1359,6 +1359,181 @@ static int test_extend_persists_across_reopen(void) return 0; } +/* ========================================================================= + * bstack_extend_sparse / bstack_extend_sparse_batched + * ====================================================================== */ + +static int test_extend_sparse_writes_prefix_and_zeros_rest(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"abc", 3, NULL) == 0); + uint64_t off; + CHECK(bstack_extend_sparse(bs, (uint8_t *)"XY", 2, 6, &off) == 0); + CHECK(off == 3); + + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 9); + uint8_t buf[9]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(w == 9); + CHECK(memcmp(buf, "abcXY\x00\x00\x00\x00", 9) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_empty_buf_is_pure_extend(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"ab", 2, NULL) == 0); + uint64_t off; + CHECK(bstack_extend_sparse(bs, NULL, 0, 4, &off) == 0); + CHECK(off == 2); + + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "ab\x00\x00\x00\x00", 6) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_zero_length_is_noop(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"data", 4, NULL) == 0); + uint64_t off; + CHECK(bstack_extend_sparse(bs, NULL, 0, 0, &off) == 0); + CHECK(off == 4); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 4); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_buf_longer_than_length_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + errno = 0; + CHECK(bstack_extend_sparse(bs, (uint8_t *)"toolong", 7, 3, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_batched_scatters_buffers(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"..", 2, NULL) == 0); + bstack_iovec_t writes[2] = { + { 0, (uint8_t *)"AA", 2 }, + { 5, (uint8_t *)"BB", 2 }, + }; + uint64_t off; + CHECK(bstack_extend_sparse_batched(bs, writes, 2, 8, &off) == 0); + CHECK(off == 2); + + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 10); + uint8_t buf[10]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "..AA\x00\x00\x00" "BB" "\x00", 10) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_batched_empty_is_pure_extend(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + uint64_t off; + CHECK(bstack_extend_sparse_batched(bs, NULL, 0, 4, &off) == 0); + CHECK(off == 0); + uint8_t buf[4]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "\x00\x00\x00\x00", 4) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_batched_overlap_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + bstack_iovec_t writes[2] = { + { 0, (uint8_t *)"aaa", 3 }, + { 2, (uint8_t *)"bb", 2 }, + }; + errno = 0; + CHECK(bstack_extend_sparse_batched(bs, writes, 2, 8, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_batched_out_of_range_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + bstack_iovec_t writes[1] = { { 3, (uint8_t *)"zzz", 3 } }; + errno = 0; + CHECK(bstack_extend_sparse_batched(bs, writes, 1, 5, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_extend_sparse_persists_across_reopen(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + + { + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"hi", 2, NULL) == 0); + CHECK(bstack_extend_sparse(bs, (uint8_t *)"Z", 1, 4, NULL) == 0); + bstack_close(bs); + } + { + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "hiZ\x00\x00\x00", 6) == 0); + bstack_close(bs); + } + + unlink(tmp); + return 0; +} + #ifdef BSTACK_FEATURE_SET /* ========================================================================= @@ -1493,6 +1668,87 @@ static int test_zero_persists_across_reopen(void) return 0; } +/* ------------------------------------------------------------------------- + * bstack_repeat (compiled only with -DBSTACK_FEATURE_SET) + * ---------------------------------------------------------------------- */ + +static int test_repeat_fills_with_pattern_copies(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"............", 12, NULL) == 0); + CHECK(bstack_repeat(bs, 0, (uint8_t *)"ab", 2, 6) == 0); + uint8_t buf[12]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(w == 12); + CHECK(memcmp(buf, "abababababab", 12) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_repeat_at_offset_leaves_neighbours(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"XXXXXXXXXX", 10, NULL) == 0); + CHECK(bstack_repeat(bs, 2, (uint8_t *)"yz", 2, 3) == 0); + uint8_t buf[10]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "XXyzyzyzXX", 10) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_repeat_empty_or_zero_count_is_noop(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"helloworld", 10, NULL) == 0); + CHECK(bstack_repeat(bs, 0, (uint8_t *)"", 0, 5) == 0); + CHECK(bstack_repeat(bs, 0, (uint8_t *)"ab", 2, 0) == 0); + uint8_t buf[10]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "helloworld", 10) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_repeat_rejects_write_past_end(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"hello", 5, NULL) == 0); + errno = 0; + CHECK(bstack_repeat(bs, 0, (uint8_t *)"ab", 2, 4) == -1); /* 8 into 5 */ + CHECK(errno == EINVAL); + uint8_t buf[5]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "hello", 5) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_repeat_persists_across_reopen(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"........", 8, NULL) == 0); + CHECK(bstack_repeat(bs, 0, (uint8_t *)"QW", 2, 4) == 0); + bstack_close(bs); + bs = bstack_open(tmp); + CHECK(bs != NULL); + uint8_t buf[8]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "QWQWQWQW", 8) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + #endif /* BSTACK_FEATURE_SET */ /* ========================================================================= @@ -1878,6 +2134,146 @@ static int test_try_extend_persists_across_reopen(void) return 0; } +/* ---- bstack_try_extend_sparse --------------------------------------------- */ + +static int test_try_extend_sparse_matching_writes(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"hello", 5, NULL) == 0); + int ok = -1; + CHECK(bstack_try_extend_sparse(bs, 5, (uint8_t *)"XY", 2, 6, &ok) == 0); + CHECK(ok == 1); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 11); + uint8_t buf[11]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "helloXY\x00\x00\x00\x00", 11) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_mismatching_returns_false(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"hello", 5, NULL) == 0); + int ok = -1; + CHECK(bstack_try_extend_sparse(bs, 3, (uint8_t *)"XY", 2, 6, &ok) == 0); + CHECK(ok == 0); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 5); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_malformed_errors_even_on_mismatch(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"hello", 5, NULL) == 0); + /* Size does not match (3 != 5), but the malformed request still errors. */ + errno = 0; + CHECK(bstack_try_extend_sparse(bs, 3, (uint8_t *)"toolong", 7, 2, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 5); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_persists_across_reopen(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + + { + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"hi", 2, NULL) == 0); + int ok = -1; + CHECK(bstack_try_extend_sparse(bs, 2, (uint8_t *)"Z", 1, 4, &ok) == 0); + CHECK(ok == 1); + bstack_close(bs); + } + { + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "hiZ\x00\x00\x00", 6) == 0); + bstack_close(bs); + } + + unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_batched_matching_scatters(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"..", 2, NULL) == 0); + bstack_iovec_t writes[2] = { + { 0, (uint8_t *)"AA", 2 }, + { 5, (uint8_t *)"BB", 2 }, + }; + int ok = -1; + CHECK(bstack_try_extend_sparse_batched(bs, 2, writes, 2, 8, &ok) == 0); + CHECK(ok == 1); + uint8_t buf[10]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "..AA\x00\x00\x00" "BB" "\x00", 10) == 0); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_batched_mismatching_returns_false(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"..", 2, NULL) == 0); + bstack_iovec_t writes[1] = { { 0, (uint8_t *)"AA", 2 } }; + int ok = -1; + CHECK(bstack_try_extend_sparse_batched(bs, 99, writes, 1, 8, &ok) == 0); + CHECK(ok == 0); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 2); + + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_try_extend_sparse_batched_overlap_errors_even_on_mismatch(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + + CHECK(bstack_push(bs, (uint8_t *)"..", 2, NULL) == 0); + bstack_iovec_t writes[2] = { + { 0, (uint8_t *)"aaa", 3 }, + { 2, (uint8_t *)"bb", 2 }, + }; + /* Size does not match (99 != 2), but the malformed batch still errors. */ + errno = 0; + CHECK(bstack_try_extend_sparse_batched(bs, 99, writes, 2, 8, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 2); + + bstack_close(bs); unlink(tmp); + return 0; +} + /* ---- bstack_try_discard --------------------------------------------------- */ static int test_try_discard_matching_returns_true(void) @@ -4026,6 +4422,172 @@ static int test_is_empty_consistent_with_len(void) * main * ====================================================================== */ +/* ------------------------------------------------------------------------- + * bstack_resize / bstack_ensure / bstack_ensure_with + * ---------------------------------------------------------------------- */ + +static int test_resize_grows_with_zeros(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"abc", 3, NULL) == 0); + uint64_t initial; + CHECK(bstack_resize(bs, 6, &initial) == 0); + CHECK(initial == 3); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 6); + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(w == 6); + CHECK(memcmp(buf, "abc\0\0\0", 6) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_resize_shrinks(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"helloworld", 10, NULL) == 0); + uint64_t initial; + CHECK(bstack_resize(bs, 5, &initial) == 0); + CHECK(initial == 10); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 5); + uint8_t buf[5]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "hello", 5) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_resize_same_size_is_noop(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"hello", 5, NULL) == 0); + uint64_t initial; + CHECK(bstack_resize(bs, 5, &initial) == 0); + CHECK(initial == 5); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 5); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_resize_shrink_below_locked_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"helloworld", 10, NULL) == 0); + CHECK(bstack_lock_up_to(bs, 5) == 0); + errno = 0; + CHECK(bstack_resize(bs, 3, NULL) == -1); + CHECK(errno == EINVAL); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 10); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_resize_persists_across_reopen(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"hi", 2, NULL) == 0); + CHECK(bstack_resize(bs, 4, NULL) == 0); + bstack_close(bs); + bs = bstack_open(tmp); + CHECK(bs != NULL); + uint8_t buf[4]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(w == 4); + CHECK(memcmp(buf, "hi\0\0", 4) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_ensure_grows_short_payload_with_zeros(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"abc", 3, NULL) == 0); + uint64_t initial; + CHECK(bstack_ensure(bs, 6, &initial) == 0); + CHECK(initial == 3); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 6); + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "abc\0\0\0", 6) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_ensure_noop_when_already_long_enough(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"helloworld", 10, NULL) == 0); + uint64_t initial; + CHECK(bstack_ensure(bs, 5, &initial) == 0); + CHECK(initial == 10); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 10); + bstack_close(bs); unlink(tmp); + return 0; +} + +#ifdef BSTACK_FEATURE_ATOMIC +static int fill_xyz_cb(uint8_t *buf, size_t len, void *ctx) +{ + (void)ctx; + /* The region arrives zero-filled; overwrite it with a visible pattern. */ + for (size_t i = 0; i < len; i++) + buf[i] = (uint8_t)('X' + (i % 3)); + return 0; +} + +static int abort_cb(uint8_t *buf, size_t len, void *ctx) +{ + (void)buf; (void)len; (void)ctx; + return -1; +} + +static int test_ensure_with_grows_and_calls_callback(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"abc", 3, NULL) == 0); + uint64_t initial; + CHECK(bstack_ensure_with(bs, 6, fill_xyz_cb, NULL, &initial) == 0); + CHECK(initial == 3); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 6); + uint8_t buf[6]; size_t w; + CHECK(bstack_peek(bs, 0, buf, &w) == 0); + CHECK(memcmp(buf, "abcXYZ", 6) == 0); + bstack_close(bs); unlink(tmp); + return 0; +} + +static int test_ensure_with_noop_when_long_enough(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); + CHECK(bs != NULL); + CHECK(bstack_push(bs, (uint8_t *)"helloworld", 10, NULL) == 0); + uint64_t initial; + /* Callback would abort, but it must not be called since no growth is needed. */ + CHECK(bstack_ensure_with(bs, 5, abort_cb, NULL, &initial) == 0); + CHECK(initial == 10); + uint64_t len; CHECK(bstack_len(bs, &len) == 0); CHECK(len == 10); + bstack_close(bs); unlink(tmp); + return 0; +} +#endif /* BSTACK_FEATURE_ATOMIC */ + int main(void) { /* Functional */ @@ -4094,11 +4656,35 @@ int main(void) T(test_discard_leaves_correct_tail); T(test_discard_persists_across_reopen); + /* bstack_resize / bstack_ensure / bstack_ensure_with */ + T(test_resize_grows_with_zeros); + T(test_resize_shrinks); + T(test_resize_same_size_is_noop); + T(test_resize_shrink_below_locked_errors); + T(test_resize_persists_across_reopen); + T(test_ensure_grows_short_payload_with_zeros); + T(test_ensure_noop_when_already_long_enough); +#ifdef BSTACK_FEATURE_ATOMIC + T(test_ensure_with_grows_and_calls_callback); + T(test_ensure_with_noop_when_long_enough); +#endif + /* bstack_extend */ T(test_extend_appends_zeros); T(test_extend_zero_is_noop); T(test_extend_persists_across_reopen); + /* bstack_extend_sparse / bstack_extend_sparse_batched */ + T(test_extend_sparse_writes_prefix_and_zeros_rest); + T(test_extend_sparse_empty_buf_is_pure_extend); + T(test_extend_sparse_zero_length_is_noop); + T(test_extend_sparse_buf_longer_than_length_errors); + T(test_extend_sparse_batched_scatters_buffers); + T(test_extend_sparse_batched_empty_is_pure_extend); + T(test_extend_sparse_batched_overlap_errors); + T(test_extend_sparse_batched_out_of_range_errors); + T(test_extend_sparse_persists_across_reopen); + /* bstack_lock_up_to / bstack_locked_len / bstack_open_locked_up_to */ T(test_locked_len_is_zero_by_default); T(test_lock_up_to_sets_boundary); @@ -4143,6 +4729,13 @@ int main(void) /* bstack_set / bstack_zero — locked-region protection */ T(test_set_respects_locked_region); T(test_zero_respects_locked_region); + + /* bstack_repeat */ + T(test_repeat_fills_with_pattern_copies); + T(test_repeat_at_offset_leaves_neighbours); + T(test_repeat_empty_or_zero_count_is_noop); + T(test_repeat_rejects_write_past_end); + T(test_repeat_persists_across_reopen); #endif #ifdef BSTACK_FEATURE_ATOMIC @@ -4172,6 +4765,15 @@ int main(void) T(test_try_extend_empty_buf_matching); T(test_try_extend_persists_across_reopen); + /* bstack_try_extend_sparse / bstack_try_extend_sparse_batched */ + T(test_try_extend_sparse_matching_writes); + T(test_try_extend_sparse_mismatching_returns_false); + T(test_try_extend_sparse_malformed_errors_even_on_mismatch); + T(test_try_extend_sparse_persists_across_reopen); + T(test_try_extend_sparse_batched_matching_scatters); + T(test_try_extend_sparse_batched_mismatching_returns_false); + T(test_try_extend_sparse_batched_overlap_errors_even_on_mismatch); + /* bstack_try_discard */ T(test_try_discard_matching_returns_true); T(test_try_discard_mismatching_returns_false); diff --git a/c/test_bstack_bytevec.c b/c/test_bstack_bytevec.c index 72494b8f..d82270b2 100644 --- a/c/test_bstack_bytevec.c +++ b/c/test_bstack_bytevec.c @@ -6,6 +6,7 @@ #include "bstack_bytevec.h" +#include #include #include #include @@ -65,6 +66,55 @@ static void make_tmp(char *buf, size_t n) } #endif +/* ── shared test fixtures ──────────────────────────────────────────────────── */ + +typedef struct { + char tmp[64]; + bstack_t *bs; + first_fit_bstack_allocator_t *ff; +} env_t; + +static int env_open(env_t *e) +{ + make_tmp(e->tmp, sizeof e->tmp); + e->bs = bstack_open(e->tmp); + if (!e->bs) + return -1; + e->ff = first_fit_bstack_allocator_new(e->bs); + if (!e->ff) + return -1; + return 0; +} + +static bstack_allocator_t *env_alloc(env_t *e) +{ + return (bstack_allocator_t *)e->ff; +} + +static void env_close(env_t *e) +{ + bstack_close(first_fit_bstack_allocator_into_stack(e->ff)); + bv_unlink(e->tmp); +} + +/* Compare a vec's logical contents to exp[0..n). Returns 1 on match. */ +static int vec_eq(const bstack_bytevec_t *v, const uint8_t *exp, uint64_t n) +{ + uint64_t len = 0, bl = 0; + uint8_t *buf = NULL; + int ok; + + if (bstack_bytevec_len(v, &len) != 0 || len != n) + return 0; + if (n == 0) + return 1; + if (bstack_bytevec_read_bytes(v, &buf, &bl) != 0) + return 0; + ok = (bl == n) && (memcmp(buf, exp, (size_t)n) == 0); + free(buf); + return ok; +} + static int test_bytevec_push_get_pop(void) { char tmp[64]; @@ -111,9 +161,458 @@ static int test_bytevec_push_get_pop(void) return 0; } +/* ── set / fill (BSTACK_FEATURE_SET) ───────────────────────────────────────── */ + +static int test_bytevec_set(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + int ok = -1; + CHECK(bstack_bytevec_set(&v, 2, 0x99, &ok) == 0); + CHECK(ok == 1); + + static const uint8_t after[] = {1, 2, 0x99, 4}; + CHECK(vec_eq(&v, after, sizeof after)); + + /* Out of range: no write, ok == 0, contents unchanged. */ + ok = -1; + CHECK(bstack_bytevec_set(&v, 4, 0x77, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, after, sizeof after)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_fill(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4, 5}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + CHECK(bstack_bytevec_fill(&v, 0xAB) == 0); + static const uint8_t filled[] = {0xAB, 0xAB, 0xAB, 0xAB, 0xAB}; + CHECK(vec_eq(&v, filled, sizeof filled)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + + /* Fill of an empty vec is a no-op. */ + bstack_bytevec_t empty; + CHECK(bstack_bytevec_new(env_alloc(&e), &empty) == 0); + CHECK(bstack_bytevec_fill(&empty, 0xCD) == 0); + uint64_t len = 1; + CHECK(bstack_bytevec_len(&empty, &len) == 0); + CHECK(len == 0); + CHECK(bstack_bytevec_dealloc(empty) == 0); + + env_close(&e); + return 0; +} + +#ifdef BSTACK_FEATURE_ATOMIC + +/* ── atomic byte-movers (BSTACK_FEATURE_SET + BSTACK_FEATURE_ATOMIC) ────────── */ + +static int test_bytevec_extend_from_within(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + int ok = -1; + CHECK(bstack_bytevec_extend_from_within(&v, 1, 2, &ok) == 0); + CHECK(ok == 1); + static const uint8_t after[] = {1, 2, 3, 4, 2, 3}; + CHECK(vec_eq(&v, after, sizeof after)); + + /* count == 0 is a successful no-op even with an otherwise-huge start. */ + ok = -1; + CHECK(bstack_bytevec_extend_from_within(&v, 1000, 0, &ok) == 0); + CHECK(ok == 1); + CHECK(vec_eq(&v, after, sizeof after)); + + /* Out of range (start + count > len) → ok == 0, unchanged. */ + ok = -1; + CHECK(bstack_bytevec_extend_from_within(&v, 5, 2, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, after, sizeof after)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_insert(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 4}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + int ok = -1; + CHECK(bstack_bytevec_insert(&v, 2, 3, &ok) == 0); /* insert into middle */ + CHECK(ok == 1); + static const uint8_t mid[] = {1, 2, 3, 4}; + CHECK(vec_eq(&v, mid, sizeof mid)); + + CHECK(bstack_bytevec_insert(&v, 4, 5, &ok) == 0); /* insert at end (== len) */ + CHECK(ok == 1); + static const uint8_t end[] = {1, 2, 3, 4, 5}; + CHECK(vec_eq(&v, end, sizeof end)); + + ok = -1; + CHECK(bstack_bytevec_insert(&v, 6, 9, &ok) == 0); /* index > len → no-op */ + CHECK(ok == 0); + CHECK(vec_eq(&v, end, sizeof end)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_remove(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + uint8_t got = 0; + int ok = -1; + CHECK(bstack_bytevec_remove(&v, 1, &got, &ok) == 0); /* remove middle */ + CHECK(ok == 1); + CHECK(got == 2); + static const uint8_t after[] = {1, 3, 4}; + CHECK(vec_eq(&v, after, sizeof after)); + + ok = -1; + CHECK(bstack_bytevec_remove(&v, 3, &got, &ok) == 0); /* index >= len → no-op */ + CHECK(ok == 0); + CHECK(vec_eq(&v, after, sizeof after)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_swap_remove(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + uint8_t got = 0; + int ok = -1; + CHECK(bstack_bytevec_swap_remove(&v, 1, &got, &ok) == 0); /* hole filled by last */ + CHECK(ok == 1); + CHECK(got == 2); + static const uint8_t after[] = {1, 4, 3}; + CHECK(vec_eq(&v, after, sizeof after)); + + /* Removing the last element is the degenerate (index == last) case. */ + CHECK(bstack_bytevec_swap_remove(&v, 2, &got, &ok) == 0); + CHECK(ok == 1); + CHECK(got == 3); + static const uint8_t after2[] = {1, 4}; + CHECK(vec_eq(&v, after2, sizeof after2)); + + ok = -1; + CHECK(bstack_bytevec_swap_remove(&v, 5, &got, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, after2, sizeof after2)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_extend_from_bstack_slice(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + /* A raw block on the same BStack, holding {3, 4, 5}. */ + static const uint8_t src_bytes[] = {3, 4, 5}; + bstack_slice_t src; + CHECK(bstack_allocator_alloc(env_alloc(&e), sizeof src_bytes, &src) == 0); + CHECK(bstack_slice_write_range(src, 0, src_bytes, sizeof src_bytes) == 0); + + CHECK(bstack_bytevec_extend_from_bstack_slice(&v, src) == 0); + static const uint8_t after[] = {1, 2, 3, 4, 5}; + CHECK(vec_eq(&v, after, sizeof after)); + CHECK(bstack_allocator_dealloc(env_alloc(&e), src) == 0); + + /* Cross-BStack misuse → -1 / EINVAL. */ + env_t e2; + CHECK(env_open(&e2) == 0); + bstack_slice_t foreign; + CHECK(bstack_allocator_alloc(env_alloc(&e2), 2, &foreign) == 0); + errno = 0; + CHECK(bstack_bytevec_extend_from_bstack_slice(&v, foreign) == -1); + CHECK(errno == EINVAL); + CHECK(vec_eq(&v, after, sizeof after)); /* unchanged */ + CHECK(bstack_allocator_dealloc(env_alloc(&e2), foreign) == 0); + env_close(&e2); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_copy_into_bstack_slice(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4, 5}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + bstack_slice_t dst; + CHECK(bstack_allocator_alloc(env_alloc(&e), 3, &dst) == 0); + + int ok = -1; + CHECK(bstack_bytevec_copy_into_bstack_slice(&v, 1, dst, &ok) == 0); + CHECK(ok == 1); + uint8_t got[3] = {0}; + CHECK(bstack_slice_read_range(dst, 0, 3, got) == 0); + CHECK(got[0] == 2 && got[1] == 3 && got[2] == 4); + + /* Out of range (start + dst.len > len) → ok == 0. */ + ok = -1; + CHECK(bstack_bytevec_copy_into_bstack_slice(&v, 3, dst, &ok) == 0); + CHECK(ok == 0); + + /* Cross-BStack misuse → -1 / EINVAL. */ + env_t e2; + CHECK(env_open(&e2) == 0); + bstack_slice_t foreign; + CHECK(bstack_allocator_alloc(env_alloc(&e2), 3, &foreign) == 0); + errno = 0; + CHECK(bstack_bytevec_copy_into_bstack_slice(&v, 0, foreign, &ok) == -1); + CHECK(errno == EINVAL); + CHECK(bstack_allocator_dealloc(env_alloc(&e2), foreign) == 0); + env_close(&e2); + + CHECK(bstack_allocator_dealloc(env_alloc(&e), dst) == 0); + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_append_from_owned(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + static const uint8_t owned_bytes[] = {3, 4, 5}; + bstack_slice_t owned; + CHECK(bstack_allocator_alloc(env_alloc(&e), sizeof owned_bytes, &owned) == 0); + CHECK(bstack_slice_write_range(owned, 0, owned_bytes, sizeof owned_bytes) == 0); + + /* Consumes and frees `owned`; must not dealloc it afterwards. */ + CHECK(bstack_bytevec_append_from_owned(&v, owned) == 0); + static const uint8_t after[] = {1, 2, 3, 4, 5}; + CHECK(vec_eq(&v, after, sizeof after)); + + /* Cross-BStack misuse → -1 / EINVAL, and `foreign` is still consumed + * (freed through its own allocator), so no double free below. */ + env_t e2; + CHECK(env_open(&e2) == 0); + bstack_slice_t foreign; + CHECK(bstack_allocator_alloc(env_alloc(&e2), 2, &foreign) == 0); + errno = 0; + CHECK(bstack_bytevec_append_from_owned(&v, foreign) == -1); + CHECK(errno == EINVAL); + CHECK(vec_eq(&v, after, sizeof after)); /* unchanged */ + env_close(&e2); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_move_tail_into(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4, 5}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + bstack_slice_t dest; + CHECK(bstack_allocator_alloc(env_alloc(&e), 2, &dest) == 0); + + int ok = -1; + CHECK(bstack_bytevec_move_tail_into(&v, dest, &ok) == 0); + CHECK(ok == 1); + static const uint8_t after[] = {1, 2, 3}; + CHECK(vec_eq(&v, after, sizeof after)); + uint8_t moved[2] = {0}; + CHECK(bstack_slice_read_range(dest, 0, 2, moved) == 0); + CHECK(moved[0] == 4 && moved[1] == 5); + CHECK(bstack_allocator_dealloc(env_alloc(&e), dest) == 0); + + /* dest.len > len → ok == 0, unchanged. */ + bstack_slice_t big; + CHECK(bstack_allocator_alloc(env_alloc(&e), 4, &big) == 0); + ok = -1; + CHECK(bstack_bytevec_move_tail_into(&v, big, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, after, sizeof after)); + CHECK(bstack_allocator_dealloc(env_alloc(&e), big) == 0); + + /* Cross-BStack misuse → -1 / EINVAL. */ + env_t e2; + CHECK(env_open(&e2) == 0); + bstack_slice_t foreign; + CHECK(bstack_allocator_alloc(env_alloc(&e2), 1, &foreign) == 0); + errno = 0; + CHECK(bstack_bytevec_move_tail_into(&v, foreign, &ok) == -1); + CHECK(errno == EINVAL); + CHECK(bstack_allocator_dealloc(env_alloc(&e2), foreign) == 0); + env_close(&e2); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_split_off(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4, 5}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + bstack_bytevec_t tail; + int ok = -1; + CHECK(bstack_bytevec_split_off(&v, 2, &tail, &ok) == 0); + CHECK(ok == 1); + static const uint8_t head[] = {1, 2}; + static const uint8_t tbytes[] = {3, 4, 5}; + CHECK(vec_eq(&v, head, sizeof head)); + CHECK(vec_eq(&tail, tbytes, sizeof tbytes)); + CHECK(bstack_bytevec_dealloc(tail) == 0); + + /* at == len yields an empty tail. */ + bstack_bytevec_t empty_tail; + CHECK(bstack_bytevec_split_off(&v, 2, &empty_tail, &ok) == 0); + CHECK(ok == 1); + uint64_t tlen = 1; + CHECK(bstack_bytevec_len(&empty_tail, &tlen) == 0); + CHECK(tlen == 0); + CHECK(bstack_bytevec_dealloc(empty_tail) == 0); + + /* at > len → ok == 0, `out` untouched, `v` unchanged. */ + bstack_bytevec_t untouched; + memset(&untouched, 0xEE, sizeof untouched); + ok = -1; + CHECK(bstack_bytevec_split_off(&v, 3, &untouched, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, head, sizeof head)); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +static int test_bytevec_drain(void) +{ + env_t e; + CHECK(env_open(&e) == 0); + + static const uint8_t data[] = {1, 2, 3, 4, 5}; + bstack_bytevec_t v; + CHECK(bstack_bytevec_from_data(env_alloc(&e), data, sizeof data, &v) == 0); + + uint8_t *buf = NULL; + uint64_t n = 0; + int ok = -1; + CHECK(bstack_bytevec_drain(&v, 1, 4, &buf, &n, &ok) == 0); + CHECK(ok == 1); + CHECK(n == 3); + CHECK(buf != NULL); + CHECK(buf[0] == 2 && buf[1] == 3 && buf[2] == 4); + free(buf); + static const uint8_t after[] = {1, 5}; + CHECK(vec_eq(&v, after, sizeof after)); + + /* Empty range → success, NULL buffer, len 0. */ + buf = (uint8_t *)0x1; + n = 99; + ok = -1; + CHECK(bstack_bytevec_drain(&v, 1, 1, &buf, &n, &ok) == 0); + CHECK(ok == 1); + CHECK(buf == NULL); + CHECK(n == 0); + CHECK(vec_eq(&v, after, sizeof after)); + + /* Out of range (end > len) → ok == 0, unchanged. */ + ok = -1; + CHECK(bstack_bytevec_drain(&v, 0, 3, &buf, &n, &ok) == 0); + CHECK(ok == 0); + CHECK(vec_eq(&v, after, sizeof after)); + + /* Inverted range (start > end) → ok == 0. */ + ok = -1; + CHECK(bstack_bytevec_drain(&v, 2, 1, &buf, &n, &ok) == 0); + CHECK(ok == 0); + + CHECK(bstack_bytevec_dealloc(v) == 0); + env_close(&e); + return 0; +} + +#endif /* BSTACK_FEATURE_ATOMIC */ + int main(void) { T(test_bytevec_push_get_pop); + T(test_bytevec_set); + T(test_bytevec_fill); +#ifdef BSTACK_FEATURE_ATOMIC + T(test_bytevec_extend_from_within); + T(test_bytevec_insert); + T(test_bytevec_remove); + T(test_bytevec_swap_remove); + T(test_bytevec_extend_from_bstack_slice); + T(test_bytevec_copy_into_bstack_slice); + T(test_bytevec_append_from_owned); + T(test_bytevec_move_tail_into); + T(test_bytevec_split_off); + T(test_bytevec_drain); +#endif printf("\n%d/%d passed\n", g_passed, g_total); return (g_passed == g_total) ? 0 : 1; } diff --git a/c/test_first_fit.c b/c/test_first_fit.c index d648d8c3..d6317340 100644 --- a/c/test_first_fit.c +++ b/c/test_first_fit.c @@ -1106,8 +1106,387 @@ static int test_realloc_copy_move_cascade_reclaims_arena(void) ff_unlink(tmp); return 0; } +/* ========================================================================= + * bstack_slice_t: cas_on / cas_on_ne / cas_on_masked / process + * (BSTACK_FEATURE_SET + BSTACK_FEATURE_ATOMIC). Allocator-agnostic; a + * first_fit allocator is just the simplest source of two live slices. + * ====================================================================== */ + +static int test_slice_cas_on_match_swaps_and_returns_old(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {1, 2}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t expected[2] = {1, 2}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 0; + CHECK(bstack_slice_cas_on(target, guard, expected, 2, new_bytes, 2, + old_buf, &ok) == 0); + CHECK(ok == 1); + CHECK(memcmp(old_buf, target_buf, 2) == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, new_bytes, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_no_match_leaves_target_untouched(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {1, 2}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t expected[2] = {0, 0}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 1; + CHECK(bstack_slice_cas_on(target, guard, expected, 2, new_bytes, 2, + old_buf, &ok) == 0); + CHECK(ok == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, target_buf, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +/* The guard may be the target itself — the plain single-region CAS. */ +static int test_slice_cas_on_self_guard_swaps(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t target_buf[2] = {1, 2}; + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t expected[2] = {1, 2}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 0; + CHECK(bstack_slice_cas_on(target, target, expected, 2, new_bytes, 2, + old_buf, &ok) == 0); + CHECK(ok == 1); + CHECK(memcmp(old_buf, target_buf, 2) == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, new_bytes, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_expected_length_mismatch_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + + uint8_t expected[1] = {0}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + errno = 0; + CHECK(bstack_slice_cas_on(target, guard, expected, 1, new_bytes, 2, + old_buf, NULL) == -1); + CHECK(errno == EINVAL); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_new_bytes_length_mismatch_errors(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {1, 2}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + + uint8_t expected[2] = {1, 2}; + uint8_t new_bytes[1] = {3}; + uint8_t old_buf[2]; + errno = 0; + CHECK(bstack_slice_cas_on(target, guard, expected, 2, new_bytes, 1, + old_buf, NULL) == -1); + CHECK(errno == EINVAL); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_cross_stack_errors(void) +{ + char t1[64], t2[64]; + make_tmp(t1, sizeof t1); + make_tmp(t2, sizeof t2); + bstack_t *b1 = bstack_open(t1); CHECK(b1); + bstack_t *b2 = bstack_open(t2); CHECK(b2); + first_fit_bstack_allocator_t *a1 = first_fit_bstack_allocator_new(b1); CHECK(a1); + first_fit_bstack_allocator_t *a2 = first_fit_bstack_allocator_new(b2); CHECK(a2); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a1, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a2, 2, &target) == 0); + + uint8_t expected[2] = {0, 0}; + uint8_t new_bytes[2] = {1, 1}; + uint8_t old_buf[2]; + errno = 0; + CHECK(bstack_slice_cas_on(target, guard, expected, 2, new_bytes, 2, + old_buf, NULL) == -1); + CHECK(errno == EINVAL); + + bstack_close(first_fit_bstack_allocator_into_stack(a1)); + bstack_close(first_fit_bstack_allocator_into_stack(a2)); + ff_unlink(t1); ff_unlink(t2); return 0; +} + +static int test_slice_cas_on_ne_no_match_swaps_and_returns_old(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {1, 2}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t expected[2] = {0, 0}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 0; + CHECK(bstack_slice_cas_on_ne(target, guard, expected, 2, new_bytes, 2, + old_buf, &ok) == 0); + CHECK(ok == 1); + CHECK(memcmp(old_buf, target_buf, 2) == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, new_bytes, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_ne_match_returns_none(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {1, 2}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t expected[2] = {1, 2}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 1; + CHECK(bstack_slice_cas_on_ne(target, guard, expected, 2, new_bytes, 2, + old_buf, &ok) == 0); + CHECK(ok == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, target_buf, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_masked_match_swaps_and_returns_old(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[2] = {0xff, 0x0f}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 2) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + /* mask = [0xff, 0xf0]: masked guard = [0xff, 0x00], + * masked expected = [0xff, 0x00] -> match */ + uint8_t mask[2] = {0xff, 0xf0}; + uint8_t expected[2] = {0xff, 0x0f}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 0; + CHECK(bstack_slice_cas_on_masked(target, guard, mask, expected, 2, + new_bytes, 2, old_buf, &ok) == 0); + CHECK(ok == 1); + CHECK(memcmp(old_buf, target_buf, 2) == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, new_bytes, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int test_slice_cas_on_masked_no_match_returns_none(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t guard, target; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 1, &guard) == 0); + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 2, &target) == 0); + uint8_t guard_buf[1] = {0x0f}; + uint8_t target_buf[2] = {9, 9}; + CHECK(bstack_slice_write(guard, guard_buf, 1) == 0); + CHECK(bstack_slice_write(target, target_buf, 2) == 0); + + uint8_t mask[1] = {0xff}; + uint8_t expected[1] = {0xff}; + uint8_t new_bytes[2] = {3, 4}; + uint8_t old_buf[2]; + int ok = 1; + CHECK(bstack_slice_cas_on_masked(target, guard, mask, expected, 1, + new_bytes, 2, old_buf, &ok) == 0); + CHECK(ok == 0); + uint8_t rbuf[2]; + CHECK(bstack_slice_read(target, rbuf) == 0); + CHECK(memcmp(rbuf, target_buf, 2) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + +static int double_bytes_cb(uint8_t *buf, size_t len, void *ctx) +{ + size_t i; + (void)ctx; + for (i = 0; i < len; i++) + buf[i] = (uint8_t)(buf[i] * 2); + return 0; +} + +static int test_slice_process_transforms_in_place(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + first_fit_bstack_allocator_t *a = first_fit_bstack_allocator_new(bs); + CHECK(a); + + bstack_slice_t s; + CHECK(bstack_allocator_alloc((bstack_allocator_t *)a, 4, &s) == 0); + uint8_t wbuf[4] = {1, 2, 3, 4}; + CHECK(bstack_slice_write(s, wbuf, 4) == 0); + CHECK(bstack_slice_process(s, double_bytes_cb, NULL) == 0); + uint8_t rbuf[4]; + CHECK(bstack_slice_read(s, rbuf) == 0); + uint8_t expect[4] = {2, 4, 6, 8}; + CHECK(memcmp(rbuf, expect, 4) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a)); + ff_unlink(tmp); return 0; +} + #endif /* BSTACK_FEATURE_ATOMIC */ +/* A slice issued by one allocator instance must be refused by another: the + * language cannot catch it, so the allocator does, at run time, before + * touching any metadata. See "Foreign slices" in bstack_alloc.h. */ +static int test_foreign_slice_is_rejected(void) +{ + char t1[64], t2[64]; + make_tmp(t1, sizeof t1); + make_tmp(t2, sizeof t2); + { + bstack_t *b1 = bstack_open(t1); CHECK(b1); + bstack_t *b2 = bstack_open(t2); CHECK(b2); + first_fit_bstack_allocator_t *a1 = first_fit_bstack_allocator_new(b1); CHECK(a1); + first_fit_bstack_allocator_t *a2 = first_fit_bstack_allocator_new(b2); CHECK(a2); + bstack_allocator_t *g1 = (bstack_allocator_t *)a1; + bstack_allocator_t *g2 = (bstack_allocator_t *)a2; + bstack_slice_t s, out, own; + + CHECK(bstack_allocator_alloc(g1, 64, &s) == 0); + CHECK(bstack_slice_is_from(s, g1)); + CHECK(!bstack_slice_is_from(s, g2)); + + errno = 0; + CHECK(bstack_allocator_dealloc(g2, s) == -1); + CHECK(errno == EINVAL); + + errno = 0; + CHECK(bstack_allocator_realloc(g2, s, 128, &out) == -1); + CHECK(errno == EINVAL); + + /* One foreign slice rejects the whole batch — nothing is freed. */ + { + bstack_slice_t batch[2]; + CHECK(bstack_allocator_alloc(g2, 32, &batch[0]) == 0); + batch[1] = s; + errno = 0; + CHECK(bstack_allocator_dealloc_bulk(g2, batch, 2) == -1); + CHECK(errno == EINVAL); + CHECK(bstack_allocator_dealloc(g2, batch[0]) == 0); + } + + /* Neither allocator's bookkeeping was touched: a2 still round-trips + * its own allocation, and a1 can still free the original region. */ + CHECK(bstack_allocator_alloc(g2, 64, &own) == 0); + CHECK(bstack_allocator_dealloc(g2, own) == 0); + CHECK(bstack_allocator_dealloc(g1, s) == 0); + + bstack_close(first_fit_bstack_allocator_into_stack(a1)); + bstack_close(first_fit_bstack_allocator_into_stack(a2)); + } + ff_unlink(t1); ff_unlink(t2); return 0; +} + /* ========================================================================= * main * ====================================================================== */ @@ -1122,6 +1501,7 @@ int main(void) T(test_persist_reopen); T(test_realloc_small); T(test_slice_read_range); + T(test_foreign_slice_is_rejected); /* Fuzz */ T(test_fuzz_alloc_dealloc); @@ -1138,6 +1518,19 @@ int main(void) T(test_dealloc_non_tail_cascade_reclaims_arena); T(test_realloc_copy_move_cascade_reclaims_arena); T(test_recovery_needed_already_set_rejects_mutation); + + /* bstack_slice_t cross-region CAS and in-place transform */ + T(test_slice_cas_on_match_swaps_and_returns_old); + T(test_slice_cas_on_no_match_leaves_target_untouched); + T(test_slice_cas_on_self_guard_swaps); + T(test_slice_cas_on_expected_length_mismatch_errors); + T(test_slice_cas_on_new_bytes_length_mismatch_errors); + T(test_slice_cas_on_cross_stack_errors); + T(test_slice_cas_on_ne_no_match_swaps_and_returns_old); + T(test_slice_cas_on_ne_match_returns_none); + T(test_slice_cas_on_masked_match_swaps_and_returns_old); + T(test_slice_cas_on_masked_no_match_returns_none); + T(test_slice_process_transforms_in_place); #endif printf("\n%d/%d passed\n", g_passed, g_total); diff --git a/c/test_ghost_tree.c b/c/test_ghost_tree.c index 09508166..9d90ce34 100644 --- a/c/test_ghost_tree.c +++ b/c/test_ghost_tree.c @@ -894,6 +894,83 @@ static int test_realloc_misaligned_error(void) return 0; } +/* Regression: a length too large to round up to a 32-byte multiple must be + * rejected, not wrapped. Unchecked, `len + 31` wrapped a near-UINT64_MAX + * request down to a 32-byte block and alloc handed back a slice claiming the + * original length. ALGT_MAX_ALLOC is internal, so it is recomputed here from + * the arena start (48) exactly as bstack_alloc.c derives it. */ +#define GT_MAX_ALLOC ((UINT64_MAX - UINT64_C(48)) & ~UINT64_C(31)) + +static int test_alloc_rejects_unalignable_length(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + ghost_tree_bstack_allocator_t *a = ghost_tree_bstack_allocator_new(bs); + CHECK(a); + bstack_allocator_t *al = (bstack_allocator_t *)a; + + { + uint64_t bad[3]; + int i; + bad[0] = GT_MAX_ALLOC + 1; + bad[1] = UINT64_MAX - 5; + bad[2] = UINT64_MAX; + for (i = 0; i < 3; i++) { + bstack_slice_t s; + errno = 0; + CHECK(bstack_allocator_alloc(al, bad[i], &s) == -1); + CHECK(errno == EINVAL); + } + } + + /* The allocator is untouched and still works. */ + { + bstack_slice_t s; + CHECK(bstack_allocator_alloc(al, 64, &s) == 0); + CHECK(s.len == 64); + CHECK(bstack_allocator_dealloc(al, s) == 0); + } + + bstack_close(ghost_tree_bstack_allocator_into_stack(a)); + gt_unlink(tmp); + return 0; +} + +/* Same rejection on realloc, which reached the wrap through the tail-shrink + * path with an underflowed padding length. The block is untouched, so the + * caller's handle stays live. */ +static int test_realloc_rejects_unalignable_length(void) +{ + char tmp[64]; make_tmp(tmp, sizeof tmp); + bstack_t *bs = bstack_open(tmp); CHECK(bs); + ghost_tree_bstack_allocator_t *a = ghost_tree_bstack_allocator_new(bs); + CHECK(a); + bstack_allocator_t *al = (bstack_allocator_t *)a; + + bstack_slice_t s; + CHECK(bstack_allocator_alloc(al, 64, &s) == 0); + { + bstack_slice_t out; + errno = 0; + CHECK(bstack_allocator_realloc(al, s, GT_MAX_ALLOC + 1, &out) == -1); + CHECK(errno == EINVAL); + } + /* Still a live block: writable, readable, and freeable. */ + { + unsigned char buf[64]; + memset(buf, 0xA5, sizeof buf); + CHECK(bstack_slice_write(s, buf, sizeof buf) == 0); + memset(buf, 0, sizeof buf); + CHECK(bstack_slice_read(s, buf) == 0); + CHECK(buf[0] == 0xA5 && buf[63] == 0xA5); + } + CHECK(bstack_allocator_dealloc(al, s) == 0); + + bstack_close(ghost_tree_bstack_allocator_into_stack(a)); + gt_unlink(tmp); + return 0; +} + /* ── alloc_bulk / dealloc_bulk ─────────────────────────────────────────── */ static int test_alloc_bulk_contiguous(void) @@ -1651,6 +1728,8 @@ int main(void) T(test_realloc_grow_nontail); T(test_dealloc_misaligned_error); T(test_realloc_misaligned_error); + T(test_alloc_rejects_unalignable_length); + T(test_realloc_rejects_unalignable_length); T(test_alloc_bulk_contiguous); T(test_alloc_bulk_with_zeros); T(test_dealloc_bulk_merges); diff --git a/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index 837d1f45..c10c56c7 100644 --- a/src/alloc/checked_slab.rs +++ b/src/alloc/checked_slab.rs @@ -7,7 +7,7 @@ //! and lets `dealloc` detect double-free at runtime before the free list can be //! corrupted. -use super::{BStackAllocator, BStackSlice}; +use super::{BStackAllocator, BStackSlice, ensure_own_slice}; use crate::BStack; #[cfg(feature = "atomic")] use crate::BStackGenOp; @@ -20,7 +20,7 @@ use std::sync::Mutex; use std::{collections::HashSet, fmt, io}; #[cfg(feature = "set")] -const ALCK_MAGIC: [u8; 8] = *b"ALCK\x00\x01\x01\x00"; +const ALCK_MAGIC: [u8; 8] = *b"ALCK\x00\x01\x02\x00"; /// Compatibility prefix checked on open: `ALCK` + major 0 + minor 1. /// Any file whose first 6 bytes match is considered compatible. @@ -179,6 +179,10 @@ pub struct CheckedSlabBStackAllocator { _not_sync: PhantomData>, } +/// `Sync` is removed deliberately by `_not_sync`; `RefUnwindSafe` is collateral. +#[cfg(not(feature = "atomic"))] +impl std::panic::RefUnwindSafe for CheckedSlabBStackAllocator {} + /// How a single block looks to the recovery scan. /// /// Only used by the non-`atomic` recovery path; the `atomic` path inlines the @@ -395,6 +399,8 @@ impl CheckedSlabBStackAllocator { } /// Return the usable bytes per slab block (the `data_size` passed to [`new`](Self::new)). + #[inline] + #[must_use] pub fn data_size(&self) -> u64 { self.block_size - Self::OVERHEAD } @@ -1233,10 +1239,12 @@ impl BStackAllocator for CheckedSlabBStackAllocator { type Error = io::Error; type Allocated<'a> = BStackSlice<'a, Self>; + #[inline] fn stack(&self) -> &BStack { &self.stack } + #[inline] fn into_stack(self) -> BStack { self.stack } @@ -1335,6 +1343,7 @@ impl BStackAllocator for CheckedSlabBStackAllocator { /// | tail (any block count) | 1 (`discard`) | crash-safe by inheritance | /// | free list | 2 (`set` + `set`) | crash leaks freed blocks; double-free guard unaffected | fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "CheckedSlabBStackAllocator::dealloc")?; if slice.is_empty() && slice.start() == 0 { return Ok(()); } @@ -1439,6 +1448,7 @@ impl BStackAllocator for CheckedSlabBStackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "CheckedSlabBStackAllocator::realloc")?; if slice.is_empty() && slice.start() == 0 { return self.alloc(new_len); } @@ -1556,32 +1566,21 @@ impl BStackAllocator for CheckedSlabBStackAllocator { } // Shrink path (new_n < old_n). - // Overhead is the commit point for both tail and non-tail paths: write - // it first so a crash after this point leaves an orphaned (but safely - // unreferenced) tail region or leaked blocks that recover() can reclaim, - // rather than an overhead that claims more blocks than the file contains. - self.write_overhead(block_start, Self::IN_USE_BIT | new_n)?; - - // Tail shrink: try_discard atomically checks tail == sentinel and - // removes the excess under bstack's write lock, so no other thread can - // race between the check and the truncation. On failure the slice is - // not at the tail; fall through to recycle the excess blocks. - #[cfg(feature = "atomic")] - if self - .stack - .try_discard(sentinel, old_backing - new_backing)? - { - // SAFETY: - // 1. No overflow: slice.start() + new_len ≤ block_start + OVERHEAD + new_n * block_size − OVERHEAD ≤ u64::MAX - // because new_n * block_size ≤ new_backing ≤ stack_len. - // 2. In bounds: tail discarded down to new_backing. - // 3. Alloc origin: slice.start() is unchanged; overhead records new_n. - return Ok(unsafe { BStackSlice::from_raw_parts(self, slice.start(), new_len) }); - } + let excess_backing = old_backing - new_backing; + let excess_count = old_n - new_n; + let excess_start = block_start.checked_add(new_backing).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "excess start overflows u64") + })?; + // Non-atomic tail shrink fast path: commit the smaller count, then + // discard the tail. Commit-first is safe here — a crash before the + // discard leaves an orphaned tail past `new_n` that `recover` reclaims, + // and nothing live follows the arena tail — and it avoids scrubbing + // bytes that are about to be truncated away. #[cfg(not(feature = "atomic"))] if sentinel == self.stack.len()? { - self.stack.discard(old_backing - new_backing)?; + self.write_overhead(block_start, Self::IN_USE_BIT | new_n)?; + self.stack.discard(excess_backing)?; // SAFETY: // 1. No overflow: slice.start() + new_len ≤ block_start + OVERHEAD + new_n * block_size − OVERHEAD ≤ u64::MAX // because new_n * block_size ≤ new_backing ≤ stack_len. @@ -1590,32 +1589,66 @@ impl BStackAllocator for CheckedSlabBStackAllocator { return Ok(unsafe { BStackSlice::from_raw_parts(self, slice.start(), new_len) }); } - // Shrink non-tail: recycle the excess blocks into the free list. + // General shrink (non-tail, and every atomic shrink). // - // Ordering matters, and the commit must come first. Shrinking the - // first block's count is the commit point: before it the old view - // (old_n blocks, original payload) is fully intact; after it the new - // view (new_n blocks) is in force. Only once committed do we write - // free-list metadata into the excess blocks (which clobbers their old - // payload) and repoint free_head. A crash before the commit leaves - // the original allocation untouched; a crash after it leaks the - // excess blocks but never corrupts a live allocation. Writing the - // free run first would shred the tail payload while the header still - // claims old_n, leaving a recovered allocation that is neither - // cleanly old nor cleanly new. + // Crash-order matters: the excess blocks must never be left holding the + // allocation's stale payload once the header records the smaller count. + // Such bytes can mimic in-use overhead words and desynchronise + // `recover`'s linear scan — which would then stride over and reclaim an + // unrelated live allocation, corrupting it. So the excess is scrubbed to + // a clean, zero-overhead free run *before* the header is shrunk: // - // Overhead was already written above (the commit point). - let excess_start = block_start - .checked_add(new_n.checked_mul(self.block_size).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "free start multiplication overflows u64", - ) - })?) - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "free start overflows u64") - })?; - self.push_free_blocks(excess_start, old_n - new_n)?; + // 1. `write_free_run` clears every excess overhead + payload byte and + // links the run internally (`free_head` untouched). A fault here + // leaves the excess untouched and the original allocation whole. + // 2. `write_overhead` commits the smaller count — the shrunk view is + // now in force. A fault before the splice leaves the scrubbed excess + // as zero-overhead leaked blocks that `recover` reclaims one by one, + // staying aligned; it never strides into a live allocation. + // 3. Publish the run onto `free_head` (or discard it when it is the + // arena tail, on the atomic path). + // + // (The previous order — commit the count first, scrub second — left a + // crash window in which the header claimed `new_n` while the excess still + // held the caller's stale payload, which is exactly what desynced + // `recover`.) + self.write_free_run(excess_start, excess_count)?; + self.write_overhead(block_start, Self::IN_USE_BIT | new_n)?; + + #[cfg(feature = "atomic")] + { + // Discard when the scrubbed excess is the tail; otherwise splice the + // pre-built run onto free_head with one cross_exchange. + if !self.stack.try_discard(sentinel, excess_backing)? { + let last_block = excess_start + .checked_add((excess_count - 1).checked_mul(self.block_size).ok_or_else( + || { + io::Error::new( + io::ErrorKind::InvalidInput, + "last free-list offset overflows u64", + ) + }, + )?) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "last block offset overflows u64", + ) + })?; + self.stack.cross_exchange( + last_block + Self::OVERHEAD, + Self::FREE_HEAD_OFFSET, + 8, + )?; + } + } + #[cfg(not(feature = "atomic"))] + { + // Non-tail (tail handled above): the run already points at the old + // head, so publishing its first block as the new head splices it. + self.stack + .set(Self::FREE_HEAD_OFFSET, excess_start.to_le_bytes())?; + } // SAFETY: // 1. No overflow: slice.start() + new_len ≤ block_start + OVERHEAD + new_n * block_size − OVERHEAD ≤ u64::MAX // because new_n * block_size ≤ old_backing ≤ stack_len. @@ -2007,7 +2040,7 @@ mod tests { let _g = Guard(path); let alloc = CheckedSlabBStackAllocator::new(stack, 8).unwrap(); // block_size 16 let s = alloc.alloc(40).unwrap(); // 3 blocks: block 48, stack -> 96 - s.write(&[0xFFu8; 40]).unwrap(); // fill so orphan blocks read as garbage + s.write([0xFFu8; 40]).unwrap(); // fill so orphan blocks read as garbage assert_eq!(alloc.stack().len().unwrap(), 96); // Simulate a realloc tail-shrink crash: commit new_n=1, skip the discard. alloc.stack().set(48, in_use(1).to_le_bytes()).unwrap(); @@ -2040,7 +2073,7 @@ mod tests { { let alloc = CheckedSlabBStackAllocator::new(stack, 8).unwrap(); let s = alloc.alloc(40).unwrap(); // 3 blocks, stack -> 96 - s.write(&[0xFFu8; 40]).unwrap(); + s.write([0xFFu8; 40]).unwrap(); alloc.stack().set(48, in_use(1).to_le_bytes()).unwrap(); // crash sim } let reopened = CheckedSlabBStackAllocator::open(BStack::open(&path).unwrap()).unwrap(); @@ -2100,7 +2133,7 @@ mod tests { let mut set = live.lock().unwrap(); assert!(set.insert(off), "duplicate live offset {off}"); } - slice.write(&[tid as u8; 8]).unwrap(); + slice.write([tid as u8; 8]).unwrap(); let data = slice.read().unwrap(); assert_eq!(data, vec![tid as u8; 8]); { @@ -2151,7 +2184,7 @@ mod tests { thread::spawn(move || { let a: &CheckedSlabBStackAllocator = &alloc; let mut slice = a.alloc(SMALL).unwrap(); - slice.write(&[tid as u8; SMALL as usize]).unwrap(); + slice.write([tid as u8; SMALL as usize]).unwrap(); for _ in 0..ROUNDS { // Grow: tail → try_extend_zeros; non-tail → copy to new region. diff --git a/src/alloc/debug_checking.rs b/src/alloc/debug_checking.rs index 1e143aba..d47366d5 100644 --- a/src/alloc/debug_checking.rs +++ b/src/alloc/debug_checking.rs @@ -68,6 +68,28 @@ use std::io; use std::ops::Range; use std::sync::Mutex; +/// Reject a handle that was not issued by `alloc`, before any tracking state or +/// inner-allocator call is touched. The wrapper's analogue of +/// [`super::ensure_own_slice`]; `op` names the calling method. +#[inline] +fn ensure_own_handle( + alloc: &DebugCheckingAllocator, + handle: &DebugHandle<'_, A>, + op: &'static str, +) -> io::Result<()> +where + A: BStackAllocator, +{ + if handle.is_from(alloc) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{op}: handle was issued by a different allocator instance"), + )) + } +} + /// Returns `true` if two half-open byte ranges overlap. fn overlaps(a: &Range, b: &Range) -> bool { !a.is_empty() && !b.is_empty() && a.start.max(b.start) < a.end.min(b.end) @@ -192,6 +214,7 @@ fn record_freed_region(state: &mut DebugState, region: Range) { /// - Any two ranges within `allocated` overlap /// - Any two ranges within `freed` overlap /// - Any range in `allocated` overlaps with any range in `freed` +#[track_caller] fn validate_initial_state(allocated: &mut HashSet>, freed: &mut HashSet>) { // Filter out empty ranges allocated.retain(|r| !r.is_empty()); @@ -287,9 +310,20 @@ where Self { alloc, inner } } + /// Returns `true` if this handle was issued by `alloc`. + /// + /// The wrapper's analogue of [`BStackSlice::is_from`]: a handle records the + /// wrapper that produced it, but nothing stops it being handed to a second + /// wrapper of the same type. + #[inline] + fn is_from(&self, alloc: &DebugCheckingAllocator) -> bool { + std::ptr::eq(self.alloc, alloc) + } + /// Return the inner allocator's handle. /// /// To inspect the region (offset, length), convert it with `.try_into::>()`. + #[must_use] pub fn inner(&self) -> &A::Allocated<'a> { &self.inner } @@ -392,6 +426,7 @@ where /// The allocator starts with empty tracking sets. If you're reopening /// a file from a previous session and want to pre-populate those sets, /// use [`Self::with_state`] instead. + #[must_use] pub fn new(inner: A) -> Self { Self { inner, @@ -413,6 +448,8 @@ where /// - Any two ranges within `allocated` overlap /// - Any two ranges within `freed` overlap /// - Any range in `allocated` overlaps with any range in `freed` + #[must_use] + #[track_caller] pub fn with_state( inner: A, allocated: impl IntoIterator>, @@ -431,11 +468,13 @@ where } /// Return a reference to the inner allocator. + #[must_use] pub fn inner(&self) -> &A { &self.inner } /// Consume this allocator and return the inner allocator. + #[must_use] pub fn into_inner(self) -> A { self.inner } @@ -527,6 +566,7 @@ where handle: Self::Allocated<'a>, new_len: u64, ) -> io::Result> { + ensure_own_handle(self, &handle, "DebugCheckingAllocator::realloc")?; // Extract old region info before handing the inner handle to the inner realloc let old_slice: BStackSlice<'_, A> = handle.inner.try_into().map_err(|e| { io::Error::other(format!( @@ -576,6 +616,7 @@ where } fn dealloc(&self, handle: Self::Allocated<'_>) -> io::Result<()> { + ensure_own_handle(self, &handle, "DebugCheckingAllocator::dealloc")?; let slice: BStackSlice<'_, A> = match handle.inner.try_into() { Ok(slice) => slice, Err(e) => { @@ -646,6 +687,9 @@ where fn dealloc_bulk<'a>(&'a self, handles: impl AsRef<[Self::Allocated<'a>]>) -> io::Result<()> { let handles = handles.as_ref(); + if let Some(foreign) = handles.iter().find(|h| !h.is_from(self)) { + ensure_own_handle(self, foreign, "DebugCheckingAllocator::dealloc_bulk")?; + } // Pass 1: convert and validate all handles without mutating tracking state. // `pending_freed` accumulates regions already cleared in this batch so @@ -753,6 +797,7 @@ mod tests { } #[test] + #[allow(clippy::single_range_in_vec_init)] fn test_untracked_disjoint_alloc_is_allowed() { let c = DebugCheckingAllocator::with_state(MockAllocator, [0..100], [200..300]); c.record_allocation(120, 50); @@ -1129,6 +1174,7 @@ mod tests { let _guard = TestGuard(path); let config = Rc::new(RefCell::new(MockAllocatorConfig::default())); let inner = ControllableMockAllocator::new(stack, config.clone()); + #[allow(clippy::single_range_in_vec_init)] let alloc = DebugCheckingAllocator::with_state(inner, [], [150..300]); let handle = alloc.alloc(100)?; @@ -1187,7 +1233,7 @@ mod tests { let alloc = DebugCheckingAllocator::new(inner); let handle = alloc.alloc(100).unwrap(); - let stale_handle = handle.clone(); + let stale_handle = handle; let _new_handle = alloc.realloc(handle, 60).unwrap(); alloc.dealloc(stale_handle).unwrap(); @@ -1343,7 +1389,7 @@ mod tests { let inner = ControllableMockAllocator::new(stack, config); let alloc = DebugCheckingAllocator::new(inner); - let handles = alloc.alloc_bulk(&[100, 200, 300])?; + let handles = alloc.alloc_bulk([100, 200, 300])?; assert_eq!(handles.len(), 3); // Verify all were tracked @@ -1369,7 +1415,7 @@ mod tests { let inner = ControllableMockAllocator::new(stack, config); let alloc = DebugCheckingAllocator::new(inner); - let handles = alloc.alloc_bulk(&[100, 200, 300])?; + let handles = alloc.alloc_bulk([100, 200, 300])?; alloc.dealloc_bulk(handles)?; @@ -1394,7 +1440,7 @@ mod tests { let inner = ControllableMockAllocator::new(stack, config.clone()); let alloc = DebugCheckingAllocator::new(inner); - let handles = alloc.alloc_bulk(&[100, 200, 300])?; + let handles = alloc.alloc_bulk([100, 200, 300])?; // Make dealloc_bulk fail config.borrow_mut().fail_dealloc_bulk = true; @@ -1429,7 +1475,7 @@ mod tests { let inner = ControllableMockAllocator::new(stack, config); let alloc = DebugCheckingAllocator::new(inner); - let handles = alloc.alloc_bulk(&[100, 200]).unwrap(); + let handles = alloc.alloc_bulk([100, 200]).unwrap(); alloc.dealloc_bulk(&handles).unwrap(); // Second dealloc_bulk with same regions should panic during validation alloc.record_deallocation(0, 100); @@ -1438,6 +1484,7 @@ mod tests { // --- Tests for with_state() validation --- #[test] + #[allow(clippy::single_range_in_vec_init)] fn test_with_state_valid_disjoint_ranges() { let c = DebugCheckingAllocator::with_state(MockAllocator, [0..100, 200..300], [400..500]); let state = c.state.lock().unwrap(); @@ -1462,7 +1509,7 @@ mod tests { #[test] #[should_panic(expected = "Initial allocated set contains overlapping ranges")] fn test_with_state_panics_on_overlapping_allocated() { - DebugCheckingAllocator::with_state( + let _ = DebugCheckingAllocator::with_state( MockAllocator, [0..100, 50..150], // overlapping [], @@ -1472,7 +1519,7 @@ mod tests { #[test] #[should_panic(expected = "Initial freed set contains overlapping ranges")] fn test_with_state_panics_on_overlapping_freed() { - DebugCheckingAllocator::with_state( + let _ = DebugCheckingAllocator::with_state( MockAllocator, [], [0..100, 50..150], // overlapping @@ -1482,7 +1529,7 @@ mod tests { #[test] #[should_panic(expected = "allocated range")] fn test_with_state_panics_on_allocated_freed_overlap() { - DebugCheckingAllocator::with_state( + let _ = DebugCheckingAllocator::with_state( MockAllocator, [0..100, 200..300], [50..150, 400..500], // [50, 150) overlaps [0, 100) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index e5d39130..1d8b5dd9 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -1,4 +1,4 @@ -use super::{BStackAllocator, BStackSlice}; +use super::{BStackAllocator, BStackSlice, ensure_own_slice}; use crate::BStack; #[cfg(not(feature = "atomic"))] use std::cell::Cell; @@ -163,6 +163,10 @@ pub struct FirstFitBStackAllocator { _not_sync: PhantomData>, } +/// `Sync` is removed deliberately by `_not_sync`; `RefUnwindSafe` is collateral. +#[cfg(not(feature = "atomic"))] +impl std::panic::RefUnwindSafe for FirstFitBStackAllocator {} + #[cfg(feature = "set")] impl fmt::Debug for FirstFitBStackAllocator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -738,8 +742,21 @@ impl FirstFitBStackAllocator { || size % 8 != 0 || size.checked_add(Self::BLOCK_OVERHEAD_SIZE).is_none() { - // Invalid size: this is mid-arena corruption, not a partial tail write. - // Refuse to silently discard all data that follows. + // The header does not describe a valid block. Two cases: + // * All-zero trailing region → an interrupted tail-grow + // `realloc`, which `extend`s (zero-filling) the payload + // before rewriting the header/footer to cover it. The + // valid block ends at `pos` and the zeros beyond it have + // no header (`size` reads 0). A real block is never + // all-zero (size ≥ MIN_BLOCK_PAYLOAD_SIZE), so roll the + // extension back by truncating to `pos` — restoring the + // pre-grow tail the failed `realloc` handed back. + // * Anything else → genuine mid-arena corruption; fail + // loudly rather than discard the data that follows. + if self.stack.get(pos, stack_len)?.iter().all(|&b| b == 0) { + self.stack.discard(remaining)?; + break; + } return Err(io::Error::new( io::ErrorKind::InvalidData, format!( @@ -796,6 +813,23 @@ impl FirstFitBStackAllocator { } } + // Normalize the footer to the (authoritative) header size. Every + // block-resizing operation commits its new size to the header before + // the matching footer — a coalescing free writes header then footer, + // a tail grow writes header then footer, a split's header is fixed by + // the partial-split check above — so on a crash between those two + // writes the header is correct and the footer is stale. The walk + // follows headers, so a stale footer slips through undetected here yet + // corrupts a later neighbour's coalesce (which reads this footer) and + // eventually desyncs the walk. Rewriting the footer to match makes the + // block whole. Healthy blocks already agree, so this is a no-op for them. + let footer_pos = pos + Self::BLOCK_HEADER_SIZE + size; + let mut footer_buf = [0u8; 8]; + self.stack.get_into(footer_pos, &mut footer_buf)?; + if u64::from_le_bytes(footer_buf) != size { + self.stack.set(footer_pos, size.to_le_bytes().as_slice())?; + } + if is_free { free_blocks.push(pos + Self::BLOCK_HEADER_SIZE); } @@ -832,10 +866,12 @@ impl BStackAllocator for FirstFitBStackAllocator { type Error = io::Error; type Allocated<'a> = BStackSlice<'a, Self>; + #[inline] fn stack(&self) -> &BStack { &self.stack } + #[inline] fn into_stack(self) -> BStack { self.stack } @@ -897,6 +933,7 @@ impl BStackAllocator for FirstFitBStackAllocator { } fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "FirstFitBStackAllocator::dealloc")?; if slice.is_empty() && slice.start() == 0 { return Ok(()); } @@ -956,6 +993,7 @@ impl BStackAllocator for FirstFitBStackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "FirstFitBStackAllocator::realloc")?; if slice.is_empty() && slice.start() == 0 { return self.alloc(new_len); } @@ -1038,21 +1076,17 @@ impl BStackAllocator for FirstFitBStackAllocator { }); } std::cmp::Ordering::Less => { - // No recovery_needed: the header/footer writes and the discard touch - // only this tail block, not the free list. The lock held above excludes - // concurrent tail modification; within that, the discard is a single - // atomic BStack call. - // Write new footer before discarding so it lands at the right position - self.stack.set( - slice.start() + aligned_new_len, - aligned_new_len.to_le_bytes(), - )?; - self.stack.set( - slice.start() - Self::BLOCK_HEADER_SIZE, - aligned_new_len.to_le_bytes(), - )?; - self.stack.discard(aligned_current_len - aligned_new_len)?; - // SAFETY: slice shrunk in place at tail + // Keep the block; don't reclaim the tail in place. A + // physical shrink needs a header write plus a discard + // (metadata + size change) that cannot be one + // crash-atomic call, and the block-walking recovery + // cannot parse the torn intermediate (a fault between the + // header rewrite and the discard leaves header, footer, + // and physical size disagreeing). Narrowing only the + // user-visible length (an oversized block, exactly as a + // non-tail shrink already does) needs no writes; the tail + // is reclaimed when the block is freed. + // SAFETY: same block, new_len ≤ old len ≤ block size. return Ok(unsafe { BStackSlice::from_raw_parts(self, slice.start(), new_len) }); diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index 76538688..9d7cb02e 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -1,4 +1,6 @@ -use super::{BStackAllocator, BStackBulkAllocator, BStackSlice}; +use super::{ + BStackAllocator, BStackBulkAllocator, BStackSlice, ensure_own_slice, ensure_own_slices, +}; use crate::BStack; #[cfg(not(feature = "atomic"))] use std::cell::Cell; @@ -9,7 +11,7 @@ use std::marker::PhantomData; #[cfg(feature = "atomic")] use std::sync::Mutex; -const ALGT_MAGIC: [u8; 8] = *b"ALGT\x00\x01\x02\x00"; +const ALGT_MAGIC: [u8; 8] = *b"ALGT\x00\x01\x04\x00"; const ALGT_MAGIC_PREFIX: [u8; 6] = *b"ALGT\x00\x01"; /// Payload offset of the magic number. @@ -22,6 +24,15 @@ const ARENA_START: u64 = 48; /// Minimum allocation size — exactly the size of one AVL node. const MIN_ALLOC: u64 = 32; +/// Largest length [`GhostTreeBstackAllocator::align_up_len`] will round up. +/// +/// Anything larger is rejected as [`io::ErrorKind::InvalidInput`] rather than +/// wrapped. Unchecked, `len + 31` wraps a near-`u64::MAX` request down to a +/// 32-byte block, which `alloc` would hand back as a handle claiming the +/// original length, and which `realloc` would carry into the tail-shrink path +/// as an underflowed padding size. +const MAX_ALLOC: u64 = (u64::MAX - ARENA_START) & !31; + /// Null / absent pointer sentinel stored in AVL node child fields. const NULL_PTR: u64 = 0; @@ -35,6 +46,13 @@ const MAX_AVL_DEPTH: u32 = 128; const NODE_SIZE_OFF: u64 = 0; const NODE_BF_OFF: u64 = 8; // i8 balance factor const NODE_HEIGHT_OFF: u64 = 9; // u8 height (max ~59 for balanced; slightly more tolerated) +// Cached child heights, denormalized so a parent read during a down-pass yields +// the height of its untouched (sibling) child without a separate read of that +// child on the way back up. Maintained by every node write; rebuilt from +// scratch by `coalesce_and_rebalance` on open, so old-format arenas (whose +// reserved bytes were zero) self-upgrade transparently. +const NODE_LH_OFF: u64 = 10; // u8 cached height of the left child +const NODE_RH_OFF: u64 = 11; // u8 cached height of the right child const NODE_LEFT_OFF: u64 = 16; const NODE_RIGHT_OFF: u64 = 24; @@ -43,13 +61,21 @@ const NODE_RIGHT_OFF: u64 = 24; /// Used by [`avl_insert`](GhostTreeBstackAllocator::avl_insert) and /// [`avl_find_best_fit_and_remove`](GhostTreeBstackAllocator::avl_find_best_fit_and_remove) /// to record the path so that balance factors and heights can be updated on -/// the way back up without recursion. +/// the way back up without recursion. Recorded on a fixed-size stack array of +/// [`MAX_AVL_DEPTH`] entries, so it must be `Copy` and cheaply default-able. +#[derive(Clone, Copy, Default)] struct PathEntry { ptr: u64, size: u64, left: u64, right: u64, went_left: bool, + /// This node's cached child heights, read from its denormalized cache during + /// the down-pass. The sibling (untouched) child's height is stable across + /// the operation — nothing off the path is modified — so the up-pass writes + /// this node with both child heights known and reads neither. + lh: u8, + rh: u8, } /// A pure-AVL general-purpose allocator built on top of a [`BStack`]. @@ -82,7 +108,7 @@ struct PathEntry { /// ┌─────────────────────────────┐ payload offset 0 /// │ User-reserved (32 bytes) │ /// ├─────────────────────────────┤ offset 32 -/// │ Magic number (8 bytes) │ "ALGT\x00\x01\x02\x00" +/// │ Magic number (8 bytes) │ "ALGT\x00\x01\x04\x00" /// ├─────────────────────────────┤ offset 40 /// │ AVL root pointer (8 B) │ absolute payload offset of the root node /// ├─────────────────────────────┤ offset 48 ← arena start (32-byte aligned) @@ -165,6 +191,10 @@ pub struct GhostTreeBstackAllocator { _not_sync: PhantomData>, } +/// `Sync` is removed deliberately by `_not_sync`; `RefUnwindSafe` is collateral. +#[cfg(not(feature = "atomic"))] +impl std::panic::RefUnwindSafe for GhostTreeBstackAllocator {} + impl fmt::Debug for GhostTreeBstackAllocator { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("GhostTreeBstackAllocator") @@ -274,13 +304,32 @@ impl GhostTreeBstackAllocator { Ok((size, bf, height, left, right)) } - /// Write a complete AVL node at `ptr`. + /// Read the node at `ptr` for a down-pass, returning `(size, left, right, + /// lh, rh)` where `lh`/`rh` are the node's cached child heights. Same single + /// `get` as [`read_node`](Self::read_node); the cache lets the up-pass skip + /// re-reading the untouched sibling child. + fn read_node_hc(&self, ptr: u64) -> io::Result<(u64, u64, u64, u8, u8)> { + let buf = &mut [0u8; 32]; + self.stack.get_into(ptr, buf)?; + let size = read_buf_le!(buf, NODE_SIZE_OFF => u64); + let left = read_buf_le!(buf, NODE_LEFT_OFF => u64); + let right = read_buf_le!(buf, NODE_RIGHT_OFF => u64); + let lh = read_buf_le!(buf, NODE_LH_OFF => u8); + let rh = read_buf_le!(buf, NODE_RH_OFF => u8); + Ok((size, left, right, lh, rh)) + } + + /// Write a complete AVL node at `ptr`, including the denormalized child-height + /// cache (`lh`, `rh` = heights of `left`, `right`). + #[allow(clippy::too_many_arguments)] // one serialization site; grouping into a struct would not aid clarity fn write_node( &self, ptr: u64, size: u64, bf: i8, height: u8, + lh: u8, + rh: u8, left: u64, right: u64, ) -> io::Result<()> { @@ -288,6 +337,8 @@ impl GhostTreeBstackAllocator { write_buf!(size => buf, NODE_SIZE_OFF); write_buf!(bf => buf, NODE_BF_OFF); write_buf!(height => buf, NODE_HEIGHT_OFF); + write_buf!(lh => buf, NODE_LH_OFF); + write_buf!(rh => buf, NODE_RH_OFF); write_buf!(left => buf, NODE_LEFT_OFF); write_buf!(right => buf, NODE_RIGHT_OFF); self.stack.set(ptr, buf)?; @@ -301,9 +352,16 @@ impl GhostTreeBstackAllocator { } /// Round `len` up to the next multiple of 32, with a floor of [`MIN_ALLOC`]. + /// + /// Returns `None` if `len` exceeds [`MAX_ALLOC`], i.e. if the round-up + /// would overflow `u64`; every caller turns that into + /// [`io::ErrorKind::InvalidInput`]. #[inline] - fn align_up_len(len: u64) -> u64 { - ((len + 31) & !31).max(MIN_ALLOC) + fn align_up_len(len: u64) -> Option { + if len > MAX_ALLOC { + return None; + } + Some(((len + 31) & !31).max(MIN_ALLOC)) } /// Return the stored height of the subtree rooted at `ptr` (0 for [`NULL_PTR`]). @@ -318,30 +376,48 @@ impl GhostTreeBstackAllocator { Ok(height) } - /// Write `(size, left, right)` to `ptr`, computing bf and height from the - /// children's stored heights in one pass. Returns the balance factor. + /// Write `(size, left, right)` to `ptr`, computing bf and height in one pass, + /// and return `(bf, height)`. /// - /// Replaces the `write_node(…, 0, 0, …) + avl_update_bf` pair: instead of - /// writing stale zeros and reading back, we read the two child heights once, - /// compute both fields, and write the node exactly once. + /// A child's height passed as `Some` is used directly — the caller already + /// knows it, e.g. from the node written in the previous up-pass step or from + /// a sibling untouched by a rotation — which avoids a `get_into` (lock + + /// syscall) to re-read that child. `None` reads the height from the child. #[inline] - fn avl_write_and_update(&self, ptr: u64, size: u64, left: u64, right: u64) -> io::Result { - let lh = self.avl_height(left)? as i16; - let rh = self.avl_height(right)? as i16; + fn avl_write_h( + &self, + ptr: u64, + size: u64, + left: u64, + right: u64, + lh: Option, + rh: Option, + ) -> io::Result<(i8, u8)> { + let lh = match lh { + Some(h) => h as i16, + None => self.avl_height(left)? as i16, + }; + let rh = match rh { + Some(h) => h as i16, + None => self.avl_height(right)? as i16, + }; let bf = (rh - lh) as i8; let height = (1 + lh.max(rh)) as u8; - self.write_node(ptr, size, bf, height, left, right)?; - Ok(bf) + self.write_node(ptr, size, bf, height, lh as u8, rh as u8, left, right)?; + Ok((bf, height)) } - /// Recompute bf and height for `node` from its children's stored heights, - /// write both back, and return the balance factor. - /// - /// O(1) — delegates to [`avl_write_and_update`](Self::avl_write_and_update). + /// Write `(size, left, right)` to `ptr`, reading both child heights, and + /// return `(bf, height)`. Thin wrapper over [`avl_write_h`](Self::avl_write_h). #[inline] - fn avl_update_bf(&self, node: u64) -> io::Result { - let (size, _, _, left, right) = self.read_node(node)?; - self.avl_write_and_update(node, size, left, right) + fn avl_write_and_update( + &self, + ptr: u64, + size: u64, + left: u64, + right: u64, + ) -> io::Result<(i8, u8)> { + self.avl_write_h(ptr, size, left, right, None, None) } /// Right-rotate around `node`; return the new subtree root. @@ -353,12 +429,24 @@ impl GhostTreeBstackAllocator { /// / \ / \ /// L M M R /// ``` - fn avl_rotate_right(&self, node: u64) -> io::Result { - let (node_sz, _, _, pivot, node_r) = self.read_node(node)?; - let (pivot_sz, _, _, pivot_l, pivot_r) = self.read_node(pivot)?; - self.avl_write_and_update(node, node_sz, pivot_r, node_r)?; - self.avl_write_and_update(pivot, pivot_sz, pivot_l, node)?; - Ok(pivot) + fn avl_rotate_right(&self, node: u64) -> io::Result<(u64, u8)> { + // Read both nodes' cached child heights so neither rewrite re-reads a + // child. (`node`'s left child is `pivot`, whose height is not needed.) + let (node_sz, pivot, node_r, _node_lh, node_rh) = self.read_node_hc(node)?; + let (pivot_sz, pivot_l, pivot_r, pivot_lh, pivot_rh) = self.read_node_hc(pivot)?; + // `node`'s new children (pivot_r, node_r) → heights (pivot_rh, node_rh). + let (_, node_h) = self.avl_write_h( + node, + node_sz, + pivot_r, + node_r, + Some(pivot_rh), + Some(node_rh), + )?; + // `pivot`'s new children (pivot_l, node) → heights (pivot_lh, node_h). + let (_, pivot_h) = + self.avl_write_h(pivot, pivot_sz, pivot_l, node, Some(pivot_lh), Some(node_h))?; + Ok((pivot, pivot_h)) } /// Left-rotate around `node`; return the new subtree root. @@ -370,28 +458,45 @@ impl GhostTreeBstackAllocator { /// / \ / \ /// M R L M /// ``` - fn avl_rotate_left(&self, node: u64) -> io::Result { - let (node_sz, _, _, node_l, pivot) = self.read_node(node)?; - let (pivot_sz, _, _, pivot_l, pivot_r) = self.read_node(pivot)?; - self.avl_write_and_update(node, node_sz, node_l, pivot_l)?; - self.avl_write_and_update(pivot, pivot_sz, node, pivot_r)?; - Ok(pivot) + fn avl_rotate_left(&self, node: u64) -> io::Result<(u64, u8)> { + // Read both nodes' cached child heights so neither rewrite re-reads a + // child. (`node`'s right child is `pivot`, whose height is not needed.) + let (node_sz, node_l, pivot, node_lh, _node_rh) = self.read_node_hc(node)?; + let (pivot_sz, pivot_l, pivot_r, pivot_lh, pivot_rh) = self.read_node_hc(pivot)?; + // `node`'s new children (node_l, pivot_l) → heights (node_lh, pivot_lh). + let (_, node_h) = self.avl_write_h( + node, + node_sz, + node_l, + pivot_l, + Some(node_lh), + Some(pivot_lh), + )?; + // `pivot`'s new children (node, pivot_r) → heights (node_h, pivot_rh). + let (_, pivot_h) = + self.avl_write_h(pivot, pivot_sz, node, pivot_r, Some(node_h), Some(pivot_rh))?; + Ok((pivot, pivot_h)) } /// Fix imbalance at `node` after an insert or remove, then return the - /// (possibly new) subtree root. Children must already be balanced. + /// (possibly new) subtree root and its height. Children must already be + /// balanced. + /// + /// The caller passes the `bf` and `height` already computed by the + /// [`avl_write_h`](Self::avl_write_h) that installed `node`'s current + /// children, so the common in-balance case needs no further I/O — it just + /// returns `(node, height)`. /// /// Uses `< -1` / `> 1` rather than `== -2` / `== 2` so that a node whose /// balance factor exceeds ±2 (possible after crash recovery) still gets /// corrected instead of silently passed over. - fn avl_rebalance(&self, node: u64) -> io::Result { - let bf = self.avl_update_bf(node)?; + fn avl_rebalance(&self, node: u64, bf: i8, height: u8) -> io::Result<(u64, u8)> { if bf < -1 { let (_, _, _, left, _) = self.read_node(node)?; let (_, left_bf, _, _, _) = self.read_node(left)?; if left_bf > 0 { // Left-right case: rotate left child left first. - let new_left = self.avl_rotate_left(left)?; + let (new_left, _) = self.avl_rotate_left(left)?; let (node_sz, _, _, _, node_r) = self.read_node(node)?; self.avl_write_and_update(node, node_sz, new_left, node_r)?; } @@ -401,13 +506,13 @@ impl GhostTreeBstackAllocator { let (_, right_bf, _, _, _) = self.read_node(right)?; if right_bf < 0 { // Right-left case: rotate right child right first. - let new_right = self.avl_rotate_right(right)?; + let (new_right, _) = self.avl_rotate_right(right)?; let (node_sz, _, _, node_l, _) = self.read_node(node)?; self.avl_write_and_update(node, node_sz, node_l, new_right)?; } self.avl_rotate_left(node) } else { - Ok(node) + Ok((node, height)) } } @@ -415,41 +520,55 @@ impl GhostTreeBstackAllocator { fn avl_insert(&self, ptr: u64, size: u64) -> io::Result<()> { let root = self.read_root()?; - // Down-pass: walk to the insertion position, recording the path. - let mut path: Vec = Vec::with_capacity(MAX_AVL_DEPTH as usize); + // Down-pass: walk to the insertion position, recording the path on a + // fixed-size stack array. `MAX_AVL_DEPTH` is a compile-time bound, so + // this avoids a heap allocation on every insert under the mutex. + let mut path = [PathEntry::default(); MAX_AVL_DEPTH as usize]; + let mut path_len = 0usize; let mut current = root; while current != NULL_PTR { - if path.len() >= MAX_AVL_DEPTH as usize { + if path_len >= MAX_AVL_DEPTH as usize { return Err(io::Error::new( io::ErrorKind::InvalidData, "AVL insert exceeded maximum depth: corrupted tree (possible cycle)", )); } - let (root_sz, _, _, left, right) = self.read_node(current)?; + let (root_sz, left, right, lh, rh) = self.read_node_hc(current)?; let went_left = (size, ptr) < (root_sz, current); - path.push(PathEntry { + path[path_len] = PathEntry { ptr: current, size: root_sz, left, right, went_left, - }); + lh, + rh, + }; + path_len += 1; current = if went_left { left } else { right }; } - // Write the new leaf. - self.write_node(ptr, size, 0, 1, NULL_PTR, NULL_PTR)?; + // Write the new leaf (height 1, null children → cached child heights 0). + self.write_node(ptr, size, 0, 1, 0, 0, NULL_PTR, NULL_PTR)?; - // Up-pass: propagate the new child pointer and rebalance each ancestor. + // Up-pass: install the new child pointer in each ancestor and rebalance. + // Both child heights are known — the modified child from the previous + // iteration, the untouched sibling from this node's cache read on the + // way down — so `avl_write_h` reads neither. The `(bf, height)` it + // returns is handed to `avl_rebalance`, so the in-balance case does no + // further I/O: one write per level, zero reads. let mut child = ptr; - for entry in path.iter().rev() { - let (new_left, new_right) = if entry.went_left { - (child, entry.right) + let mut child_h = 1u8; // leaf height + for entry in path[..path_len].iter().rev() { + let (new_left, new_right, lh, rh) = if entry.went_left { + (child, entry.right, Some(child_h), Some(entry.rh)) } else { - (entry.left, child) + (entry.left, child, Some(entry.lh), Some(child_h)) }; - self.avl_write_and_update(entry.ptr, entry.size, new_left, new_right)?; - child = self.avl_rebalance(entry.ptr)?; + let (bf, h) = self.avl_write_h(entry.ptr, entry.size, new_left, new_right, lh, rh)?; + let (new_root, new_h) = self.avl_rebalance(entry.ptr, bf, h)?; + child = new_root; + child_h = new_h; } self.write_root(child) } @@ -460,27 +579,45 @@ impl GhostTreeBstackAllocator { /// Returns `(min_ptr, min_size, new_subtree_root)`. The minimum node always /// has no left child, so its replacement is its right child (or [`NULL_PTR`]). fn avl_remove_min(&self, root: u64) -> io::Result<(u64, u64, u64)> { - // Walk left, recording (ptr, size, right_child) for each ancestor. - let mut path: Vec<(u64, u64, u64)> = Vec::with_capacity(MAX_AVL_DEPTH as usize); + // Walk left, recording (ptr, size, right_child, right_child_height) for + // each ancestor on a fixed-size stack array (no heap allocation under the + // mutex). The cached right-child height lets the up-pass write each + // ancestor with both child heights known. + let mut path = [(0u64, 0u64, 0u64, 0u8); MAX_AVL_DEPTH as usize]; + let mut path_len = 0usize; let mut current = root; loop { - let (size, _, _, left, right) = self.read_node(current)?; + let (size, left, right, _lh, rh) = self.read_node_hc(current)?; if left == NULL_PTR { - // `current` is the minimum; replace it with its right child. + // `current` is the minimum; replace it with its right child, whose + // height is `current`'s cached right height. `child` is always + // the left side going up; both child heights are known each step, + // so `avl_write_h` reads neither. let mut child = right; - for &(anc_ptr, anc_sz, anc_right) in path.iter().rev() { - self.avl_write_and_update(anc_ptr, anc_sz, child, anc_right)?; - child = self.avl_rebalance(anc_ptr)?; + let mut child_h = rh; + for &(anc_ptr, anc_sz, anc_right, anc_right_h) in path[..path_len].iter().rev() { + let (bf, h) = self.avl_write_h( + anc_ptr, + anc_sz, + child, + anc_right, + Some(child_h), + Some(anc_right_h), + )?; + let (new_root, new_h) = self.avl_rebalance(anc_ptr, bf, h)?; + child = new_root; + child_h = new_h; } return Ok((current, size, child)); } - if path.len() >= MAX_AVL_DEPTH as usize { + if path_len >= MAX_AVL_DEPTH as usize { return Err(io::Error::new( io::ErrorKind::InvalidData, "AVL min exceeded maximum depth: corrupted tree (possible cycle)", )); } - path.push((current, size, right)); + path[path_len] = (current, size, right, rh); + path_len += 1; current = left; } } @@ -500,37 +637,45 @@ impl GhostTreeBstackAllocator { return Ok(None); } - // Down-pass: record the full traversal path and the index of the last + // Down-pass: record the full traversal path (on a fixed-size stack + // array, no heap allocation under the mutex) and the index of the last // node that satisfies size >= min_size (the best fit). - let mut path: Vec = Vec::with_capacity(MAX_AVL_DEPTH as usize); + let mut path = [PathEntry::default(); MAX_AVL_DEPTH as usize]; + let mut path_len = 0usize; let mut last_fit_idx: Option = None; let mut current = root; while current != NULL_PTR { - if path.len() >= MAX_AVL_DEPTH as usize { + if path_len >= MAX_AVL_DEPTH as usize { return Err(io::Error::new( io::ErrorKind::InvalidData, "AVL find exceeded maximum depth: corrupted tree (possible cycle)", )); } - let (root_sz, _, _, left, right) = self.read_node(current)?; + let (root_sz, left, right, lh, rh) = self.read_node_hc(current)?; if root_sz >= min_size { - last_fit_idx = Some(path.len()); - path.push(PathEntry { + last_fit_idx = Some(path_len); + path[path_len] = PathEntry { ptr: current, size: root_sz, left, right, went_left: true, - }); + lh, + rh, + }; + path_len += 1; current = left; } else { - path.push(PathEntry { + path[path_len] = PathEntry { ptr: current, size: root_sz, left, right, went_left: false, - }); + lh, + rh, + }; + path_len += 1; current = right; } } @@ -544,30 +689,45 @@ impl GhostTreeBstackAllocator { let found_size = path[fit_idx].size; let found_left = path[fit_idx].left; let found_right = path[fit_idx].right; + // The found node's cached child heights (it was reached via a left + // descent, so its right subtree — path[fit_idx+1..] — is what the search + // exhausted; both children are untouched by the removal of this node). + let found_lh = path[fit_idx].lh; + let found_rh = path[fit_idx].rh; // Remove the best-fit node. The left subtree (path[fit_idx+1..]) was // searched and yielded nothing, so found_left is returned unchanged. - let replacement = if found_left == NULL_PTR { - found_right + // `repl_h` is the replacement subtree's height, seeding the up-pass — + // known in every case now (single child from the cache, successor from + // its rebalance). + let (replacement, repl_h): (u64, u8) = if found_left == NULL_PTR { + (found_right, found_rh) } else if found_right == NULL_PTR { - found_left + (found_left, found_lh) } else { // Two children: replace with in-order successor (min of right subtree). let (succ, succ_sz, new_right) = self.avl_remove_min(found_right)?; - self.avl_write_and_update(succ, succ_sz, found_left, new_right)?; - self.avl_rebalance(succ)? + let (bf, h) = self.avl_write_and_update(succ, succ_sz, found_left, new_right)?; + let (new_root, new_h) = self.avl_rebalance(succ, bf, h)?; + (new_root, new_h) }; - // Up-pass: update path[0..fit_idx] (path[fit_idx] was removed). + // Up-pass: update path[0..fit_idx] (path[fit_idx] was removed). Both + // child heights are known each step — the modified child threaded from + // below, the untouched sibling from this node's cache — so `avl_write_h` + // reads neither and the in-balance case does no further I/O. let mut child = replacement; + let mut child_h = repl_h; for entry in path[..fit_idx].iter().rev() { - let (new_left, new_right) = if entry.went_left { - (child, entry.right) + let (new_left, new_right, lh, rh) = if entry.went_left { + (child, entry.right, Some(child_h), Some(entry.rh)) } else { - (entry.left, child) + (entry.left, child, Some(entry.lh), Some(child_h)) }; - self.avl_write_and_update(entry.ptr, entry.size, new_left, new_right)?; - child = self.avl_rebalance(entry.ptr)?; + let (bf, h) = self.avl_write_h(entry.ptr, entry.size, new_left, new_right, lh, rh)?; + let (new_root, new_h) = self.avl_rebalance(entry.ptr, bf, h)?; + child = new_root; + child_h = new_h; } self.write_root(child)?; Ok(Some((found_ptr, found_size))) @@ -711,10 +871,12 @@ impl BStackAllocator for GhostTreeBstackAllocator { type Error = io::Error; type Allocated<'a> = BStackSlice<'a, Self>; + #[inline] fn stack(&self) -> &BStack { &self.stack } + #[inline] fn into_stack(self) -> BStack { self.stack } @@ -735,7 +897,12 @@ impl BStackAllocator for GhostTreeBstackAllocator { // SAFETY: zero-length slice at offset 0 is safe return Ok(unsafe { BStackSlice::from_raw_parts(self, 0, 0) }); } - let aligned = Self::align_up_len(len); + let aligned = Self::align_up_len(len).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "alloc: length exceeds the maximum allocatable size", + ) + })?; { #[cfg(feature = "atomic")] let guard = self.lock.lock().unwrap(); @@ -784,6 +951,7 @@ impl BStackAllocator for GhostTreeBstackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "GhostTreeBstackAllocator::realloc")?; if slice.is_empty() { return self.alloc(new_len); } @@ -800,8 +968,14 @@ impl BStackAllocator for GhostTreeBstackAllocator { } let old_len = slice.len(); // Re-align to recover the true underlying block sizes. - let aligned_old = Self::align_up_len(old_len); - let aligned_new = Self::align_up_len(new_len); + let too_long = || { + io::Error::new( + io::ErrorKind::InvalidInput, + "realloc: length exceeds the maximum allocatable size", + ) + }; + let aligned_old = Self::align_up_len(old_len).ok_or_else(too_long)?; + let aligned_new = Self::align_up_len(new_len).ok_or_else(too_long)?; if aligned_new == aligned_old { // Same underlying block — just update the visible length. @@ -821,17 +995,35 @@ impl BStackAllocator for GhostTreeBstackAllocator { let freed_tail = aligned_old - aligned_new; let tail_ptr = slice.start() + aligned_new; - // Atomic fast path: discard the tail block without taking the lock. + // Atomic fast path: zero the sub-block padding BEFORE discarding the + // tail. These two steps cannot be fused into one crash-atomic call + // here (that needs the in-sequence tail-replace primitive), so their + // order decides what a crash between them leaves behind. Zeroing + // first means a crash leaves the retained block with a zeroed + // `[new_len, aligned_new)` padding — the zeroed-memory invariant that + // a later same-block grow relies on (it does not re-zero) — and an + // as-yet-unreclaimed tail, which is a benign leak. Discarding first + // would instead leave stale bytes in that padding, which a later grow + // would hand back to the caller. The padding is owned exclusively by + // this allocation, so zeroing it before confirming the tail is safe. + // `try_discard` then atomically checks tail == sentinel and removes + // the freed tail under bstack's write lock; on failure the block is + // not the tail and we fall through to the non-tail shrink below (which + // re-zeros the region, harmlessly, before the AVL insert). #[cfg(feature = "atomic")] - if self - .stack - .try_discard(slice.start() + aligned_old, freed_tail)? { if new_len < aligned_new { self.stack .zero(slice.start() + new_len, aligned_new - new_len)?; } - return Ok(unsafe { BStackSlice::from_raw_parts(self, slice.start(), new_len) }); + if self + .stack + .try_discard(slice.start() + aligned_old, freed_tail)? + { + return Ok(unsafe { + BStackSlice::from_raw_parts(self, slice.start(), new_len) + }); + } } #[cfg(not(feature = "atomic"))] @@ -888,6 +1080,7 @@ impl BStackAllocator for GhostTreeBstackAllocator { /// Multi-call: a crash after the zero but before the AVL insert permanently /// loses the block. fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "GhostTreeBstackAllocator::dealloc")?; if slice.is_empty() { return Ok(()); } @@ -898,7 +1091,12 @@ impl BStackAllocator for GhostTreeBstackAllocator { )); } let ptr = slice.start(); - let true_len = Self::align_up_len(slice.len()); + let true_len = Self::align_up_len(slice.len()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "dealloc: slice length exceeds the maximum allocatable size", + ) + })?; // Atomic fast path: discard the tail block without taking the lock. // try_discard succeeds only if the stack size is still ptr + true_len, @@ -953,8 +1151,20 @@ impl BStackBulkAllocator for GhostTreeBstackAllocator { let aligned: Vec = lengths .iter() - .map(|&l| if l == 0 { 0 } else { Self::align_up_len(l) }) - .collect(); + .map(|&l| { + if l == 0 { + Some(0) + } else { + Self::align_up_len(l) + } + }) + .collect::>>() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "alloc_bulk: length exceeds the maximum allocatable size", + ) + })?; let total = aligned .iter() @@ -1033,6 +1243,7 @@ impl BStackBulkAllocator for GhostTreeBstackAllocator { slices: impl AsRef<[Self::Allocated<'a>]>, ) -> Result<(), Self::Error> { let slices = slices.as_ref(); + ensure_own_slices(self, slices, "GhostTreeBstackAllocator::dealloc_bulk")?; // Collect, validate, and convert to (ptr, aligned_size) pairs. let mut entries: Vec<(u64, u64)> = Vec::new(); @@ -1046,7 +1257,13 @@ impl BStackBulkAllocator for GhostTreeBstackAllocator { "dealloc_bulk: invalid slice origin", )); } - entries.push((s.start(), Self::align_up_len(s.len()))); + let true_len = Self::align_up_len(s.len()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "dealloc_bulk: slice length exceeds the maximum allocatable size", + ) + })?; + entries.push((s.start(), true_len)); } if entries.is_empty() { @@ -1207,7 +1424,7 @@ mod tests { let a = alloc.alloc(64).unwrap(); let b = alloc.alloc(64).unwrap(); let a_start = a.start(); - a.write(&[0xAAu8; 64]).unwrap(); + a.write([0xAAu8; 64]).unwrap(); alloc.dealloc(a).unwrap(); // Read the raw bytes where a used to live. let raw = alloc.stack().get(a_start, a_start + 64).unwrap(); @@ -1225,7 +1442,7 @@ mod tests { let _g = Guard(path); let slices: Vec<_> = (0..16).map(|i| alloc.alloc(i * 7 + 1).unwrap()).collect(); for s in &slices { - if s.len() > 0 { + if !s.is_empty() { // Arena starts at payload offset 48; the 16-byte BStack header means // all payload offsets ≡ 16 (mod 32) map to 32-byte-aligned disk addresses. assert_eq!( @@ -1293,7 +1510,7 @@ mod tests { let (alloc, path) = open_fresh(); let _g = Guard(path); let s = alloc.alloc(32).unwrap(); - s.write(&[0x5Au8; 32]).unwrap(); + s.write([0x5Au8; 32]).unwrap(); let start = s.start(); // Realloc to a different len with the same aligned block size. let s2 = alloc.realloc(s, 16).unwrap(); @@ -1311,7 +1528,7 @@ mod tests { let _g = Guard(path); let s = alloc.alloc(128).unwrap(); let start = s.start(); - s.write(&[0xBBu8; 128]).unwrap(); + s.write([0xBBu8; 128]).unwrap(); let s2 = alloc.realloc(s, 32).unwrap(); assert_eq!(s2.start(), start); assert_eq!(alloc.stack().len().unwrap(), start + 32); @@ -1320,6 +1537,35 @@ mod tests { alloc.dealloc(s2).unwrap(); } + #[test] + fn realloc_tail_shrink_then_grow_reads_zeros() { + // A tail shrink zeroes the sub-block padding it leaves behind, upholding + // the zeroed-memory invariant, so a later same-block grow — which does + // not re-zero, trusting that invariant — never hands back stale bytes. + // (The atomic path zeroes the padding before discarding the freed tail so + // that even a crash between the two never strands stale padding.) + let (alloc, path) = open_fresh(); + let _g = Guard(path); + let s = alloc.alloc(64).unwrap(); + let start = s.start(); + s.write([0xEEu8; 64]).unwrap(); + // Shrink into the first 32-byte sub-block: the padding [20, 32) and the + // freed tail [32, 64) must not survive as live 0xEE bytes. + let s2 = alloc.realloc(s, 20).unwrap(); + assert_eq!(s2.start(), start); + // Grow back within the retained block; the newly-exposed [20, 30) must + // read zero, not the old 0xEE. + let s3 = alloc.realloc(s2, 30).unwrap(); + assert_eq!(s3.start(), start); + let buf = s3.read().unwrap(); + assert!(buf[..20].iter().all(|&b| b == 0xEE), "live bytes preserved"); + assert!( + buf[20..].iter().all(|&b| b == 0), + "grown region reads zero, not stale padding" + ); + alloc.dealloc(s3).unwrap(); + } + #[test] fn realloc_shrink_nontail_inserts_remainder() { let (alloc, path) = open_fresh(); @@ -1327,7 +1573,7 @@ mod tests { let s = alloc.alloc(128).unwrap(); let anchor = alloc.alloc(32).unwrap(); let start = s.start(); - s.write(&[0xCCu8; 128]).unwrap(); + s.write([0xCCu8; 128]).unwrap(); let stack_len = alloc.stack().len().unwrap(); let s2 = alloc.realloc(s, 32).unwrap(); assert_eq!(s2.start(), start); @@ -1341,13 +1587,66 @@ mod tests { alloc.dealloc(anchor).unwrap(); } + // A length that cannot be rounded up to a 32-byte multiple without + // overflowing `u64` is rejected up front. Unchecked, `len + 31` wrapped a + // near-`u64::MAX` request down to a 32-byte block and `alloc` returned a + // handle claiming the original length. + #[test] + fn alloc_rejects_unalignable_length() { + let (alloc, path) = open_fresh(); + let _g = Guard(path); + for len in [MAX_ALLOC + 1, u64::MAX - 5, u64::MAX] { + let err = alloc.alloc(len).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "len {len}"); + } + // The allocator is untouched and still works. + let s = alloc.alloc(64).unwrap(); + alloc.dealloc(s).unwrap(); + } + + // `MAX_ALLOC` is the exact boundary, not a rounded-down guess: it is + // 32-aligned so it rounds up to itself, and it leaves room for a block at + // `ARENA_START`. One byte more does not. + #[test] + fn max_alloc_is_the_exact_alignment_boundary() { + assert_eq!(MAX_ALLOC % MIN_ALLOC, 0); + assert!(ARENA_START.checked_add(MAX_ALLOC).is_some()); + assert_eq!( + GhostTreeBstackAllocator::align_up_len(MAX_ALLOC), + Some(MAX_ALLOC) + ); + assert_eq!(GhostTreeBstackAllocator::align_up_len(MAX_ALLOC + 1), None); + } + + // Same rejection on the realloc path, which reached it via the tail-shrink + // zeroing: the wrapped `aligned_new` made `aligned_new - new_len` underflow + // into an out-of-range length. The block is untouched, so the caller's + // handle stays valid. + #[test] + fn realloc_rejects_unalignable_length() { + let (alloc, path) = open_fresh(); + let _g = Guard(path); + let s = alloc.alloc(128).unwrap(); + let start = s.start(); + let stack_len = alloc.stack().len().unwrap(); + + let err = alloc.realloc(s, u64::MAX - 5).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + + // The block and the stack are untouched. + assert_eq!(s.start(), start); + assert_eq!(s.len(), 128); + assert_eq!(alloc.stack().len().unwrap(), stack_len); + alloc.dealloc(s).unwrap(); + } + #[test] fn realloc_grow_tail_extends_in_place() { let (alloc, path) = open_fresh(); let _g = Guard(path); let s = alloc.alloc(32).unwrap(); let start = s.start(); - s.write(&[0xDDu8; 32]).unwrap(); + s.write([0xDDu8; 32]).unwrap(); let s2 = alloc.realloc(s, 96).unwrap(); assert_eq!(s2.start(), start); let buf = s2.read().unwrap(); @@ -1362,7 +1661,7 @@ mod tests { let _g = Guard(path); let s = alloc.alloc(32).unwrap(); let anchor = alloc.alloc(32).unwrap(); - s.write(&[0xEEu8; 32]).unwrap(); + s.write([0xEEu8; 32]).unwrap(); let s2 = alloc.realloc(s, 96).unwrap(); // s2 is a new allocation (different address from anchor). assert_ne!(s2.start(), anchor.start()); @@ -1466,13 +1765,61 @@ mod tests { alloc2.dealloc(anchor2).unwrap(); } + #[test] + fn reopen_rebuilds_stale_child_height_cache() { + // A legacy 0.1.2 arena has arbitrary bytes where the 0.1.3 child-height + // cache now lives (those offsets were reserved before the cache existed). + // `coalesce_and_rebalance` runs on every open and rebuilds the whole tree, + // recomputing each node's cache from its children's own (always-maintained) + // `height` field — so a stale or garbage cache is healed on open and never + // trusted across a reopen. This guards the 0.1.x on-disk compatibility. + let (alloc, path) = open_fresh(); + let _g = Guard(path.clone()); + // Three free nodes kept non-adjacent by allocated anchors, so they do not + // coalesce into one on reopen and the rebuilt tree really has >1 node. + let f1 = alloc.alloc(64).unwrap(); + let a1 = alloc.alloc(64).unwrap(); + let f2 = alloc.alloc(48).unwrap(); + let a2 = alloc.alloc(64).unwrap(); + let f3 = alloc.alloc(80).unwrap(); + let a3 = alloc.alloc(64).unwrap(); + let (s1, s2, s3) = (f1.start(), f2.start(), f3.start()); + alloc.dealloc(f1).unwrap(); + alloc.dealloc(f2).unwrap(); + alloc.dealloc(f3).unwrap(); + // Drop the anchor handles without freeing them: their blocks stay + // allocated (out of the tree), separating the three free nodes. + let _ = (a1, a2, a3); + + // Clobber every free node's child-height cache with garbage (0xFF reads as + // height 255), mimicking a legacy file whose reserved bytes are not a cache. + let stack = alloc.into_stack(); + for &n in &[s1, s2, s3] { + stack.set(n + NODE_LH_OFF, [0xFFu8]).unwrap(); + stack.set(n + NODE_RH_OFF, [0xFFu8]).unwrap(); + } + drop(stack); + + // Reopen heals the cache via the rebuild; the tree stays consistent and the + // free blocks are reusable with correct round-trips. + let alloc2 = reopen(&path); + let r1 = alloc2.alloc(48).unwrap(); + r1.write([0x33u8; 48]).unwrap(); + assert_eq!(r1.read().unwrap(), vec![0x33u8; 48]); + let r2 = alloc2.alloc(64).unwrap(); + r2.write([0x44u8; 64]).unwrap(); + assert_eq!(r2.read().unwrap(), vec![0x44u8; 64]); + alloc2.dealloc(r1).unwrap(); + alloc2.dealloc(r2).unwrap(); + } + #[test] fn data_survives_reopen() { let (alloc, path) = open_fresh(); let _g = Guard(path.clone()); let s = alloc.alloc(64).unwrap(); let start = s.start(); - s.write(&[0xABu8; 64]).unwrap(); + s.write([0xABu8; 64]).unwrap(); drop(alloc.into_stack()); let alloc2 = reopen(&path); @@ -1532,7 +1879,7 @@ mod tests { let mut set = live.lock().unwrap(); assert!(set.insert(off), "duplicate live offset {off}"); } - slice.write(&[tid as u8; 32]).unwrap(); + slice.write([tid as u8; 32]).unwrap(); let data = slice.read().unwrap(); assert_eq!(data, vec![tid as u8; 32]); { @@ -1580,7 +1927,7 @@ mod tests { thread::spawn(move || { let a: &GhostTreeBstackAllocator = &alloc; let mut slice = a.alloc(SMALL).unwrap(); - slice.write(&[tid as u8; SMALL as usize]).unwrap(); + slice.write([tid as u8; SMALL as usize]).unwrap(); for _ in 0..ROUNDS { // Grow: tail → try_extend_zeros; non-tail → copy to new region. @@ -1653,7 +2000,7 @@ mod tests { } } for (s, &sz) in slices.iter().zip(SIZES.iter()) { - s.write(&vec![tid as u8; sz as usize]).unwrap(); + s.write(vec![tid as u8; sz as usize]).unwrap(); let data = s.read().unwrap(); assert_eq!(data, vec![tid as u8; sz as usize]); } diff --git a/src/alloc/guarded.rs b/src/alloc/guarded.rs index 2bd60dbd..69c1010a 100644 --- a/src/alloc/guarded.rs +++ b/src/alloc/guarded.rs @@ -79,6 +79,7 @@ where /// allows mutation, it must ensure that all hooks are properly fired on /// subsequent reads and writes, and that any necessary synchronization is /// performed to prevent data races or undefined behavior. + #[inline] fn as_slice(&self) -> Result, io::Error> { Err(io::Error::new( io::ErrorKind::Unsupported, @@ -95,6 +96,7 @@ where /// Returns `true` if this guarded view contains no data. /// /// This is a convenience method that defaults to `self.len() == 0`. + #[inline] fn is_empty(&self) -> bool { self.len() == 0 } @@ -124,6 +126,7 @@ where /// `offset` is absolute to the [`crate::BStack`], and `len` is the number of /// bytes of the raw block to be read (before any `post_read` transformation). /// Return `Err` to deny the operation. + #[inline] fn pre_read(&self, _offset: u64, _len: u64) -> io::Result<()> { Ok(()) } @@ -138,6 +141,7 @@ where /// /// Callers that need a fixed output size should check the returned slice length /// and return `InvalidData` if it differs from the expected length. + #[inline] fn post_read<'d>(&self, data: &'d [u8]) -> io::Result> { Ok(Cow::Borrowed(data)) } @@ -149,6 +153,7 @@ where /// /// Return `Cow::Borrowed` to pass through without allocation; return /// `Cow::Owned` for encryption, compression, or other transformations. + #[inline] fn pre_write<'d>(&self, data: &'d [u8]) -> io::Result> { Ok(Cow::Borrowed(data)) } @@ -161,6 +166,7 @@ where /// `offset` is absolute offset within the [`crate::BStack`], and `len` is the length of the /// original data passed to `pre_write` (not the transformed length returned by `pre_write`). /// (before any `pre_write` transformation). + #[inline] fn post_write(&self, _offset: u64, _len: u64) -> io::Result<()> { Ok(()) } @@ -276,6 +282,7 @@ where /// # Panics /// /// Panics if the specified range is out of bounds of the apparent slice. + #[inline] fn subview_range( &self, range: std::ops::Range, diff --git a/src/alloc/linear.rs b/src/alloc/linear.rs index a1923a57..bcdc56d8 100644 --- a/src/alloc/linear.rs +++ b/src/alloc/linear.rs @@ -1,4 +1,6 @@ -use super::{BStackAllocator, BStackBulkAllocator, BStackSlice}; +use super::{ + BStackAllocator, BStackBulkAllocator, BStackSlice, ensure_own_slice, ensure_own_slices, +}; use crate::BStack; #[cfg(not(feature = "atomic"))] use std::cell::Cell; @@ -29,7 +31,7 @@ use std::{fmt, io}; /// | Operation | Without `atomic` | With `atomic` | /// |----------------------|---------------------|-------------------------| /// | `alloc` | [`BStack::extend`] | [`BStack::extend`] | -/// | `realloc` grow | [`BStack::extend`] | [`BStack::try_extend`] | +/// | `realloc` grow | [`BStack::extend`] | [`BStack::try_extend_zeros`] | /// | `realloc` shrink | [`BStack::discard`] | [`BStack::try_discard`] | /// | `dealloc` (tail) | [`BStack::discard`] | [`BStack::try_discard`] | /// | `dealloc` (non-tail) | no-op | no-op | @@ -46,7 +48,7 @@ use std::{fmt, io}; /// /// * **`alloc`** / **`alloc_bulk`**: a single [`BStack::extend`] returns a /// distinct region to every caller regardless of concurrency. -/// * **`realloc`**: uses [`BStack::try_extend`] / [`BStack::try_discard`] +/// * **`realloc`**: uses [`BStack::try_extend_zeros`] / [`BStack::try_discard`] /// with `slice.end()` as the sentinel. If the tail has moved (another /// thread raced), the call returns [`io::ErrorKind::Unsupported`] — the /// same error as a non-tail realloc on a single thread. @@ -79,8 +81,14 @@ pub struct LinearBStackAllocator { _not_sync: PhantomData>, } +/// `Sync` is removed deliberately by `_not_sync`; `RefUnwindSafe` is collateral. +#[cfg(not(feature = "atomic"))] +impl std::panic::RefUnwindSafe for LinearBStackAllocator {} + impl LinearBStackAllocator { /// Create a new `LinearBStackAllocator` that takes ownership of `stack`. + #[inline] + #[must_use] pub fn new(stack: BStack) -> Self { Self { stack, @@ -98,12 +106,14 @@ impl fmt::Debug for LinearBStackAllocator { } impl From for LinearBStackAllocator { + #[inline] fn from(stack: BStack) -> Self { Self::new(stack) } } impl From for BStack { + #[inline] fn from(alloc: LinearBStackAllocator) -> Self { alloc.into_stack() } @@ -113,14 +123,17 @@ impl BStackAllocator for LinearBStackAllocator { type Error = io::Error; type Allocated<'a> = BStackSlice<'a, Self>; + #[inline] fn stack(&self) -> &BStack { &self.stack } + #[inline] fn into_stack(self) -> BStack { self.stack } + #[inline] fn alloc(&self, len: u64) -> io::Result> { let offset = self.stack.extend(len)?; // SAFETY: offset and len come from a fresh allocation via self.stack.extend(len) @@ -133,6 +146,7 @@ impl BStackAllocator for LinearBStackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "LinearBStackAllocator::realloc")?; let current_tail = self.stack.len()?; if slice.end() != current_tail { return Err(io::Error::new( @@ -158,7 +172,7 @@ impl BStackAllocator for LinearBStackAllocator { } // With the `atomic` feature the tail check and the modification are a - // single locked step (`try_extend`/`try_discard`), eliminating the TOCTOU + // single locked step (`try_extend_zeros`/`try_discard`), eliminating the TOCTOU // race that exists in the non-atomic version. A `false` return means // another thread moved the tail first; we surface this as `Unsupported`, // the same error returned for a non-tail slice on a single thread. @@ -168,12 +182,18 @@ impl BStackAllocator for LinearBStackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "LinearBStackAllocator::realloc")?; match new_len.cmp(&slice.len()) { std::cmp::Ordering::Equal => Ok(slice), std::cmp::Ordering::Greater => { - let delta = new_len - slice.len(); - let zeros = vec![0u8; delta as usize]; - if !self.stack.try_extend(slice.end(), zeros)? { + // `try_extend_zeros` rather than `try_extend` of a zero + // buffer: same tail guard and same result, but the growth is + // realised by one `set_len` on a sparse file, so the zeros + // cost no write I/O and no heap staging. + if !self + .stack + .try_extend_zeros(slice.end(), new_len - slice.len())? + { return Err(io::Error::new( io::ErrorKind::Unsupported, "LinearBStackAllocator does not support reallocation of non-tail slices; \ @@ -201,6 +221,7 @@ impl BStackAllocator for LinearBStackAllocator { #[cfg(not(feature = "atomic"))] fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "LinearBStackAllocator::dealloc")?; let current_tail = self.stack.len()?; if slice.end() == current_tail { self.stack.discard(slice.len())?; @@ -210,6 +231,7 @@ impl BStackAllocator for LinearBStackAllocator { #[cfg(feature = "atomic")] fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "LinearBStackAllocator::dealloc")?; // try_discard is a no-op when the tail has moved, matching non-tail dealloc semantics. self.stack.try_discard(slice.end(), slice.len())?; Ok(()) @@ -283,6 +305,7 @@ impl BStackBulkAllocator for LinearBStackAllocator { slices: impl AsRef<[Self::Allocated<'a>]>, ) -> Result<(), Self::Error> { let slices = slices.as_ref(); + ensure_own_slices(self, slices, "LinearBStackAllocator::dealloc_bulk")?; if slices.is_empty() { return Ok(()); } @@ -314,6 +337,7 @@ impl BStackBulkAllocator for LinearBStackAllocator { slices: impl AsRef<[Self::Allocated<'a>]>, ) -> Result<(), Self::Error> { let slices = slices.as_ref(); + ensure_own_slices(self, slices, "LinearBStackAllocator::dealloc_bulk")?; if slices.is_empty() { return Ok(()); } diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index 0bd4ac36..5afce316 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -72,6 +72,12 @@ //! [`recover`](CheckedSlabBStackAllocator::recover) single-flight. //! *Experimental.* //! +//! Every allocator above is `UnwindSafe` and `RefUnwindSafe` in all +//! configurations. Without `atomic` the latter is an explicit impl: the +//! `PhantomData>` marker that removes `Sync` would otherwise take +//! `RefUnwindSafe` with it, and the only interior mutability is the +//! [`BStack`](crate::BStack)'s own poisoning lock. +//! //! * [`BStackByteVec`] — a growable byte (`u8`) vector backed by a //! [`BStack`] allocation (requires both `alloc` **and** `set`). Mirrors the //! core [`Vec`] API: `push`, `pop`, `get`, `read_bytes`, `as_slice`, @@ -143,6 +149,25 @@ //! copy the data to a new allocation and update the metadata accordingly, //! and must return an error if they do not support this operation. //! +//! # Foreign slices +//! +//! What the borrow checker does *not* prove is that a slice goes back to the +//! allocator that issued it. Two allocators of the same type are the same +//! type, and [`BStackSlice`] is `Copy`, so for `a1` and `a2` of type `A`, +//! `a2.dealloc(s1)` compiles. No lifetime or type discipline rules that out. +//! It is also not a soundness problem: a slice is an `(offset, len)` +//! coordinate pair into a file, not a pointer, and reaches the payload only +//! through [`BStack`]'s bounds-checked I/O — the damage is `a2` recording a +//! free block it never owned, which is corruption, not undefined behaviour. +//! +//! Rejecting a foreign slice is therefore the *allocator's* job, at run time. +//! Every allocator here checks ownership at the top of `realloc`, `dealloc` +//! and `dealloc_bulk` — before touching any metadata — and returns +//! [`io::ErrorKind::InvalidInput`]; `dealloc_bulk` rejects the whole batch and +//! frees nothing. The slice is `Copy`, so a refused caller still holds it. +//! Custom implementors should do the same; [`BStackSlice::is_from`] is the +//! check. +//! //! # Crash consistency //! //! Every individual [`BStack`] operation — [`extend`](BStack::extend), @@ -215,6 +240,45 @@ pub mod slice; pub use slice::BStackSliceWriter; pub use slice::{BStackSlice, BStackSliceReader}; +/// Reject a slice that was not issued by `allocator`. +/// +/// `op` names the calling method for the error message. See the module's +/// "Foreign slices" section for why this must be a run-time check and why it +/// is not a soundness issue. +#[inline] +pub(crate) fn ensure_own_slice( + allocator: &A, + slice: &BStackSlice<'_, A>, + op: &'static str, +) -> io::Result<()> { + if slice.is_from(allocator) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{op}: slice was issued by a different allocator instance"), + )) + } +} + +/// Bulk analogue of [`ensure_own_slice`]: rejects the whole batch if any slice +/// is foreign, so a bad batch frees nothing rather than stopping part-way. +#[inline] +pub(crate) fn ensure_own_slices( + allocator: &A, + slices: &[BStackSlice<'_, A>], + op: &'static str, +) -> io::Result<()> { + if slices.iter().all(|s| s.is_from(allocator)) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{op}: slice was issued by a different allocator instance"), + )) + } +} + /// A trait for types that own a [`BStack`] and manage contiguous byte regions /// within its payload. /// @@ -356,6 +420,7 @@ pub trait BStackAllocator: Sized { /// /// The default never errors. Overriding implementations may return /// `Self::Error` from underlying operations. + #[inline] fn dealloc(&self, _handle: Self::Allocated<'_>) -> Result<(), Self::Error> { Ok(()) } @@ -363,6 +428,7 @@ pub trait BStackAllocator: Sized { /// Return the current logical length of the backing stack payload. /// /// Delegates to [`BStack::len`]. + #[inline] fn len(&self) -> io::Result { self.stack().len() } @@ -370,6 +436,7 @@ pub trait BStackAllocator: Sized { /// Return `true` if the backing stack is empty. /// /// Delegates to [`BStack::is_empty`]. + #[inline] fn is_empty(&self) -> io::Result { self.stack().is_empty() } diff --git a/src/alloc/slab.rs b/src/alloc/slab.rs index c4ac661a..a4501c7a 100644 --- a/src/alloc/slab.rs +++ b/src/alloc/slab.rs @@ -4,7 +4,7 @@ //! O(1) alloc and dealloc by keeping all blocks the same size and tracking //! freed blocks in an intrusive singly-linked free list. -use super::{BStackAllocator, BStackSlice}; +use super::{BStackAllocator, BStackSlice, ensure_own_slice}; use crate::BStack; #[cfg(feature = "atomic")] use crate::BStackGenOp; @@ -142,6 +142,10 @@ pub struct SlabBStackAllocator { _not_sync: PhantomData>, } +/// `Sync` is removed deliberately by `_not_sync`; `RefUnwindSafe` is collateral. +#[cfg(not(feature = "atomic"))] +impl std::panic::RefUnwindSafe for SlabBStackAllocator {} + #[cfg(feature = "set")] impl SlabBStackAllocator { /// Bytes before the allocator header reserved for caller use. @@ -288,6 +292,8 @@ impl SlabBStackAllocator { } /// Return the `block_size` this allocator was created with. + #[inline] + #[must_use] pub fn block_size(&self) -> u64 { self.block_size } @@ -540,10 +546,12 @@ impl BStackAllocator for SlabBStackAllocator { type Error = io::Error; type Allocated<'a> = BStackSlice<'a, Self>; + #[inline] fn stack(&self) -> &BStack { &self.stack } + #[inline] fn into_stack(self) -> BStack { self.stack } @@ -594,6 +602,7 @@ impl BStackAllocator for SlabBStackAllocator { /// /// Double-freeing a slice corrupts the free list; this allocator does not guard against it. fn dealloc(&self, slice: BStackSlice<'_, Self>) -> io::Result<()> { + ensure_own_slice(self, &slice, "SlabBStackAllocator::dealloc")?; if slice.is_empty() && slice.start() == Self::SENTINEL { return Ok(()); } @@ -644,6 +653,7 @@ impl BStackAllocator for SlabBStackAllocator { slice: BStackSlice<'a, Self>, new_len: u64, ) -> io::Result> { + ensure_own_slice(self, &slice, "SlabBStackAllocator::realloc")?; if slice.is_empty() && slice.start() == Self::SENTINEL { return self.alloc(new_len); } @@ -1042,7 +1052,7 @@ mod tests { let mut set = live.lock().unwrap(); assert!(set.insert(off), "duplicate live offset {off}"); } - slice.write(&[tid as u8; 16]).unwrap(); + slice.write([tid as u8; 16]).unwrap(); let data = slice.read().unwrap(); assert_eq!(data, vec![tid as u8; 16]); { @@ -1089,7 +1099,7 @@ mod tests { thread::spawn(move || { let a: &SlabBStackAllocator = &alloc; let mut slice = a.alloc(SMALL).unwrap(); - slice.write(&[tid as u8; SMALL as usize]).unwrap(); + slice.write([tid as u8; SMALL as usize]).unwrap(); for _ in 0..ROUNDS { // Grow: tail → try_extend_zeros; non-tail → copy to new region. diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index 6c737490..8ba878f3 100644 --- a/src/alloc/slice.rs +++ b/src/alloc/slice.rs @@ -53,6 +53,13 @@ impl<'a, A: BStackAllocator> fmt::Debug for BStackSlice<'a, A> { } } +/// The region's half-open byte range within the payload, as `start..end`. +impl<'a, A: BStackAllocator> fmt::Display for BStackSlice<'a, A> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}..{}", self.start(), self.end()) + } +} + impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// Create a new `BStackSlice`. /// @@ -107,6 +114,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// the allocator's persistent metadata in a way that is difficult or /// impossible to recover from. #[inline] + #[must_use] pub unsafe fn from_raw_parts(allocator: &'a A, offset: u64, len: u64) -> Self { Self { allocator, @@ -125,6 +133,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// Useful as a sentinel or default value when a slice field must be /// initialized before a real allocation is available. #[inline] + #[must_use] pub fn empty(allocator: &'a A) -> Self { Self { allocator, @@ -138,6 +147,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// Layout: `offset` as 8 bytes little-endian, then `len` as 8 bytes /// little-endian. Reconstruct with [`BStackSlice::from_bytes`]. #[inline] + #[must_use] pub fn to_bytes(&self) -> [u8; 16] { let mut out = [0u8; 16]; out[..8].copy_from_slice(&self.offset.to_le_bytes()); @@ -154,6 +164,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// that lie within the bounds of the underlying allocator's payload. /// Passing an arbitrary or corrupted byte array is undefined behaviour. #[inline] + #[must_use] pub unsafe fn from_bytes(allocator: &'a A, bytes: [u8; 16]) -> Self { let offset = u64::from_le_bytes(bytes[..8].try_into().unwrap()); let len = u64::from_le_bytes(bytes[8..].try_into().unwrap()); @@ -166,6 +177,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// Returns the start offset of this slice within the payload. #[inline] + #[must_use] pub fn start(&self) -> u64 { self.offset } @@ -173,34 +185,61 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// The exclusive end offset of this slice within the payload /// (`self.start() + self.len()`). #[inline] + #[must_use] pub fn end(&self) -> u64 { self.offset + self.len } /// Returns the range of this slice as `start..end` within the payload. #[inline] + #[must_use] pub fn range(&self) -> Range { self.start()..self.end() } /// Returns the length of this slice in bytes. #[inline] + #[must_use] pub fn len(&self) -> u64 { self.len } /// Returns `true` if this slice spans zero bytes. #[inline] + #[must_use] pub fn is_empty(&self) -> bool { self.len == 0 } /// Return the underlying allocator. #[inline] + #[must_use] pub fn allocator(&self) -> &'a A { self.allocator } + /// Returns `true` if this slice was issued by `allocator`. + /// + /// A slice records which allocator produced it, but the type system cannot + /// enforce that it is only ever handed back to *that* instance — two + /// allocators of the same type are the same type, and `BStackSlice` is + /// `Copy`, so `a2.dealloc(s1)` type-checks. It is not a soundness issue + /// either: a slice is an `(offset, len)` coordinate pair into a file, not + /// a pointer, and reaches the payload only through [`BStack`]'s + /// bounds-checked I/O — the damage would be `a2` recording a free block it + /// never owned. + /// + /// Rejecting a foreign slice is therefore the allocator's job, at run + /// time. Every built-in allocator checks ownership at the top of + /// `realloc`, `dealloc` and `dealloc_bulk`, before touching any metadata, + /// and fails with [`io::ErrorKind::InvalidInput`]. This is the check they + /// use, and custom allocators should do the same. + #[inline] + #[must_use] + pub fn is_from(&self, allocator: &A) -> bool { + std::ptr::eq(self.allocator, allocator) + } + /// Return the underlying stack. /// /// Note: `Bstack` does not require mutability for any of its operations, @@ -209,6 +248,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// and prefer methods on [`BStackSlice`] such as [`read`](BStackSlice::read) and /// [`write`](BStackSlice::write) that delegate to the stack internally. #[inline] + #[must_use] pub fn stack(&self) -> &BStack { self.allocator.stack() } @@ -222,6 +262,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// /// Panics if `start > end` or `end > self.len()`. #[inline] + #[must_use] + #[track_caller] pub fn subslice(&self, start: u64, end: u64) -> BStackSlice<'a, A> { self.subslice_range(start..end) } @@ -234,6 +276,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// # Panics /// /// Panics if `range.start > range.end` or `range.end > self.len()`. + #[must_use] + #[track_caller] pub fn subslice_range(&self, range: Range) -> BStackSlice<'a, A> { assert!(range.start <= range.end, "range start must be <= end"); assert!(range.end <= self.len, "range end must be <= slice length"); @@ -244,6 +288,138 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { } } + /// Split into two sub-views at `mid`, relative to this slice's start. + /// + /// Equivalent to `(self.subslice(0, mid), self.subslice(mid, self.len()))`, + /// following `std` slice naming. + /// + /// # Panics + /// + /// Panics if `mid > self.len()`. + #[inline] + #[track_caller] + pub fn split_at(&self, mid: u64) -> (BStackSlice<'a, A>, BStackSlice<'a, A>) { + assert!(mid <= self.len(), "split_at: mid must be <= slice length"); + (self.subslice(0, mid), self.subslice(mid, self.len())) + } + + /// Split into two independent sub-views at `mid`, relative to this slice's + /// start. + /// + /// The returned slices are independent — like [`subslice`](Self::subslice), + /// they carry the original `&'a A` allocator lifetime rather than borrowing + /// from `self`. + /// + /// # Panics + /// + /// Panics if `mid > self.len()`. + #[inline] + #[track_caller] + pub fn split_at_mut(&mut self, mid: u64) -> (BStackSlice<'a, A>, BStackSlice<'a, A>) { + assert!( + mid <= self.len(), + "split_at_mut: mid must be <= slice length" + ); + (self.subslice(0, mid), self.subslice(mid, self.len())) + } + + /// Return a sub-view of the first `n` bytes. + /// + /// The returned slice has length `min(n, self.len())`. + #[inline] + #[must_use] + pub fn head(&self, n: u64) -> BStackSlice<'a, A> { + let n = n.min(self.len()); + self.subslice(0, n) + } + + /// Return a sub-view of the last `n` bytes. + /// + /// The returned slice has length `min(n, self.len())`. + #[inline] + #[must_use] + pub fn tail(&self, n: u64) -> BStackSlice<'a, A> { + let n = n.min(self.len()); + self.subslice(self.len() - n, self.len()) + } + + /// Read the byte at `index`, or `None` if out of bounds. + #[inline] + pub fn get(&self, index: u64) -> io::Result> { + if index >= self.len() { + return Ok(None); + } + let mut buf = [0u8; 1]; + self.stack().get_into(self.start() + index, &mut buf)?; + Ok(Some(buf[0])) + } + + /// Returns `true` if the slice contains `needle`. + #[inline] + pub fn contains(&self, needle: u8) -> io::Result { + Ok(self.read()?.contains(&needle)) + } + + /// Returns `true` if the slice begins with `prefix`. + pub fn starts_with(&self, prefix: &[u8]) -> io::Result { + let n = prefix.len() as u64; + if n > self.len() { + return Ok(false); + } + Ok(self.head(n).read()? == prefix) + } + + /// Returns `true` if the slice ends with `suffix`. + pub fn ends_with(&self, suffix: &[u8]) -> io::Result { + let n = suffix.len() as u64; + if n > self.len() { + return Ok(false); + } + Ok(self.tail(n).read()? == suffix) + } + + /// Returns the index of the first occurrence of `needle`, or `None` if not + /// found. + #[inline] + pub fn find(&self, needle: u8) -> io::Result> { + Ok(self + .read()? + .iter() + .position(|&b| b == needle) + .map(|i| i as u64)) + } + + /// Returns the index of the last occurrence of `needle`, or `None` if not + /// found. + #[inline] + pub fn rfind(&self, needle: u8) -> io::Result> { + Ok(self + .read()? + .iter() + .rposition(|&b| b == needle) + .map(|i| i as u64)) + } + + /// Returns the index of the first byte satisfying `predicate`, or `None`. + #[inline] + pub fn position(&self, predicate: impl Fn(u8) -> bool) -> io::Result> { + Ok(self + .read()? + .iter() + .position(|&b| predicate(b)) + .map(|i| i as u64)) + } + + /// Returns the index of the last byte satisfying `predicate`, or `None`. + #[inline] + pub fn rposition(&self, predicate: impl Fn(u8) -> bool) -> io::Result> { + Ok(self + .read()? + .iter() + .rposition(|&b| predicate(b)) + .map(|i| i as u64)) + } + /// Read the entire slice into a newly allocated `Vec`. /// /// Delegates to [`BStack::get`]. @@ -382,10 +558,360 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { self.stack().zero(self.start() + start, n) } + /// Fill the entire slice with `value`. + /// + /// A single crash-atomic [`BStack::repeat`] call. + /// + /// Requires the `set` feature. + #[cfg(feature = "set")] + #[inline] + pub fn fill(&mut self, value: u8) -> io::Result<()> { + self.stack().repeat(self.start(), [value], self.len()) + } + + /// Fill the slice by calling `f` once per byte. + /// + /// The generated bytes are staged in memory and committed with a single + /// crash-atomic [`write`](Self::write) call. + /// + /// Requires the `set` feature. + #[cfg(feature = "set")] + #[inline] + pub fn fill_with(&mut self, mut f: impl FnMut() -> u8) -> io::Result<()> { + let buf: Vec = (0..self.len()).map(|_| f()).collect(); + self.write(buf) + } + + /// Copy `src` into this slice. + /// + /// A single crash-atomic [`BStack::set`] call. + /// + /// Requires the `set` feature. + /// + /// # Panics + /// + /// Panics if `src.len() != self.len()`. + #[cfg(feature = "set")] + #[inline] + #[track_caller] + pub fn copy_from_slice(&mut self, src: &[u8]) -> io::Result<()> { + assert_eq!( + src.len() as u64, + self.len(), + "copy_from_slice: length mismatch" + ); + self.stack().set(self.start(), src) + } + + /// Copy the contents of `src` into this slice. + /// + /// A single crash-atomic [`BStack::copy`] call. `src` and `self` may + /// overlap or refer to the same region. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `src.len() != self.len()`. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if `src` is backed by a + /// different [`BStack`]. + #[cfg(all(feature = "set", feature = "atomic"))] + #[track_caller] + pub fn copy_from_bstack_slice(&mut self, src: &BStackSlice<'_, A>) -> io::Result<()> { + assert_eq!( + src.len(), + self.len(), + "copy_from_bstack_slice: length mismatch" + ); + if !std::ptr::eq(src.stack(), self.stack()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackSlice::copy_from_bstack_slice: source belongs to a different BStack", + )); + } + if self.is_empty() { + return Ok(()); + } + self.stack().copy(src.start(), self.start(), self.len()) + } + + /// Copy `src_range` (relative to this slice) to `dest` (relative to this + /// slice), within this slice. + /// + /// A single crash-atomic [`BStack::copy`] call; overlapping source and + /// destination are handled correctly. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `src_range.start > src_range.end`, if `src_range.end > + /// self.len()`, or if `dest + src_range.len()` overflows `u64` or exceeds + /// `self.len()`. + #[cfg(all(feature = "set", feature = "atomic"))] + #[track_caller] + pub fn copy_within(&mut self, src_range: Range, dest: u64) -> io::Result<()> { + assert!( + src_range.start <= src_range.end, + "copy_within: range start must be <= end" + ); + assert!( + src_range.end <= self.len(), + "copy_within: range end must be <= slice length" + ); + let n = src_range.end - src_range.start; + let dest_end = dest + .checked_add(n) + .expect("copy_within: dest + len overflows u64"); + assert!( + dest_end <= self.len(), + "copy_within: dest range exceeds slice length" + ); + if n == 0 { + return Ok(()); + } + self.stack() + .copy(self.start() + src_range.start, self.start() + dest, n) + } + + /// Overwrite this slice with `new_bytes` if `guard`'s current contents + /// equal `expected`. + /// + /// One crash-atomic [`BStack::eq_crds`] call: `guard`'s bytes are read + /// and compared to `expected`, and if they match, `self` is overwritten + /// with `new_bytes` — all under the same write lock, so no other thread + /// can observe the comparison and the write as separate steps. Returns + /// the prior contents of `self` as `Ok(Some(_))` if the swap ran, or + /// `Ok(None)` if the comparison failed, leaving `self` untouched. + /// + /// `guard` may be a view into the same or a different region of `self`'s + /// [`BStack`], including `self` itself. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if `guard` and `self` are + /// backed by different [`BStack`]s, if `expected.as_ref().len() != + /// guard.len()`, or if `new_bytes.as_ref().len() != self.len()`. + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn cas_on( + &mut self, + guard: &BStackSlice<'_, A>, + expected: impl AsRef<[u8]>, + new_bytes: impl AsRef<[u8]>, + ) -> io::Result>> { + let expected = expected.as_ref(); + let new_bytes = new_bytes.as_ref(); + self.check_cas_args("cas_on", guard, expected, new_bytes)?; + self.stack() + .eq_crds(guard.start(), expected, self.start(), new_bytes) + } + + /// Overwrite this slice with `new_bytes` if `guard`'s current contents do + /// **not** equal `expected`. + /// + /// Like [`cas_on`](Self::cas_on) but wraps [`BStack::ne_crds`]: the swap + /// runs when the comparison fails rather than when it succeeds. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Errors + /// + /// Same conditions as [`cas_on`](Self::cas_on). + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn cas_on_ne( + &mut self, + guard: &BStackSlice<'_, A>, + expected: impl AsRef<[u8]>, + new_bytes: impl AsRef<[u8]>, + ) -> io::Result>> { + let expected = expected.as_ref(); + let new_bytes = new_bytes.as_ref(); + self.check_cas_args("cas_on_ne", guard, expected, new_bytes)?; + self.stack() + .ne_crds(guard.start(), expected, self.start(), new_bytes) + } + + /// Overwrite this slice with `new_bytes` if `guard`'s current contents + /// equal `expected` under a bitwise `mask`. + /// + /// Like [`cas_on`](Self::cas_on) but wraps [`BStack::masked_eq_crds`]: + /// the condition is `(guard[i] & mask[i]) == (expected[i] & mask[i])` for + /// every byte `i`. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Errors + /// + /// Same conditions as [`cas_on`](Self::cas_on), plus + /// [`io::ErrorKind::InvalidInput`] if `mask.as_ref().len() != + /// expected.as_ref().len()` (checked by [`BStack::masked_eq_crds`] + /// itself). + #[cfg(all(feature = "set", feature = "atomic"))] + pub fn cas_on_masked( + &mut self, + guard: &BStackSlice<'_, A>, + mask: impl AsRef<[u8]>, + expected: impl AsRef<[u8]>, + new_bytes: impl AsRef<[u8]>, + ) -> io::Result>> { + let expected = expected.as_ref(); + let new_bytes = new_bytes.as_ref(); + self.check_cas_args("cas_on_masked", guard, expected, new_bytes)?; + self.stack() + .masked_eq_crds(guard.start(), mask, expected, self.start(), new_bytes) + } + + /// Shared argument validation for the `cas_on*` family: same [`BStack`], + /// `expected` sized to `guard`, `new_bytes` sized to `self`. + #[cfg(all(feature = "set", feature = "atomic"))] + fn check_cas_args( + &self, + method: &str, + guard: &BStackSlice<'_, A>, + expected: &[u8], + new_bytes: &[u8], + ) -> io::Result<()> { + if !std::ptr::eq(self.stack(), guard.stack()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("BStackSlice::{method}: guard belongs to a different BStack"), + )); + } + if expected.len() as u64 != guard.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "BStackSlice::{method}: expected length ({}) != guard length ({})", + expected.len(), + guard.len() + ), + )); + } + if new_bytes.len() as u64 != self.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "BStackSlice::{method}: new_bytes length ({}) != self length ({})", + new_bytes.len(), + self.len() + ), + )); + } + Ok(()) + } + + /// Swap the contents of this slice with `other`. + /// + /// A single crash-atomic [`BStack::cross_exchange`] call. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `self.len() != other.len()`. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if `other` is backed by a + /// different [`BStack`]. + #[cfg(all(feature = "set", feature = "atomic"))] + #[track_caller] + pub fn swap(&mut self, other: &mut BStackSlice<'_, A>) -> io::Result<()> { + assert_eq!(self.len(), other.len(), "swap: length mismatch"); + if !std::ptr::eq(self.stack(), other.stack()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackSlice::swap: slices belong to different BStacks", + )); + } + if self.is_empty() || self.start() == other.start() { + return Ok(()); + } + self.stack() + .cross_exchange(self.start(), other.start(), self.len()) + } + + /// Run a length-preserving transform over this slice's bytes in place. + /// + /// One crash-atomic [`BStack::process`] call: the slice's bytes are + /// read, handed to `f` for in-memory mutation, then written back, all + /// under the same write lock. `f` must not change the buffer's length — + /// this only rewrites `self`'s existing bytes, so no allocator + /// interaction is needed. [`reverse`](Self::reverse), + /// [`rotate_left`](Self::rotate_left), and + /// [`rotate_right`](Self::rotate_right) are built on the same primitive. + /// + /// Requires the `set` and `atomic` features. + #[cfg(all(feature = "set", feature = "atomic"))] + #[inline] + pub fn process(&mut self, f: F) -> io::Result<()> { + self.stack().process(self.start(), self.end(), f) + } + + /// Reverse the byte order of this slice in place. + /// + /// A single crash-atomic [`BStack::process`] call: the bytes are read, + /// reversed in memory, then committed in one write. + /// + /// Requires the `set` and `atomic` features. + #[cfg(all(feature = "set", feature = "atomic"))] + #[inline] + pub fn reverse(&mut self) -> io::Result<()> { + self.stack() + .process(self.start(), self.end(), |buf| buf.reverse()) + } + + /// Rotate the slice in place such that the bytes at `[mid, len)` move to + /// the front. + /// + /// A single crash-atomic [`BStack::process`] call. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `mid > self.len()`. + #[cfg(all(feature = "set", feature = "atomic"))] + #[track_caller] + pub fn rotate_left(&mut self, mid: u64) -> io::Result<()> { + assert!( + mid <= self.len(), + "rotate_left: mid must be <= slice length" + ); + self.stack().process(self.start(), self.end(), |buf| { + buf.rotate_left(mid as usize) + }) + } + + /// Rotate the slice in place such that the last `k` bytes move to the + /// front. + /// + /// A single crash-atomic [`BStack::process`] call. + /// + /// Requires the `set` and `atomic` features. + /// + /// # Panics + /// + /// Panics if `k > self.len()`. + #[cfg(all(feature = "set", feature = "atomic"))] + #[track_caller] + pub fn rotate_right(&mut self, k: u64) -> io::Result<()> { + assert!(k <= self.len(), "rotate_right: k must be <= slice length"); + self.stack() + .process(self.start(), self.end(), |buf| buf.rotate_right(k as usize)) + } + /// Create a cursor-based reader positioned at the start of this slice. /// /// The reader implements [`io::Read`] and [`io::Seek`] in the coordinate /// space `[0, self.len())`. + #[inline] + #[must_use] pub fn reader(&self) -> BStackSliceReader<'a, A> { BStackSliceReader { slice: *self, @@ -397,6 +923,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// /// `offset` is relative to `self.start()`. Seeking past `self.len()` is /// allowed; subsequent reads return `Ok(0)`. + #[inline] + #[must_use] pub fn reader_at(&self, offset: u64) -> BStackSliceReader<'a, A> { BStackSliceReader { slice: *self, @@ -408,6 +936,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// /// Requires the `set` feature. #[cfg(feature = "set")] + #[inline] + #[must_use] pub fn writer(&self) -> BStackSliceWriter<'a, A> { BStackSliceWriter { slice: *self, @@ -422,6 +952,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// /// Requires the `set` feature. #[cfg(feature = "set")] + #[inline] + #[must_use] pub fn writer_at(&self, offset: u64) -> BStackSliceWriter<'a, A> { BStackSliceWriter { slice: *self, @@ -436,6 +968,7 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// compare [`start`](BStackSlice::start) and [`len`](BStackSlice::len) /// explicitly if allocator identity matters. impl<'a, A: BStackAllocator> PartialEq for BStackSlice<'a, A> { + #[inline] fn eq(&self, other: &Self) -> bool { self.offset == other.offset && self.len == other.len } @@ -497,6 +1030,7 @@ pub struct BStackSliceReader<'a, A: BStackAllocator> { } impl<'a, A: BStackAllocator> Clone for BStackSliceReader<'a, A> { + #[inline] fn clone(&self) -> Self { *self } @@ -515,15 +1049,31 @@ impl<'a, A: BStackAllocator> fmt::Debug for BStackSliceReader<'a, A> { } } +/// The slice's range and the cursor, as `start..end@cursor` — the cursor +/// relative to the slice, as [`position`](Self::position) reports it. +impl<'a, A: BStackAllocator> fmt::Display for BStackSliceReader<'a, A> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}..{}@{}", + self.slice.start(), + self.slice.end(), + self.cursor + ) + } +} + impl<'a, A: BStackAllocator> BStackSliceReader<'a, A> { /// Return the current cursor position within the slice (not the payload). #[inline] + #[must_use] pub fn position(&self) -> u64 { self.cursor } /// Return the underlying [`BStackSlice`]. #[inline] + #[must_use] pub fn slice(&self) -> BStackSlice<'a, A> { self.slice } @@ -650,16 +1200,33 @@ impl<'a, A: BStackAllocator> fmt::Debug for BStackSliceWriter<'a, A> { } } +/// The slice's range and the cursor, as `start..end@cursor` — the cursor +/// relative to the slice, as [`position`](Self::position) reports it. +#[cfg(feature = "set")] +impl<'a, A: BStackAllocator> fmt::Display for BStackSliceWriter<'a, A> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}..{}@{}", + self.slice.start(), + self.slice.end(), + self.cursor + ) + } +} + #[cfg(feature = "set")] impl<'a, A: BStackAllocator> BStackSliceWriter<'a, A> { /// Return the current cursor position within the slice (not the payload). #[inline] + #[must_use] pub fn position(&self) -> u64 { self.cursor } /// Return the underlying [`BStackSlice`]. #[inline] + #[must_use] pub fn slice(&self) -> BStackSlice<'a, A> { self.slice } @@ -685,6 +1252,7 @@ impl<'a, A: BStackAllocator> io::Write for BStackSliceWriter<'a, A> { } /// No-op: every [`write`](io::Write::write) is already durably synced. + #[inline] fn flush(&mut self) -> io::Result<()> { Ok(()) } diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index acac4f3b..9a3b2bec 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -5,6 +5,8 @@ use super::{BStackSlice, BStackSliceAllocator}; use std::fmt; use std::io; +#[cfg(feature = "atomic")] +use std::ops::Range; /// Byte offset of the first element within the block (past the 16-byte header). const HEADER_LEN: u64 = 16; @@ -75,7 +77,10 @@ const HEADER_LEN: u64 = 16; /// | `truncate` | write `len` → zero removed slots | Crash after `len` write but before zero: stale bytes may remain in now out-of-range slots, but reads never include them because they are beyond `len`. | /// | `resize` (grow) | `reserve` → write elements → write `len` | Elements between the old and new `len` may be partially written. | /// | `clear` | (delegates to `truncate(0)`) | See `truncate`. | -/// | `reserve` | `realloc` → write `cap` | Crash between the two: cap field may reflect the old value; the block is larger than cap indicates. Harmless — the next `push` re-checks and may realloc again unnecessarily. | +/// | `reserve`, `reserve_exact` | `realloc` → write `cap` | Crash between the two: cap field may reflect the old value; the block is larger than cap indicates. Harmless — the next `push` re-checks and may realloc again unnecessarily. | +/// | `shrink_to`, `shrink_to_fit` | `realloc` (shrink) → write `cap` | Crash between the two: block is smaller than the stale `cap` claims; the next `push` re-checks and grows as needed. | +/// | `set` | write element | Single crash-atomic write; no torn state. | +/// | `fill` | one `repeat` | Single crash-atomic fill of the whole `len`; no torn state. | /// /// In all cases the header is re-read from disk on the next call, so the /// on-disk `(len, cap)` always reflects the last fully committed step. The @@ -177,7 +182,12 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { } /// Reallocate the block to hold `new_cap` bytes, updating `self.slice`. - fn grow_to(&mut self, new_cap: u64) -> io::Result<()> { + /// + /// Handles both growth (`push`, `reserve`, `reserve_exact`) and shrink + /// (`shrink_to`, `shrink_to_fit`): the underlying allocator `realloc` + /// reallocates in either direction, so a `new_cap` below the current + /// capacity shrinks the backing block. + fn realloc_to(&mut self, new_cap: u64) -> io::Result<()> { let new_size = Self::block_size(new_cap)?; // SAFETY: Slice origin requirement is upheld because `self.slice` is // the original allocation handle returned by the constructor, and @@ -195,6 +205,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// /// Allocates a 16-byte block for the header only. The first /// [`push`](Self::push) will trigger a reallocation to 4 bytes. + #[inline] pub fn new(alloc: &'a A) -> io::Result { let slice = alloc.alloc(HEADER_LEN)?; // Header is zero-initialised by the allocator: len=0, cap=0. @@ -202,6 +213,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { } /// Create an empty `BStackByteVec` pre-sized for at least `capacity` bytes. + #[inline] pub fn with_capacity(capacity: u64, alloc: &'a A) -> io::Result { let slice = alloc.alloc(Self::block_size(capacity)?)?; let vec = Self { slice }; @@ -232,6 +244,8 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// by one of the `BStackByteVec` constructors on the same allocator, and the /// block header must have been written by a `BStackByteVec`. Passing an /// unrelated slice is undefined behaviour. + #[inline] + #[must_use] pub unsafe fn from_raw_block(slice: BStackSlice<'a, A>) -> Self { Self { slice } } @@ -239,6 +253,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// Return the number of bytes currently stored. /// /// Re-reads `len` from the block header on every call. + #[inline] pub fn len(&self) -> io::Result { Ok(self.read_header()?.0) } @@ -247,11 +262,13 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// reallocation. /// /// Re-reads `cap` from the block header on every call. + #[inline] pub fn capacity(&self) -> io::Result { Ok(self.read_header()?.1) } /// Return `true` if the vec contains no bytes. + #[inline] pub fn is_empty(&self) -> io::Result { Ok(self.len()? == 0) } @@ -288,6 +305,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// Panics if the `len` read from the block header is corrupt (larger than /// the block can hold), causing the computed end offset to exceed the /// block's length. Corruption is not a recoverable condition here. + #[track_caller] pub fn as_slice(&self) -> io::Result> { let (len, _) = self.read_header()?; Ok(self.slice.subslice(HEADER_LEN, HEADER_LEN + len)) @@ -301,13 +319,40 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { let (len, cap) = self.read_header()?; if len == cap { let new_cap = cap.saturating_mul(2).max(4); - self.grow_to(new_cap)?; + self.realloc_to(new_cap)?; self.write_cap_field(new_cap)?; } self.write_byte_at(len, value)?; self.write_len_field(len + 1) } + /// Append every byte in `data` to the end of the vec. + /// + /// This is the bulk counterpart to [`push`](Self::push): it reserves the + /// required capacity in a single reallocation (if any) and writes all of + /// `data` with a single durable `set` before committing the new `len`, + /// rather than issuing a grow/write/len cycle per byte. A no-op when + /// `data` is empty. + /// + /// # Crash consistency + /// + /// The step order is `reserve` → write elements → write `len`. A crash + /// before the `len` write leaves the appended bytes on disk but beyond the + /// committed `len`, so they are invisible; re-running `extend_from_slice` + /// with the same `data` recovers correctly. + pub fn extend_from_slice(&mut self, data: &[u8]) -> io::Result<()> { + if data.is_empty() { + return Ok(()); + } + let (len, _) = self.read_header()?; + let additional = data.len() as u64; + // `reserve` re-reads the header, checks `len + additional` for overflow, + // and grows the block if needed; `len` is left unchanged. + self.reserve(additional)?; + self.write_bytes_at(len, data)?; + self.write_len_field(len + additional) + } + /// Remove and return the last byte, or `None` if empty. /// /// `len` is decremented before the vacated slot is zeroed. @@ -341,6 +386,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// Remove all bytes without releasing the allocation. /// /// Equivalent to `truncate(0)`. + #[inline] pub fn clear(&mut self) -> io::Result<()> { self.truncate(0) } @@ -361,11 +407,89 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { return Ok(()); } let new_cap = needed.max(cap.saturating_mul(2)); - self.grow_to(new_cap)?; + self.realloc_to(new_cap)?; self.write_cap_field(new_cap)?; Ok(()) } + /// Reserve capacity for exactly `additional` more bytes, without the + /// amortising over-allocation of [`reserve`](Self::reserve). + /// + /// After this call `capacity() >= len() + additional`, growing the block to + /// exactly `len + additional` when it is currently too small. Does nothing + /// if the current capacity is already sufficient. Prefer [`reserve`](Self::reserve) + /// when more insertions are expected; use this when the final size is known. + pub fn reserve_exact(&mut self, additional: u64) -> io::Result<()> { + let (len, cap) = self.read_header()?; + let needed = len.checked_add(additional).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "BStackByteVec::reserve_exact: capacity overflow", + ) + })?; + if needed <= cap { + return Ok(()); + } + self.realloc_to(needed)?; + self.write_cap_field(needed) + } + + /// Shrink the capacity to `min_capacity`, but never below the current `len`. + /// + /// No-op if the current capacity is already `<= min_capacity` (so it never + /// grows). Mirrors the reallocation path used for growth, reallocating the + /// block to `max(len, min_capacity)` bytes. + pub fn shrink_to(&mut self, min_capacity: u64) -> io::Result<()> { + let (len, cap) = self.read_header()?; + let target = min_capacity.max(len); + if target >= cap { + return Ok(()); + } + self.realloc_to(target)?; + self.write_cap_field(target) + } + + /// Shrink the capacity to match `len`, releasing all spare capacity. + /// + /// Equivalent to `shrink_to(0)`. + pub fn shrink_to_fit(&mut self) -> io::Result<()> { + self.shrink_to(0) + } + + /// Overwrite the byte at `index` with `value`. + /// + /// A single in-place, crash-atomic write to an existing slot; capacity and + /// `len` are unchanged. + /// + /// Returns `Ok(None)` if `index >= len` (nothing is written), mirroring + /// [`get`](Self::get); `Ok(Some(()))` on success. `Err` is reserved for I/O + /// failures. + pub fn set(&mut self, index: u64, value: u8) -> io::Result> { + let (len, _) = self.read_header()?; + if index >= len { + return Ok(None); + } + self.write_byte_at(index, value)?; + Ok(Some(())) + } + + /// Overwrite every logical byte with `value`. + /// + /// Backed by a single [`crate::BStack::repeat`] over the populated region, + /// with the same durability as `set` (on this line `repeat` writes the whole + /// region directly — it has no write-in-progress journal). A no-op on an + /// empty vec; capacity and `len` are unchanged. + pub fn fill(&mut self, value: u8) -> io::Result<()> { + let (len, _) = self.read_header()?; + if len == 0 { + return Ok(()); + } + // Fill only the populated region `[0, len)` with a single `repeat`, + // mirroring `BStackSlice::fill`. + let mut region = self.slice.subslice(HEADER_LEN, HEADER_LEN + len); + region.fill(value) + } + /// Set the length to `new_len`, filling any new slots with `value`. /// /// If `new_len <= len`, equivalent to [`truncate`](Self::truncate) and @@ -394,6 +518,7 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// immutably for the iterator's lifetime, preventing concurrent mutation. /// Each byte is read from disk on demand; errors surface as /// `io::Result::Err` items. + #[inline] pub fn iter(&self) -> io::Result> { let (len, _) = self.read_header()?; Ok(BStackByteVecIter { @@ -424,6 +549,8 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// using a stale handle with `realloc` or `dealloc` can corrupt allocator /// state or lose data. Re-fetch with `raw_block()` after any mutation that /// may reallocate. + #[inline] + #[must_use] pub unsafe fn raw_block(&self) -> BStackSlice<'a, A> { self.slice } @@ -432,6 +559,8 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// /// The caller takes responsibility for the allocation. Reconstruct with /// [`BStackByteVec::from_raw_block`]. + #[inline] + #[must_use] pub fn into_raw_block(self) -> BStackSlice<'a, A> { self.slice } @@ -446,11 +575,436 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// After this call the backing storage is released; no further I/O on any /// handle derived from this vec (e.g. a prior [`raw_block`](Self::raw_block) /// copy) is valid. + #[inline] pub fn dealloc(self) -> io::Result<()> { self.slice.allocator().dealloc(self.slice) } } +impl<'a, A: BStackSliceAllocator> io::Write for BStackByteVec<'a, A> { + /// Append `buf` via [`extend_from_slice`](Self::extend_from_slice) and + /// return `buf.len()`. + /// + /// Every call re-reads the 16-byte header via `read_header` and may + /// `realloc` to grow capacity, so `write_all` over many small chunks is + /// materially worse than one `extend_from_slice` call. Call + /// [`reserve`](Self::reserve) beforehand to avoid the repeated regrowth. + fn write(&mut self, buf: &[u8]) -> io::Result { + self.extend_from_slice(buf)?; + Ok(buf.len()) + } + + /// A no-op: every [`extend_from_slice`](Self::extend_from_slice) is + /// already durably synced through the underlying [`crate::BStack`] write. + #[inline] + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +// ── atomic bulk / positional operations (requires the `atomic` feature) ───────── + +/// Operations built on the crash-atomic in-file byte movers +/// [`crate::BStack::copy`] and [`crate::BStack::cross_exchange`]. +/// +/// These need the `atomic` Cargo feature **in addition** to the `alloc` + `set` +/// features the rest of the type requires, so they are compiled only when +/// `atomic` is enabled; the base API is unaffected. +/// +/// ## Crash-consistency classes +/// +/// The append-only movers ([`extend_from_within`](Self::extend_from_within), +/// [`extend_from_bstack_slice`](Self::extend_from_bstack_slice), +/// [`append_from_owned`](Self::append_from_owned)) copy into spare capacity and +/// commit `len` last, so a crash before the commit leaves the extra bytes +/// invisible — the same benign, re-runnable model as [`push`](Self::push). +/// +/// The in-place movers ([`insert`](Self::insert), [`remove`](Self::remove), +/// [`swap_remove`](Self::swap_remove), [`move_tail_into`](Self::move_tail_into)) +/// mutate the live region before committing the new `len`. Every individual +/// `BStack` call is still crash-atomic, so the on-disk `(len, cap)` header is +/// never left invalid, but the multi-step method is not atomic: a crash between +/// the byte move and the `len` commit leaves a *logically torn* (but +/// structurally valid) vec that is not automatically recovered. Callers needing +/// all-or-nothing semantics for these must layer their own journaling. +#[cfg(feature = "atomic")] +impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { + /// Absolute payload offset of logical byte `index` within the backing + /// [`crate::BStack`], i.e. the coordinate accepted by [`crate::BStack::copy`], + /// [`crate::BStack::cross_exchange`], and [`crate::BStack::repeat`]. + /// + /// Equal to the block's start plus the 16-byte header plus `index`. Must be + /// recomputed after any reallocation, since the block's start may move. + fn abs_offset(&self, index: u64) -> u64 { + self.slice + .start() + .saturating_add(HEADER_LEN) + .saturating_add(index) + } + + /// Append a copy of the existing bytes `[start, start + count)` to the end of + /// the vec. + /// + /// The source range must lie within the current `len`. Backed by a single + /// crash-atomic [`crate::BStack::copy`] into spare capacity; benign crash + /// model identical to [`push`](Self::push) (`reserve` → copy → write `len`, so + /// a crash before the commit leaves the copied bytes invisible and re-running + /// recovers). + /// + /// Returns `Ok(None)` if the source range is out of bounds — `start + count` + /// overflows `u64` or exceeds `len` — and `Ok(Some(()))` on success (an empty + /// range is a successful no-op). `Err` is reserved for I/O failures. + pub fn extend_from_within(&mut self, start: u64, count: u64) -> io::Result> { + if count == 0 { + return Ok(Some(())); + } + let (len, _) = self.read_header()?; + // Out of bounds (overflow or past `len`) → None, per the get()-style contract. + match start.checked_add(count) { + Some(end) if end <= len => {} + _ => return Ok(None), + } + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + self.reserve(count)?; + // Recompute offsets after `reserve`: a realloc may have moved the block. + let src = self.abs_offset(start); + let dst = self.abs_offset(len); + stack.copy(src, dst, count)?; + self.write_len_field(len + count)?; + Ok(Some(())) + } + + /// Insert `value` at `index`, shifting every byte at or after `index` one slot + /// to the right. + /// + /// The shift is a single crash-atomic [`crate::BStack::copy`] (an overlapping + /// move, handled internally by the write-in-progress journal). In-place + /// mover: see the impl-level note — a crash between the shift and the `len` + /// commit leaves a logically torn but structurally valid vec. + /// + /// Returns `Ok(None)` if `index > len` (out of bounds; nothing is inserted) + /// and `Ok(Some(()))` on success. `Err` is reserved for I/O failures. + pub fn insert(&mut self, index: u64, value: u8) -> io::Result> { + let (len, _) = self.read_header()?; + if index > len { + return Ok(None); + } + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + self.reserve(1)?; + if index < len { + let n = len - index; + stack.copy(self.abs_offset(index), self.abs_offset(index + 1), n)?; + } + self.write_byte_at(index, value)?; + self.write_len_field(len + 1)?; + Ok(Some(())) + } + + /// Remove and return the byte at `index`, shifting every later byte one slot + /// to the left (preserves order). + /// + /// The shift is a single crash-atomic [`crate::BStack::copy`]; the vacated + /// tail slot is then zeroed as in [`pop`](Self::pop). In-place mover: a crash + /// between the shift and the `len` commit leaves a logically torn but + /// structurally valid vec (see the impl-level note). + /// + /// Returns `Ok(None)` if `index >= len` (out of bounds; nothing is removed) + /// and `Ok(Some(byte))` with the removed byte on success. `Err` is reserved + /// for I/O failures. + pub fn remove(&mut self, index: u64) -> io::Result> { + let (len, _) = self.read_header()?; + if index >= len { + return Ok(None); + } + let value = self.read_byte_at(index)?; + let tail = len - index - 1; + if tail > 0 { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + stack.copy(self.abs_offset(index + 1), self.abs_offset(index), tail)?; + } + self.write_len_field(len - 1)?; + self.zero_byte_at(len - 1)?; + Ok(Some(value)) + } + + /// Remove the byte at `index` and return it, replacing the hole with the last + /// byte (O(1), does **not** preserve order). + /// + /// Uses a single crash-atomic [`crate::BStack::cross_exchange`] to swap the + /// element into the tail slot, which is then dropped as in [`pop`](Self::pop). + /// In-place mover: a crash after the exchange but before the `len` commit + /// leaves the element at `index` and the last element swapped — a reordering, + /// not corruption; the vec stays structurally valid (see the impl-level note). + /// + /// Returns `Ok(None)` if `index >= len` (out of bounds; nothing is removed) + /// and `Ok(Some(byte))` with the removed byte on success. `Err` is reserved + /// for I/O failures. + pub fn swap_remove(&mut self, index: u64) -> io::Result> { + let (len, _) = self.read_header()?; + if index >= len { + return Ok(None); + } + let value = self.read_byte_at(index)?; + let last = len - 1; + if index != last { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + stack.cross_exchange(self.abs_offset(index), self.abs_offset(last), 1)?; + } + self.write_len_field(last)?; + self.zero_byte_at(last)?; + Ok(Some(value)) + } + + /// Append the bytes of an on-disk [`BStackSlice`] to the end of the vec. + /// + /// `src` must be backed by the same [`crate::BStack`] as this vec (the bytes + /// are copied within one file). Backed by a single crash-atomic + /// [`crate::BStack::copy`] into spare capacity; benign crash model identical + /// to [`push`](Self::push). + /// + /// # Errors + /// + /// [`io::ErrorKind::InvalidInput`] if `src` is backed by a different `BStack`. + pub fn extend_from_bstack_slice(&mut self, src: &BStackSlice<'_, A>) -> io::Result<()> { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + if !std::ptr::eq(src.stack(), stack) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackByteVec::extend_from_bstack_slice: source belongs to a different BStack", + )); + } + let n = src.len(); + if n == 0 { + return Ok(()); + } + let src_start = src.start(); + let (len, _) = self.read_header()?; + self.reserve(n)?; + let dst = self.abs_offset(len); + stack.copy(src_start, dst, n)?; + self.write_len_field(len + n) + } + + /// Copy `dst.len()` bytes from the vec, starting at logical `start`, into the + /// destination [`BStackSlice`] (overwriting it). + /// + /// The number of bytes copied is the destination's length. `dst` must be + /// backed by the same [`crate::BStack`] as this vec. A single crash-atomic + /// [`crate::BStack::copy`]; the vec itself is not modified. + /// + /// Returns `Ok(None)` if the source range is out of bounds — `start + + /// dst.len()` overflows `u64` or exceeds `len` — and `Ok(Some(()))` on success + /// (an empty destination is a successful no-op). + /// + /// # Errors + /// + /// [`io::ErrorKind::InvalidInput`] if `dst` is backed by a different `BStack` + /// (a misuse, distinct from an out-of-range request); otherwise `Err` is + /// reserved for I/O failures. + pub fn copy_into_bstack_slice( + &self, + start: u64, + dst: &mut BStackSlice<'_, A>, + ) -> io::Result> { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + if !std::ptr::eq(dst.stack(), stack) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackByteVec::copy_into_bstack_slice: destination belongs to a different BStack", + )); + } + let n = dst.len(); + if n == 0 { + return Ok(Some(())); + } + let (len, _) = self.read_header()?; + // Out of bounds (overflow or past `len`) → None. + match start.checked_add(n) { + Some(end) if end <= len => {} + _ => return Ok(None), + } + stack.copy(self.abs_offset(start), dst.start(), n)?; + Ok(Some(())) + } + + /// Append the bytes of `other` to the vec, then deallocate `other` — a move + /// that consumes the handle. + /// + /// `other`'s bytes are copied into spare capacity with a single crash-atomic + /// [`crate::BStack::copy`], `len` is committed, and `other` is freed through + /// its allocator. `other` must be backed by the same [`crate::BStack`] as + /// this vec. The copy targets invisible spare capacity and `len` is committed + /// before the free, so a crash before the free leaves the vec correct with + /// `other` merely still allocated (recoverable), never data loss. + /// + /// # Errors + /// + /// [`io::ErrorKind::InvalidInput`] if `other` is backed by a different + /// `BStack`; otherwise propagates the append or dealloc I/O error. `other` is + /// consumed — and, wherever possible, freed — on every path, so it is never + /// leaked silently. + pub fn append_from_owned(&mut self, other: BStackSlice<'a, A>) -> io::Result<()> { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + let other_alloc: &'a A = other.allocator(); + if !std::ptr::eq(other_alloc.stack(), stack) { + // `other` belongs to a different BStack (a misuse). Free it through its + // own allocator so the call is not a leak; if that free itself fails, + // surface the I/O error rather than swallowing it — either way the + // dealloc result is not discarded. + other_alloc.dealloc(other)?; + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackByteVec::append_from_owned: source belongs to a different BStack", + )); + } + // Append first, capturing any error, but always fall through to the free + // so `other` is never leaked on an append failure. + let appended = (|| -> io::Result<()> { + let n = other.len(); + if n == 0 { + return Ok(()); + } + let src = other.start(); + let (len, _) = self.read_header()?; + self.reserve(n)?; + let dst = self.abs_offset(len); + stack.copy(src, dst, n)?; + self.write_len_field(len + n) + })(); + let freed = other_alloc.dealloc(other); + appended.and(freed) + } + + /// Move the last `dest.len()` bytes of the vec into `dest`, shrinking the vec + /// by that many bytes. + /// + /// The tail is swapped into `dest` with a single crash-atomic + /// [`crate::BStack::cross_exchange`] (so the moved bytes exist in exactly one + /// place afterward), and the vacated tail — now holding `dest`'s former + /// contents — is dropped and zeroed by shrinking `len` via + /// [`truncate`](Self::truncate). `dest` must be backed by the same + /// [`crate::BStack`] and sized to exactly the tail being moved. + /// + /// In-place mover: a crash after the exchange but before the truncate leaves + /// the vec's still-visible tail holding `dest`'s former bytes — a logically + /// torn but structurally valid state (see the impl-level note). `dest` holds + /// the moved bytes once the exchange commits. + /// + /// Returns `Ok(None)` if `dest.len() > len` (out of bounds; the vec is + /// unchanged) and `Ok(Some(()))` on success (a zero-length `dest` is a + /// successful no-op). + /// + /// # Errors + /// + /// [`io::ErrorKind::InvalidInput`] if `dest` is backed by a different `BStack` + /// (a misuse, distinct from an out-of-range request); otherwise `Err` is + /// reserved for I/O failures. + pub fn move_tail_into(&mut self, dest: &mut BStackSlice<'a, A>) -> io::Result> { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + if !std::ptr::eq(dest.stack(), stack) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "BStackByteVec::move_tail_into: destination belongs to a different BStack", + )); + } + let n = dest.len(); + if n == 0 { + return Ok(Some(())); + } + let (len, _) = self.read_header()?; + if n > len { + return Ok(None); + } + let start = len - n; + stack.cross_exchange(self.abs_offset(start), dest.start(), n)?; + self.truncate(start)?; + Ok(Some(())) + } + + /// Split the vec in two at `at`: `self` keeps `[0, at)` and a new vec holding + /// `[at, len)` is returned. + /// + /// The new vec is allocated with exactly `len - at` bytes of capacity and + /// the tail is transferred with a single crash-atomic [`crate::BStack::copy`] + /// straight between the two blocks, never passing through process memory — + /// this is why the method is only available under `atomic`; there is no + /// in-memory-copy fallback. In-place mover: a crash after the copy but + /// before `self`'s `len` commit leaves the tail bytes duplicated in both + /// vecs — a logically torn but structurally valid state (see the + /// impl-level note on this block). + /// + /// Returns `Ok(None)` if `at > len` (out of bounds; `self` is unchanged) and + /// `Ok(Some(tail))` on success (`at == len` returns an empty tail). + pub fn split_off(&mut self, at: u64) -> io::Result> { + let (len, _) = self.read_header()?; + if at > len { + return Ok(None); + } + let tail_len = len - at; + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + // Even when length is zero, allocate a new vec with a 16-byte header and zero capacity. + let tail = Self::with_capacity(tail_len, alloc)?; + if tail_len > 0 { + stack.copy(self.abs_offset(at), tail.abs_offset(0), tail_len)?; + tail.write_len_field(tail_len)?; + } + self.truncate(at)?; + Ok(Some(tail)) + } + + /// Remove the bytes in `range`, shifting every later byte down to close the + /// gap, and return the removed bytes. + /// + /// The removed bytes are read out first, the tail (if any) is shifted down + /// with a single crash-atomic [`crate::BStack::copy`], and the shorter `len` + /// is committed last. Like [`split_off`](Self::split_off), only available + /// under `atomic`: there is no crash-atomic way to perform the shift + /// without it, and no in-memory-copy fallback is offered. + /// In-place mover: a crash after the shift but before the `len` commit + /// leaves the payload already compacted while `len` still claims the old, + /// larger extent — a logically torn but structurally valid state (see the + /// impl-level note on this block). + /// + /// Returns `Ok(None)` if `range.start > range.end` or `range.end > len` + /// (out of bounds; the vec is unchanged) and `Ok(Some(bytes))` with the + /// removed bytes on success (an empty range is a successful no-op that + /// returns an empty `Vec`). + pub fn drain(&mut self, range: Range) -> io::Result>> { + let (len, _) = self.read_header()?; + if range.start > range.end || range.end > len { + return Ok(None); + } + let count = range.end - range.start; + if count == 0 { + return Ok(Some(Vec::new())); + } + let removed = self + .slice + .read_range(Self::byte_offset(range.start), Self::byte_offset(range.end))?; + let tail = len - range.end; + if tail > 0 { + let alloc: &'a A = self.slice.allocator(); + let stack = alloc.stack(); + stack.copy( + self.abs_offset(range.end), + self.abs_offset(range.start), + tail, + )?; + } + self.truncate(len - count)?; + Ok(Some(removed)) + } +} + // ── iterator ────────────────────────────────────────────────────────────────── /// An iterator over the bytes of a [`BStackByteVec`]. @@ -458,12 +1012,29 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { /// Constructed by [`BStackByteVec::iter`]. `len` is snapshotted at /// construction; bytes pushed after construction are not visible. Each byte /// is read from disk on demand; I/O errors surface as `Err` items. +/// +/// Implements [`ExactSizeIterator`]: the remaining count is tracked as `u64` +/// and is exact on 64-bit targets. On targets where `usize` is narrower than +/// `u64` (e.g. 32-bit), a count that doesn't fit in `usize` is clamped to +/// `usize::MAX` rather than silently truncated — `size_hint()`/`len()` then +/// under-report, but never wrap to a smaller, wrong value. pub struct BStackByteVecIter<'b, 'a: 'b, A: BStackSliceAllocator> { vec: &'b BStackByteVec<'a, A>, index: u64, len: u64, } +impl<'b, 'a: 'b, A: BStackSliceAllocator> Clone for BStackByteVecIter<'b, 'a, A> { + #[inline] + fn clone(&self) -> Self { + BStackByteVecIter { + vec: self.vec, + index: self.index, + len: self.len, + } + } +} + impl<'b, 'a: 'b, A: BStackSliceAllocator> fmt::Debug for BStackByteVecIter<'b, 'a, A> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("BStackByteVecIter") @@ -476,6 +1047,7 @@ impl<'b, 'a: 'b, A: BStackSliceAllocator> fmt::Debug for BStackByteVecIter<'b, ' impl<'b, 'a: 'b, A: BStackSliceAllocator> Iterator for BStackByteVecIter<'b, 'a, A> { type Item = io::Result; + #[inline] fn next(&mut self) -> Option { if self.index >= self.len { return None; @@ -485,6 +1057,7 @@ impl<'b, 'a: 'b, A: BStackSliceAllocator> Iterator for BStackByteVecIter<'b, 'a, Some(result) } + #[inline] fn size_hint(&self) -> (usize, Option) { // `self.index <= self.len` is invariant; subtraction cannot underflow. // On 32-bit platforms the cast saturates to usize::MAX, which is a @@ -494,6 +1067,24 @@ impl<'b, 'a: 'b, A: BStackSliceAllocator> Iterator for BStackByteVecIter<'b, 'a, } } +impl<'b, 'a: 'b, A: BStackSliceAllocator> DoubleEndedIterator for BStackByteVecIter<'b, 'a, A> { + #[inline] + fn next_back(&mut self) -> Option { + if self.index >= self.len { + return None; + } + self.len -= 1; + Some(self.vec.read_byte_at(self.len)) + } +} + +impl<'b, 'a: 'b, A: BStackSliceAllocator> ExactSizeIterator for BStackByteVecIter<'b, 'a, A> {} + +impl<'b, 'a: 'b, A: BStackSliceAllocator> std::iter::FusedIterator + for BStackByteVecIter<'b, 'a, A> +{ +} + // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -589,6 +1180,41 @@ mod tests { assert_eq!(v2.get(2).unwrap(), Some(3u8)); } + #[test] + fn extend_from_slice_bulk_appends() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + v.push(1).unwrap(); + v.extend_from_slice(&[2, 3, 4, 5]).unwrap(); + assert_eq!(v.len().unwrap(), 5); + assert_eq!(v.read_bytes().unwrap(), vec![1, 2, 3, 4, 5]); + } + + #[test] + fn extend_from_slice_empty_is_noop() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(b"hi", &alloc).unwrap(); + let cap_before = v.capacity().unwrap(); + v.extend_from_slice(&[]).unwrap(); + assert_eq!(v.len().unwrap(), 2); + assert_eq!(v.capacity().unwrap(), cap_before); + assert_eq!(v.read_bytes().unwrap(), b"hi"); + } + + #[test] + fn extend_from_slice_persists_via_raw_block() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + v.extend_from_slice(b"hello world").unwrap(); + let block = v.into_raw_block(); + let v2 = unsafe { BStackByteVec::from_raw_block(block) }; + assert_eq!(v2.len().unwrap(), 11); + assert_eq!(v2.read_bytes().unwrap(), b"hello world"); + } + #[test] fn reopen_header_recovery() { // Verify that (len, cap) survive a drop-and-reopen via from_raw_block. @@ -701,6 +1327,37 @@ mod tests { assert_eq!(v.len().unwrap(), 3); } + // ── io::Write ───────────────────────────────────────────────────────────── + + #[test] + fn io_write_appends_and_returns_len() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + let n = io::Write::write(&mut v, &[1u8, 2, 3]).unwrap(); + assert_eq!(n, 3); + assert_eq!(v.read_bytes().unwrap(), [1u8, 2, 3]); + } + + #[test] + fn io_write_write_all_appends_multiple_chunks() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + io::Write::write_all(&mut v, &[1u8, 2]).unwrap(); + io::Write::write_all(&mut v, &[3u8, 4, 5]).unwrap(); + assert_eq!(v.read_bytes().unwrap(), [1u8, 2, 3, 4, 5]); + } + + #[test] + fn io_write_flush_is_noop() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[7u8], &alloc).unwrap(); + io::Write::flush(&mut v).unwrap(); + assert_eq!(v.read_bytes().unwrap(), [7u8]); + } + // ── truncate / clear ────────────────────────────────────────────────────── #[test] @@ -871,6 +1528,58 @@ mod tests { assert_eq!(count, 0); } + #[test] + fn iter_double_ended_meets_in_the_middle() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let src = [3u8, 1, 4, 1, 5]; + let v = BStackByteVec::from_slice(&src, &alloc).unwrap(); + + let reversed: Vec = v.iter().unwrap().rev().map(|r| r.unwrap()).collect(); + assert_eq!(reversed, [5u8, 1, 4, 1, 3]); + + let mut it = v.iter().unwrap(); + assert_eq!(it.next().unwrap().unwrap(), 3); + assert_eq!(it.next_back().unwrap().unwrap(), 5); + assert_eq!(it.next().unwrap().unwrap(), 1); + assert_eq!(it.next_back().unwrap().unwrap(), 1); + assert_eq!(it.next_back().unwrap().unwrap(), 4); + assert!(it.next().is_none()); + assert!(it.next_back().is_none()); + } + + #[test] + fn iter_exact_size_and_fused() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackByteVec::from_slice(&[1u8, 2, 3], &alloc).unwrap(); + let mut it = v.iter().unwrap(); + assert_eq!(it.len(), 3); + it.next().unwrap().unwrap(); + it.next_back().unwrap().unwrap(); + assert_eq!(it.len(), 1); + it.next().unwrap().unwrap(); + assert_eq!(it.len(), 0); + // Fused: exhausted from either end, and it stays exhausted. + assert!(it.next().is_none()); + assert!(it.next().is_none()); + assert!(it.next_back().is_none()); + } + + #[test] + fn iter_clone_forks_position() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackByteVec::from_slice(&[9u8, 8, 7, 6], &alloc).unwrap(); + let mut it = v.iter().unwrap(); + it.next().unwrap().unwrap(); + let forked = it.clone(); + let a: Vec = it.map(|r| r.unwrap()).collect(); + let b: Vec = forked.map(|r| r.unwrap()).collect(); + assert_eq!(a, [8u8, 7, 6]); + assert_eq!(b, a); + } + // ── integration: block_size overflow ───────────────────────────────────── #[test] @@ -970,4 +1679,435 @@ mod tests { v.pop().unwrap(); assert_eq!(v.as_slice().unwrap().len(), 1); } + + // ── set / fill / reserve_exact / shrink_to / shrink_to_fit ──────────────── + + #[test] + fn set_overwrites_existing_byte() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + assert_eq!(v.set(1, 99).unwrap(), Some(())); + assert_eq!(v.read_bytes().unwrap(), [1, 99, 3]); + // Out of bounds → None, not an error, and nothing is written. + assert_eq!(v.set(3, 0).unwrap(), None); + assert_eq!(v.set(u64::MAX, 0).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [1, 99, 3]); + } + + #[test] + fn fill_overwrites_all_bytes() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4], &alloc).unwrap(); + v.fill(0xEE).unwrap(); + assert_eq!(v.read_bytes().unwrap(), [0xEE; 4]); + assert_eq!(v.len().unwrap(), 4); + } + + #[test] + fn fill_on_empty_vec_is_noop() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + v.fill(0x55).unwrap(); + assert_eq!(v.len().unwrap(), 0); + assert!(v.read_bytes().unwrap().is_empty()); + } + + #[test] + fn reserve_exact_grows_to_exact_capacity() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + v.push(1).unwrap(); // len=1, cap>=4 + v.reserve_exact(9).unwrap(); // needs exactly len+9 = 10 + assert_eq!(v.capacity().unwrap(), 10); + assert_eq!(v.len().unwrap(), 1); + assert_eq!(v.get(0).unwrap(), Some(1u8)); // data preserved + } + + #[test] + fn reserve_exact_noop_when_sufficient() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::with_capacity(10, &alloc).unwrap(); + v.push(7).unwrap(); // len=1, cap=10 + v.reserve_exact(5).unwrap(); // needs 6 <= 10 → no-op + assert_eq!(v.capacity().unwrap(), 10); + } + + #[test] + fn reserve_exact_overflow_returns_error() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::new(&alloc).unwrap(); + v.push(1).unwrap(); // len=1 + let err = v.reserve_exact(u64::MAX).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[test] + fn shrink_to_fit_releases_spare_capacity() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::with_capacity(16, &alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + v.push(3).unwrap(); + assert_eq!(v.capacity().unwrap(), 16); + v.shrink_to_fit().unwrap(); + assert_eq!(v.capacity().unwrap(), 3); // now exactly len + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); // data preserved + } + + #[test] + fn shrink_to_respects_len_lower_bound_and_never_grows() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::with_capacity(20, &alloc).unwrap(); + for b in [1u8, 2, 3, 4, 5] { + v.push(b).unwrap(); // len=5, cap=20 + } + v.shrink_to(2).unwrap(); // below len → clamps up to len (5) + assert_eq!(v.capacity().unwrap(), 5); + v.shrink_to(100).unwrap(); // above cap → no-op + assert_eq!(v.capacity().unwrap(), 5); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3, 4, 5]); // data preserved + } + + #[test] + fn set_and_capacity_persist_across_reopen() { + // set + reserve_exact must survive a drop-and-reopen via from_raw_block. + let path = temp_path(); + let _g = Guard(path.clone()); + + let block_bytes = { + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let mut v = BStackByteVec::from_slice(&[10u8, 20, 30], &alloc).unwrap(); + v.set(1, 200).unwrap(); + v.reserve_exact(5).unwrap(); // cap becomes exactly 8 + let bytes: [u8; 16] = v.into_raw_block().into(); + bytes + }; + + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let block = unsafe { crate::alloc::BStackSlice::from_bytes(&alloc, block_bytes) }; + let v = unsafe { BStackByteVec::from_raw_block(block) }; + assert_eq!(v.len().unwrap(), 3); + assert_eq!(v.capacity().unwrap(), 8); + assert_eq!(v.read_bytes().unwrap(), [10, 200, 30]); + } + + // ── atomic movers: extend_from_within / insert / remove / swap_remove ───── + + #[cfg(feature = "atomic")] + #[test] + fn extend_from_within_appends_copy() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4], &alloc).unwrap(); + assert_eq!(v.extend_from_within(1, 2).unwrap(), Some(())); // copies [2, 3] + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3, 4, 2, 3]); + // Empty range is a successful no-op. + assert_eq!(v.extend_from_within(0, 0).unwrap(), Some(())); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3, 4, 2, 3]); + // Source range out of bounds → None (overflow or past len), vec untouched. + assert_eq!(v.extend_from_within(4, 5).unwrap(), None); + assert_eq!(v.extend_from_within(u64::MAX, 1).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3, 4, 2, 3]); + } + + #[cfg(feature = "atomic")] + #[test] + fn insert_shifts_bytes_right() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[10, 20, 30], &alloc).unwrap(); + assert_eq!(v.insert(1, 99).unwrap(), Some(())); + assert_eq!(v.read_bytes().unwrap(), [10, 99, 20, 30]); + assert_eq!(v.insert(4, 40).unwrap(), Some(())); // at the end + assert_eq!(v.read_bytes().unwrap(), [10, 99, 20, 30, 40]); + // Index past len → None, vec untouched. + assert_eq!(v.insert(10, 0).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [10, 99, 20, 30, 40]); + } + + #[cfg(feature = "atomic")] + #[test] + fn remove_shifts_bytes_left() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[10, 20, 30, 40], &alloc).unwrap(); + assert_eq!(v.remove(1).unwrap(), Some(20)); + assert_eq!(v.read_bytes().unwrap(), [10, 30, 40]); + assert_eq!(v.remove(2).unwrap(), Some(40)); // last element + assert_eq!(v.read_bytes().unwrap(), [10, 30]); + // Index out of bounds → None, vec untouched. + assert_eq!(v.remove(5).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [10, 30]); + } + + #[cfg(feature = "atomic")] + #[test] + fn swap_remove_replaces_with_last() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[10, 20, 30, 40], &alloc).unwrap(); + assert_eq!(v.swap_remove(1).unwrap(), Some(20)); + // slot 1 now holds the former last element (40); order is not preserved. + assert_eq!(v.read_bytes().unwrap(), [10, 40, 30]); + assert_eq!(v.swap_remove(2).unwrap(), Some(30)); // removing the last element + assert_eq!(v.read_bytes().unwrap(), [10, 40]); + // Index out of bounds → None, vec untouched. + assert_eq!(v.swap_remove(9).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [10, 40]); + } + + // ── atomic cross-slice movers ───────────────────────────────────────────── + + #[cfg(feature = "atomic")] + #[test] + fn extend_from_bstack_slice_appends() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + // Pre-size so the vec never reallocs once `src` becomes the tail + // (LinearBStackAllocator can only grow the tail allocation). + let mut v = BStackByteVec::with_capacity(16, &alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + let src = alloc.alloc(3).unwrap(); + src.write([7u8, 8, 9]).unwrap(); + v.extend_from_bstack_slice(&src).unwrap(); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 7, 8, 9]); + } + + #[cfg(feature = "atomic")] + #[test] + fn extend_from_bstack_slice_rejects_foreign_stack() { + let (alloc2, path2) = make_alloc(); + let _g2 = Guard(path2); + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::with_capacity(16, &alloc).unwrap(); + v.push(1).unwrap(); + // `foreign` is backed by a different BStack → misuse Err. + let foreign = alloc2.alloc(2).unwrap(); + foreign.write([5u8, 6]).unwrap(); + let err = v.extend_from_bstack_slice(&foreign).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(v.read_bytes().unwrap(), [1]); // vec untouched + } + + #[cfg(feature = "atomic")] + #[test] + fn copy_into_bstack_slice_writes_destination() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackByteVec::from_slice(&[10, 20, 30, 40, 50], &alloc).unwrap(); + let mut dst = alloc.alloc(3).unwrap(); + assert_eq!(v.copy_into_bstack_slice(1, &mut dst).unwrap(), Some(())); + assert_eq!(dst.read().unwrap(), [20, 30, 40]); + // start + dst.len() beyond the vec's len → None. + assert_eq!(v.copy_into_bstack_slice(4, &mut dst).unwrap(), None); + // Overflow of start + dst.len() → None. + assert_eq!(v.copy_into_bstack_slice(u64::MAX, &mut dst).unwrap(), None); + } + + #[cfg(feature = "atomic")] + #[test] + fn copy_into_bstack_slice_rejects_foreign_stack() { + let (alloc2, path2) = make_alloc(); + let _g2 = Guard(path2); + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + let mut foreign = alloc2.alloc(2).unwrap(); + let err = v.copy_into_bstack_slice(0, &mut foreign).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[cfg(feature = "atomic")] + #[test] + fn append_from_owned_moves_and_frees() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::with_capacity(16, &alloc).unwrap(); + v.push(1).unwrap(); + v.push(2).unwrap(); + v.push(3).unwrap(); + let owned = alloc.alloc(2).unwrap(); + owned.write([8u8, 9]).unwrap(); + v.append_from_owned(owned).unwrap(); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3, 8, 9]); + } + + #[cfg(feature = "atomic")] + #[test] + fn append_from_owned_rejects_and_frees_foreign_stack() { + let (alloc2, path2) = make_alloc(); + let _g2 = Guard(path2); + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2], &alloc).unwrap(); + // `foreign` belongs to a different BStack → misuse Err, and it must be + // freed (consumed) rather than leaked; the stack shrinks back on dealloc. + let size_before = alloc2.stack().len().unwrap(); + let foreign = alloc2.alloc(4).unwrap(); + assert!(alloc2.stack().len().unwrap() > size_before); + let err = v.append_from_owned(foreign).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + // Tail slice freed through its own allocator: stack reclaimed. + assert_eq!(alloc2.stack().len().unwrap(), size_before); + assert_eq!(v.read_bytes().unwrap(), [1, 2]); // vec untouched + } + + #[cfg(feature = "atomic")] + #[test] + fn move_tail_into_transfers_tail_bytes() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4, 5], &alloc).unwrap(); + let mut dest = alloc.alloc(2).unwrap(); + assert_eq!(v.move_tail_into(&mut dest).unwrap(), Some(())); + assert_eq!(dest.read().unwrap(), [4, 5]); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + assert_eq!(v.len().unwrap(), 3); + // A tail larger than len → None, vec unchanged. + let mut too_big = alloc.alloc(9).unwrap(); + assert_eq!(v.move_tail_into(&mut too_big).unwrap(), None); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + } + + #[cfg(feature = "atomic")] + #[test] + fn move_tail_into_rejects_foreign_stack() { + let (alloc2, path2) = make_alloc(); + let _g2 = Guard(path2); + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + let mut foreign = alloc2.alloc(1).unwrap(); + let err = v.move_tail_into(&mut foreign).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); // vec untouched + } + + #[cfg(feature = "atomic")] + #[test] + fn insert_persists_across_reopen() { + // An insert + remove must survive a drop-and-reopen via from_raw_block. + let path = temp_path(); + let _g = Guard(path.clone()); + + let block_bytes = { + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let mut v = BStackByteVec::from_slice(&[10u8, 20, 30], &alloc).unwrap(); + v.insert(1, 99).unwrap(); // [10, 99, 20, 30] + v.remove(3).unwrap(); // [10, 99, 20] + let bytes: [u8; 16] = v.into_raw_block().into(); + bytes + }; + + let alloc = LinearBStackAllocator::new(BStack::open(&path).unwrap()); + let block = unsafe { crate::alloc::BStackSlice::from_bytes(&alloc, block_bytes) }; + let v = unsafe { BStackByteVec::from_raw_block(block) }; + assert_eq!(v.len().unwrap(), 3); + assert_eq!(v.read_bytes().unwrap(), [10, 99, 20]); + } + + // ── split_off (alloc + set + atomic) ─────────────────────────────────────── + + #[cfg(feature = "atomic")] + #[test] + fn split_off_moves_tail_into_new_vec() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4, 5], &alloc).unwrap(); + let tail = v.split_off(2).unwrap().unwrap(); + assert_eq!(v.read_bytes().unwrap(), [1, 2]); + assert_eq!(tail.read_bytes().unwrap(), [3, 4, 5]); + assert_eq!(tail.capacity().unwrap(), 3); + } + + #[cfg(feature = "atomic")] + #[test] + fn split_off_at_zero_moves_everything() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + let tail = v.split_off(0).unwrap().unwrap(); + assert_eq!(v.read_bytes().unwrap(), Vec::::new()); + assert_eq!(tail.read_bytes().unwrap(), [1, 2, 3]); + } + + #[cfg(feature = "atomic")] + #[test] + fn split_off_at_len_returns_empty_tail() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + let tail = v.split_off(3).unwrap().unwrap(); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + assert_eq!(tail.read_bytes().unwrap(), Vec::::new()); + } + + #[cfg(feature = "atomic")] + #[test] + fn split_off_past_len_returns_none() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + assert!(v.split_off(4).unwrap().is_none()); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + } + + // ── drain (alloc + set + atomic) ─────────────────────────────────────────── + + #[cfg(feature = "atomic")] + #[test] + fn drain_removes_interior_range_and_returns_it() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4, 5], &alloc).unwrap(); + let removed = v.drain(1..3).unwrap().unwrap(); + assert_eq!(removed, [2, 3]); + assert_eq!(v.read_bytes().unwrap(), [1, 4, 5]); + assert_eq!(v.len().unwrap(), 3); + } + + #[cfg(feature = "atomic")] + #[test] + fn drain_at_tail_needs_no_shift() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3, 4], &alloc).unwrap(); + let removed = v.drain(2..4).unwrap().unwrap(); + assert_eq!(removed, [3, 4]); + assert_eq!(v.read_bytes().unwrap(), [1, 2]); + } + + #[cfg(feature = "atomic")] + #[test] + fn drain_empty_range_is_noop() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + let removed = v.drain(1..1).unwrap().unwrap(); + assert_eq!(removed, Vec::::new()); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + } + + #[cfg(feature = "atomic")] + #[test] + fn drain_out_of_bounds_returns_none() { + let (alloc, path) = make_alloc(); + let _g = Guard(path); + let mut v = BStackByteVec::from_slice(&[1, 2, 3], &alloc).unwrap(); + assert!(v.drain(2..4).unwrap().is_none()); + #[allow(clippy::reversed_empty_ranges)] + let out_of_order = v.drain(3..2).unwrap(); + assert!(out_of_order.is_none()); + assert_eq!(v.read_bytes().unwrap(), [1, 2, 3]); + } } diff --git a/src/alloc_fuzz_tests.rs b/src/alloc_fuzz_tests.rs index 1a94f4d3..58d27d05 100644 --- a/src/alloc_fuzz_tests.rs +++ b/src/alloc_fuzz_tests.rs @@ -1,6 +1,6 @@ #![cfg(all(test, feature = "alloc", feature = "set"))] -mod alloc_fuzz_tests { +mod tests { use crate::alloc::{ BStackSlice, BStackSliceAllocator, FirstFitBStackAllocator, GhostTreeBstackAllocator, SlabBStackAllocator, diff --git a/src/lib.rs b/src/lib.rs index 60db3908..99f56e14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,7 @@ //! ``` //! //! * **`magic`** — 8 bytes: `BSTK` + major(1 B) + minor(1 B) + patch(1 B) + reserved(1 B). -//! This version writes `BSTK\x00\x01\x0f\x00` (0.1.15). [`open`](BStack::open) +//! This version writes `BSTK\x00\x01\x11\x00` (0.1.17). [`open`](BStack::open) //! accepts any file whose first 6 bytes match `BSTK\x00\x01` (any 0.1.x) and //! rejects anything with a different major or minor. //! * **`clen`** — little-endian `u64` recording the *committed* payload length. @@ -534,8 +534,8 @@ use windows_sys::Win32::Storage::FileSystem::{ #[cfg(windows)] use windows_sys::Win32::System::IO::OVERLAPPED; -/// Full magic for files written by this version (`BSTK` + major 0 + minor 1 + patch 15 + 0). -const MAGIC: [u8; 8] = *b"BSTK\x00\x01\x0f\x00"; +/// Full magic for files written by this version (`BSTK` + major 0 + minor 1 + patch 17 + 0). +const MAGIC: [u8; 8] = *b"BSTK\x00\x01\x11\x00"; /// Compatibility prefix checked on open: `BSTK` + major 0 + minor 1. /// Any file whose first 6 bytes match is considered a compatible 0.1.x file. @@ -619,6 +619,97 @@ fn write_committed_len(file: &mut File, clen: &mut u64, len: u64) -> io::Result< Ok(()) } +/// Validate a batch of sparse-extend writes against a declared extension of +/// `length` bytes. +/// +/// `blocks` holds `(relative_offset, data)` pairs (already stripped of empty +/// `data`), where each relative offset is measured from the current tail. Every +/// block must fit within the freshly grown region `[0, length)` — a block whose +/// range `[off, off + data.len())` runs past `length` (or overflows `u64`) is +/// rejected — and blocks must be pairwise non-overlapping. On success `blocks` is +/// left sorted by relative offset. `op` names the operation for error messages. +/// +/// Shared by `extend_sparse_batched` and `try_extend_sparse_batched`. +fn validate_sparse_blocks(blocks: &mut [(u64, &[u8])], length: u64, op: &str) -> io::Result<()> { + for (off, data) in blocks.iter() { + let end = off.checked_add(data.len() as u64).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{op}: relative offset ({off}) + len ({}) overflows u64", + data.len() + ), + ) + })?; + if end > length { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{op}: write range [{off}, {end}) exceeds declared length ({length})"), + )); + } + } + // Reject overlap: sort by offset, then check each block ends at or before the + // next one begins. + blocks.sort_by_key(|(off, _)| *off); + for pair in blocks.windows(2) { + let (a_off, a_data) = pair[0]; + let (b_off, _) = pair[1]; + let a_end = a_off + a_data.len() as u64; + if a_end > b_off { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{op}: write range [{a_off}, {a_end}) overlaps [{b_off}, ...)"), + )); + } + } + Ok(()) +} + +/// Commit a *sparse* payload growth to `new_len`, writing only `blocks` into the +/// freshly grown region and leaving the rest zero-filled by the filesystem. +/// +/// `logical_offset` is the pre-op payload size (the tail the growth is anchored +/// at); `file_end == HEADER_SIZE + logical_offset` is the pre-op raw file size; +/// `new_len == logical_offset + length` is the post-op payload size (already +/// overflow-checked by the caller). Each `(rel, data)` block is written at logical +/// offset `logical_offset + rel`; callers guarantee every block fits within +/// `[logical_offset, new_len)` and that blocks do not overlap. +/// +/// No journal is needed: the entire grown region sits beyond the committed +/// length, so a crash before the header commit rolls back by truncation, exactly +/// like [`extend`](BStack::extend). On any failure the file is rolled back +/// (best-effort) via `set_len(file_end)` and the header reset to `logical_offset`. +fn commit_sparse_extend( + file: &mut File, + clen: &mut u64, + logical_offset: u64, + file_end: u64, + new_len: u64, + blocks: &[(u64, &[u8])], +) -> io::Result<()> { + file.set_len(HEADER_SIZE + new_len)?; + for (rel, data) in blocks { + if let Err(e) = file + .seek(SeekFrom::Start(HEADER_SIZE + logical_offset + rel)) + .and_then(|_| file.write_all(data)) + { + let _ = file.set_len(file_end); + return Err(e); + } + } + if let Err(e) = write_committed_len(file, clen, new_len).and_then(|_| durable_sync(file)) { + // Roll back: truncate away the growth and reset the header. The cache is + // reset up front so it reflects the rolled-back file even if the + // best-effort header rewrite below fails. + let _ = file.set_len(file_end); + *clen = logical_offset; + let _ = write_committed_len(file, clen, logical_offset); + let _ = durable_sync(file); + return Err(e); + } + Ok(()) +} + /// Read `len` bytes from absolute file position `offset` without modifying /// the file-position cursor, so the caller only needs a shared (read) lock. /// @@ -978,6 +1069,133 @@ impl BStack { Ok(logical_offset) } + /// Sparsely grow the payload by `length` bytes, writing `buf` at the start of + /// the freshly grown region and leaving the remaining `length - buf.len()` + /// bytes zero. + /// + /// Returns the **logical** byte offset at which the growth begins — i.e. the + /// payload size immediately before the call, the anchor `buf` is written at. + /// + /// This is a more efficient alternative to [`push`](Self::push) of a large + /// mostly-zero buffer when you need a large zero-filled region with only a + /// small prefix of real data: the whole `length` is realised with a single + /// `set_len`, so the tail past `buf` costs no write I/O (it reads back as zero + /// from the sparse file), and only `buf` and the header commit are synced. + /// + /// `length = 0` is valid only when `buf` is empty; it writes nothing and + /// returns the current end offset. An empty `buf` with `length > 0` is + /// equivalent to [`extend(length)`](Self::extend). + /// + /// # Atomicity + /// + /// Either the file is grown, `buf` written, the header committed-length + /// updated, and the whole thing durably synced, or the file is left unchanged + /// (best-effort rollback via `set_len` + header reset). No journal is needed: + /// the entire grown region sits beyond the committed length, so a crash before + /// the header commit rolls back by truncation. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if `buf.len()` exceeds `length`, or + /// if the current payload size plus `length` overflows `u64`. Also propagates + /// any I/O error from `set_len`, `write_all`, or `durable_sync`. + pub fn extend_sparse(&self, buf: impl AsRef<[u8]>, length: u64) -> io::Result { + let buf = buf.as_ref(); + if buf.len() as u64 > length { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "extend_sparse: buffer length ({}) exceeds extension length ({length})", + buf.len() + ), + )); + } + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let logical_offset = file_end - HEADER_SIZE; + + if length == 0 { + return Ok(logical_offset); + } + let new_len = logical_offset.checked_add(length).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "extend_sparse: payload size + length overflows u64", + ) + })?; + let one = [(0u64, buf)]; + let blocks: &[(u64, &[u8])] = if buf.is_empty() { &[] } else { &one }; + commit_sparse_extend(file, clen, logical_offset, file_end, new_len, blocks)?; + Ok(logical_offset) + } + + /// Sparsely grow the payload by `length` bytes, scattering several buffers + /// into the freshly grown region and leaving the gaps between them zero. + /// + /// `writes` is any iterator of `(relative_offset, data)` pairs, where each + /// relative offset is measured from the current tail (the returned offset). + /// Each `data` is written at logical offset `tail + relative_offset`; the + /// bytes not covered by any buffer read back as zero. Returns the **logical** + /// byte offset at which the growth begins (the current payload size, the + /// anchor every relative offset is measured from). + /// + /// Like [`extend_sparse`](Self::extend_sparse), the whole `length` is realised + /// with a single `set_len`, so the zero gaps cost no write I/O and only the + /// buffers and the header commit are written and synced. Empty `data` slices + /// are ignored. + /// + /// The writes must be **pairwise non-overlapping** and each must fit within + /// the grown region `[0, length)`; violations are rejected as invalid input. + /// `length = 0` is valid only when every buffer is empty. + /// + /// # Atomicity + /// + /// Either every buffer lands, the header committed-length is updated, and the + /// whole thing is durably synced, or the file is left unchanged (best-effort + /// rollback via `set_len` + header reset). No journal is needed: the entire + /// grown region sits beyond the committed length. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if any `relative_offset + + /// data.len()` overflows `u64` or exceeds `length`, if two writes overlap, or + /// if the current payload size plus `length` overflows `u64`. Also propagates + /// any I/O error from `set_len`, `write_all`, or `durable_sync`. + pub fn extend_sparse_batched(&self, writes: I, length: u64) -> io::Result + where + I: IntoIterator, + D: AsRef<[u8]>, + { + // Materialise the inputs so their `AsRef` slices can be borrowed while we + // validate and stage; drop empty writes (they touch nothing). + let owned: Vec<(u64, D)> = writes.into_iter().collect(); + let mut blocks: Vec<(u64, &[u8])> = owned + .iter() + .map(|(off, d)| (*off, d.as_ref())) + .filter(|(_, d)| !d.is_empty()) + .collect(); + validate_sparse_blocks(&mut blocks, length, "extend_sparse_batched")?; + + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let logical_offset = file_end - HEADER_SIZE; + + if length == 0 { + // Every block was validated to fit within `[0, 0)`, so `blocks` is empty. + return Ok(logical_offset); + } + let new_len = logical_offset.checked_add(length).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "extend_sparse_batched: payload size + length overflows u64", + ) + })?; + commit_sparse_extend(file, clen, logical_offset, file_end, new_len, &blocks)?; + Ok(logical_offset) + } + /// Remove and return the last `n` bytes of the file. /// /// `n = 0` is valid: no bytes are removed and an empty `Vec` is returned. @@ -1403,6 +1621,174 @@ impl BStack { Ok(()) } + /// Grow or shrink the payload to exactly `target` bytes and durable-sync. + /// Any newly grown region is filled with zeros. + /// + /// Returns the payload size immediately before the resize. `target` equal + /// to the current payload size is a valid no-op. + /// + /// # Atomicity + /// + /// Growth follows [`extend`](Self::extend)'s guarantees; shrinkage follows + /// [`discard`](Self::discard)'s. Either the resize completes, the header + /// committed-length is updated, and the whole thing is durably synced, or + /// the file is left unchanged (best-effort rollback via `set_len` + header + /// reset on a growth failure). + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if shrinking would cut into the + /// locked region `[0, locked_len)`. Propagates any I/O error from + /// `set_len`, `write_committed_len`, or `durable_sync`. + pub fn resize(&self, target: u64) -> io::Result { + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let data_size = file_end - HEADER_SIZE; + + if target == data_size { + return Ok(data_size); + } + if target < data_size { + let locked = self.locked.load(Ordering::Acquire); + if target < locked { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("resize({target}) would shrink payload below locked length ({locked})"), + )); + } + file.set_len(HEADER_SIZE + target)?; + // The truncation is the commit point (mirrors `discard`): update the + // cache before the header write, which `?` could skip on error. + *clen = target; + write_committed_len(file, clen, target)?; + durable_sync(file)?; + return Ok(data_size); + } + + // Grow (mirrors `extend`): the OS zero-fills the new region; commit the + // new length, rolling the file back on a commit failure. + file.set_len(HEADER_SIZE + target)?; + if let Err(e) = write_committed_len(file, clen, target).and_then(|_| durable_sync(file)) { + let _ = file.set_len(file_end); + *clen = data_size; + let _ = write_committed_len(file, clen, data_size); + let _ = durable_sync(file); + return Err(e); + } + Ok(data_size) + } + + /// Grow the payload to at least `target` bytes, filling the new region with + /// zeros, and durable-sync. A no-op if the payload is already `target` + /// bytes or longer. + /// + /// Returns the payload size immediately before the call — the grow-only, + /// unconditional counterpart of [`resize`](Self::resize). + /// + /// # Atomicity + /// + /// Same guarantees as [`extend`](Self::extend). + /// + /// # Errors + /// + /// Propagates any I/O error from `set_len`, `write_committed_len`, or + /// `durable_sync`. + pub fn ensure(&self, target: u64) -> io::Result { + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let data_size = file_end - HEADER_SIZE; + + if target <= data_size { + return Ok(data_size); + } + + file.set_len(HEADER_SIZE + target)?; + if let Err(e) = write_committed_len(file, clen, target).and_then(|_| durable_sync(file)) { + let _ = file.set_len(file_end); + *clen = data_size; + let _ = write_committed_len(file, clen, data_size); + let _ = durable_sync(file); + return Err(e); + } + Ok(data_size) + } + + /// Grow the payload to at least `target` bytes, only if it is currently + /// shorter, handing the freshly allocated tail to `f` for initialization + /// before it is committed. + /// + /// If the payload is already `target` bytes or longer, `f` is not called + /// and nothing changes. Otherwise `f` is called with a zero-filled + /// `&mut [u8]` of length `target - old_len` — exactly the region + /// [`ensure`](Self::ensure) would have appended — and whatever `f` leaves + /// in that buffer is what lands on disk; the callback is the only way to + /// populate the grown tail with anything but zeros. + /// + /// Returns the payload size immediately before the call. + /// + /// # Feature flag + /// + /// Only available when the `atomic` Cargo feature is enabled. + /// + /// # Atomicity + /// + /// Crash-atomic on the same terms as [`extend`](Self::extend): `f` runs in + /// memory, under the write lock, before any of its output reaches disk, and + /// the grown region sits beyond the committed length until the final header + /// write, so a crash never observes a partially initialized tail. Either + /// the grown region — with `f`'s edits applied — is committed and durably + /// synced, or the file is left unchanged (best-effort rollback on failure). + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::OutOfMemory`] if `target - old_len` exceeds + /// `isize::MAX` bytes — the maximum size of a single allocation. Propagates + /// any I/O error from `set_len`, `write_all`, or `durable_sync`. + #[cfg(feature = "atomic")] + pub fn ensure_with(&self, target: u64, f: F) -> io::Result + where + F: FnOnce(&mut [u8]), + { + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let data_size = file_end - HEADER_SIZE; + + if target <= data_size { + return Ok(data_size); + } + + // A single allocation can never exceed `isize::MAX` bytes (Rust's own + // allocator limit), which also covers 32-bit targets where `usize` is + // narrower than `u64`. + let growth = target - data_size; + if growth > isize::MAX as u64 { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "ensure_with: growth too large to buffer on this platform", + )); + } + let mut buf = vec![0u8; growth as usize]; + f(&mut buf); + // The cursor is at `file_end` (from the seek above); appending `buf` + // grows the file. The bytes sit beyond the committed length until the + // header write below, so a crash rolls back by truncation. + if let Err(e) = file.write_all(&buf) { + let _ = file.set_len(file_end); + return Err(e); + } + if let Err(e) = write_committed_len(file, clen, target).and_then(|_| durable_sync(file)) { + let _ = file.set_len(file_end); + *clen = data_size; + let _ = write_committed_len(file, clen, data_size); + let _ = durable_sync(file); + return Err(e); + } + Ok(data_size) + } + /// Overwrite `data` bytes in place starting at logical `offset`. /// /// The file size is never changed: if `offset + data.len()` would exceed @@ -1512,6 +1898,76 @@ impl BStack { file.write_all(&zeros)?; durable_sync(file) } + + /// Overwrite `[offset, offset + count * pattern.len())` in place with + /// `count` back-to-back copies of `pattern`. + /// + /// An empty `pattern` or `count == 0` is a no-op. The file size is never + /// changed: if the write would exceed the current payload size the call is + /// rejected. This is the general form of [`zero`](Self::zero) (which is + /// `repeat` of the single byte `0x00`). + /// + /// # Feature flag + /// + /// Only available when the `set` Cargo feature is enabled. + /// + /// # Durability + /// + /// Equivalent to [`set`](Self::set): the whole region is written and durably + /// synced before returning. Unlike the write-in-progress-journal + /// implementation on the 0.4.x line — which journals only the pattern and + /// count — this version has no such journal and writes the full + /// `count * pattern.len()` bytes directly, so a crash-safe fill of a large + /// region is slower and stages the expanded buffer in memory. + pub fn repeat(&self, offset: u64, pattern: impl AsRef<[u8]>, count: u64) -> io::Result<()> { + let pattern = pattern.as_ref(); + if pattern.is_empty() || count == 0 { + return Ok(()); + } + let total = (pattern.len() as u64).checked_mul(count).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "repeat: count * pattern.len() overflows u64", + ) + })?; + let end = offset.checked_add(total).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "repeat: offset + count*pattern.len() overflows u64", + ) + })?; + let total = usize::try_from(total).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "repeat: total length exceeds platform pointer size", + ) + })?; + let mut guard = self.lock.write().unwrap(); + let file = &mut guard.0; + // Load `locked` under the write lock (see `set` for rationale). + let locked = self.locked.load(Ordering::Acquire); + if offset < locked { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("repeat: range [{offset}, {end}) overlaps locked region [0, {locked})"), + )); + } + let data_size = file.seek(SeekFrom::End(0))?.saturating_sub(HEADER_SIZE); + if end > data_size { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("repeat: write end ({end}) exceeds payload size ({data_size})"), + )); + } + // Stage the whole expanded region and write it in one pass (no journal). + let mut buf = Vec::with_capacity(total); + while buf.len() < total { + buf.extend_from_slice(pattern); + } + file.seek(SeekFrom::Start(HEADER_SIZE + offset))?; + file.write_all(&buf)?; + durable_sync(file) + } } // --------------------------------------------------------------------------- @@ -1822,6 +2278,130 @@ impl BStack { Ok(true) } + /// Sparsely grow the payload by `length` bytes with `buf` at the start, only + /// if the current logical payload size equals `s`. + /// + /// The size-guarded counterpart of [`extend_sparse`](Self::extend_sparse). + /// Returns `Ok(true)` if the size matched and the growth was applied (or + /// `length = 0` and no I/O was needed); returns `Ok(false)` without modifying + /// the file if the size does not match. See [`extend_sparse`](Self::extend_sparse) + /// for the sparse-write semantics and efficiency rationale. + /// + /// A malformed request (`buf.len()` exceeding `length`) is rejected with an + /// error regardless of whether the size matches, so it always surfaces rather + /// than being masked by a size mismatch. + /// + /// # Feature flag + /// + /// Only available when the `atomic` Cargo feature is enabled. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if `buf.len()` exceeds `length`, or + /// if the current payload size plus `length` overflows `u64`. Propagates any + /// I/O error from `set_len`, `write_all`, or `durable_sync`. + #[cfg(feature = "atomic")] + pub fn try_extend_sparse( + &self, + s: u64, + buf: impl AsRef<[u8]>, + length: u64, + ) -> io::Result { + let buf = buf.as_ref(); + if buf.len() as u64 > length { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "try_extend_sparse: buffer length ({}) exceeds extension length ({length})", + buf.len() + ), + )); + } + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let data_size = file_end - HEADER_SIZE; + if data_size != s { + return Ok(false); + } + if length == 0 { + return Ok(true); + } + let new_len = data_size.checked_add(length).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "try_extend_sparse: data_size + length overflows u64", + ) + })?; + let one = [(0u64, buf)]; + let blocks: &[(u64, &[u8])] = if buf.is_empty() { &[] } else { &one }; + commit_sparse_extend(file, clen, data_size, file_end, new_len, blocks)?; + Ok(true) + } + + /// Sparsely grow the payload by `length` bytes, scattering several buffers + /// into the grown region, only if the current logical payload size equals `s`. + /// + /// The size-guarded counterpart of + /// [`extend_sparse_batched`](Self::extend_sparse_batched). Returns `Ok(true)` + /// if the size matched and the growth was applied (or `length = 0` and no I/O + /// was needed); returns `Ok(false)` without modifying the file if the size + /// does not match. See [`extend_sparse_batched`](Self::extend_sparse_batched) + /// for the scatter semantics. + /// + /// A malformed batch (overlapping writes, or a write past `length`) is + /// rejected with an error regardless of whether the size matches, so it always + /// surfaces rather than being masked by a size mismatch. + /// + /// # Feature flag + /// + /// Only available when the `atomic` Cargo feature is enabled. + /// + /// # Errors + /// + /// Returns [`io::ErrorKind::InvalidInput`] if any `relative_offset + + /// data.len()` overflows `u64` or exceeds `length`, if two writes overlap, or + /// if the current payload size plus `length` overflows `u64`. Propagates any + /// I/O error from `set_len`, `write_all`, or `durable_sync`. + #[cfg(feature = "atomic")] + pub fn try_extend_sparse_batched( + &self, + s: u64, + writes: I, + length: u64, + ) -> io::Result + where + I: IntoIterator, + D: AsRef<[u8]>, + { + let owned: Vec<(u64, D)> = writes.into_iter().collect(); + let mut blocks: Vec<(u64, &[u8])> = owned + .iter() + .map(|(off, d)| (*off, d.as_ref())) + .filter(|(_, d)| !d.is_empty()) + .collect(); + validate_sparse_blocks(&mut blocks, length, "try_extend_sparse_batched")?; + + let mut guard = self.lock.write().unwrap(); + let (file, clen) = &mut *guard; + let file_end = file.seek(SeekFrom::End(0))?; + let data_size = file_end - HEADER_SIZE; + if data_size != s { + return Ok(false); + } + if length == 0 { + return Ok(true); + } + let new_len = data_size.checked_add(length).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "try_extend_sparse_batched: data_size + length overflows u64", + ) + })?; + commit_sparse_extend(file, clen, data_size, file_end, new_len, &blocks)?; + Ok(true) + } + /// Append `n` zero bytes only if the current logical payload size equals `s`. /// /// Returns `Ok(true)` if the size matched and `n` zero bytes were appended @@ -3495,6 +4075,7 @@ impl BStack { /// # Errors /// /// Never actually fails; returns [`io::Result`] for source compatibility. + #[inline] pub fn len(&self) -> io::Result { Ok(self.lock.read().unwrap().1) } @@ -3504,6 +4085,7 @@ impl BStack { /// # Errors /// /// Never actually fails; returns [`io::Result`] for source compatibility. + #[inline] pub fn is_empty(&self) -> io::Result { Ok(self.lock.read().unwrap().1 == 0) } @@ -3515,6 +4097,8 @@ impl BStack { /// touch them return [`io::ErrorKind::InvalidInput`]. For /// [`get`](Self::get) and [`get_into`](Self::get_into), reads to ranges /// entirely within it skip the rwlock. + #[inline] + #[must_use] pub fn locked_len(&self) -> u64 { self.locked.load(Ordering::Acquire) } @@ -3663,6 +4247,7 @@ impl BStack { /// Propagates all errors from [`open`](Self::open). Returns /// [`io::ErrorKind::InvalidInput`] if `n` exceeds the payload length of /// the opened file. + #[inline] pub fn open_locked_up_to(path: impl AsRef, n: u64) -> io::Result { let stack = Self::open(path)?; stack.lock_up_to(n)?; @@ -3681,6 +4266,7 @@ impl BStack { /// # Errors /// /// Propagates all errors from [`open`](Self::open). + #[inline] pub fn open_cached(path: impl AsRef) -> io::Result { let mut stack = Self::open(path)?; stack.cache_enabled = true; @@ -3698,6 +4284,7 @@ impl BStack { /// [`lock_up_to`](Self::lock_up_to). /// Returns [`io::ErrorKind::InvalidInput`] if `n` exceeds the payload /// length of the opened file. + #[inline] pub fn open_locked_up_to_cached(path: impl AsRef, n: u64) -> io::Result { let stack = Self::open_cached(path)?; stack.lock_up_to(n)?; @@ -3719,11 +4306,13 @@ impl BStack { /// [`flush`](io::Write::flush) is a no-op because every `write` is already /// durable. impl io::Write for BStack { + #[inline] fn write(&mut self, buf: &[u8]) -> io::Result { self.push(buf)?; Ok(buf.len()) } + #[inline] fn flush(&mut self) -> io::Result<()> { Ok(()) } @@ -3735,11 +4324,13 @@ impl io::Write for BStack { /// `RwLock`), the `Write` implementation is also available on `&BStack`, /// mirroring the standard library's `impl Write for &File`. impl io::Write for &BStack { + #[inline] fn write(&mut self, buf: &[u8]) -> io::Result { self.push(buf)?; Ok(buf.len()) } + #[inline] fn flush(&mut self) -> io::Result<()> { Ok(()) } @@ -3766,14 +4357,24 @@ impl Eq for BStack {} /// time. Pointer identity is therefore the only meaningful equality: a stack /// is equal to itself and to nothing else. impl PartialEq for BStack { + #[inline] fn eq(&self, other: &Self) -> bool { std::ptr::eq(self, other) } } -/// Hashes the instance address, consistent with the pointer-identity [`PartialEq`]. +/// Hashes the raw fd (Unix) / handle (Windows), consistent with the +/// pointer-identity [`PartialEq`]: unique per live instance, and unlike the +/// address it is stable across moves. On other platforms the instance address +/// is hashed, which is not move-stable. impl Hash for BStack { + #[inline] fn hash(&self, state: &mut H) { + #[cfg(unix)] + self.fd.hash(state); + #[cfg(windows)] + self.handle.hash(state); + #[cfg(not(any(unix, windows)))] (self as *const BStack).hash(state); } } @@ -3817,8 +4418,26 @@ pub struct BStackReader<'a> { offset: u64, } +impl<'a> fmt::Debug for BStackReader<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BStackReader") + .field("position", &self.offset) + .field("len", &self.stack.len().ok()) + .finish_non_exhaustive() + } +} + +/// The read position within the payload, as `@position`. +impl<'a> fmt::Display for BStackReader<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "@{}", self.offset) + } +} + impl BStack { /// Create a [`BStackReader`] positioned at the start of the payload. + #[inline] + #[must_use] pub fn reader(&self) -> BStackReader<'_> { BStackReader { stack: self, @@ -3830,6 +4449,8 @@ impl BStack { /// /// Seeking past the current end is allowed; [`read`](io::Read::read) will /// return `Ok(0)` until new data is pushed past that point. + #[inline] + #[must_use] pub fn reader_at(&self, offset: u64) -> BStackReader<'_> { BStackReader { stack: self, @@ -3840,24 +4461,29 @@ impl BStack { impl<'a> BStackReader<'a> { /// Return the current logical read offset within the payload. + #[inline] + #[must_use] pub fn position(&self) -> u64 { self.offset } } impl<'a> From<&'a BStack> for BStackReader<'a> { + #[inline] fn from(stack: &'a BStack) -> Self { stack.reader() } } impl<'a> From> for &'a BStack { + #[inline] fn from(val: BStackReader<'a>) -> Self { val.stack } } impl<'a> PartialOrd for BStackReader<'a> { + #[inline] fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } @@ -3869,6 +4495,7 @@ impl<'a> PartialOrd for BStackReader<'a> { /// and within that group the natural read order (smaller offset first) applies. /// This ordering is consistent with the pointer-identity [`PartialEq`]. impl<'a> Ord for BStackReader<'a> { + #[inline] fn cmp(&self, other: &Self) -> std::cmp::Ordering { let self_ptr = self.stack as *const BStack as usize; let other_ptr = other.stack as *const BStack as usize; diff --git a/src/test.rs b/src/test.rs index c5db1572..2de5d26d 100644 --- a/src/test.rs +++ b/src/test.rs @@ -237,7 +237,7 @@ mod tests { let _g = Guard(p); let off0 = s.push(b"abc").unwrap(); - let off1 = s.push(&[]).unwrap(); + let off1 = s.push([]).unwrap(); let off2 = s.push(b"def").unwrap(); assert_eq!(off0, 0); @@ -861,6 +861,233 @@ mod tests { assert_eq!(s2.peek(0).unwrap(), b"hi\x00\x00"); } + // ---- resize --------------------------------------------------------- + + #[test] + fn resize_grows_with_zeros() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"abc").unwrap(); + let initial = s.resize(6).unwrap(); + assert_eq!(initial, 3); + assert_eq!(s.len().unwrap(), 6); + assert_eq!(s.peek(0).unwrap(), b"abc\x00\x00\x00"); + } + + #[test] + fn resize_shrinks() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + let initial = s.resize(5).unwrap(); + assert_eq!(initial, 10); + assert_eq!(s.len().unwrap(), 5); + assert_eq!(s.peek(0).unwrap(), b"hello"); + } + + #[test] + fn resize_same_size_is_noop() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let initial = s.resize(5).unwrap(); + assert_eq!(initial, 5); + assert_eq!(s.peek(0).unwrap(), b"hello"); + } + + #[test] + fn resize_to_zero_truncates() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let initial = s.resize(0).unwrap(); + assert_eq!(initial, 5); + assert_eq!(s.len().unwrap(), 0); + } + + #[test] + fn resize_shrink_below_locked_errors() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + s.lock_up_to(5).unwrap(); + let err = s.resize(3).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 10); + } + + #[test] + fn resize_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + s.push(b"hi").unwrap(); + s.resize(4).unwrap(); + drop(s); + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"hi\x00\x00"); + } + + // ---- ensure --------------------------------------------------------- + + #[test] + fn ensure_grows_short_payload_with_zeros() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"abc").unwrap(); + let initial = s.ensure(6).unwrap(); + assert_eq!(initial, 3); + assert_eq!(s.len().unwrap(), 6); + assert_eq!(s.peek(0).unwrap(), b"abc\x00\x00\x00"); + } + + #[test] + fn ensure_noop_when_already_long_enough() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + let initial = s.ensure(5).unwrap(); + assert_eq!(initial, 10); + assert_eq!(s.len().unwrap(), 10); + assert_eq!(s.peek(0).unwrap(), b"helloworld"); + } + + #[test] + fn ensure_noop_when_exact_size() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let initial = s.ensure(5).unwrap(); + assert_eq!(initial, 5); + assert_eq!(s.len().unwrap(), 5); + } + + #[test] + fn ensure_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + s.push(b"hi").unwrap(); + s.ensure(4).unwrap(); + drop(s); + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"hi\x00\x00"); + } + + // ---- ensure_with (feature-gated) ----------------------------------- + + #[cfg(feature = "atomic")] + #[test] + fn ensure_with_grows_and_calls_callback_on_new_region() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"abc").unwrap(); + let initial = s + .ensure_with(6, |buf| { + assert_eq!(buf.len(), 3); + assert_eq!(buf, &[0u8, 0, 0]); + buf.copy_from_slice(b"XYZ"); + }) + .unwrap(); + assert_eq!(initial, 3); + assert_eq!(s.len().unwrap(), 6); + assert_eq!(s.peek(0).unwrap(), b"abcXYZ"); + } + + #[cfg(feature = "atomic")] + #[test] + fn ensure_with_skips_callback_when_already_long_enough() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + let mut called = false; + let initial = s + .ensure_with(5, |_| { + called = true; + }) + .unwrap(); + assert_eq!(initial, 10); + assert!(!called); + assert_eq!(s.peek(0).unwrap(), b"helloworld"); + } + + #[cfg(feature = "atomic")] + #[test] + fn ensure_with_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + s.push(b"hi").unwrap(); + s.ensure_with(5, |buf| buf.copy_from_slice(b"ZZZ")).unwrap(); + drop(s); + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"hiZZZ"); + } + + // ---- repeat (feature-gated) --------------------------------------------- + + #[cfg(feature = "set")] + #[test] + fn repeat_fills_with_pattern_copies() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"............").unwrap(); // 12 bytes + s.repeat(0, b"ab", 6).unwrap(); // "ab" x 6 = 12 bytes + assert_eq!(s.peek(0).unwrap(), b"abababababab"); + assert_eq!(s.len().unwrap(), 12); + } + + #[cfg(feature = "set")] + #[test] + fn repeat_at_offset_leaves_neighbours() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"XXXXXXXXXX").unwrap(); // 10 bytes + s.repeat(2, b"yz", 3).unwrap(); // fills [2,8) with "yzyzyz" + assert_eq!(s.peek(0).unwrap(), b"XXyzyzyzXX"); + } + + #[cfg(feature = "set")] + #[test] + fn repeat_single_byte_pattern_matches_zero_style_fill() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + s.repeat(0, b"\x00", 5).unwrap(); + assert_eq!(s.peek(0).unwrap(), b"\x00\x00\x00\x00\x00world"); + } + + #[cfg(feature = "set")] + #[test] + fn repeat_empty_pattern_or_zero_count_is_noop() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"helloworld").unwrap(); + s.repeat(0, b"", 5).unwrap(); + s.repeat(0, b"ab", 0).unwrap(); + assert_eq!(s.peek(0).unwrap(), b"helloworld"); + } + + #[cfg(feature = "set")] + #[test] + fn repeat_past_end_is_rejected() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let err = s.repeat(0, b"ab", 4).unwrap_err(); // would write 8 into 5 + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(s.peek(0).unwrap(), b"hello"); + } + + #[cfg(feature = "set")] + #[test] + fn repeat_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + s.push(b"........").unwrap(); // 8 bytes + s.repeat(0, b"QW", 4).unwrap(); + drop(s); + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"QWQWQWQW"); + } + // ---- zero (feature-gated) ----------------------------------------------- #[cfg(feature = "set")] @@ -1432,7 +1659,7 @@ mod tests { for i in 0..RECORDS { let mut rec = [0u8; RSIZE as usize]; rec[0] = i as u8; - s.push(&rec).unwrap(); + s.push(rec).unwrap(); } let s = Arc::new(s); @@ -1484,7 +1711,7 @@ mod tests { let mut data = [0u8; ITEM]; data[0] = t as u8; data[1..9].copy_from_slice(&(i as u64).to_le_bytes()); - let off = s.push(&data).unwrap(); + let off = s.push(data).unwrap(); (off, t, i) }) .collect::>() @@ -1534,7 +1761,7 @@ mod tests { let s = Arc::clone(&s); thread::spawn(move || { for _ in 0..PUSHES_PER_THREAD { - s.push(&[0xBEu8; ITEM as usize]).unwrap(); + s.push([0xBEu8; ITEM as usize]).unwrap(); } }) }) @@ -1564,88 +1791,344 @@ mod tests { (PUSH_THREADS * PUSHES_PER_THREAD) as u64 * ITEM ); } -} -// ------------------------------------------------------------------------- -// Allocator tests + // ---- extend_sparse ------------------------------------------------------ -#[cfg(all(test, feature = "alloc"))] -mod alloc_tests { - use crate::BStack; - use crate::alloc::{BStackAllocator, BStackBulkAllocator, BStackSlice, LinearBStackAllocator}; - use std::io::{Read, Seek, SeekFrom}; - use std::sync::atomic::{AtomicU64, Ordering}; + #[test] + fn extend_sparse_writes_prefix_and_zeros_rest() { + let (s, p) = mk_stack(); + let _g = Guard(p); - fn mk_alloc() -> (LinearBStackAllocator, std::path::PathBuf) { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let id = COUNTER.fetch_add(1, Ordering::Relaxed); - let pid = std::process::id(); - let path = std::env::temp_dir().join(format!("bstack_alloc_test_{pid}_{id}.bin")); - let stack = BStack::open(&path).unwrap(); - (LinearBStackAllocator::new(stack), path) + s.push(b"abc").unwrap(); + let off = s.extend_sparse(b"XY", 6).unwrap(); + assert_eq!(off, 3); + assert_eq!(s.len().unwrap(), 9); + assert_eq!(s.peek(0).unwrap(), b"abcXY\x00\x00\x00\x00"); } - struct Guard(std::path::PathBuf); - impl Drop for Guard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } + #[test] + fn extend_sparse_full_length_prefix() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let off = s.extend_sparse(b"hello", 5).unwrap(); + assert_eq!(off, 0); + assert_eq!(s.peek(0).unwrap(), b"hello"); } - // 1. alloc returns correct offset and len #[test] - fn alloc_offset_and_len() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let s = alloc.alloc(32).unwrap(); - assert_eq!(s.start(), 0); - assert_eq!(s.len(), 32); - assert!(!s.is_empty()); - assert_eq!(s.end(), 32); + fn extend_sparse_empty_buf_is_pure_extend() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + s.push(b"ab").unwrap(); + let off = s.extend_sparse(b"", 4).unwrap(); + assert_eq!(off, 2); + assert_eq!(s.peek(0).unwrap(), b"ab\x00\x00\x00\x00"); } - // 2. alloc(0) is a valid no-op #[test] - fn alloc_zero_len() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let s = alloc.alloc(0).unwrap(); - assert_eq!(s.len(), 0); - assert!(s.is_empty()); - assert_eq!(alloc.len().unwrap(), 0); + fn extend_sparse_zero_length_is_noop() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + s.push(b"data").unwrap(); + let off = s.extend_sparse(b"", 0).unwrap(); + assert_eq!(off, 4); + assert_eq!(s.len().unwrap(), 4); } - // 3. successive allocs produce non-overlapping regions #[test] - fn alloc_sequential_offsets() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let a = alloc.alloc(8).unwrap(); - let b = alloc.alloc(16).unwrap(); - assert_eq!(a.start(), 0); - assert_eq!(a.len(), 8); - assert_eq!(b.start(), 8); - assert_eq!(b.len(), 16); - assert_eq!(alloc.len().unwrap(), 24); + fn extend_sparse_buf_longer_than_length_errors() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let err = s.extend_sparse(b"toolong", 3).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 0); } - // 4. read returns zero bytes from a freshly allocated region #[test] - fn alloc_read_zeros() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let s = alloc.alloc(4).unwrap(); - let data = s.read().unwrap(); - assert_eq!(data, vec![0u8; 4]); + fn extend_sparse_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + + s.push(b"hi").unwrap(); + s.extend_sparse(b"Z", 4).unwrap(); + drop(s); + + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"hiZ\x00\x00\x00"); } - // 5. read_into with exact-size buffer succeeds + // ---- extend_sparse_batched ---------------------------------------------- + #[test] - fn read_into_exact_size() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let s = alloc.alloc(4).unwrap(); - let mut buf = [0u8; 4]; + fn extend_sparse_batched_scatters_buffers() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + s.push(b"..").unwrap(); + let off = s + .extend_sparse_batched(vec![(0u64, b"AA".as_slice()), (5, b"BB".as_slice())], 8) + .unwrap(); + assert_eq!(off, 2); + assert_eq!(s.len().unwrap(), 10); + assert_eq!(s.peek(0).unwrap(), b"..AA\x00\x00\x00BB\x00"); + } + + #[test] + fn extend_sparse_batched_ignores_empty_and_reorders() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let off = s + .extend_sparse_batched( + vec![ + (4u64, b"cc".as_slice()), + (0, b"".as_slice()), + (0, b"a".as_slice()), + ], + 6, + ) + .unwrap(); + assert_eq!(off, 0); + assert_eq!(s.peek(0).unwrap(), b"a\x00\x00\x00cc"); + } + + #[test] + fn extend_sparse_batched_overlap_errors() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let err = s + .extend_sparse_batched(vec![(0u64, b"aaa".as_slice()), (2, b"bb".as_slice())], 8) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 0); + } + + #[test] + fn extend_sparse_batched_out_of_range_errors() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let err = s + .extend_sparse_batched(vec![(3u64, b"zzz".as_slice())], 5) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 0); + } + + #[test] + fn extend_sparse_batched_empty_is_pure_extend() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + let off = s + .extend_sparse_batched(Vec::<(u64, Vec)>::new(), 4) + .unwrap(); + assert_eq!(off, 0); + assert_eq!(s.peek(0).unwrap(), b"\x00\x00\x00\x00"); + } + + // ---- try_extend_sparse (atomic) ----------------------------------------- + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_matching_size_writes_returns_true() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let ok = s.try_extend_sparse(5, b"XY", 6).unwrap(); + assert!(ok); + assert_eq!(s.len().unwrap(), 11); + assert_eq!(s.peek(0).unwrap(), b"helloXY\x00\x00\x00\x00"); + } + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_mismatching_size_returns_false() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + let ok = s.try_extend_sparse(3, b"XY", 6).unwrap(); + assert!(!ok); + assert_eq!(s.len().unwrap(), 5); + assert_eq!(s.peek(0).unwrap(), b"hello"); + } + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_buf_longer_than_length_errors_even_on_mismatch() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"hello").unwrap(); + // Size does not match (3 != 5), but the malformed request still errors. + let err = s.try_extend_sparse(3, b"toolong", 2).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 5); + } + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_persists_across_reopen() { + let (s, p) = mk_stack(); + let _g = Guard(p.clone()); + s.push(b"hi").unwrap(); + s.try_extend_sparse(2, b"Z", 4).unwrap(); + drop(s); + let s2 = BStack::open(&p).unwrap(); + assert_eq!(s2.peek(0).unwrap(), b"hiZ\x00\x00\x00"); + } + + // ---- try_extend_sparse_batched (atomic) --------------------------------- + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_batched_matching_scatters_returns_true() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"..").unwrap(); + let ok = s + .try_extend_sparse_batched(2, vec![(0u64, b"AA".as_slice()), (5, b"BB".as_slice())], 8) + .unwrap(); + assert!(ok); + assert_eq!(s.peek(0).unwrap(), b"..AA\x00\x00\x00BB\x00"); + } + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_batched_mismatching_returns_false() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"..").unwrap(); + let ok = s + .try_extend_sparse_batched(99, vec![(0u64, b"AA".as_slice())], 8) + .unwrap(); + assert!(!ok); + assert_eq!(s.len().unwrap(), 2); + } + + #[cfg(feature = "atomic")] + #[test] + fn try_extend_sparse_batched_overlap_errors_even_on_mismatch() { + let (s, p) = mk_stack(); + let _g = Guard(p); + s.push(b"..").unwrap(); + let err = s + .try_extend_sparse_batched( + 99, + vec![(0u64, b"aaa".as_slice()), (2, b"bb".as_slice())], + 8, + ) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert_eq!(s.len().unwrap(), 2); + } + + // The hash is keyed on the fd/handle, not the instance address, so moving + // the value does not change it. `PartialEq` stays pointer identity, which + // a moved value cannot violate — nothing else holds a reference to compare. + #[test] + fn hash_is_stable_across_a_move() { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + fn hash_of(s: &BStack) -> u64 { + let mut h = DefaultHasher::new(); + s.hash(&mut h); + h.finish() + } + + let (s, path) = mk_stack(); + let _g = Guard(path); + let before = hash_of(&s); + let moved = Box::new(s); // forces a move to a new address + assert_eq!(hash_of(&moved), before); + } +} + +// ------------------------------------------------------------------------- +// Allocator tests + +#[cfg(all(test, feature = "alloc"))] +mod alloc_tests { + use crate::BStack; + use crate::alloc::{BStackAllocator, BStackBulkAllocator, BStackSlice, LinearBStackAllocator}; + use std::io::{Read, Seek, SeekFrom}; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn mk_alloc() -> (LinearBStackAllocator, std::path::PathBuf) { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + let path = std::env::temp_dir().join(format!("bstack_alloc_test_{pid}_{id}.bin")); + let stack = BStack::open(&path).unwrap(); + (LinearBStackAllocator::new(stack), path) + } + + struct Guard(std::path::PathBuf); + impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + // 1. alloc returns correct offset and len + #[test] + fn alloc_offset_and_len() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(32).unwrap(); + assert_eq!(s.start(), 0); + assert_eq!(s.len(), 32); + assert!(!s.is_empty()); + assert_eq!(s.end(), 32); + } + + // 2. alloc(0) is a valid no-op + #[test] + fn alloc_zero_len() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(0).unwrap(); + assert_eq!(s.len(), 0); + assert!(s.is_empty()); + assert_eq!(alloc.len().unwrap(), 0); + } + + // 3. successive allocs produce non-overlapping regions + #[test] + fn alloc_sequential_offsets() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let a = alloc.alloc(8).unwrap(); + let b = alloc.alloc(16).unwrap(); + assert_eq!(a.start(), 0); + assert_eq!(a.len(), 8); + assert_eq!(b.start(), 8); + assert_eq!(b.len(), 16); + assert_eq!(alloc.len().unwrap(), 24); + } + + // 4. read returns zero bytes from a freshly allocated region + #[test] + fn alloc_read_zeros() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + let data = s.read().unwrap(); + assert_eq!(data, vec![0u8; 4]); + } + + // 5. read_into with exact-size buffer succeeds + #[test] + fn read_into_exact_size() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + let mut buf = [0u8; 4]; s.read_into(&mut buf).unwrap(); assert_eq!(buf, [0u8; 4]); } @@ -1708,6 +2191,21 @@ mod alloc_tests { assert_eq!(alloc.len().unwrap(), 16); } + // The grown tail reads back as zeros, whether it was realised by + // `extend` (no `atomic`) or `try_extend_zeros` (with `atomic`). + #[cfg(feature = "set")] + #[test] + fn realloc_tail_grow_zero_fills_the_new_bytes() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(8).unwrap(); + s.write([0xFFu8; 8]).unwrap(); + let s2 = alloc.realloc(s, 16).unwrap(); + let buf = s2.read().unwrap(); + assert!(buf[..8].iter().all(|&b| b == 0xFF)); + assert!(buf[8..].iter().all(|&b| b == 0)); + } + // 10. realloc tail-shrink decreases len #[test] fn realloc_tail_shrink() { @@ -2085,267 +2583,790 @@ mod alloc_tests { #[cfg(feature = "set")] #[test] - fn writer_ord_earlier_slice_before_later() { + fn writer_ord_earlier_slice_before_later() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let a = alloc.alloc(8).unwrap(); // offset 0..8 + let b = alloc.alloc(8).unwrap(); // offset 8..16 + assert!(a.writer() < b.writer()); + } + + // ---- Cross-type PartialOrd (reader ↔ writer) ---------------------------- + + #[cfg(feature = "set")] + #[test] + fn reader_writer_cross_partial_ord() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let r3 = s.reader_at(3); + let w5 = s.writer_at(5); + let w3 = s.writer_at(3); + let r5 = s.reader_at(5); + assert!(r3 < w5); + assert!(w3 < r5); + assert_eq!(r3.partial_cmp(&w3), Some(std::cmp::Ordering::Equal)); + assert_eq!(w5.partial_cmp(&r5), Some(std::cmp::Ordering::Equal)); + } + + #[cfg(feature = "set")] + #[test] + fn reader_writer_cross_ord_transitivity() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(20).unwrap(); + // r2 < w8 < r15: check transitivity across types + let r2 = s.reader_at(2); + let w8 = s.writer_at(8); + let r15 = s.reader_at(15); + assert!(r2 < w8); + assert!(w8 < r15); + assert!(r2 < r15); + } + + // ---- BStackBulkAllocator: alloc_bulk ------------------------------------ + + // 1. Empty lengths → empty Vec, stack unchanged. + #[test] + fn bulk_alloc_empty() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([]).unwrap(); + assert!(slices.is_empty()); + assert_eq!(alloc.len().unwrap(), 0); + } + + // 2. Correct offsets and lengths for a multi-element batch. + #[test] + fn bulk_alloc_offsets_and_lens() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); + assert_eq!(slices.len(), 3); + assert_eq!(slices[0].start(), 0); + assert_eq!(slices[0].len(), 8); + assert_eq!(slices[1].start(), 8); + assert_eq!(slices[1].len(), 16); + assert_eq!(slices[2].start(), 24); + assert_eq!(slices[2].len(), 32); + } + + // 3. Stack length after bulk alloc equals sum of all lengths. + #[test] + fn bulk_alloc_stack_len() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + alloc.alloc_bulk([10_u64, 20, 30]).unwrap(); + assert_eq!(alloc.len().unwrap(), 60); + } + + // 4. Single-element bulk is equivalent to a single alloc. + #[test] + fn bulk_alloc_single_element() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([64_u64]).unwrap(); + assert_eq!(slices.len(), 1); + assert_eq!(slices[0].start(), 0); + assert_eq!(slices[0].len(), 64); + assert_eq!(alloc.len().unwrap(), 64); + } + + // 5. Zero-length entries produce valid empty slices without changing the + // file layout (position is preserved relative to non-zero neighbours). + #[test] + fn bulk_alloc_zero_len_entries() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([0_u64, 8, 0]).unwrap(); + assert_eq!(slices.len(), 3); + assert!(slices[0].is_empty()); + assert_eq!(slices[1].start(), 0); + assert_eq!(slices[1].len(), 8); + assert!(slices[2].is_empty()); + assert_eq!(alloc.len().unwrap(), 8); + } + + // 6. All freshly allocated bulk regions are zero-initialised. + #[test] + fn bulk_alloc_reads_zeros() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([4_u64, 8, 4]).unwrap(); + for s in &slices { + assert_eq!(s.read().unwrap(), vec![0u8; s.len() as usize]); + } + } + + // 7. Slices from alloc_bulk are non-overlapping and contiguous. + #[test] + fn bulk_alloc_non_overlapping_contiguous() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([5_u64, 10, 15, 20]).unwrap(); + for i in 0..slices.len() - 1 { + assert_eq!(slices[i].end(), slices[i + 1].start()); + } + } + + // 8. alloc_bulk followed by individual alloc places the next alloc after + // the bulk region (i.e. the bulk consumed exactly the right amount). + #[test] + fn bulk_alloc_then_individual() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + alloc.alloc_bulk([8_u64, 16]).unwrap(); + let s = alloc.alloc(4).unwrap(); + assert_eq!(s.start(), 24); + } + + // 9. Lengths that sum to >u64::MAX return InvalidInput without touching + // the file. + #[test] + fn bulk_alloc_overflow_is_error() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let err = alloc.alloc_bulk([u64::MAX, 1]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(alloc.len().unwrap(), 0); + } + + // ---- BStackBulkAllocator: dealloc_bulk --------------------------------- + + // 10. Empty slice list is a no-op. + #[test] + fn bulk_dealloc_empty() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + alloc.alloc(32).unwrap(); + alloc.dealloc_bulk([]).unwrap(); + assert_eq!(alloc.len().unwrap(), 32); + } + + // 11. All slices form a contiguous tail → everything reclaimed, stack empty. + #[test] + fn bulk_dealloc_all_tail_reclaimed() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); + alloc.dealloc_bulk(slices).unwrap(); + assert_eq!(alloc.len().unwrap(), 0); + } + + // 12. Only the contiguous tail region is reclaimed; slices not touching + // the tail (or separated from it by a gap) are ignored. + #[test] + fn bulk_dealloc_non_tail_slice_ignored() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let a = alloc.alloc(8).unwrap(); // 0..8 (not tail after b is allocated) + let b = alloc.alloc(16).unwrap(); // 8..24 (tail) + // dealloc_bulk only sees `a`; `b` is alive but not passed in. + // Since `a` does not reach the tail the call is a no-op. + alloc.dealloc_bulk([a]).unwrap(); + assert_eq!(alloc.len().unwrap(), 24); + // Now dealloc b (the tail) normally to clean up. + alloc.dealloc(b).unwrap(); + } + + // 13. Slices supplied in reverse order produce the same result. + #[test] + fn bulk_dealloc_reverse_order_same_result() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); + // Reverse the Vec before passing it; all three still cover the tail. + let mut rev = slices; + rev.reverse(); + alloc.dealloc_bulk(rev).unwrap(); + assert_eq!(alloc.len().unwrap(), 0); + } + + // 14. Gap between supplied slices limits how far back reclamation reaches. + #[test] + fn bulk_dealloc_gap_limits_reclamation() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let a = alloc.alloc(8).unwrap(); // 0..8 + let _b = alloc.alloc(8).unwrap(); // 8..16 ← not included in dealloc_bulk + let c = alloc.alloc(8).unwrap(); // 16..24 (tail) + // dealloc_bulk([a, c]): c touches the tail, a does not reach c, + // so only c is reclaimed. + alloc.dealloc_bulk([a, c]).unwrap(); + assert_eq!(alloc.len().unwrap(), 16); + } + + // 15. Single tail slice → equivalent to single dealloc. + #[test] + fn bulk_dealloc_single_tail() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + alloc.alloc(8).unwrap(); + let tail = alloc.alloc(16).unwrap(); + alloc.dealloc_bulk([tail]).unwrap(); + assert_eq!(alloc.len().unwrap(), 8); + } + + // 16. Single non-tail slice → no-op. + #[test] + fn bulk_dealloc_single_nontail_noop() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let head = alloc.alloc(8).unwrap(); + alloc.alloc(16).unwrap(); // keeps head non-tail + alloc.dealloc_bulk([head]).unwrap(); + assert_eq!(alloc.len().unwrap(), 24); + } + + // 17. alloc_bulk then dealloc_bulk round-trip leaves the stack empty. + #[test] + fn bulk_roundtrip_empty() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([4_u64, 8, 12, 16]).unwrap(); + alloc.dealloc_bulk(slices).unwrap(); + assert!(alloc.is_empty().unwrap()); + } + + // 18. Partial dealloc_bulk reclaims only the tail suffix; a subsequent + // alloc_bulk reuses the freed space. + #[test] + fn bulk_dealloc_partial_then_realloc() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); + let (head, tail) = slices.split_at(1); + // Reclaim only the last two slices (tail suffix). + alloc.dealloc_bulk(tail).unwrap(); + assert_eq!(alloc.len().unwrap(), 8); + // head[0] (0..8) is still live; a new bulk alloc goes right after it. + let new = alloc.alloc_bulk([4_u64, 4]).unwrap(); + assert_eq!(new[0].start(), 8); + assert_eq!(new[1].start(), 12); + let _ = head; // keep the borrow alive + } + + // ------------------------------------------------------------------------- + // std-slice-style ergonomic methods on BStackSlice (ported from master) + // ------------------------------------------------------------------------- + + // ---- read-only (no extra feature) --------------------------------------- + + #[test] + fn slice_get_in_and_out_of_bounds() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + assert_eq!(s.get(0).unwrap(), Some(0)); // fresh region is zeroed + assert_eq!(s.get(3).unwrap(), Some(0)); + assert_eq!(s.get(4).unwrap(), None); // out of bounds + } + + #[test] + fn slice_head_and_tail() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let h = s.head(3); + assert_eq!(h.start(), s.start()); + assert_eq!(h.len(), 3); + // over-long request is clamped + assert_eq!(s.head(100).len(), 10); + + let t = s.tail(3); + assert_eq!(t.start(), s.start() + 7); + assert_eq!(t.len(), 3); + assert_eq!(s.tail(100).len(), 10); + assert_eq!(s.tail(100).start(), s.start()); + } + + #[test] + fn slice_split_at() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(10).unwrap(); + let (l, r) = s.split_at(3); + assert_eq!(l.start(), s.start()); + assert_eq!(l.len(), 3); + assert_eq!(r.start(), s.start() + 3); + assert_eq!(r.len(), 7); + // boundary: mid == len + let (l, r) = s.split_at(10); + assert_eq!(l.len(), 10); + assert_eq!(r.len(), 0); + } + + #[test] + #[should_panic(expected = "split_at: mid must be <= slice length")] + fn slice_split_at_out_of_bounds() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + let _ = s.split_at(5); + } + + #[test] + fn slice_split_at_mut() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(10).unwrap(); + let (l, r) = s.split_at_mut(4); + assert_eq!(l.len(), 4); + assert_eq!(r.len(), 6); + assert_eq!(r.start(), s.start() + 4); + // boundary: mid == 0 + let (l, r) = s.split_at_mut(0); + assert_eq!(l.len(), 0); + assert_eq!(r.len(), 10); + } + + #[test] + fn slice_contains_zeroed() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + assert!(s.contains(0).unwrap()); + assert!(!s.contains(1).unwrap()); + } + + #[test] + fn slice_starts_and_ends_with_zeroed() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + assert!(s.starts_with(&[0, 0]).unwrap()); + assert!(!s.starts_with(&[1]).unwrap()); + assert!(!s.starts_with(&[0, 0, 0, 0, 0]).unwrap()); // longer than slice + assert!(s.ends_with(&[0, 0]).unwrap()); + assert!(!s.ends_with(&[1]).unwrap()); + assert!(!s.ends_with(&[0, 0, 0, 0, 0]).unwrap()); + } + + #[test] + fn slice_find_and_rfind_zeroed() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + assert_eq!(s.find(0).unwrap(), Some(0)); + assert_eq!(s.find(9).unwrap(), None); + assert_eq!(s.rfind(0).unwrap(), Some(3)); + assert_eq!(s.rfind(9).unwrap(), None); + } + + #[test] + fn slice_position_and_rposition_zeroed() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(4).unwrap(); + assert_eq!(s.position(|b| b == 0).unwrap(), Some(0)); + assert_eq!(s.position(|b| b == 7).unwrap(), None); + assert_eq!(s.rposition(|b| b == 0).unwrap(), Some(3)); + assert_eq!(s.rposition(|b| b == 7).unwrap(), None); + } + + // Stronger read-only coverage over non-trivial written data. + #[cfg(feature = "set")] + #[test] + fn slice_search_over_written_data() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let s = alloc.alloc(6).unwrap(); + s.write(b"abcabc").unwrap(); + assert!(s.contains(b'c').unwrap()); + assert!(!s.contains(b'z').unwrap()); + assert!(s.starts_with(b"abc").unwrap()); + assert!(!s.starts_with(b"abd").unwrap()); + assert!(s.ends_with(b"abc").unwrap()); + assert!(!s.ends_with(b"abd").unwrap()); + assert_eq!(s.find(b'b').unwrap(), Some(1)); + assert_eq!(s.rfind(b'b').unwrap(), Some(4)); + assert_eq!(s.find(b'z').unwrap(), None); + assert_eq!(s.position(|x| x == b'c').unwrap(), Some(2)); + assert_eq!(s.rposition(|x| x == b'c').unwrap(), Some(5)); + } + + // ---- write (needs `set`) ------------------------------------------------ + + #[cfg(feature = "set")] + #[test] + fn slice_fill() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + let mut s = alloc.alloc(6).unwrap(); + s.fill(0xAB).unwrap(); + assert_eq!(s.read().unwrap(), vec![0xAB; 6]); + // boundary: empty slice fill is a no-op + let mut e = alloc.alloc(0).unwrap(); + e.fill(0xFF).unwrap(); + assert_eq!(e.read().unwrap(), Vec::::new()); + } + + #[cfg(feature = "set")] + #[test] + fn slice_fill_with() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let a = alloc.alloc(8).unwrap(); // offset 0..8 - let b = alloc.alloc(8).unwrap(); // offset 8..16 - assert!(a.writer() < b.writer()); + let mut s = alloc.alloc(5).unwrap(); + let mut n = 0u8; + s.fill_with(|| { + let v = n; + n += 1; + v + }) + .unwrap(); + assert_eq!(s.read().unwrap(), vec![0, 1, 2, 3, 4]); } - // ---- Cross-type PartialOrd (reader ↔ writer) ---------------------------- - #[cfg(feature = "set")] #[test] - fn reader_writer_cross_partial_ord() { + fn slice_copy_from_slice() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let s = alloc.alloc(10).unwrap(); - let r3 = s.reader_at(3); - let w5 = s.writer_at(5); - let w3 = s.writer_at(3); - let r5 = s.reader_at(5); - assert!(r3 < w5); - assert!(w3 < r5); - assert_eq!(r3.partial_cmp(&w3), Some(std::cmp::Ordering::Equal)); - assert_eq!(w5.partial_cmp(&r5), Some(std::cmp::Ordering::Equal)); + let mut s = alloc.alloc(4).unwrap(); + s.copy_from_slice(b"WXYZ").unwrap(); + assert_eq!(s.read().unwrap(), b"WXYZ"); } #[cfg(feature = "set")] #[test] - fn reader_writer_cross_ord_transitivity() { + #[should_panic(expected = "copy_from_slice: length mismatch")] + fn slice_copy_from_slice_length_mismatch() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let s = alloc.alloc(20).unwrap(); - // r2 < w8 < r15: check transitivity across types - let r2 = s.reader_at(2); - let w8 = s.writer_at(8); - let r15 = s.reader_at(15); - assert!(r2 < w8); - assert!(w8 < r15); - assert!(r2 < r15); + let mut s = alloc.alloc(4).unwrap(); + s.copy_from_slice(b"TOOLONG").unwrap(); } - // ---- BStackBulkAllocator: alloc_bulk ------------------------------------ + // ---- atomic compound (needs `set` + `atomic`) --------------------------- - // 1. Empty lengths → empty Vec, stack unchanged. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_empty() { + fn slice_copy_from_bstack_slice() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([]).unwrap(); - assert!(slices.is_empty()); - assert_eq!(alloc.len().unwrap(), 0); + let src = alloc.alloc(4).unwrap(); + src.write(b"DATA").unwrap(); + let mut dst = alloc.alloc(4).unwrap(); + dst.copy_from_bstack_slice(&src).unwrap(); + assert_eq!(dst.read().unwrap(), b"DATA"); } - // 2. Correct offsets and lengths for a multi-element batch. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_offsets_and_lens() { - let (alloc, path) = mk_alloc(); - let _g = Guard(path); - let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); - assert_eq!(slices.len(), 3); - assert_eq!(slices[0].start(), 0); - assert_eq!(slices[0].len(), 8); - assert_eq!(slices[1].start(), 8); - assert_eq!(slices[1].len(), 16); - assert_eq!(slices[2].start(), 24); - assert_eq!(slices[2].len(), 32); + fn slice_copy_from_bstack_slice_different_stack_errors() { + let (alloc1, path1) = mk_alloc(); + let _g1 = Guard(path1); + let (alloc2, path2) = mk_alloc(); + let _g2 = Guard(path2); + let src = alloc1.alloc(4).unwrap(); + let mut dst = alloc2.alloc(4).unwrap(); + let err = dst.copy_from_bstack_slice(&src).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - // 3. Stack length after bulk alloc equals sum of all lengths. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_stack_len() { + fn slice_copy_within() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - alloc.alloc_bulk([10_u64, 20, 30]).unwrap(); - assert_eq!(alloc.len().unwrap(), 60); + let mut s = alloc.alloc(6).unwrap(); + s.write(b"ABCDEF").unwrap(); + // copy [0,2) -> dest 4 + s.copy_within(0..2, 4).unwrap(); + assert_eq!(s.read().unwrap(), b"ABCDAB"); + // boundary: empty range is a no-op + s.copy_within(2..2, 0).unwrap(); + assert_eq!(s.read().unwrap(), b"ABCDAB"); } - // 4. Single-element bulk is equivalent to a single alloc. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_single_element() { + fn slice_swap() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([64_u64]).unwrap(); - assert_eq!(slices.len(), 1); - assert_eq!(slices[0].start(), 0); - assert_eq!(slices[0].len(), 64); - assert_eq!(alloc.len().unwrap(), 64); + let mut a = alloc.alloc(3).unwrap(); + let mut b = alloc.alloc(3).unwrap(); + a.write(b"AAA").unwrap(); + b.write(b"BBB").unwrap(); + a.swap(&mut b).unwrap(); + assert_eq!(a.read().unwrap(), b"BBB"); + assert_eq!(b.read().unwrap(), b"AAA"); + // boundary: swapping a slice with itself (same start) is a no-op + let mut c = a; + a.swap(&mut c).unwrap(); + assert_eq!(a.read().unwrap(), b"BBB"); } - // 5. Zero-length entries produce valid empty slices without changing the - // file layout (position is preserved relative to non-zero neighbours). + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_zero_len_entries() { + fn slice_swap_different_stack_errors() { + let (alloc1, path1) = mk_alloc(); + let _g1 = Guard(path1); + let (alloc2, path2) = mk_alloc(); + let _g2 = Guard(path2); + let mut a = alloc1.alloc(3).unwrap(); + let mut b = alloc2.alloc(3).unwrap(); + let err = a.swap(&mut b).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_reverse() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([0_u64, 8, 0]).unwrap(); - assert_eq!(slices.len(), 3); - assert!(slices[0].is_empty()); - assert_eq!(slices[1].start(), 0); - assert_eq!(slices[1].len(), 8); - assert!(slices[2].is_empty()); - assert_eq!(alloc.len().unwrap(), 8); + let mut s = alloc.alloc(5).unwrap(); + s.write(b"abcde").unwrap(); + s.reverse().unwrap(); + assert_eq!(s.read().unwrap(), b"edcba"); + // boundary: reversing an empty slice is a no-op + let mut e = alloc.alloc(0).unwrap(); + e.reverse().unwrap(); } - // 6. All freshly allocated bulk regions are zero-initialised. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_reads_zeros() { + fn slice_rotate_left() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([4_u64, 8, 4]).unwrap(); - for s in &slices { - assert_eq!(s.read().unwrap(), vec![0u8; s.len() as usize]); - } + let mut s = alloc.alloc(5).unwrap(); + s.write(b"abcde").unwrap(); + s.rotate_left(2).unwrap(); + assert_eq!(s.read().unwrap(), b"cdeab"); + // boundary: mid == len is a full rotation (identity) + s.rotate_left(5).unwrap(); + assert_eq!(s.read().unwrap(), b"cdeab"); } - // 7. Slices from alloc_bulk are non-overlapping and contiguous. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_non_overlapping_contiguous() { + fn slice_rotate_right() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([5_u64, 10, 15, 20]).unwrap(); - for i in 0..slices.len() - 1 { - assert_eq!(slices[i].end(), slices[i + 1].start()); - } + let mut s = alloc.alloc(5).unwrap(); + s.write(b"abcde").unwrap(); + s.rotate_right(2).unwrap(); + assert_eq!(s.read().unwrap(), b"deabc"); + // boundary: k == 0 is the identity + s.rotate_right(0).unwrap(); + assert_eq!(s.read().unwrap(), b"deabc"); } - // 8. alloc_bulk followed by individual alloc places the next alloc after - // the bulk region (i.e. the bulk consumed exactly the right amount). + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_then_individual() { + #[should_panic(expected = "rotate_left: mid must be <= slice length")] + fn slice_rotate_left_out_of_bounds() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - alloc.alloc_bulk([8_u64, 16]).unwrap(); - let s = alloc.alloc(4).unwrap(); - assert_eq!(s.start(), 24); + let mut s = alloc.alloc(4).unwrap(); + let _ = s.rotate_left(5); } - // 9. Lengths that sum to >u64::MAX return InvalidInput without touching - // the file. + // ---- BStackSlice: cas_on / cas_on_ne / cas_on_masked -------------------- + + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_alloc_overflow_is_error() { + fn slice_cas_on_match_swaps_and_returns_old() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let err = alloc.alloc_bulk([u64::MAX, 1]).unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!(alloc.len().unwrap(), 0); + let guard = alloc.alloc(2).unwrap(); + guard.write([1u8, 2]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + let old = target.cas_on(&guard, [1u8, 2], [3u8, 4]).unwrap(); + assert_eq!(old, Some(vec![9, 9])); + assert_eq!(target.read().unwrap(), [3, 4]); } - // ---- BStackBulkAllocator: dealloc_bulk --------------------------------- - - // 10. Empty slice list is a no-op. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_empty() { + fn slice_cas_on_no_match_leaves_target_untouched() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - alloc.alloc(32).unwrap(); - alloc.dealloc_bulk([]).unwrap(); - assert_eq!(alloc.len().unwrap(), 32); + let guard = alloc.alloc(2).unwrap(); + guard.write([1u8, 2]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + let result = target.cas_on(&guard, [0u8, 0], [3u8, 4]).unwrap(); + assert_eq!(result, None); + assert_eq!(target.read().unwrap(), [9, 9]); } - // 11. All slices form a contiguous tail → everything reclaimed, stack empty. + // The guard may be the target itself — the plain single-region CAS. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_all_tail_reclaimed() { + fn slice_cas_on_self_guard_swaps() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); - alloc.dealloc_bulk(slices).unwrap(); - assert_eq!(alloc.len().unwrap(), 0); + let mut target = alloc.alloc(2).unwrap(); + target.write([1u8, 2]).unwrap(); + let guard = target; + let old = target.cas_on(&guard, [1u8, 2], [3u8, 4]).unwrap(); + assert_eq!(old, Some(vec![1, 2])); + assert_eq!(target.read().unwrap(), [3, 4]); } - // 12. Only the contiguous tail region is reclaimed; slices not touching - // the tail (or separated from it by a gap) are ignored. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_non_tail_slice_ignored() { + fn slice_cas_on_expected_length_mismatch_errors() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let a = alloc.alloc(8).unwrap(); // 0..8 (not tail after b is allocated) - let b = alloc.alloc(16).unwrap(); // 8..24 (tail) - // dealloc_bulk only sees `a`; `b` is alive but not passed in. - // Since `a` does not reach the tail the call is a no-op. - alloc.dealloc_bulk([a]).unwrap(); - assert_eq!(alloc.len().unwrap(), 24); - // Now dealloc b (the tail) normally to clean up. - alloc.dealloc(b).unwrap(); + let guard = alloc.alloc(2).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + let err = target.cas_on(&guard, [0u8], [3u8, 4]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - // 13. Slices supplied in reverse order produce the same result. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_reverse_order_same_result() { + fn slice_cas_on_new_bytes_length_mismatch_errors() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); - // Reverse the Vec before passing it; all three still cover the tail. - let mut rev = slices; - rev.reverse(); - alloc.dealloc_bulk(rev).unwrap(); - assert_eq!(alloc.len().unwrap(), 0); + let guard = alloc.alloc(2).unwrap(); + guard.write([1u8, 2]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + let err = target.cas_on(&guard, [1u8, 2], [3u8]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); } - // 14. Gap between supplied slices limits how far back reclamation reaches. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_gap_limits_reclamation() { + fn slice_cas_on_cross_stack_errors() { + let (alloc_a, path_a) = mk_alloc(); + let _g_a = Guard(path_a); + let (alloc_b, path_b) = mk_alloc(); + let _g_b = Guard(path_b); + let guard = alloc_a.alloc(2).unwrap(); + let mut target = alloc_b.alloc(2).unwrap(); + let err = target.cas_on(&guard, [0u8, 0], [1u8, 1]).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_ne_no_match_swaps_and_returns_old() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let a = alloc.alloc(8).unwrap(); // 0..8 - let _b = alloc.alloc(8).unwrap(); // 8..16 ← not included in dealloc_bulk - let c = alloc.alloc(8).unwrap(); // 16..24 (tail) - // dealloc_bulk([a, c]): c touches the tail, a does not reach c, - // so only c is reclaimed. - alloc.dealloc_bulk([a, c]).unwrap(); - assert_eq!(alloc.len().unwrap(), 16); + let guard = alloc.alloc(2).unwrap(); + guard.write([1u8, 2]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + let old = target.cas_on_ne(&guard, [0u8, 0], [3u8, 4]).unwrap(); + assert_eq!(old, Some(vec![9, 9])); + assert_eq!(target.read().unwrap(), [3, 4]); } - // 15. Single tail slice → equivalent to single dealloc. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_single_tail() { + fn slice_cas_on_ne_match_returns_none() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - alloc.alloc(8).unwrap(); - let tail = alloc.alloc(16).unwrap(); - alloc.dealloc_bulk([tail]).unwrap(); - assert_eq!(alloc.len().unwrap(), 8); + let guard = alloc.alloc(2).unwrap(); + guard.write([1u8, 2]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + let result = target.cas_on_ne(&guard, [1u8, 2], [3u8, 4]).unwrap(); + assert_eq!(result, None); + assert_eq!(target.read().unwrap(), [9, 9]); } - // 16. Single non-tail slice → no-op. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_single_nontail_noop() { + fn slice_cas_on_masked_match_swaps_and_returns_old() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let head = alloc.alloc(8).unwrap(); - alloc.alloc(16).unwrap(); // keeps head non-tail - alloc.dealloc_bulk([head]).unwrap(); - assert_eq!(alloc.len().unwrap(), 24); + let guard = alloc.alloc(2).unwrap(); + guard.write([0xffu8, 0x0f]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + // mask = [0xff, 0xf0]: masked guard = [0xff, 0x00] == masked expected + let old = target + .cas_on_masked(&guard, [0xffu8, 0xf0], [0xffu8, 0x0f], [3u8, 4]) + .unwrap(); + assert_eq!(old, Some(vec![9, 9])); + assert_eq!(target.read().unwrap(), [3, 4]); } - // 17. alloc_bulk then dealloc_bulk round-trip leaves the stack empty. + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_roundtrip_empty() { + fn slice_cas_on_masked_no_match_returns_none() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([4_u64, 8, 12, 16]).unwrap(); - alloc.dealloc_bulk(slices).unwrap(); - assert!(alloc.is_empty().unwrap()); + let guard = alloc.alloc(1).unwrap(); + guard.write([0x0fu8]).unwrap(); + let mut target = alloc.alloc(2).unwrap(); + target.write([9u8, 9]).unwrap(); + let result = target + .cas_on_masked(&guard, [0xffu8], [0xffu8], [3u8, 4]) + .unwrap(); + assert_eq!(result, None); + assert_eq!(target.read().unwrap(), [9, 9]); } - // 18. Partial dealloc_bulk reclaims only the tail suffix; a subsequent - // alloc_bulk reuses the freed space. + // ---- BStackSlice: process ---------------------------------------------- + + #[cfg(all(feature = "set", feature = "atomic"))] #[test] - fn bulk_dealloc_partial_then_realloc() { + fn slice_process_transforms_in_place() { let (alloc, path) = mk_alloc(); let _g = Guard(path); - let slices = alloc.alloc_bulk([8_u64, 16, 32]).unwrap(); - let (head, tail) = slices.split_at(1); - // Reclaim only the last two slices (tail suffix). - alloc.dealloc_bulk(tail.to_vec()).unwrap(); - assert_eq!(alloc.len().unwrap(), 8); - // head[0] (0..8) is still live; a new bulk alloc goes right after it. - let new = alloc.alloc_bulk([4_u64, 4]).unwrap(); - assert_eq!(new[0].start(), 8); - assert_eq!(new[1].start(), 12); - let _ = head; // keep the borrow alive + let mut s = alloc.alloc(4).unwrap(); + s.write([1u8, 2, 3, 4]).unwrap(); + s.process(|buf| { + for b in buf.iter_mut() { + *b *= 2; + } + }) + .unwrap(); + assert_eq!(s.read().unwrap(), [2, 4, 6, 8]); + } + + // ── Foreign slices ──────────────────────────────────────────────────── + + #[test] + fn dealloc_and_realloc_reject_a_slice_from_another_instance() { + let (a1, p1) = mk_alloc(); + let _g1 = Guard(p1); + let (a2, p2) = mk_alloc(); + let _g2 = Guard(p2); + + let s = a1.alloc(64).unwrap(); + assert!(s.is_from(&a1)); + assert!(!s.is_from(&a2)); + + let err = a2.dealloc(s).expect_err("a2 must refuse a1's slice"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + let err = a2.realloc(s, 128).expect_err("a2 must refuse a1's slice"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + + // Both allocators are untouched by the refusals: the slice is `Copy`, + // so nothing was lost, and each still serves its own handles. + assert_eq!(a1.len().unwrap(), 64); + let own = a2.alloc(64).unwrap(); + a2.dealloc(own).unwrap(); + a1.dealloc(s).unwrap(); + } + + #[test] + fn dealloc_bulk_rejects_a_batch_containing_a_foreign_slice() { + let (a1, p1) = mk_alloc(); + let _g1 = Guard(p1); + let (a2, p2) = mk_alloc(); + let _g2 = Guard(p2); + + let own = a2.alloc(32).unwrap(); + let foreign = a1.alloc(32).unwrap(); + + // One foreign slice poisons the batch: nothing is freed, including the + // slice that did belong to `a2`. + let err = a2 + .dealloc_bulk([own, foreign]) + .expect_err("a2 must refuse a batch holding a1's slice"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(a2.len().unwrap(), 32); + + a2.dealloc(own).unwrap(); + a1.dealloc(foreign).unwrap(); } } @@ -2406,7 +3427,7 @@ mod first_fit_tests { // Push 48 bytes with wrong magic let mut hdr = [0u8; 48]; hdr[16..24].copy_from_slice(b"WRONGHDR"); - stack.push(&hdr).unwrap(); + stack.push(hdr).unwrap(); } let stack = BStack::open(&path).unwrap(); assert!(FirstFitBStackAllocator::new(stack).is_err()); @@ -2715,10 +3736,16 @@ mod first_fit_tests { let (alloc, path) = mk_ff("realloc_tail_shrink"); let _g = Guard(path); let s = alloc.alloc(32).unwrap(); + let s_start = s.start(); + let before_len = alloc.len().unwrap(); let s2 = alloc.realloc(s, 16).unwrap(); - assert_eq!(s2.start(), s.start()); + assert_eq!(s2.start(), s_start); assert_eq!(s2.len(), 16); - assert_eq!(alloc.len().unwrap(), ALFF_HDR_OFFSET + 16 + BLOCK_OVERHEAD); + // A tail shrink keeps the block at its physical size (an oversized block, + // exactly as a non-tail shrink does) rather than rewriting the header and + // discarding the tail as separate, non-crash-atomic steps. The stack + // length is therefore unchanged; the excess is reclaimed on free. + assert_eq!(alloc.len().unwrap(), before_len); } #[test] @@ -2859,7 +3886,7 @@ mod first_fit_tests { let b = alloc.alloc(80).unwrap(); let _c = alloc.alloc(16).unwrap(); // Write garbage into B so the overlap area is dirty before freeing. - b.write(&vec![0xFFu8; 80]).unwrap(); + b.write(vec![0xFFu8; 80]).unwrap(); alloc.dealloc(b).unwrap(); let _a2 = alloc.realloc(a, 32).unwrap(); // merge + split let rem = alloc.alloc(64).unwrap(); @@ -2949,12 +3976,12 @@ mod first_fit_tests { let mut alff = [0u8; 48]; alff[16..24].copy_from_slice(b"ALFF\x00\x01\x01\x00"); alff[24..28].copy_from_slice(&1u32.to_le_bytes()); // recovery_needed - stack.push(&alff).unwrap(); + stack.push(alff).unwrap(); // Block A header: size=80, flags=0 (allocated, but header not yet shrunk) let mut a_hdr = [0u8; 16]; a_hdr[..8].copy_from_slice(&80u64.to_le_bytes()); - stack.push(&a_hdr).unwrap(); + stack.push(a_hdr).unwrap(); // Block A payload (80 bytes): inner footer + second sub-block embedded let mut a_pay = [0u8; 80]; @@ -2965,16 +3992,16 @@ mod first_fit_tests { // [48..52): is_free = 1 a_pay[48..52].copy_from_slice(&1u32.to_le_bytes()); // [52..80): zeros (reserved + second sub-block payload) - stack.push(&a_pay).unwrap(); + stack.push(a_pay).unwrap(); // Outer footer: F=24 - stack.push(&24u64.to_le_bytes()).unwrap(); + stack.push(24u64.to_le_bytes()).unwrap(); // Sentinel block: header(size=16,flags=0) + payload(16 zeros) + footer(16) let mut sent = [0u8; 40]; sent[..8].copy_from_slice(&16u64.to_le_bytes()); sent[32..40].copy_from_slice(&16u64.to_le_bytes()); - stack.push(&sent).unwrap(); + stack.push(sent).unwrap(); } let alloc = FirstFitBStackAllocator::new(BStack::open(&path).unwrap()).unwrap(); @@ -3036,9 +4063,9 @@ mod first_fit_tests { let stack = alloc.into_stack(); // Corrupt: set recovery_needed=1 and scramble free_head to garbage - stack.set(24, &1u32.to_le_bytes()).unwrap(); // flags byte → recovery_needed=1 + stack.set(24, 1u32.to_le_bytes()).unwrap(); // flags byte → recovery_needed=1 stack - .set(FREE_HEAD_OFFSET, &0xDEADBEEFu64.to_le_bytes()) + .set(FREE_HEAD_OFFSET, 0xDEADBEEFu64.to_le_bytes()) .unwrap(); drop(stack); @@ -3091,6 +4118,81 @@ mod first_fit_tests { assert_eq!(alloc2.len().unwrap(), before_len); } + #[test] + fn recovery_rolls_back_interrupted_tail_grow() { + // A tail-block-grow `realloc` `extend`s (zero-filling) the payload before + // rewriting the header/footer to cover it. A crash in that window leaves a + // valid block followed by an all-zero region with no block header — which + // the recovery scan used to read as a size-0 block and reject the whole + // file. Recovery now rolls the extension back by truncation. + let (alloc, path) = mk_ff("recovery_tailgrow"); + let _g = Guard(path.clone()); + let a = alloc.alloc(32).unwrap(); + a.write([0xA7u8; 32]).unwrap(); + let a_start = a.start(); + let stack = alloc.into_stack(); + let before_len = stack.len().unwrap(); + + // Reproduce the stranded state: a zero-filled tail region past the block + // with no header of its own, plus recovery_needed set. + stack.extend(64).unwrap(); + stack.set(24, 1u32.to_le_bytes()).unwrap(); // recovery_needed = 1 + drop(stack); + + let stack2 = BStack::open(&path).unwrap(); + let alloc2 = FirstFitBStackAllocator::new(stack2) + .expect("recovery must roll back the interrupted tail grow, not error"); + // The interrupted extension is rolled back... + assert_eq!(alloc2.len().unwrap(), before_len); + // ...and the block's bytes survive. + assert_eq!( + alloc2.stack().get(a_start, a_start + 32).unwrap(), + vec![0xA7u8; 32] + ); + } + + #[test] + fn recovery_normalizes_stale_footer() { + // Every block-resizing operation commits its new size to the header + // before the matching footer, so a crash between the two writes leaves a + // correct header and a stale footer. The walk follows headers, so the + // mismatch slips through undetected yet corrupts a later neighbour's + // coalesce (which reads this footer). Recovery now normalizes every + // block's footer to its authoritative header size. + let (alloc, path) = mk_ff("recovery_footer"); + let _g = Guard(path.clone()); + let a = alloc.alloc(32).unwrap(); + let _b = alloc.alloc(16).unwrap(); // keeps A a non-tail block + let a_start = a.start(); + let stack = alloc.into_stack(); + + // block_start = payload - BLOCK_HEADER_SIZE(16); header size at + // block_start; footer at block_start + 16 + size. Corrupt the footer to a + // clearly-bogus, non-8-aligned value (so the partial-split check ignores + // it), leaving header/footer disagreeing. + let block_start = a_start - 16; + let size = u64::from_le_bytes( + <[u8; 8]>::try_from(stack.get(block_start, block_start + 8).unwrap()).unwrap(), + ); + let footer_pos = block_start + 16 + size; + stack.set(footer_pos, 0xDEADu64.to_le_bytes()).unwrap(); + stack.set(24, 1u32.to_le_bytes()).unwrap(); // recovery_needed = 1 + drop(stack); + + let stack2 = BStack::open(&path).unwrap(); + let alloc2 = FirstFitBStackAllocator::new(stack2) + .expect("recovery must heal the stale footer, not error"); + // Recovery restored the footer to the authoritative header size. + let footer = u64::from_le_bytes( + <[u8; 8]>::try_from(alloc2.stack().get(footer_pos, footer_pos + 8).unwrap()).unwrap(), + ); + assert_eq!(footer, size, "recovery normalizes footer to header size"); + // The allocator remains usable. + let r = alloc2.alloc(16).unwrap(); + r.write([0x22u8; 16]).unwrap(); + assert_eq!(r.read().unwrap(), vec![0x22u8; 16]); + } + // ----------------------------------------------------------------------- // into_stack / stack() accessors @@ -3193,7 +4295,7 @@ mod first_fit_tests { thread::spawn(move || { let pat = (tid as u8).wrapping_add(0x40); let mut slice = alloc.alloc(16).unwrap(); - slice.write(&vec![pat; 16]).unwrap(); + slice.write(vec![pat; 16]).unwrap(); let mut prev_len = 16u64; // Sizes oscillate up and down to exercise both branches. @@ -3216,7 +4318,7 @@ mod first_fit_tests { // Re-stamp the full new length so the next iteration // can re-verify against `pat`. - slice.write(&vec![pat; new_len as usize]).unwrap(); + slice.write(vec![pat; new_len as usize]).unwrap(); prev_len = new_len; } @@ -4238,11 +5340,11 @@ mod atomic_tests { let r = match step { 0 => Some(BStackGenOp::Read { offset: 0, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }), 1 => Some(BStackGenOp::Write { offset: 5, - data: unsafe { core::mem::transmute::<&[u8], _>(&buf[..]) }, + data: unsafe { core::mem::transmute::<&[u8], &[u8]>(&buf[..]) }, }), _ => None, }; @@ -4275,7 +5377,7 @@ mod atomic_tests { let r = match step { 0 => Some(BStackGenOp::Read { offset: 0, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut ptr_buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut ptr_buf[..]) }, }), 1 => { // The previous read has already filled `ptr_buf` by the @@ -4283,7 +5385,9 @@ mod atomic_tests { let target = u64::from_le_bytes(ptr_buf); Some(BStackGenOp::Read { offset: target, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut node_buf[..]) }, + buf: unsafe { + core::mem::transmute::<&mut [u8], &mut [u8]>(&mut node_buf[..]) + }, }) } _ => None, @@ -4384,7 +5488,7 @@ mod atomic_tests { 0 => Some(BStackGenOp::Read { offset: 0, // SAFETY: `ptr_buf` outlives this whole `process_gen` call. - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut ptr_buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut ptr_buf[..]) }, }), 1 => { let target = u64::from_le_bytes(ptr_buf); @@ -4479,7 +5583,7 @@ mod atomic_tests { // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Read { offset: 0, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }) }) .unwrap_err(); @@ -4544,7 +5648,7 @@ mod atomic_tests { // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Read { offset: 0, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }) }) .unwrap(); @@ -4599,7 +5703,7 @@ mod atomic_tests { match calls { // SAFETY: `buf` outlives this whole `process_gen` call. 1 => Some(BStackGenOp::Pop { - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }), _ => Some(BStackGenOp::Write { offset: 0, @@ -4639,7 +5743,7 @@ mod atomic_tests { .process_gen(|| { // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Pop { - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }) }) .unwrap_err(); @@ -4661,7 +5765,7 @@ mod atomic_tests { .process_gen(|| { // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Pop { - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }) }) .unwrap_err(); @@ -4754,7 +5858,7 @@ mod atomic_tests { match calls { // SAFETY: `size` outlives this whole `process_gen` call. 1 => Some(BStackGenOp::Len { - out: unsafe { core::mem::transmute::<&mut u64, _>(&mut size) }, + out: unsafe { core::mem::transmute::<&mut u64, &mut u64>(&mut size) }, }), _ => Some(BStackGenOp::Discard { len: size - 4 }), } @@ -4779,7 +5883,7 @@ mod atomic_tests { match calls { // SAFETY: `size` outlives this whole `process_gen` call. 1 => Some(BStackGenOp::Len { - out: unsafe { core::mem::transmute::<&mut u64, _>(&mut size) }, + out: unsafe { core::mem::transmute::<&mut u64, &mut u64>(&mut size) }, }), _ => None, } @@ -4811,14 +5915,14 @@ mod atomic_tests { let r = match step { // SAFETY: `size` outlives this whole `process_gen` call. 0 => Some(BStackGenOp::Len { - out: unsafe { core::mem::transmute::<&mut u64, _>(&mut size) }, + out: unsafe { core::mem::transmute::<&mut u64, &mut u64>(&mut size) }, }), 1 => { let n = (size - 8) as usize; buf = vec![0u8; n]; // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Pop { - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }) } _ => None, @@ -4874,7 +5978,7 @@ mod atomic_tests { // read the same value and one increment would be lost. let (s, p) = mk_stack(); let _g = Guard(p); - s.push(&0u64.to_le_bytes()).unwrap(); + s.push(0u64.to_le_bytes()).unwrap(); let s = Arc::new(s); const THREADS: usize = 8; @@ -4893,7 +5997,7 @@ mod atomic_tests { offset: 0, // SAFETY: `buf` outlives this whole `process_gen` call. buf: unsafe { - core::mem::transmute::<&mut [u8], _>(&mut buf[..]) + core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) }, }), 1 => { @@ -4902,7 +6006,9 @@ mod atomic_tests { Some(BStackGenOp::Write { offset: 0, // SAFETY: `buf` outlives this whole `process_gen` call. - data: unsafe { core::mem::transmute::<&[u8], _>(&buf[..]) }, + data: unsafe { + core::mem::transmute::<&[u8], &[u8]>(&buf[..]) + }, }) } _ => None, @@ -4984,7 +6090,7 @@ mod atomic_tests { offset: 0, // SAFETY: `head_buf` outlives this whole `process_gen` call. buf: unsafe { - core::mem::transmute::<&mut [u8], _>(&mut head_buf[..]) + core::mem::transmute::<&mut [u8], &mut [u8]>(&mut head_buf[..]) }, }), 1 => { @@ -4998,7 +6104,9 @@ mod atomic_tests { offset: head, // SAFETY: `next_buf` outlives this whole `process_gen` call. buf: unsafe { - core::mem::transmute::<&mut [u8], _>(&mut next_buf[..]) + core::mem::transmute::<&mut [u8], &mut [u8]>( + &mut next_buf[..], + ) }, }) } @@ -5006,7 +6114,9 @@ mod atomic_tests { 2 => Some(BStackGenOp::Write { offset: 0, // SAFETY: `next_buf` outlives this whole `process_gen` call. - data: unsafe { core::mem::transmute::<&[u8], _>(&next_buf[..]) }, + data: unsafe { + core::mem::transmute::<&[u8], &[u8]>(&next_buf[..]) + }, }), _ => None, }; @@ -5092,7 +6202,9 @@ mod atomic_tests { // SAFETY: `buf` outlives this whole `process_gen` call. Some(BStackGenOp::Read { offset: 0, - buf: unsafe { core::mem::transmute::<&mut [u8], _>(&mut buf[..]) }, + buf: unsafe { + core::mem::transmute::<&mut [u8], &mut [u8]>(&mut buf[..]) + }, }) } 1 => { @@ -5504,6 +6616,7 @@ mod atomic_tests { #[cfg(feature = "atomic")] #[test] + #[allow(clippy::single_range_in_vec_init)] fn get_batched_zero_length_range_returns_empty_buf() { let (s, p) = mk_stack(); let _g = Guard(p); @@ -5515,6 +6628,7 @@ mod atomic_tests { #[cfg(feature = "atomic")] #[test] + #[allow(clippy::single_range_in_vec_init)] fn get_batched_out_of_bounds_returns_error() { let (s, p) = mk_stack(); let _g = Guard(p); @@ -5525,6 +6639,8 @@ mod atomic_tests { #[cfg(feature = "atomic")] #[test] + #[allow(clippy::single_range_in_vec_init)] + #[allow(clippy::reversed_empty_ranges)] fn get_batched_end_less_than_start_returns_error() { let (s, p) = mk_stack(); let _g = Guard(p);