From 11027ecf299c19b10858d506c9127ed0abc3cae1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 03:12:10 -0700 Subject: [PATCH 01/32] Port .gitignore --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From b68786edfa59236d014d9acab5f801581eb88e66 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 10:13:26 -0700 Subject: [PATCH 02/32] [alloc] Backport checked-slab non-tail-shrink realloc recovery fix Backport of #51 to the 0.2.x line. An interrupted non-tail-shrink `realloc` in `CheckedSlabBStackAllocator` (Rust) / `checked_slab_bstack_allocator_realloc` (C) could make recovery corrupt an unrelated live allocation: the shrink committed the block's smaller count before scrubbing the excess 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. Invert the order: scrub the excess to a clean zero-overhead free run (`write_free_run`) before committing the smaller count. The non-atomic tail shrink keeps its commit-then-discard fast path (safe at the arena tail). Magic bumped 0.1.1 -> 0.1.2 (patch byte only; the 6-byte compat prefix is unchanged, so existing 0.1.x files still open). Unlike the 0.4.x original, the 0.2.x allocator API has no surviving-handle-on-failure mechanism, so the port keeps only the on-disk crash-ordering (no `recovered`/`-2` bookkeeping). Correctness strictly increases: the neighbour-corruption path is gone; the worst remaining outcome is a leak or an allocation with zeroed tail bytes. Tests (all green): Rust alloc,set / alloc,set,atomic; C test-checked-slab / test-checked-slab-atomic. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ c/bstack_alloc.c | 101 ++++++++++++++++++++------------ src/alloc/checked_slab.rs | 119 +++++++++++++++++++++++--------------- 3 files changed, 140 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a69df5a..86e58699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [0.2.5] - 2026-06-15 ### Added diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 7dd4b3f1..dc5420b5 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -3909,7 +3909,7 @@ 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) --- */ @@ -5162,44 +5162,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 +5203,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/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index 837d1f45..7eaf29d3 100644 --- a/src/alloc/checked_slab.rs +++ b/src/alloc/checked_slab.rs @@ -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. @@ -1556,32 +1556,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 +1579,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. From 350eca221d77be0ce1abfbea4f3f2f5b6a39df6f Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 11:00:02 -0700 Subject: [PATCH 03/32] [alloc] Backport first_fit crash-atomic tail-shrink and recovery fixes Backport of two first_fit fixes to the 0.2.x line, both surfaced by the allocator fault-injection fuzz. #28 (crash-atomic realloc tail-shrink): reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault mid-sequence left 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 physical size (an oversized block, as a non-tail shrink already does); the tail is reclaimed on free. Behaviour change: a tail realloc shrink no longer returns space to the file immediately. #35 (two recovery bugs): * Interrupted tail *grow*: extend zero-fills the payload before the header/footer are rewritten, so a crash left a valid block followed by a headerless all-zero region that the recovery scan read as a size-0 block and rejected -- turning a recoverable crash into a hard open failure. Recovery now rolls an all-zero trailing region back by truncation (a real block is never all-zero); genuine mid-arena corruption still fails loudly. * Coalescing free commits the merged size to the header before the footer, so a crash left a stale footer that the header-following walk missed, later letting a neighbour's coalesce overlap two blocks and desync the walk into a hard open failure. Recovery now normalizes every block's footer to its authoritative header as it walks. Both fixes are self-contained recovery/realloc logic with no dependency on the 0.4.0 surviving-handle API or tail-replace primitives. Added two targeted recovery tests that construct the corrupted on-disk state directly (no fault-injection framework); updated realloc_tail_shrink to assert the new oversized-block behaviour. Tests (all green): Rust alloc,set / alloc,set,atomic; C test-first-fit / test-first-fit-atomic. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + c/bstack_alloc.c | 78 ++++++++++++++++++++++++++++++-------- src/alloc/first_fit.rs | 60 ++++++++++++++++++++--------- src/test.rs | 85 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 190 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e58699..ba10c478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. ## [0.2.5] - 2026-06-15 diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index dc5420b5..bec13f37 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -1213,11 +1213,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 +1296,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; @@ -1552,19 +1603,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; diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index e5d39130..b2259b11 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -738,8 +738,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 +809,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); } @@ -1038,21 +1068,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/test.rs b/src/test.rs index c5db1572..3fc62d73 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2715,10 +2715,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] @@ -3091,6 +3097,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 From 9e3bd3a12d714a46d1583d59f3cd54d1d295d9fa Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 11:50:23 -0700 Subject: [PATCH 04/32] [c] Add BSTACK_TEST_NO_DURABLE_SYNC test-only no-op for plat_durable_sync Opt-in compile define that turns plat_durable_sync into a no-op on both the Windows and POSIX paths. In-process test/fuzz runs tear the store down logically and reopen it in-process rather than surviving a real power loss, so skipping the physical sync leaves both the exercised logic and the on-disk bytes unchanged, while on macOS F_FULLFSYNC otherwise dominates C test runtime (minutes -> seconds). Inert unless the define is set; the default build and all production paths still sync. Never enable for a build that must survive a real crash. Mirrors the tooling introduced upstream (#39) so the 0.2.x C allocator test suites can run quickly during backport validation. Co-Authored-By: Claude Opus 4.8 --- c/bstack.c | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/c/bstack.c b/c/bstack.c index 400f2ce5..cb15e6d4 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -125,10 +125,22 @@ static void win_set_errno(void) } } +/* 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 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) @@ -190,14 +202,21 @@ static int plat_ftruncate(bstack_fd_t h, uint64_t size) #else /* !_WIN32 */ +/* No-op under BSTACK_TEST_NO_DURABLE_SYNC — see the note on the Windows + * definition above. Test/fuzz builds only, never production. */ static 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) From 959ecc91b05617fbca9f9e64fda3cae92f0a6a38 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 11:50:36 -0700 Subject: [PATCH 05/32] [alloc] Fix ghost_tree atomic tail-shrink stranding stale padding Under `atomic` / BSTACK_FEATURE_ATOMIC, GhostTreeBstackAllocator's in-place tail shrink discarded the freed tail (try_discard) BEFORE zeroing the retained block's sub-block padding [new_len, aligned_new). 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, ghost_tree.rs:60), so it would hand those stale bytes back to the caller. Zero the padding BEFORE discarding the tail, matching the non-atomic path, which was already correct. A crash now leaves at worst a zeroed retained block plus an unreclaimed tail (a benign leak ghost_tree already tolerates), never stale padding. Operation ordering only -- no on-disk format change, no magic bump. This is NOT the 0.4.x ghost_tree fix (8d5c9d9 / f226b76): that one fuses the two steps with the in-sequence tail-replace primitive (Atrunc / BSTACK_GEN_SPLICE, absent here) and reorders the non-atomic path to discard-first purely to drive the 0.4.0 surviving-handle-on-failure API (also absent here) -- porting it verbatim would REGRESS the 0.2.x non-atomic path. The zeroed-memory invariant violation is the part that matters without handles, and the zero-before-discard reorder closes it. Added realloc_tail_shrink_then_grow_reads_zeros as an invariant guard. Tests (all green): Rust alloc,set / alloc,set,atomic; C test-ghost-tree / test-ghost-tree-atomic. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + c/bstack_alloc.c | 27 ++++++++++++------- src/alloc/ghost_tree.rs | 57 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba10c478..65078d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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). ## [0.2.5] - 2026-06-15 diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index bec13f37..5288e568 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -2861,23 +2861,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; } diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index 76538688..8dda2a5e 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -821,17 +821,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"))] @@ -1320,6 +1338,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(); From 6271ad029017a10569b7a93722132c4af04e0195 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 12:01:06 -0700 Subject: [PATCH 06/32] [alloc] Port ghost_tree smaller-AVL-critical-section optimization Port of the 0.4.x ghost_tree AVL optimization (#39: 3dbeaef + 8631408 Rust, 90f60ac C) to the 0.2.x line. The AVL internals operate on raw offsets and were byte-identical to the optimization's base, so the diffs applied cleanly; no dependency on any 0.4.0 primitive or the three-type handle API. alloc/dealloc/realloc of non-tail blocks do less work under the allocator mutex: the rebalance up-pass no longer re-reads and re-writes each ancestor through a redundant balance-factor pass (the bf and height from the node write are threaded into avl_rebalance), and each node now caches its two child heights in the AVL header's previously-reserved bytes, so the up-pass and rotations write one node per level and read no children in the common in-balance case. Rust also swaps the per-op heap Vec path buffer for a stack array of the fixed MAX_AVL_DEPTH bound. Purely internal -- ~25-33% lower per-op latency under real F_FULLFSYNC, no API or observable-behaviour change. On-disk: magic bumped ALGT\x00\x01\x02\x00 -> ALGT\x00\x01\x03\x00 for the child-height cache. Existing 0.1.x files stay compatible: only the first 6 bytes are checked on open, and coalesce_and_rebalance (run every open) rebuilds the whole tree bottom-up via avl_write_and_update, which recomputes every node's cache from its children's own maintained height fields -- so a legacy/zeroed/crash-torn cache is healed on open and never trusted across a reopen. Added reopen_rebuilds_stale_child_height_cache to guard that contract. Tests (all green): Rust alloc,set (25) / alloc,set,atomic (28); C test-ghost-tree (34) / test-ghost-tree-atomic (37). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + c/bstack_alloc.c | 280 ++++++++++++++++++++++---------- src/alloc/ghost_tree.rs | 350 ++++++++++++++++++++++++++++++---------- 3 files changed, 467 insertions(+), 168 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65078d87..a3f67b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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). + ## [0.2.5] - 2026-06-15 ### Added diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 5288e568..07ce5bd8 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -2104,7 +2104,7 @@ static void bstack_alloc_lock_destroy(void *lock) * 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,3,0}; static const uint8_t algt_magic_prefix[6] = {'A','L','G','T',0,1}; typedef struct { @@ -2113,6 +2113,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 ------------------------------------------------- */ @@ -2155,9 +2157,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, @@ -2173,18 +2180,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]; @@ -2196,81 +2230,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 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; } @@ -2288,10 +2354,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; @@ -2299,32 +2365,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); } @@ -2337,27 +2418,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; @@ -2365,9 +2455,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; } @@ -2386,8 +2477,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) { @@ -2399,10 +2491,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; @@ -2416,6 +2508,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; } @@ -2430,34 +2524,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; diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index 8dda2a5e..c7397eda 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -9,7 +9,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\x03\x00"; const ALGT_MAGIC_PREFIX: [u8; 6] = *b"ALGT\x00\x01"; /// Payload offset of the magic number. @@ -35,6 +35,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 +50,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`]. @@ -274,13 +289,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 +322,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)?; @@ -318,30 +354,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 +407,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 +436,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 +484,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 +498,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 +557,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 +615,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 +667,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))) @@ -1513,6 +1651,54 @@ 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(); From e6d15a112f095bf4b3766a48f5bdd0732a047579 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 12:54:22 -0700 Subject: [PATCH 07/32] [all] Add #[must_use]/#[track_caller]/#[inline] and C BSTACK_WARN_UNUSED_RESULT Compile-time annotation sweeps ported from the 0.4.x line (Rust: e8d323f, 2d2ec56, 6bc23e2; C: f194a41), adapted to the 0.2.x public API. Attributes only -- no logic, signatures, or behaviour changed. - #[must_use] (Rust) / BSTACK_WARN_UNUSED_RESULT (C): public functions whose return reports success/failure or hands back a result that should not be silently discarded now warn if the caller ignores them. Result-returning Rust fns are left alone (Result is already must_use). 36 Rust annotations; the C macro plus ~84 declaration annotations across bstack.h / bstack_alloc.h / bstack_bytevec.h, and two `(void)` casts on intentional cleanup-path discards in bstack_bytevec.c. - #[track_caller]: BStackSlice / BStackByteVec methods with a documented panic precondition now report the caller's source location on panic. - #[inline]: short public functions across the crate, for cross-crate inlining. 0.4.x annotations on types absent from 0.2.x (BStackOwnedSlice, BStackChunk, BStackAllocError/BStackBulkAllocError, the segregated allocator, and 0.4.x-only BStack ops) were skipped. Verified: cargo check (default / alloc,set / alloc,set,atomic) clean; C libbstack-alloc-set{,-atomic}.a compile clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +++ c/bstack.h | 49 +++++++++++++++++++++++++++++++++++++ c/bstack_alloc.h | 36 +++++++++++++++++++++++++++ c/bstack_bytevec.c | 4 +-- c/bstack_bytevec.h | 15 ++++++++++++ src/alloc/checked_slab.rs | 4 +++ src/alloc/debug_checking.rs | 7 ++++++ src/alloc/first_fit.rs | 2 ++ src/alloc/ghost_tree.rs | 2 ++ src/alloc/guarded.rs | 7 ++++++ src/alloc/linear.rs | 7 ++++++ src/alloc/mod.rs | 3 +++ src/alloc/slab.rs | 4 +++ src/alloc/slice.rs | 30 +++++++++++++++++++++++ src/alloc/vec.rs | 17 +++++++++++++ src/lib.rs | 23 +++++++++++++++++ 16 files changed, 211 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f67b65..74af6a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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 diff --git a/c/bstack.h b/c/bstack.h index 5aca11ad..82745891 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,6 +138,7 @@ 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); /* @@ -124,6 +147,7 @@ int bstack_discard(bstack_t *bs, size_t n); * 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 +155,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 +179,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 +191,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 +208,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 +217,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 +229,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,6 +241,7 @@ 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 */ @@ -239,6 +270,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 +286,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 +301,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,6 +316,7 @@ 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); /* @@ -304,6 +339,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,6 +356,7 @@ 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); /* @@ -338,6 +375,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 +399,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 +512,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 +529,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 +550,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 +615,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 +634,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 +650,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 +670,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 +687,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 +706,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 +724,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.h b/c/bstack_alloc.h index e57feeb3..76958153 100644 --- a/c/bstack_alloc.h +++ b/c/bstack_alloc.h @@ -82,12 +82,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 +97,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 +106,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 +115,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 +124,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 +134,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 +142,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,6 +151,7 @@ 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 */ @@ -239,12 +248,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 +263,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 +273,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 +283,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 +293,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 +302,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 +311,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 +319,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 +354,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 +371,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 +380,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 +469,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 +488,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 +501,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 +512,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 +534,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 +547,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 +581,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 +655,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); /* @@ -686,6 +716,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 +795,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 +810,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 +907,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 +923,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 +938,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..696bced0 100644 --- a/c/bstack_bytevec.c +++ b/c/bstack_bytevec.c @@ -135,7 +135,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 +159,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; } } diff --git a/c/bstack_bytevec.h b/c/bstack_bytevec.h index 49816643..3e7c71dc 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,6 +296,7 @@ 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); /* ========================================================================= @@ -297,6 +311,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/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index 7eaf29d3..11609f07 100644 --- a/src/alloc/checked_slab.rs +++ b/src/alloc/checked_slab.rs @@ -395,6 +395,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 +1235,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 } diff --git a/src/alloc/debug_checking.rs b/src/alloc/debug_checking.rs index 1e143aba..72fbaf22 100644 --- a/src/alloc/debug_checking.rs +++ b/src/alloc/debug_checking.rs @@ -192,6 +192,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()); @@ -290,6 +291,7 @@ where /// 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 +394,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 +416,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 +436,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 } diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index b2259b11..db251c73 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -862,10 +862,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 } diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index c7397eda..29c87cea 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -849,10 +849,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 } 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..710ef3d6 100644 --- a/src/alloc/linear.rs +++ b/src/alloc/linear.rs @@ -81,6 +81,8 @@ pub struct 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 +100,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 +117,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) diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index 0bd4ac36..574baaa8 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -356,6 +356,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 +364,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 +372,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..1dfe3c27 100644 --- a/src/alloc/slab.rs +++ b/src/alloc/slab.rs @@ -288,6 +288,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 +542,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 } diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index 6c737490..cde99f48 100644 --- a/src/alloc/slice.rs +++ b/src/alloc/slice.rs @@ -107,6 +107,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 +126,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 +140,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 +157,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 +170,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,30 +178,35 @@ 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 } @@ -209,6 +219,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 +233,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 +247,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"); @@ -386,6 +401,8 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { /// /// 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 +414,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 +427,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 +443,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 +459,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 +521,7 @@ pub struct BStackSliceReader<'a, A: BStackAllocator> { } impl<'a, A: BStackAllocator> Clone for BStackSliceReader<'a, A> { + #[inline] fn clone(&self) -> Self { *self } @@ -518,12 +543,14 @@ impl<'a, A: BStackAllocator> fmt::Debug for BStackSliceReader<'a, A> { 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 } @@ -654,12 +681,14 @@ impl<'a, A: BStackAllocator> fmt::Debug for BStackSliceWriter<'a, A> { 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 +714,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..5a9e7673 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -195,6 +195,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 +203,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 +234,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 +243,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 +252,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 +295,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)) @@ -341,6 +349,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) } @@ -394,6 +403,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 +434,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 +444,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,6 +460,7 @@ 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) } @@ -476,6 +491,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 +501,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 diff --git a/src/lib.rs b/src/lib.rs index 60db3908..52677f33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3495,6 +3495,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 +3505,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 +3517,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 +3667,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 +3686,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 +3704,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 +3726,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 +3744,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,6 +3777,7 @@ 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) } @@ -3773,6 +3785,7 @@ impl PartialEq for BStack { /// Hashes the instance address, consistent with the pointer-identity [`PartialEq`]. impl Hash for BStack { + #[inline] fn hash(&self, state: &mut H) { (self as *const BStack).hash(state); } @@ -3819,6 +3832,8 @@ pub struct BStackReader<'a> { 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 +3845,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 +3857,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 +3891,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; From c0fdf961f1cc0a6a527ff4fe75f68bbf4315b6ba Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 13:25:33 -0700 Subject: [PATCH 08/32] [core] Add direct sizing APIs: resize, ensure, ensure_with (Rust + C) Ported from the 0.4.x line (Rust cd96b7a, C 6078bdd), adapted to 0.2.x which has no commit_grow/commit_shrink helpers or fault-injection macro -- the grow/shrink commit-and-rollback is inlined to match this branch's extend/discard. - resize(target): grow (zero-filled) or shrink the payload to exactly target bytes; returns the size before the call. Growth follows extend's crash-consistency, shrink follows discard's (truncation is the commit point). Rejects a shrink below the locked length. - ensure(target): grow-only, no-op if already >= target; the unconditional counterpart of resize. - ensure_with(target, f) [Rust atomic / C BSTACK_FEATURE_ATOMIC]: grow only if shorter, handing the freshly zeroed tail to f for initialization before commit. The grown region sits beyond the committed length until the final header write, so it is crash-atomic on extend's terms without needing a journal. Tests: Rust tests::resize (6), tests::ensure (4), tests::ensure_with (3, atomic). C base test 71/71, test-atomic 111/111 (7 resize/ensure + 2 ensure_with added). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 + c/bstack.c | 192 ++++++++++++++++++++++++++++++++++++++++++++++++ c/bstack.h | 39 ++++++++++ c/test_bstack.c | 179 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 170 ++++++++++++++++++++++++++++++++++++++++++ src/test.rs | 160 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 744 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74af6a22..14c17cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### 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. diff --git a/c/bstack.c b/c/bstack.c index cb15e6d4..702d17f0 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -852,6 +852,123 @@ 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; +} + /* ------------------------------------------------------------------------- * bstack_len * ---------------------------------------------------------------------- */ @@ -1609,6 +1726,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 */ /* ------------------------------------------------------------------------- diff --git a/c/bstack.h b/c/bstack.h index 82745891..2723a470 100644 --- a/c/bstack.h +++ b/c/bstack.h @@ -141,6 +141,26 @@ int bstack_get(bstack_t *bs, uint64_t start, uint64_t end, 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); + /* * 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; @@ -319,6 +339,25 @@ int bstack_try_extend(bstack_t *bs, uint64_t s, 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. diff --git a/c/test_bstack.c b/c/test_bstack.c index d13a6dcf..7217b123 100644 --- a/c/test_bstack.c +++ b/c/test_bstack.c @@ -4026,6 +4026,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,6 +4260,19 @@ 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); diff --git a/src/lib.rs b/src/lib.rs index 52677f33..9ac1254f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1403,6 +1403,176 @@ 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 diff --git a/src/test.rs b/src/test.rs index 3fc62d73..de4b43a4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -861,6 +861,166 @@ 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"); + } + // ---- zero (feature-gated) ----------------------------------------------- #[cfg(feature = "set")] From 66bf015b8a0db649a01ddadb1113c9cfb9eeb4f1 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 13:39:44 -0700 Subject: [PATCH 09/32] [core] Add sparse tail-growth: extend_sparse family (Rust + C) Ported from the 0.4.x line (6244267), adapted to 0.2.x: the shared commit helper lives in lib.rs (no io_core.rs), inlines the grow commit (no commit_grow/commit_sparse_extend helpers), uses seek+write_all for scattered writes (no write_at), and drops all fault_point! calls. - extend_sparse(buf, length) / extend_sparse_batched(writes, length): base API. Grow the payload by `length` with a single set_len (OS zero-fills the gaps), writing only the supplied buffer(s) into the new region. The batched form scatters (relative_offset, data) writes, validated as in-range and pairwise non-overlapping; empty data ignored; length == 0 is a no-op. - try_extend_sparse(s, buf, length) / try_extend_sparse_batched(s, writes, length): atomic. Add a try_extend-style size guard `s` (apply only if the current payload size equals `s`, else Ok(false)/*ok=0; a malformed request is still rejected regardless of the size match). No journal is needed: the whole grown region sits beyond the committed length, so a crash before the header commit rolls back by truncation, exactly like extend. C reuses the existing bstack_iovec_t (its typedef moved into the base section so the base batched API can use it, and its comment refreshed). Tests: Rust tests::extend_sparse (11) + tests::try_extend_sparse (7, atomic). C base test 80/80, test-atomic 127/127 (9 base + 7 atomic added). New C decls carry BSTACK_WARN_UNUSED_RESULT. Not ported (out of scope): the process_gen Sparse / BSTACK_GEN_SPARSE in-sequence variant. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + c/bstack.c | 308 +++++++++++++++++++++++++++++++++++++++++++ c/bstack.h | 109 +++++++++++++-- c/test_bstack.c | 335 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 342 ++++++++++++++++++++++++++++++++++++++++++++++++ src/test.rs | 235 +++++++++++++++++++++++++++++++++ 6 files changed, 1320 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c17cd4..964c5033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. ### Fixed diff --git a/c/bstack.c b/c/bstack.c index 702d17f0..6943a27c 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -969,6 +969,210 @@ int bstack_ensure(bstack_t *bs, uint64_t target, uint64_t *out_initial_len) 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 * ---------------------------------------------------------------------- */ @@ -1638,6 +1842,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) { diff --git a/c/bstack.h b/c/bstack.h index 2723a470..151ed7a9 100644 --- a/c/bstack.h +++ b/c/bstack.h @@ -161,6 +161,65 @@ int bstack_resize(bstack_t *bs, uint64_t target, uint64_t *out_initial_len); 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; @@ -265,16 +324,6 @@ 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. - */ -typedef struct { - uint64_t offset; - uint8_t *buf; - size_t len; -} bstack_iovec_t; - #ifdef BSTACK_FEATURE_ATOMIC /* * Atomically cut n bytes off the tail then append buf_len bytes from buf. @@ -398,6 +447,46 @@ int bstack_replace(bstack_t *bs, size_t n, 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. diff --git a/c/test_bstack.c b/c/test_bstack.c index 7217b123..f06fec9b 100644 --- a/c/test_bstack.c +++ b/c/test_bstack.c @@ -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 /* ========================================================================= @@ -1878,6 +2053,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) @@ -4278,6 +4593,17 @@ int main(void) 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); @@ -4351,6 +4677,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/src/lib.rs b/src/lib.rs index 9ac1254f..f02d6822 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. @@ -1992,6 +2210,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 diff --git a/src/test.rs b/src/test.rs index de4b43a4..e8bdd47c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1724,6 +1724,241 @@ mod tests { (PUSH_THREADS * PUSHES_PER_THREAD) as u64 * ITEM ); } + + // ---- extend_sparse ------------------------------------------------------ + + #[test] + fn extend_sparse_writes_prefix_and_zeros_rest() { + let (s, p) = mk_stack(); + let _g = Guard(p); + + 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"); + } + + #[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"); + } + + #[test] + 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"); + } + + #[test] + 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); + } + + #[test] + 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); + } + + #[test] + 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"); + } + + // ---- extend_sparse_batched ---------------------------------------------- + + #[test] + 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); + } } // ------------------------------------------------------------------------- From 04e134ed7a3e44d508f154d839aa51d353a84744 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 13:54:02 -0700 Subject: [PATCH 10/32] [core] Add BStack::repeat / bstack_repeat: in-place repeating fill (Rust + C) `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. The general form of `zero`. `set` feature (Rust) / BSTACK_FEATURE_SET (C). Ported from the 0.4.x line, but WITHOUT its fixed-size write-in-progress journal (this branch has none): the full count*pattern.len() bytes are staged in memory and written directly, then durably synced -- slower for a large region and O(n) memory, but the same result and the same durability as `set`. The API is now present so the fill-based ergonomic methods (BStackSlice::fill, BStackByteVec::fill) can build on it. Tests: Rust tests::repeat (fill/offset/single-byte/noop/past-end-reject/ reopen). C test-set 101/101, test-set-atomic 194/194 (5 repeat tests). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + c/bstack.c | 81 +++++++++++++++++++++++++++++++++++++++++++++ c/bstack.h | 15 +++++++++ c/test_bstack.c | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 70 +++++++++++++++++++++++++++++++++++++++ src/test.rs | 67 +++++++++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964c5033..b9c12fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. ### Fixed diff --git a/c/bstack.c b/c/bstack.c index 6943a27c..74acd7dd 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -1467,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 */ /* ------------------------------------------------------------------------- diff --git a/c/bstack.h b/c/bstack.h index 151ed7a9..f7a6e462 100644 --- a/c/bstack.h +++ b/c/bstack.h @@ -322,6 +322,21 @@ int bstack_set(bstack_t *bs, uint64_t offset, */ BSTACK_WARN_UNUSED_RESULT int bstack_zero(bstack_t *bs, uint64_t offset, size_t n); + +/* + * 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. + */ +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 diff --git a/c/test_bstack.c b/c/test_bstack.c index f06fec9b..c93ee7d6 100644 --- a/c/test_bstack.c +++ b/c/test_bstack.c @@ -1668,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 */ /* ========================================================================= @@ -4648,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 diff --git a/src/lib.rs b/src/lib.rs index f02d6822..172ee273 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1900,6 +1900,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) + } } // --------------------------------------------------------------------------- diff --git a/src/test.rs b/src/test.rs index e8bdd47c..5a43580c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1021,6 +1021,73 @@ mod tests { 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")] From 70388e860d406b980e2a38cfdb7ad8b87b191bda Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 14:03:05 -0700 Subject: [PATCH 11/32] [alloc+set] Add BStackSlice std-slice-style ergonomic methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from the 0.4.x line (939a089), Rust only (master has no C for these) and on BStackSlice alone (no BStackOwnedSlice on this branch). Adapted to this branch's `BStackSlice<'a, A>`: the stack is reached via the `self.stack()` method rather than master's `self.stack` field. - Read-only (alloc): get, head/tail, contains, starts_with/ends_with, find/rfind, position/rposition, split_at/split_at_mut. - Write (set): fill (one BStack::repeat call), fill_with, copy_from_slice. - Atomic compound (set + atomic, each one crash-atomic BStack call): copy_from_bstack_slice, copy_within, swap (cross_exchange), reverse, rotate_left/rotate_right (process). #[track_caller] on the methods with a panic precondition (split_at, split_at_mut, copy_from_slice, copy_from_bstack_slice, copy_within, swap, rotate_left, rotate_right) and #[must_use] on head/tail, matching master. Tests: 21 in src/test.rs (alloc_tests) — 20 under set, 29 under set,atomic. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/alloc/slice.rs | 335 +++++++++++++++++++++++++++++++++++++++++++++ src/test.rs | 316 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 652 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c12fd0..6b18225f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. ### Fixed diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index cde99f48..df64bcd8 100644 --- a/src/alloc/slice.rs +++ b/src/alloc/slice.rs @@ -259,6 +259,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`]. @@ -397,6 +529,209 @@ 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) + } + + /// 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()) + } + + /// 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 diff --git a/src/test.rs b/src/test.rs index 5a43580c..dff41911 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2809,6 +2809,322 @@ mod alloc_tests { 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 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]); + } + + #[cfg(feature = "set")] + #[test] + fn slice_copy_from_slice() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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] + #[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 mut s = alloc.alloc(4).unwrap(); + s.copy_from_slice(b"TOOLONG").unwrap(); + } + + // ---- atomic compound (needs `set` + `atomic`) --------------------------- + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_copy_from_bstack_slice() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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"); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + 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); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_copy_within() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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"); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_swap() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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"); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + 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 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(); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_rotate_left() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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"); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_rotate_right() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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"); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + #[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); + let mut s = alloc.alloc(4).unwrap(); + let _ = s.rotate_left(5); + } } // ------------------------------------------------------------------------- From de1088b99ccc21ce062996ef47b84ffad0fa32a7 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 14:09:55 -0700 Subject: [PATCH 12/32] [alloc+set] Add BStackByteVec in-place and capacity methods Ported from the 0.4.x line (03929eb), Rust only (master has no C for these), adapted to this branch's BStackByteVec<'a, A: BStackSliceAllocator> over BStackSlice. - set(index, value): single crash-atomic write; Ok(None) if index >= len (get-style convention). - fill(value): overwrite the populated region via one BStack::repeat (no-op on empty). On this line repeat has no journal, so a large fill writes the whole region directly. - reserve_exact(additional): grow to exactly len + additional (no amortised over-allocation, unlike reserve). - shrink_to(min_capacity) / shrink_to_fit(): realloc the backing block down to max(len, min_capacity) / len. The internal capacity helper grow_to was renamed realloc_to and now handles shrink as well as growth (the allocator realloc already reallocs in either direction); its two existing call sites were updated. On-disk header format unchanged. Tests: 9 added to the existing bytevec test module in vec.rs (set/fill/ reserve_exact/shrink_to/shrink_to_fit + reopen); alloc::vec::tests 40 passed under alloc,set and alloc,set,atomic. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/alloc/vec.rs | 213 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b18225f..22031bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. ### Fixed diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 5a9e7673..544d5e41 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -75,7 +75,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 +180,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 @@ -309,7 +317,7 @@ 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)?; @@ -370,11 +378,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 @@ -987,4 +1073,123 @@ 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]); + } } From e3e3d5634a30f3483938537a0909eccfc1cb2530 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 14:18:07 -0700 Subject: [PATCH 13/32] [alloc+set+atomic] Add BStackByteVec crash-atomic byte movers Ported from the 0.4.x line (03929eb), Rust only, adapted to this branch's BStackByteVec<'a, A: BStackSliceAllocator> over BStackSlice (no BStackOwnedSlice here). All gated #[cfg(feature = "atomic")] since they ride BStack::copy / cross_exchange (set + atomic on this branch). Append-only movers (benign push-style crash model): extend_from_within, extend_from_bstack_slice, append_from_owned. In-place movers (crash-atomic per step, logically torn if interrupted): insert, remove, swap_remove, move_tail_into. copy_into_bstack_slice copies vec bytes out to a same-BStack slice. append_from_owned/move_tail_into take/return BStackSlice in the positions master used BStackOwnedSlice. append_from_owned consumes and frees its argument on EVERY path (foreign-stack, append-error, and success: `let freed = alloc.dealloc(other); appended.and(freed)`), never leaking. OOB index/range or u64 overflow -> Ok(None); a cross-BStack handle on a cross-slice method -> Err(InvalidInput). master doc links to the nonexistent extend_from_slice were repointed at push. Tests: 13 added to the inline bytevec test module (happy path + OOB->None + cross-BStack misuse + reopen; the foreign-append test also asserts the rejected slice is reclaimed, proving no leak). alloc::vec::tests 53 passed under alloc,set,atomic; compiles with the methods cfg'd out under alloc,set. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/alloc/vec.rs | 545 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- 3 files changed, 547 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22031bf3..1f2600b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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` — 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 diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 544d5e41..659d1120 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -552,6 +552,334 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { } } +// ── 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(())) + } +} + // ── iterator ────────────────────────────────────────────────────────────────── /// An iterator over the bytes of a [`BStackByteVec`]. @@ -1192,4 +1520,221 @@ mod tests { 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]); + } } diff --git a/src/lib.rs b/src/lib.rs index 172ee273..6584786e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1654,9 +1654,7 @@ impl BStack { if target < locked { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "resize({target}) would shrink payload below locked length ({locked})" - ), + format!("resize({target}) would shrink payload below locked length ({locked})"), )); } file.set_len(HEADER_SIZE + target)?; From e037229baba0a3c9bfb4cfab6f5a22ab23ebacb4 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 14:25:49 -0700 Subject: [PATCH 14/32] [alloc+set] Add BStackByteVec::extend_from_slice bulk byte append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported verbatim from the 0.4.x line (03929eb) — all the helpers it needs (read_header, reserve, write_bytes_at, write_len_field) already exist on this branch. Appends an entire &[u8] in one shot: reserve once, write all bytes with a single durable set, then commit the new len (vs a grow/write/len cycle per byte). Empty input is a no-op. Crash-consistent like the other multi-step methods — a crash before the len commit leaves the appended bytes beyond the committed length, invisible, and re-running recovers. Tests: 3 added (bulk append, empty no-op, persist via raw block). alloc::vec::tests 43 (alloc,set) / 56 (alloc,set,atomic) passed. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/alloc/vec.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2600b3..d2a268f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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 diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 659d1120..5051f561 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -324,6 +324,33 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { 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. @@ -1020,6 +1047,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. From 568ae0ca0d95e5ffbce89c0a099ae3810195d2ee Mon Sep 17 00:00:00 2001 From: williamwutq Date: Sun, 23 Aug 2026 14:30:48 -0700 Subject: [PATCH 15/32] Bump version to 0.2.6 (format magic 0.1.15 -> 0.1.16) Crate version 0.2.5 -> 0.2.6 (Cargo.toml, Cargo.lock). Following the per-release convention, the BStack format-version stamp is bumped BSTK\x00\x01\x0f\x00 (0.1.15) -> BSTK\x00\x01\x10\x00 (0.1.16) in src/lib.rs, c/bstack.c, c/test_bstack.c, and the README/doc comments. This is compat-neutral: `open` gates only on the first 6 bytes (BSTK\x00\x01), so files written by any 0.1.x still open, and 0.2.6 reads older files unchanged. The on-disk layout itself is identical to 0.2.5. CHANGELOG: the [Unreleased] section is stamped [0.2.6] - 2026-08-23 and a fresh empty [Unreleased] opened. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- c/bstack.c | 2 +- c/test_bstack.c | 2 +- src/lib.rs | 6 +++--- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a268f9..bedbac0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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. diff --git a/Cargo.lock b/Cargo.lock index cb98e6f7..16b338d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bstack" -version = "0.2.5" +version = "0.2.6" dependencies = [ "criterion", "libc", diff --git a/Cargo.toml b/Cargo.toml index aa8f9dec..d171f89c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bstack" -version = "0.2.5" +version = "0.2.6" edition = "2024" authors = ["William Wu ", "Claude "] license = "MIT" diff --git a/README.md b/README.md index b214a659..629a5893 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/c/bstack.c b/c/bstack.c index 74acd7dd..10af5cd2 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, 16, 0}; static const uint8_t MAGIC_PREFIX[6] = {'B','S','T','K', 0, 1}; static const uint64_t HEADER_SIZE = 16; diff --git a/c/test_bstack.c b/c/test_bstack.c index c93ee7d6..94ba10ac 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, 16, 0}; static const uint8_t MAGIC_PREFIX[6] = {'B','S','T','K', 0, 1}; static int test_new_file_has_valid_header(void) diff --git a/src/lib.rs b/src/lib.rs index 6584786e..7bee9ea7 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\x10\x00` (0.1.16). [`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 16 + 0). +const MAGIC: [u8; 8] = *b"BSTK\x00\x01\x10\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. From 31ee2f75e374b9c5ecaa468ca56cd67eb633a33c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 01:25:15 -0700 Subject: [PATCH 16/32] [alloc] Reject unalignable lengths in GhostTreeBstackAllocator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of the 0.4.x fix. `align_up_len` / `algt_align_up_len` computed `(len + 31) & !31` unchecked, so any `len > u64::MAX - 31` wrapped and a near-`u64::MAX` request rounded *down* to a 32-byte block: `alloc` handed back a handle claiming the requested length, and `realloc` took the shrink path, physically shrinking the block to 32 bytes and returning the freed remainder to the tree while the caller's handle still claimed the huge length. Debug builds trapped on the overflowing add, so the reachable damage was release-only. Both are now checked 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 `InvalidInput` / `EINVAL`. The C helper signals the overflow with `UINT64_MAX`, matching the 0.4.x port. Magic bumped `ALGT\x00\x01\x03\x00` → `ALGT\x00\x01\x04\x00` (patch byte only; existing 0.1.x files stay compatible). The layout docs in ghost_tree.rs, bstack_alloc.h and README.md still quoted the pre-0.2.6 `\x02\x00` magic and are corrected to the new value. Rust: 31 ghost_tree tests pass. C: 36/36 (set) and 39/39 (set+atomic), including the two new rejection tests. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 +++ README.md | 2 +- c/bstack_alloc.c | 28 ++++++++- c/bstack_alloc.h | 2 +- c/test_ghost_tree.c | 79 +++++++++++++++++++++++++ src/alloc/ghost_tree.rs | 125 ++++++++++++++++++++++++++++++++++++---- 6 files changed, 229 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bedbac0a..4eb8f4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 + +- **`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 diff --git a/README.md b/README.md index 629a5893..df5999b6 100644 --- a/README.md +++ b/README.md @@ -965,7 +965,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) diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 07ce5bd8..21ff804d 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -2098,13 +2098,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,3,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 { @@ -2122,7 +2130,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; } @@ -2813,6 +2823,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. */ @@ -2877,6 +2888,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 @@ -2941,6 +2953,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. */ @@ -3110,6 +3126,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; @@ -3224,6 +3245,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++; } diff --git a/c/bstack_alloc.h b/c/bstack_alloc.h index 76958153..90f93611 100644 --- a/c/bstack_alloc.h +++ b/c/bstack_alloc.h @@ -678,7 +678,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) * 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/ghost_tree.rs b/src/alloc/ghost_tree.rs index 29c87cea..28dbedca 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -9,7 +9,7 @@ use std::marker::PhantomData; #[cfg(feature = "atomic")] use std::sync::Mutex; -const ALGT_MAGIC: [u8; 8] = *b"ALGT\x00\x01\x03\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 +22,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; @@ -97,7 +106,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) @@ -337,9 +346,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`]). @@ -875,7 +891,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(); @@ -940,8 +961,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. @@ -1056,7 +1083,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, @@ -1111,8 +1143,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() @@ -1204,7 +1248,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() { @@ -1528,6 +1578,59 @@ 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(); From 786301ed0a2049031f2957ee2f5d2da7a3a57d02 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 08:55:14 -0700 Subject: [PATCH 17/32] [alloc+set+atomic] Add BStackSlice::cas_on family and process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of the 0.4.x slice-level wrappers over the CRDS primitives that have been in the base API since 0.2.3. cas_on(guard, expected, new_bytes) is one crash-atomic BStack::eq_crds call: guard's bytes are compared to expected and, on a match, the slice is overwritten with new_bytes and its prior contents returned — all under one write lock, so no thread observes the compare and the write as separate steps. cas_on_ne and cas_on_masked wrap ne_crds and masked_eq_crds the same way. guard may be any view into the same BStack, including the slice itself. process(f) is one BStack::process call, exposing the transform reverse/rotate_left/rotate_right already ride on. Unlike the 0.4.x original, the three cas_on methods share one check_cas_args helper rather than repeating the same-BStack and two length checks inline; the error kinds and messages are unchanged. The README slice method table was still the pre-0.2.6 list, so the rows for the ergonomic and atomic-compound methods added in 0.2.6 are filled in alongside the new ones. 11 new tests; 40 slice tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 ++ README.md | 47 ++++++++++---- src/alloc/slice.rs | 145 +++++++++++++++++++++++++++++++++++++++++ src/test.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb8f4e7..28405e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`BStackSlice::cas_on`/`cas_on_ne`/`cas_on_masked`/`process` (`alloc` + `set` + `atomic`, Rust only).** `cas_on(guard, expected, new_bytes)` is one crash-atomic `BStack::eq_crds` call: if `guard`'s bytes equal `expected`, the slice is overwritten with `new_bytes` and the prior contents returned as `Some(_)`, else `None` and the slice is untouched. `cas_on_ne`/`cas_on_masked` wrap `ne_crds`/`masked_eq_crds` the same way. `guard` may be any view into the same `BStack`, including the slice itself. Each rejects a `guard` backed by a different `BStack`, or a length mismatch against `guard`/self, with `io::ErrorKind::InvalidInput`. `process(f)` is one crash-atomic `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. + ### 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. diff --git a/README.md b/README.md index df5999b6..f1eb438a 100644 --- a/README.md +++ b/README.md @@ -753,20 +753,39 @@ Produced by `BStackAllocator::alloc`; consumed by `realloc` and `dealloc`. 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` | +| `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>` diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index df64bcd8..3116c874 100644 --- a/src/alloc/slice.rs +++ b/src/alloc/slice.rs @@ -648,6 +648,134 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { .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. @@ -679,6 +807,23 @@ impl<'a, A: BStackAllocator> BStackSlice<'a, A> { .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, diff --git a/src/test.rs b/src/test.rs index dff41911..18fba503 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3125,6 +3125,165 @@ mod alloc_tests { let mut s = alloc.alloc(4).unwrap(); let _ = s.rotate_left(5); } + + // ---- BStackSlice: cas_on / cas_on_ne / cas_on_masked -------------------- + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_match_swaps_and_returns_old() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_no_match_leaves_target_untouched() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + // The guard may be the target itself — the plain single-region CAS. + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_self_guard_swaps() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_expected_length_mismatch_errors() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_new_bytes_length_mismatch_errors() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + 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 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]); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_ne_match_returns_none() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_masked_match_swaps_and_returns_old() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_cas_on_masked_no_match_returns_none() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } + + // ---- BStackSlice: process ---------------------------------------------- + + #[cfg(all(feature = "set", feature = "atomic"))] + #[test] + fn slice_process_transforms_in_place() { + let (alloc, path) = mk_alloc(); + let _g = Guard(path); + 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]); + } } // ------------------------------------------------------------------------- From ad75324b664675f323e704a90881c7e40b2b9d95 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 08:59:34 -0700 Subject: [PATCH 18/32] [alloc+set+atomic] Port BStackSlice::cas_on family and process to C C counterparts of the slice wrappers added in the previous commit: bstack_slice_cas_on, cas_on_ne, cas_on_masked and bstack_slice_process, gated on BSTACK_FEATURE_SET + BSTACK_FEATURE_ATOMIC. Each is one call into the CRDS primitive that has been in the base API since 0.2.3. The prior contents come back through an old_buf buffer plus an int *ok flag rather than an Option, matching the convention bstack_eq_crds and friends already use. As on the Rust side, the three cas_on functions share one slice_cas_check helper instead of repeating the same-bstack and two length checks; errno values are unchanged. 11 tests in test_first_fit.c, which is where the slice-level tests live (the allocator is just the simplest source of two live slices). C: 27/27 with SET+ATOMIC, 10/10 with SET alone (the new tests compile out), and the featureless bstack_alloc.c still builds. Changelog: the C port is folded into the existing unreleased Rust entry rather than added as a second one. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- c/bstack_alloc.c | 73 ++++++++++ c/bstack_alloc.h | 77 ++++++++++ c/test_first_fit.c | 340 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 491 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28405e35..23bfdca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`BStackSlice::cas_on`/`cas_on_ne`/`cas_on_masked`/`process` (`alloc` + `set` + `atomic`, Rust only).** `cas_on(guard, expected, new_bytes)` is one crash-atomic `BStack::eq_crds` call: if `guard`'s bytes equal `expected`, the slice is overwritten with `new_bytes` and the prior contents returned as `Some(_)`, else `None` and the slice is untouched. `cas_on_ne`/`cas_on_masked` wrap `ne_crds`/`masked_eq_crds` the same way. `guard` may be any view into the same `BStack`, including the slice itself. Each rejects a `guard` backed by a different `BStack`, or a length mismatch against `guard`/self, with `io::ErrorKind::InvalidInput`. `process(f)` is one crash-atomic `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. +- **`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. ### Fixed diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 21ff804d..5125ed37 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -211,6 +211,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 * ====================================================================== */ diff --git a/c/bstack_alloc.h b/c/bstack_alloc.h index 90f93611..c471b789 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. */ /* ------------------------------------------------------------------------- @@ -155,6 +158,80 @@ 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 * diff --git a/c/test_first_fit.c b/c/test_first_fit.c index d648d8c3..5030bc73 100644 --- a/c/test_first_fit.c +++ b/c/test_first_fit.c @@ -1106,6 +1106,333 @@ 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 */ /* ========================================================================= @@ -1138,6 +1465,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); From ed4fd303981ddde0e885d90357e154f4c2c2c3b0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:03:07 -0700 Subject: [PATCH 19/32] [base] Hash BStack on the raw fd/handle instead of its address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport from the 0.4.x line (the change is on master there, not on the expensive-slice-access-control branch). The fd (Unix) / handle (Windows) is equally unique per live instance and, unlike the address, does not change when the value moves. PartialEq stays pointer identity. Platforms that are neither Unix nor Windows keep the address hash, which is not move-stable. The README trait table said "hashes the instance address" and is updated; master's copy is stale in the same way. algos/EQUALITY.md, which the 0.4.x entry cross-references, is not ported — it documents equality and ordering for the chunk and owned-slice types this line does not have. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 2 +- src/lib.rs | 10 +++++++++- src/test.rs | 21 +++++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bfdca8..c9a2f848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`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 diff --git a/README.md b/README.md index f1eb438a..cc5dcb8e 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` diff --git a/src/lib.rs b/src/lib.rs index 7bee9ea7..3036424a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4363,10 +4363,18 @@ impl PartialEq for BStack { } } -/// 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); } } diff --git a/src/test.rs b/src/test.rs index 18fba503..8e72e0c6 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2026,6 +2026,27 @@ mod tests { 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); + } } // ------------------------------------------------------------------------- From e95a62a3579d24c1ecd543137a39603288da7a13 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:05:51 -0700 Subject: [PATCH 20/32] [alloc+atomic] Grow LinearBStackAllocator's tail with try_extend_zeros Backport from the 0.4.x line (master). The atomic realloc grow path staged a vec![0u8; delta] and appended it with try_extend, writing delta bytes for a region that is zero anyway. try_extend_zeros applies the identical tail guard and realises the growth with one set_len on a sparse file, so the zeroes cost no write I/O and no heap staging. Same guard semantics, same crash consistency, same zero-filled result; the type docs' crash- consistency table and thread-safety notes are updated to name the op the path actually issues. Item 5 was listed as Rust + C, but the C side needs no change: linear_vt_realloc grows with bstack_extend and never staged a buffer, on this line and on master alike. New test asserts the grown bytes read back as zeros in both builds; 96 allocator tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/alloc/linear.rs | 17 +++++++++++------ src/test.rs | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a2f848..6ab94b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`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). diff --git a/src/alloc/linear.rs b/src/alloc/linear.rs index 710ef3d6..6b90ebc5 100644 --- a/src/alloc/linear.rs +++ b/src/alloc/linear.rs @@ -29,7 +29,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 +46,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. @@ -165,7 +165,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. @@ -178,9 +178,14 @@ impl BStackAllocator for LinearBStackAllocator { 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; \ diff --git a/src/test.rs b/src/test.rs index 8e72e0c6..5dbf635c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2191,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() { From f19e4081918f1e83d466f32f68043a26c393dc28 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:10:22 -0700 Subject: [PATCH 21/32] [alloc+set] Add io::Write, split_off and drain to BStackByteVec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport from the 0.4.x line (master). io::Write forwards write(buf) to extend_from_slice and returns buf.len(); flush is a no-op since every extend_from_slice is already durably synced. Needs only alloc + set. split_off(at) keeps [0, at) and returns a new vec holding [at, len), moving the tail straight between the two on-disk blocks with one crash-atomic BStack::copy — never through process memory, which is why there is no non-atomic fallback. drain(range) reads the range out, shifts the tail down with one copy, then commits the shorter len. Both are in-place movers: crash-atomic per step, torn-but-valid as a whole, matching insert/remove/swap_remove already in this block. Out-of-range requests return Ok(None) per the type's convention. The 0.2 handle type is BStackSlice rather than BStackOwnedSlice, so drain reads through self.slice directly instead of via as_slice(). The README bytevec section predated 0.2.6 and described none of the atomic movers; the out-of-bounds convention, the two crash-consistency classes and io::Write are documented there now. 11 tests; 67 vec tests pass with atomic, 46 without. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + README.md | 5 ++ src/alloc/vec.rs | 223 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab94b97..d8bdd635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. ### Fixed diff --git a/README.md b/README.md index cc5dcb8e..1a92b3b2 100644 --- a/README.md +++ b/README.md @@ -1286,6 +1286,11 @@ 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. +- **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. ### Example diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 5051f561..0674f564 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -5,6 +5,7 @@ use super::{BStackSlice, BStackSliceAllocator}; use std::fmt; use std::io; +use std::ops::Range; /// Byte offset of the first element within the block (past the 16-byte header). const HEADER_LEN: u64 = 16; @@ -579,6 +580,27 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { } } +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 @@ -905,6 +927,81 @@ impl<'a, A: BStackSliceAllocator> BStackByteVec<'a, A> { 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 ────────────────────────────────────────────────────────────────── @@ -1194,6 +1291,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] @@ -1799,4 +1927,99 @@ mod tests { 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]); + } } From d80681ad9160013e7aeab240be774622f8af0d35 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:15:02 -0700 Subject: [PATCH 22/32] [bytevec+C] Port the bytevec byte-mover surface to C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport from the 0.4.x line (master). The C bytevec stopped at push/pop/truncate/reserve/resize/get/read_bytes; everything that mutates existing bytes or moves them around was Rust-only, including the movers 0.2.6 added on the Rust side. Adds under BSTACK_FEATURE_SET: bstack_bytevec_set and _fill. Adds under SET+ATOMIC: 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, all built on bstack_copy / bstack_cross_exchange — bytevec's first atomic build variant, so the Makefile gains libbstack-bytevec-set-atomic.a and a test-bytevec-atomic target running the same suite against it. The implementations needed no adaptation: every primitive they use (bstack_copy, bstack_cross_exchange, bstack_repeat, the bstack_slice_* range calls) has been in this line since 0.2.3/0.2.6, and the C bytevec already stores a plain bstack_slice_t, which is what master's version manipulates too. The one master-only piece left behind is bytevec_grow_to's handling of realloc's -2 "allocation lost" return, which this line's allocators do not report. Test file replaced with master's: its harness and the one pre-existing test were byte-identical, so this is purely additive. 13/13 with SET+ATOMIC, 3/3 with SET alone. Not ported: bstack_bytevec_extend_from_slice, which does not exist in C on either line. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + c/Makefile | 25 +- c/bstack_bytevec.c | 389 +++++++++++++++++++++++++++++++ c/bstack_bytevec.h | 248 ++++++++++++++++++++ c/test_bstack_bytevec.c | 499 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1161 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bdd635..1e2a4592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. ### Fixed 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_bytevec.c b/c/bstack_bytevec.c index 696bced0..b2c61b1b 100644 --- a/c/bstack_bytevec.c +++ b/c/bstack_bytevec.c @@ -90,6 +90,18 @@ static 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 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. @@ -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 3e7c71dc..51829ebf 100644 --- a/c/bstack_bytevec.h +++ b/c/bstack_bytevec.h @@ -299,6 +299,254 @@ int bstack_bytevec_reserve(bstack_bytevec_t *v, uint64_t additional); 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 * ====================================================================== */ 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; } From a99c7fc0549e56af5de4ef3fc0ce8f4ecd870c96 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:38:39 -0700 Subject: [PATCH 23/32] [alloc] Reject slices issued by a different allocator instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport from the 0.4.x line (master). Adds BStackSlice::is_from(allocator) in Rust and the bstack_slice_is_from macro in C, then uses them: every built-in allocator now checks slice ownership at the top of realloc, dealloc and dealloc_bulk, before touching any metadata, and fails with InvalidInput / EINVAL. dealloc_bulk rejects the whole batch and frees nothing rather than stopping part-way — on this line linear and first_fit implement it by looping over their own dealloc, so the batch needed an up-front pass to become all-or-nothing. Rust: linear, first_fit, ghost_tree, slab, checked_slab, and the DebugCheckingAllocator wrapper, which needed its own guard since its handle is a DebugHandle rather than a BStackSlice. Shared ensure_own_slice / ensure_own_slices helpers in alloc/mod.rs. C: linear, first_fit, ghost_tree, slab and checked_slab, via check_own_slice / check_own_slices. Two deviations from master, both because this line's handles differ: - No handle is carried back in the error. A BStackSlice is Copy and passed by value, so a refused caller still holds it; there is nothing to lose and no BStackAllocError to carry it in. - C realloc does not write the untouched slice to *out on rejection. This line's other realloc error paths leave *out alone, and the caller still holds the slice it passed. This was never a soundness issue: slices are (offset, len) coordinates into a file, not pointers, and reach the payload only through bounds- checked I/O. The damage would be the receiving allocator recording a free block it never owned. Correct programs are unaffected. Docs: a "Foreign slices" section in the alloc module, the reasoning on is_from itself (the alloc module is private, so its //! docs do not render), the same under "Foreign slices" in bstack_alloc.h, and a note plus an is_from row in the README slice section. Rust: 2 new tests, and every allocator module passes (99 alloc, 51 first_fit, 31 ghost_tree, 17 slab, 30 checked_slab, 36 debug_checking, 67 vec). C: 1 new test; all five suites pass in both SET and SET+ATOMIC. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ README.md | 9 ++++++ c/bstack_alloc.c | 63 +++++++++++++++++++++++++++++++++++++ c/bstack_alloc.h | 30 ++++++++++++++++++ c/test_first_fit.c | 53 +++++++++++++++++++++++++++++++ src/alloc/checked_slab.rs | 4 ++- src/alloc/debug_checking.rs | 37 ++++++++++++++++++++++ src/alloc/first_fit.rs | 4 ++- src/alloc/ghost_tree.rs | 7 ++++- src/alloc/linear.rs | 10 +++++- src/alloc/mod.rs | 58 ++++++++++++++++++++++++++++++++++ src/alloc/slab.rs | 4 ++- src/alloc/slice.rs | 22 +++++++++++++ src/test.rs | 48 ++++++++++++++++++++++++++++ 14 files changed, 346 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2a4592..86c0c82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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 @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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). diff --git a/README.md b/README.md index 1a92b3b2..9a4198dd 100644 --- a/README.md +++ b/README.md @@ -750,6 +750,14 @@ 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: @@ -761,6 +769,7 @@ Key methods: | `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 | diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 5125ed37..08054da2 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 * ====================================================================== */ @@ -663,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) { @@ -704,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) { @@ -741,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; @@ -1544,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 @@ -1616,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; @@ -1979,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; @@ -2952,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 || @@ -3008,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); @@ -3304,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; @@ -3836,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); @@ -3878,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); @@ -5243,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; } @@ -5296,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); diff --git a/c/bstack_alloc.h b/c/bstack_alloc.h index c471b789..9f1455f7 100644 --- a/c/bstack_alloc.h +++ b/c/bstack_alloc.h @@ -56,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 diff --git a/c/test_first_fit.c b/c/test_first_fit.c index 5030bc73..d6317340 100644 --- a/c/test_first_fit.c +++ b/c/test_first_fit.c @@ -1435,6 +1435,58 @@ static int test_slice_process_transforms_in_place(void) #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 * ====================================================================== */ @@ -1449,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); diff --git a/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index 11609f07..5c94cd5c 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; @@ -1339,6 +1339,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(()); } @@ -1443,6 +1444,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); } diff --git a/src/alloc/debug_checking.rs b/src/alloc/debug_checking.rs index 72fbaf22..425ff33e 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) @@ -288,6 +310,16 @@ 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::>()`. @@ -534,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!( @@ -583,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) => { @@ -653,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 diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index db251c73..d4d1c301 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; @@ -929,6 +929,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(()); } @@ -988,6 +989,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); } diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index 28dbedca..18bd63eb 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; @@ -945,6 +947,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); } @@ -1073,6 +1076,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(()); } @@ -1235,6 +1239,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(); diff --git a/src/alloc/linear.rs b/src/alloc/linear.rs index 6b90ebc5..c9ee686b 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; @@ -140,6 +142,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( @@ -175,6 +178,7 @@ 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 => { @@ -213,6 +217,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())?; @@ -222,6 +227,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(()) @@ -295,6 +301,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(()); } @@ -326,6 +333,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 574baaa8..a1d9fb4d 100644 --- a/src/alloc/mod.rs +++ b/src/alloc/mod.rs @@ -143,6 +143,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 +234,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. /// diff --git a/src/alloc/slab.rs b/src/alloc/slab.rs index 1dfe3c27..0bd0d206 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; @@ -598,6 +598,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(()); } @@ -648,6 +649,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); } diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index 3116c874..2343dd42 100644 --- a/src/alloc/slice.rs +++ b/src/alloc/slice.rs @@ -211,6 +211,28 @@ impl<'a, A: BStackAllocator> BStackSlice<'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, diff --git a/src/test.rs b/src/test.rs index 5dbf635c..a84ae6c8 100644 --- a/src/test.rs +++ b/src/test.rs @@ -3320,6 +3320,54 @@ mod alloc_tests { .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(); + } } // ------------------------------------------------------------------------- From a60033f728d8702f546ec13728935316fbf7eaae Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:43:05 -0700 Subject: [PATCH 24/32] [ci] Run the workflows on the 0.2-backports branch All five workflows gated their push and pull_request triggers on master alone, so nothing on this branch has been built or tested by CI. Adds 0.2-backports to both trigger lists in cci, ci, check, alloc_fuzz and devskim. The branch name is quoted so it cannot be read as a numeric scalar. Adding it to pull_request as well covers PRs targeting this branch; a PR from here into master already matched, since that filter is on the target. Also adds the test-bytevec-atomic step to CCI, which the previous commit's Makefile target introduced but nothing ran. It is skipped on Windows like the other atomic targets. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/alloc_fuzz.yml | 4 ++-- .github/workflows/cci.yml | 7 +++++-- .github/workflows/check.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/devskim.yml | 4 ++-- 5 files changed, 13 insertions(+), 10 deletions(-) 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: From 95273deb9324ae75eaa588f0ab9d3ec97387946c Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:48:33 -0700 Subject: [PATCH 25/32] [alloc+set] Gate the Range import on the atomic feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drain, the only user of std::ops::Range in vec.rs, is behind #[cfg(feature = "atomic")], so a set+alloc build without atomic saw the import as unused — an error under CI's -D warnings. Clippy now passes with -D warnings across all seven feature combinations CI checks: all-features, no-default-features, set, atomic, "set atomic", alloc and "set alloc". Co-Authored-By: Claude Opus 5 (1M context) --- src/alloc/vec.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/alloc/vec.rs b/src/alloc/vec.rs index 0674f564..9ea36519 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -5,6 +5,7 @@ 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). From f1f66f195991afa11255ffc01e8990cc73e3bea2 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 09:59:54 -0700 Subject: [PATCH 26/32] Bump version to 0.2.7 (format magic 0.1.16 -> 0.1.17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crate version 0.2.6 -> 0.2.7 (Cargo.toml, Cargo.lock). Following the per-release convention, the BStack format-version stamp is bumped BSTK\x00\x01\x10\x00 (0.1.16) -> BSTK\x00\x01\x11\x00 (0.1.17) in src/lib.rs, c/bstack.c, c/test_bstack.c, and the README/doc comments. This is compat-neutral: `open` gates only on the first 6 bytes (BSTK\x00\x01), so files written by any 0.1.x still open, and 0.2.7 reads older files unchanged. The on-disk layout itself is identical to 0.2.6. The allocator magics need no bump here: GhostTree was already bumped this cycle (ALGT 0.1.3 -> 0.1.4) for the align_up_len fix, and nothing else changed what an allocator writes — the foreign-slice guard rejects a call that was always a caller error and leaves correct programs byte-identical. CHANGELOG: the [Unreleased] section is stamped [0.2.7] - 2026-09-02 and a fresh empty [Unreleased] opened. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- c/bstack.c | 2 +- c/test_bstack.c | 2 +- src/lib.rs | 6 +++--- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c0c82e..f37daee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. diff --git a/Cargo.lock b/Cargo.lock index 16b338d4..bbd248ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bstack" -version = "0.2.6" +version = "0.2.7" dependencies = [ "criterion", "libc", diff --git a/Cargo.toml b/Cargo.toml index d171f89c..69f96699 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bstack" -version = "0.2.6" +version = "0.2.7" edition = "2024" authors = ["William Wu ", "Claude "] license = "MIT" diff --git a/README.md b/README.md index 9a4198dd..a9560246 100644 --- a/README.md +++ b/README.md @@ -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\x10\x00` (0.1.16). `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 diff --git a/c/bstack.c b/c/bstack.c index 10af5cd2..4650aad4 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, 16, 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; diff --git a/c/test_bstack.c b/c/test_bstack.c index 94ba10ac..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, 16, 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) diff --git a/src/lib.rs b/src/lib.rs index 3036424a..9c8dae1b 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\x10\x00` (0.1.16). [`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 16 + 0). -const MAGIC: [u8; 8] = *b"BSTK\x00\x01\x10\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. From e5fa249a27d18112f9e457ffa705b4bd41eb840d Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 10:03:43 -0700 Subject: [PATCH 27/32] [C] Mark the small static helpers inline, matching master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `inline` to the 33 file-local helpers master already marks `static inline` — the platform shims and header writers in bstack.c, the free-list/overhead accessors and small arithmetic helpers across the slab, checked-slab and ghost-tree allocators, and the bytevec header readers/writers and LE codecs. Determined by intersecting master's `static inline` set with this line's plain-`static` set, so nothing outside that overlap changed. 39 sites in all: a few helpers have a second definition under a different feature guard. No behaviour change — internal linkage either way, and the compiler was already free to inline these. Compiles clean (-Wall -Wextra -Wpedantic) for each of bstack.c, bstack_alloc.c and bstack_bytevec.c across no-features, SET, and SET+ATOMIC, and the test programs still link. Co-Authored-By: Claude Opus 5 (1M context) --- c/bstack.c | 34 +++++++++++++++++----------------- c/bstack_alloc.c | 28 ++++++++++++++-------------- c/bstack_bytevec.c | 16 ++++++++-------- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/c/bstack.c b/c/bstack.c index 4650aad4..f7fa3b8f 100644 --- a/c/bstack.c +++ b/c/bstack.c @@ -132,7 +132,7 @@ static void win_set_errno(void) * 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 int plat_durable_sync(bstack_fd_t h) +static inline int plat_durable_sync(bstack_fd_t h) { #ifdef BSTACK_TEST_NO_DURABLE_SYNC (void)h; @@ -143,7 +143,7 @@ static int plat_durable_sync(bstack_fd_t h) #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; } @@ -155,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; @@ -171,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; @@ -187,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; @@ -204,7 +204,7 @@ static int plat_ftruncate(bstack_fd_t h, uint64_t size) /* No-op under BSTACK_TEST_NO_DURABLE_SYNC — see the note on the Windows * definition above. Test/fuzz builds only, never production. */ -static int plat_durable_sync(bstack_fd_t fd) +static inline int plat_durable_sync(bstack_fd_t fd) { #ifdef BSTACK_TEST_NO_DURABLE_SYNC (void)fd; @@ -219,7 +219,7 @@ static int plat_durable_sync(bstack_fd_t 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; @@ -227,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; @@ -237,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; @@ -247,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); } @@ -258,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); @@ -295,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--; @@ -314,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++) @@ -328,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; @@ -336,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); @@ -368,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); } @@ -2753,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_alloc.c b/c/bstack_alloc.c index 08054da2..20c5799b 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -435,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; @@ -2267,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) @@ -2276,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); @@ -2377,7 +2377,7 @@ static int algt_avl_write_h(bstack_t *bs, uint64_t ptr, uint64_t size, /* 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 int algt_avl_write_and_update(bstack_t *bs, uint64_t ptr, +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); @@ -3605,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. @@ -3617,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, @@ -3626,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); @@ -3747,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 */ @@ -3760,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; @@ -4235,7 +4235,7 @@ static const uint8_t alck_magic_prefix[6] = {'A','L','C','K',0,1}; /* ---- 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; @@ -4243,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); @@ -4255,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, @@ -4264,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); @@ -4278,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; diff --git a/c/bstack_bytevec.c b/c/bstack_bytevec.c index b2c61b1b..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,7 +85,7 @@ 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; } @@ -97,7 +97,7 @@ static uint64_t sat_double(uint64_t cap) * index. Must be recomputed after any reallocation since the block's start * may move. */ -static uint64_t bytevec_abs_offset(const bstack_bytevec_t *v, uint64_t index) +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; } From d10bd6340d2613610d518bdcd48e365afb5b8375 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 10:35:04 -0700 Subject: [PATCH 28/32] [chore] Dependency updates --- Cargo.lock | 411 ++++++++++++----------------------------------------- 1 file changed, 88 insertions(+), 323 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bbd248ef..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,24 +23,12 @@ 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.7" @@ -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" From 96a34f552ac99530e04beb5129b791766d15161a Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 16:18:44 -0700 Subject: [PATCH 29/32] [backport] Implement `Debug` for `BStackReader` and `Display` for slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of 72abbb5 and ddb8b7b from master, restricted to the types this branch has: `BStackRange`, `BStackOwnedSlice` and `BStackChunk` do not exist here, so `Display` lands on `BStackSlice` (`start..end`), `BStackReader` (`@position`), and `BStackSliceReader`/`BStackSliceWriter` (`start..end@cursor`). `BStackReader` was the only cursor type without `Debug` — the slice reader and writer both had one — so a struct holding one could not derive `Debug`. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++++ src/alloc/slice.rs | 36 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 16 ++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f37daee0..2fa18476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ 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. + ## [0.2.7] - 2026-09-02 ### Added diff --git a/src/alloc/slice.rs b/src/alloc/slice.rs index 2343dd42..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`. /// @@ -1042,6 +1049,20 @@ 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] @@ -1179,6 +1200,21 @@ 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). diff --git a/src/lib.rs b/src/lib.rs index 9c8dae1b..99f56e14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4418,6 +4418,22 @@ 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] From f28afa4380b6d17f6249e644653ec32548f43f23 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 16:23:07 -0700 Subject: [PATCH 30/32] [chore] Fix all clippy findings on this branch `cargo clippy --all-features --lib --tests` failed with one denied `reversed_empty_ranges` error and 80 warnings; it is now clean, as are the no-default-features, `alloc`, and `alloc,set` configurations. Mechanical fixes applied by `cargo clippy --fix`: 46 needless borrows, an unnecessary `to_vec`, a `clone` on a `Copy` handle, and a length comparison to zero. Fixed by hand: * The 20 lifetime-extending transmutes in `test.rs` had an inferred target type (`transmute::<&mut [u8], _>`), which can silently reinterpret the pointee; each now names both types. * `with_state`'s `#[must_use]` result is bound with `let _` in the three `should_panic` tests that call it only for the panic. * Scoped `#[allow]`s where the lint is wrong about intent: one-element range arrays are the shape `get_batched`/`with_state` take, and `get_batched_end_less_than_start_returns_error` passes a reversed range precisely because that is what it asserts on. * Renamed the inner `mod alloc_fuzz_tests` to `mod tests`, matching the rest of the crate and dropping the module-inception warning. No behavioural change; lint-only, so no changelog entry. Co-Authored-By: Claude Opus 5 (1M context) --- src/alloc/checked_slab.rs | 8 +-- src/alloc/debug_checking.rs | 19 ++++--- src/alloc/ghost_tree.rs | 32 ++++++------ src/alloc/slab.rs | 4 +- src/alloc_fuzz_tests.rs | 2 +- src/test.rs | 98 +++++++++++++++++++++---------------- 6 files changed, 90 insertions(+), 73 deletions(-) diff --git a/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index 5c94cd5c..f7b7d796 100644 --- a/src/alloc/checked_slab.rs +++ b/src/alloc/checked_slab.rs @@ -2036,7 +2036,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(); @@ -2069,7 +2069,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(); @@ -2129,7 +2129,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]); { @@ -2180,7 +2180,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 425ff33e..d47366d5 100644 --- a/src/alloc/debug_checking.rs +++ b/src/alloc/debug_checking.rs @@ -797,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); @@ -1173,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)?; @@ -1231,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(); @@ -1387,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 @@ -1413,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)?; @@ -1438,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; @@ -1473,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); @@ -1482,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(); @@ -1506,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 [], @@ -1516,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 @@ -1526,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/ghost_tree.rs b/src/alloc/ghost_tree.rs index 18bd63eb..7743a84e 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -1420,7 +1420,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(); @@ -1438,7 +1438,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!( @@ -1506,7 +1506,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(); @@ -1524,7 +1524,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); @@ -1544,7 +1544,7 @@ mod tests { let _g = Guard(path); let s = alloc.alloc(64).unwrap(); let start = s.start(); - s.write(&[0xEEu8; 64]).unwrap(); + 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(); @@ -1569,7 +1569,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); @@ -1642,7 +1642,7 @@ mod tests { 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(); @@ -1657,7 +1657,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()); @@ -1791,8 +1791,8 @@ mod tests { // 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(); + stack.set(n + NODE_LH_OFF, [0xFFu8]).unwrap(); + stack.set(n + NODE_RH_OFF, [0xFFu8]).unwrap(); } drop(stack); @@ -1800,10 +1800,10 @@ mod tests { // free blocks are reusable with correct round-trips. let alloc2 = reopen(&path); let r1 = alloc2.alloc(48).unwrap(); - r1.write(&[0x33u8; 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(); + r2.write([0x44u8; 64]).unwrap(); assert_eq!(r2.read().unwrap(), vec![0x44u8; 64]); alloc2.dealloc(r1).unwrap(); alloc2.dealloc(r2).unwrap(); @@ -1815,7 +1815,7 @@ mod tests { 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); @@ -1875,7 +1875,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]); { @@ -1923,7 +1923,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. @@ -1996,7 +1996,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/slab.rs b/src/alloc/slab.rs index 0bd0d206..93e28a00 100644 --- a/src/alloc/slab.rs +++ b/src/alloc/slab.rs @@ -1048,7 +1048,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]); { @@ -1095,7 +1095,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_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/test.rs b/src/test.rs index a84ae6c8..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); @@ -1659,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); @@ -1711,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::>() @@ -1761,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(); } }) }) @@ -2837,7 +2837,7 @@ mod alloc_tests { 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(); + 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(); @@ -3427,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()); @@ -3886,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(); @@ -3976,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]; @@ -3992,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(); @@ -4063,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); @@ -4128,7 +4128,7 @@ mod first_fit_tests { let (alloc, path) = mk_ff("recovery_tailgrow"); let _g = Guard(path.clone()); let a = alloc.alloc(32).unwrap(); - a.write(&[0xA7u8; 32]).unwrap(); + a.write([0xA7u8; 32]).unwrap(); let a_start = a.start(); let stack = alloc.into_stack(); let before_len = stack.len().unwrap(); @@ -4136,7 +4136,7 @@ mod first_fit_tests { // 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 + stack.set(24, 1u32.to_le_bytes()).unwrap(); // recovery_needed = 1 drop(stack); let stack2 = BStack::open(&path).unwrap(); @@ -4175,8 +4175,8 @@ mod first_fit_tests { <[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 + 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(); @@ -4189,7 +4189,7 @@ mod first_fit_tests { 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(); + r.write([0x22u8; 16]).unwrap(); assert_eq!(r.read().unwrap(), vec![0x22u8; 16]); } @@ -4295,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. @@ -4318,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; } @@ -5340,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, }; @@ -5377,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 @@ -5385,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, @@ -5486,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); @@ -5581,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(); @@ -5646,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(); @@ -5701,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, @@ -5741,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(); @@ -5763,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(); @@ -5856,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 }), } @@ -5881,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, } @@ -5913,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, @@ -5976,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; @@ -5995,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 => { @@ -6004,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, @@ -6086,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 => { @@ -6100,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[..], + ) }, }) } @@ -6108,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, }; @@ -6194,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 => { @@ -6606,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); @@ -6617,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); @@ -6627,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); From cdebd3c274fa29077739418164b96b1a39d028da Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 16:27:16 -0700 Subject: [PATCH 31/32] [alloc+set] Implement the missing iterator traits for `BStackByteVecIter` Backport of 5a168c0 from master. The byte-vec iterator implemented only `Iterator`; it now also has `Clone`, `DoubleEndedIterator`, `ExactSizeIterator` and `FusedIterator`. `Clone` forks an iteration at its current position. `next_back` shrinks the snapshotted `len` and reads there, so `.rev()` and `.last()` cost one read rather than a full scan. The `len` snapshot taken at construction is what makes the iterator soundly fused. `size_hint` was already exact, including its `usize::MAX` clamp, and is untouched. Master's version of this commit justified the additions as parity with `BStackChunkIter`, which does not exist on this branch; the traits stand on their own merits here. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 2 +- src/alloc/vec.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa18476..3ae7014d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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. ## [0.2.7] - 2026-09-02 diff --git a/README.md b/README.md index a9560246..469c96d8 100644 --- a/README.md +++ b/README.md @@ -1300,7 +1300,7 @@ recoverable after a crash by reconstructing the handle from the raw block via - 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. +- **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/src/alloc/vec.rs b/src/alloc/vec.rs index 9ea36519..9a3b2bec 100644 --- a/src/alloc/vec.rs +++ b/src/alloc/vec.rs @@ -1012,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") @@ -1050,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)] @@ -1493,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] From a4c9c01f49e1df6033f2765d107fae778d0a63bc Mon Sep 17 00:00:00 2001 From: williamwutq Date: Wed, 2 Sep 2026 16:31:17 -0700 Subject: [PATCH 32/32] [alloc] Re-add `RefUnwindSafe` for allocators in non-`atomic` builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of 66204ec from master, covering the five allocators this branch has (master additionally has `SegregatedBStackAllocator`). Each carries a `PhantomData>` marker to remove `Sync` where the allocator is not thread-shareable. `Cell` removes `RefUnwindSafe` along with it, which nobody chose: `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, which is itself `RefUnwindSafe` via poisoning. `Sync` is still absent without `atomic`; verified with a throwaway probe that the five allocators gain `RefUnwindSafe` and that `Sync` still fails to resolve. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/alloc/checked_slab.rs | 4 ++++ src/alloc/first_fit.rs | 4 ++++ src/alloc/ghost_tree.rs | 4 ++++ src/alloc/linear.rs | 4 ++++ src/alloc/mod.rs | 6 ++++++ src/alloc/slab.rs | 4 ++++ 7 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae7014d..09601307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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 diff --git a/src/alloc/checked_slab.rs b/src/alloc/checked_slab.rs index f7b7d796..c10c56c7 100644 --- a/src/alloc/checked_slab.rs +++ b/src/alloc/checked_slab.rs @@ -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 diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index d4d1c301..1d8b5dd9 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -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 { diff --git a/src/alloc/ghost_tree.rs b/src/alloc/ghost_tree.rs index 7743a84e..9d7cb02e 100644 --- a/src/alloc/ghost_tree.rs +++ b/src/alloc/ghost_tree.rs @@ -191,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") diff --git a/src/alloc/linear.rs b/src/alloc/linear.rs index c9ee686b..bcdc56d8 100644 --- a/src/alloc/linear.rs +++ b/src/alloc/linear.rs @@ -81,6 +81,10 @@ 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] diff --git a/src/alloc/mod.rs b/src/alloc/mod.rs index a1d9fb4d..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`, diff --git a/src/alloc/slab.rs b/src/alloc/slab.rs index 93e28a00..a4501c7a 100644 --- a/src/alloc/slab.rs +++ b/src/alloc/slab.rs @@ -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.