Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ test_*

# VSCode
.vscode/

# GitHub
.github/

# Data
.data/

# Claude
.claude/
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bstack"
version = "0.2.5"
version = "0.2.6"
edition = "2024"
authors = ["William Wu <williamwutq@gmail.com>", "Claude <claude@anthropic.com>"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\x10\x00` (0.1.16). `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
Expand Down
Loading