From f735871ec5aed62b5011eac1924f980ee716dcc7 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 22:05:12 -0700 Subject: [PATCH 01/11] [alloc] Fix recovery bug in first_fit that is flagged by fuzz runs --- src/alloc/first_fit.rs | 75 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index 5ece4bb..97dca05 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -741,8 +741,24 @@ 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. + let remaining = stack_len - pos; + let mut trailing = vec![0u8; remaining as usize]; + self.stack.get_into(pos, &mut trailing)?; + if trailing.iter().all(|&b| b == 0) { + self.stack.discard(remaining)?; + break; + } return Err(io::Error::new( io::ErrorKind::InvalidData, format!( @@ -1467,4 +1483,59 @@ mod fault_tests { d.write([4u8; 128]).unwrap(); assert_eq!(d.read().unwrap(), vec![4u8; 128]); } + + // A tail-block-grow `realloc` `extend`s (zero-filling) the payload before + // rewriting the header/footer to cover it. A fault in that window strands a + // zero-filled tail region with no block header; the recovery scan used to + // read it as a size-0 block and reject the whole file. Recovery now rolls + // the extension back by truncation, so reopen succeeds, the block's bytes + // survive, and the allocator stays usable. Faulting the post-`extend` `zero` + // and the header `set` covers both stranded-tail variants. + fn tail_grow_fault_recovers(fault_op: &'static str) { + let path = temp_path(&format!("ff_tailgrow_{fault_op}")); + let _g = Guard(path.clone()); + + let (start, old_len) = { + let alloc = FirstFitBStackAllocator::new(BStack::open(&path).unwrap()).unwrap(); + // A single allocation is the tail block, so the grow takes the + // in-place tail-extend path. 866 → 941 crosses an 8-byte alignment + // bucket (872 → 944), so the grow really extends the file. + let mut a = alloc.alloc(866).unwrap(); + a.write([0xA7u8; 866]).unwrap(); + let (start, old_len) = (a.start(), a.len()); + + arm(&alloc, FailOpAt::new(fault_op, 0, ErrorKind::Other)); + let err = alloc + .realloc(a, 941) + .expect_err("realloc must report the injected fault"); + disarm(&alloc); + // The grow faulted before committing: the original block is handed back. + let handle = err.handle.expect("the original block must survive"); + assert_eq!((handle.start(), handle.len()), (start, old_len)); + drop(handle); + (start, old_len) + }; + + // Reopen runs recovery (recovery_needed was left set); it must not error. + let alloc = FirstFitBStackAllocator::new(BStack::open(&path).unwrap()) + .expect("recovery must roll back the interrupted tail grow, not error"); + assert_eq!( + alloc.stack().get(start, start + old_len).unwrap(), + vec![0xA7u8; old_len as usize], + "the block's bytes survive the rolled-back grow" + ); + let mut d = alloc.alloc(200).unwrap(); + d.write([0x5Cu8; 200]).unwrap(); + assert_eq!(d.read().unwrap(), vec![0x5Cu8; 200]); + } + + #[test] + fn tail_grow_fault_at_zero_recovers() { + tail_grow_fault_recovers("zero"); + } + + #[test] + fn tail_grow_fault_at_header_set_recovers() { + tail_grow_fault_recovers("set"); + } } From 312d1cf1a3769476c645be8e835acf36ae15a97e Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 22:08:44 -0700 Subject: [PATCH 02/11] Add entry to CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb6db8..fd8a909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`GhostTreeBstackAllocator::realloc` tail shrink was not crash-atomic.** Shrinking a tail allocation to a sub-block-unaligned length truncated the freed tail and zeroed the retained block's padding as two separate operations, so a fault (or crash) between them left the stack shrunk with un-zeroed padding — violating the zeroed-memory invariant and yielding a `realloc`-failure handle claiming a length past the now-shorter stack. The `atomic` path now fuses both into one crash-atomic tail-replace, so a fault leaves the block fully intact; the non-`atomic` path discards first and commits the shrink before zeroing, so a fault after the commit reports the allocation as lost (`handle: None`) rather than handing back a partially-zeroed "original". Surfaced by the new allocator fault-injection fuzz. - **`FirstFitBStackAllocator::realloc` in-place tail shrink was not crash-atomic** (Rust + C). Reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault (or crash) mid-sequence left the header, footer, and physical size disagreeing — a state first_fit's block-walking recovery cannot repair (it would truncate the whole block, losing live data). A tail shrink now narrows only the user-visible length and keeps the block at its current size — a valid "oversized" allocation, exactly as a non-tail shrink already does — and the space is reclaimed when the block is freed. Behavior change: a tail `realloc` shrink no longer returns space to the file immediately. Surfaced by the allocator fault-injection fuzz. +- **`FirstFitBStackAllocator::realloc` in-place tail *grow* left an unrecoverable file after a crash.** Growing the tail block `extend`s (zero-filling) the payload before rewriting the block header/footer to cover the new bytes; a fault (or crash) in that window left the still-valid block followed by a zero-filled region with no block header, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. 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, restoring the pre-grow tail the failed `realloc` already handed back to the caller; genuine mid-arena corruption still fails loudly. Surfaced by the allocator fault-injection fuzz. ## [0.4.0] - 2026-07-10 From 193ec40c711f4f447ebb244668e1f5ae7f7638d0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 22:12:46 -0700 Subject: [PATCH 03/11] Port fix to C --- CHANGELOG.md | 2 +- c/bstack_alloc.c | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8a909..d32a60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`GhostTreeBstackAllocator::realloc` tail shrink was not crash-atomic.** Shrinking a tail allocation to a sub-block-unaligned length truncated the freed tail and zeroed the retained block's padding as two separate operations, so a fault (or crash) between them left the stack shrunk with un-zeroed padding — violating the zeroed-memory invariant and yielding a `realloc`-failure handle claiming a length past the now-shorter stack. The `atomic` path now fuses both into one crash-atomic tail-replace, so a fault leaves the block fully intact; the non-`atomic` path discards first and commits the shrink before zeroing, so a fault after the commit reports the allocation as lost (`handle: None`) rather than handing back a partially-zeroed "original". Surfaced by the new allocator fault-injection fuzz. - **`FirstFitBStackAllocator::realloc` in-place tail shrink was not crash-atomic** (Rust + C). Reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault (or crash) mid-sequence left the header, footer, and physical size disagreeing — a state first_fit's block-walking recovery cannot repair (it would truncate the whole block, losing live data). A tail shrink now narrows only the user-visible length and keeps the block at its current size — a valid "oversized" allocation, exactly as a non-tail shrink already does — and the space is reclaimed when the block is freed. Behavior change: a tail `realloc` shrink no longer returns space to the file immediately. Surfaced by the allocator fault-injection fuzz. -- **`FirstFitBStackAllocator::realloc` in-place tail *grow* left an unrecoverable file after a crash.** Growing the tail block `extend`s (zero-filling) the payload before rewriting the block header/footer to cover the new bytes; a fault (or crash) in that window left the still-valid block followed by a zero-filled region with no block header, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. 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, restoring the pre-grow tail the failed `realloc` already handed back to the caller; genuine mid-arena corruption still fails loudly. Surfaced by the allocator fault-injection fuzz. +- **`FirstFitBStackAllocator::realloc` in-place tail *grow* left an unrecoverable file after a crash** (Rust + C). Growing the tail block `extend`s (zero-filling) the payload before rewriting the block header/footer to cover the new bytes; a fault (or crash) in that window left the still-valid block followed by a zero-filled region with no block header, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. The recovery scan in both implementations now recognises an all-zero trailing region (a real block is never all-zero) as the interrupted extension and rolls it back by truncation, restoring the pre-grow tail the failed `realloc` already handed back to the caller; genuine mid-arena corruption still fails loudly. Surfaced by the allocator fault-injection fuzz. ## [0.4.0] - 2026-07-10 diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index 25f20e3..f1aaae7 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -1234,11 +1234,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; From b1100c4a48f3d72874137f1366b21ad4655dbb80 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 22:18:24 -0700 Subject: [PATCH 04/11] Optimize check --- src/alloc/first_fit.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index 97dca05..da4a667 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -752,10 +752,7 @@ impl FirstFitBStackAllocator { // pre-grow tail the failed `realloc` handed back. // * Anything else → genuine mid-arena corruption; fail // loudly rather than discard the data that follows. - let remaining = stack_len - pos; - let mut trailing = vec![0u8; remaining as usize]; - self.stack.get_into(pos, &mut trailing)?; - if trailing.iter().all(|&b| b == 0) { + if self.stack.get(pos, stack_len)?.iter().all(|&b| b == 0) { self.stack.discard(remaining)?; break; } From fdceb077318c803ff800457e5619a09f22f778f6 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 22:58:33 -0700 Subject: [PATCH 05/11] Normalize footer size during block resizing to prevent corruption --- src/alloc/first_fit.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index da4a667..3dd51c9 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -812,6 +812,24 @@ 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); } From 0ba58351b8073c74d0f918f3687ea068bf263825 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:04:06 -0700 Subject: [PATCH 06/11] Refactor durable_sync to skip F_FULLFSYNC in test builds for performance --- src/io_core.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/io_core.rs b/src/io_core.rs index 317761d..eae9ccc 100644 --- a/src/io_core.rs +++ b/src/io_core.rs @@ -189,16 +189,32 @@ pub(crate) fn pread_exact_raw_handle(handle: isize, offset: u64, buf: &mut [u8]) /// which `fdatasync` alone does not guarantee. Falls back to `sync_data` if /// `F_FULLFSYNC` returns an error (e.g. the device doesn't support it). /// On all other platforms this delegates to `sync_data` (`fdatasync`). +/// +/// **The crate's own test builds skip the flush entirely.** The tests never +/// crash the process — crash consistency is exercised by injecting faults +/// logically and reopening the file in-process — so the physical sync changes +/// neither their observable behavior nor the on-disk bytes, yet on macOS +/// `F_FULLFSYNC` dominates their runtime (it takes the allocator fault fuzz from +/// minutes to seconds). This applies only to `cfg(test)` debug builds of this +/// crate; release builds and any dependent crate always issue the real sync. pub(crate) fn durable_sync(file: &File) -> io::Result<()> { - #[cfg(target_os = "macos")] + #[cfg(all(test, debug_assertions))] { - let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_FULLFSYNC) }; - if ret != -1 { - return Ok(()); + let _ = file; + return Ok(()); + } + #[cfg(not(all(test, debug_assertions)))] + { + #[cfg(target_os = "macos")] + { + let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_FULLFSYNC) }; + if ret != -1 { + return Ok(()); + } + // Device does not support F_FULLFSYNC; fall back to fdatasync. } - // Device does not support F_FULLFSYNC; fall back to fdatasync. + file.sync_data() } - file.sync_data() } /// Acquire an exclusive, non-blocking advisory flock on `file`. From 1197e15c13bf6bf8ae15226a02e0ba0bd4827178 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:04:28 -0700 Subject: [PATCH 07/11] Add snapshot functionality for debugging during recovery --- src/alloc_fault_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/alloc_fault_tests.rs b/src/alloc_fault_tests.rs index 544ff01..445e17e 100644 --- a/src/alloc_fault_tests.rs +++ b/src/alloc_fault_tests.rs @@ -264,6 +264,16 @@ mod alloc_fault_tests { let periodic = cfg.reopen_every > 0 && step > 0 && step % cfg.reopen_every == 0; if faulted || periodic { + // Debug aid: when `BSTACK_SNAPSHOT_PATH` is set, copy the backing + // file just *before* recovery runs, overwriting each time. If the + // recovery scan then panics, the last snapshot is the exact + // pre-recovery image of the failure (its step is written to + // `.step`) — feed it to a block walker to see which block's + // header/footer the crash left inconsistent. + if let Ok(snap) = std::env::var("BSTACK_SNAPSHOT_PATH") { + let _ = std::fs::copy(&path, &snap); + let _ = std::fs::write(format!("{snap}.step"), format!("{step}")); + } alloc = reopen_and_verify(alloc, &make, &live, bias, &format!("reopen@{step}")); } } From e787a2d7d944d06e62262dedde79ad08f3479f14 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:27:03 -0700 Subject: [PATCH 08/11] Add regression tests --- src/alloc/first_fit.rs | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index 3dd51c9..4ed79a6 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -1553,4 +1553,97 @@ mod fault_tests { fn tail_grow_fault_at_header_set_recovers() { tail_grow_fault_recovers("set"); } + + /// Walk the arena block-by-block (by header size) and assert every block's + /// footer equals its header — the invariant recovery must restore. Panics on + /// the first mismatch or an unparseable header, so a stale footer left by an + /// interrupted coalesce is caught directly. + fn assert_arena_footers_match(alloc: &FirstFitBStackAllocator, ctx: &str) { + const ARENA_START: u64 = 48; + const HDR: u64 = 16; + const OVERHEAD: u64 = 24; + const MIN: u64 = 16; + let stack = alloc.stack(); + let stack_len = stack.len().unwrap(); + let mut pos = ARENA_START; + while pos + OVERHEAD <= stack_len { + let mut hb = [0u8; 8]; + stack.get_into(pos, &mut hb).unwrap(); + let size = u64::from_le_bytes(hb); + assert!( + size >= MIN && size % 8 == 0 && pos + size + OVERHEAD <= stack_len, + "{ctx}: unparseable header {size} at {pos}" + ); + let mut fb = [0u8; 8]; + stack.get_into(pos + HDR + size, &mut fb).unwrap(); + assert_eq!( + u64::from_le_bytes(fb), + size, + "{ctx}: footer≠header at block {pos}" + ); + pos += size + OVERHEAD; + } + } + + // Freeing a block sandwiched between two free blocks coalesces all three: + // `add_to_free_list` writes the merged block's header, then its footer, as + // two separate `set`s. A fault between them leaves header=merged-size but a + // stale footer. The recovery walk follows headers, so the merged block spans + // correctly and the flag is cleared — the mismatch slips through. Left there, + // the stale footer (it still equals the right sub-block's size, so it points + // back at that sub-block's untouched interior header) later lets a + // neighbour's left-coalesce walk into the merged block's interior and + // coalesce onto a ghost header, overlapping two blocks and eventually + // desyncing the walk. Recovery now normalizes every block's footer to its + // (authoritative, written-first) header. Sweep the fault across the + // coalescing free's writes and assert the arena is whole after recovery. + #[test] + fn dealloc_three_way_coalesce_footer_fault_recovers() { + for at in 0..16u64 { + let path = temp_path(&format!("ff_coalesce_{at}")); + let _g = Guard(path.clone()); + + let g0 = { + let alloc = FirstFitBStackAllocator::new(BStack::open(&path).unwrap()).unwrap(); + // guard0 | A | B | C | tail, all non-tail middle blocks. Distinct + // sizes so the stale-footer geometry matches the fuzz's finding. + let mut g0 = alloc.alloc(64).unwrap(); + g0.write([0x10u8; 64]).unwrap(); + let a = alloc.alloc(56).unwrap(); + let b = alloc.alloc(64).unwrap(); + let c = alloc.alloc(32).unwrap(); + let _tail = alloc.alloc(64).unwrap(); + let g0s = g0.start(); + + // Free the two outer blocks so B is sandwiched between free A and C. + alloc.dealloc(a).unwrap(); + alloc.dealloc(c).unwrap(); + + // Free B: a three-way coalesce. Fault its `at`-th `set`. + arm(&alloc, FailOpAt::new("set", at, ErrorKind::Other)); + let _ = alloc.dealloc(b); // may fault (past-lost → handle None) or succeed + disarm(&alloc); + drop(g0); + g0s + }; + + // Reopen runs recovery; it must not error on the interrupted coalesce. + let alloc = FirstFitBStackAllocator::new(BStack::open(&path).unwrap()) + .unwrap_or_else(|e| panic!("at={at}: recovery must not error: {e}")); + // Recovery must have made every block whole (header == footer); a stale + // merged-block footer is exactly the bug. + assert_arena_footers_match(&alloc, &format!("at={at}")); + // Untouched neighbour survives, and the allocator stays usable. + assert_eq!( + alloc.stack().get(g0, g0 + 64).unwrap(), + vec![0x10u8; 64], + "at={at}: left guard intact" + ); + let mut r = alloc.alloc(240).unwrap(); + r.write([0x22u8; 240]).unwrap(); + assert_eq!(r.read().unwrap(), vec![0x22u8; 240], "at={at}: reuse reads back"); + alloc.dealloc(r).unwrap(); + assert_arena_footers_match(&alloc, &format!("at={at} after reuse")); + } + } } From cf93b61496a1bcc8da9815d3e2fe559c1680c0d7 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:27:14 -0700 Subject: [PATCH 09/11] Port change to c --- c/bstack_alloc.c | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/c/bstack_alloc.c b/c/bstack_alloc.c index f1aaae7..d126b0c 100644 --- a/c/bstack_alloc.c +++ b/c/bstack_alloc.c @@ -1317,6 +1317,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; From 7cde86ffb85dbc514d804a92df088c2d219cfcc0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:27:40 -0700 Subject: [PATCH 10/11] Add CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a60f..c33ab5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`GhostTreeBstackAllocator::realloc` tail shrink was not crash-atomic.** Shrinking a tail allocation to a sub-block-unaligned length truncated the freed tail and zeroed the retained block's padding as two separate operations, so a fault (or crash) between them left the stack shrunk with un-zeroed padding — violating the zeroed-memory invariant and yielding a `realloc`-failure handle claiming a length past the now-shorter stack. The `atomic` path now fuses both into one crash-atomic tail-replace, so a fault leaves the block fully intact; the non-`atomic` path discards first and commits the shrink before zeroing, so a fault after the commit reports the allocation as lost (`handle: None`) rather than handing back a partially-zeroed "original". Surfaced by the new allocator fault-injection fuzz. - **`FirstFitBStackAllocator::realloc` in-place tail shrink was not crash-atomic** (Rust + C). Reclaiming a shrunk tail block rewrote the block header and footer and discarded the tail as separate operations, so a fault (or crash) mid-sequence left the header, footer, and physical size disagreeing — a state first_fit's block-walking recovery cannot repair (it would truncate the whole block, losing live data). A tail shrink now narrows only the user-visible length and keeps the block at its current size — a valid "oversized" allocation, exactly as a non-tail shrink already does — and the space is reclaimed when the block is freed. Behavior change: a tail `realloc` shrink no longer returns space to the file immediately. Surfaced by the allocator fault-injection fuzz. - **`FirstFitBStackAllocator::realloc` in-place tail *grow* left an unrecoverable file after a crash** (Rust + C). Growing the tail block `extend`s (zero-filling) the payload before rewriting the block header/footer to cover the new bytes; a fault (or crash) in that window left the still-valid block followed by a zero-filled region with no block header, which the recovery scan read as a size-0 block and rejected as unrepairable corruption — turning a recoverable crash into a hard `open` failure. The recovery scan in both implementations now recognises an all-zero trailing region (a real block is never all-zero) as the interrupted extension and rolls it back by truncation, restoring the pre-grow tail the failed `realloc` already handed back to the caller; genuine mid-arena corruption still fails loudly. Surfaced by the allocator fault-injection fuzz. +- **`FirstFitBStackAllocator` recovery left a coalescing free's block half-updated after a crash** (Rust + C). Freeing a block wedged between two free blocks coalesces all three: `add_to_free_list` commits the merged size to the block header and then, as a separate write, to the footer. A crash between the two left the header correct but the footer stale. The recovery scan follows headers, so the merged block spanned correctly and recovery cleared the flag — but the stale footer survived. Because it still equalled the old right sub-block's size, it pointed back at that sub-block's untouched interior header, so a later neighbour's left-coalesce walked into the merged block's interior, matched the ghost header, and coalesced onto it — overlapping two blocks and eventually desyncing the recovery walk into a hard `open` failure. Recovery now normalizes every block's footer to its (authoritative, written-first) header as it walks, so any header/footer split left by an interrupted resize is healed. Surfaced by the allocator fault-injection fuzz. ## [0.4.0] - 2026-07-10 From be8b6d94e695ef30a7fea82662752899aaaaf5a0 Mon Sep 17 00:00:00 2001 From: williamwutq Date: Mon, 3 Aug 2026 23:32:48 -0700 Subject: [PATCH 11/11] fmt --- src/alloc/first_fit.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/alloc/first_fit.rs b/src/alloc/first_fit.rs index 4ed79a6..02b3129 100644 --- a/src/alloc/first_fit.rs +++ b/src/alloc/first_fit.rs @@ -1641,7 +1641,11 @@ mod fault_tests { ); let mut r = alloc.alloc(240).unwrap(); r.write([0x22u8; 240]).unwrap(); - assert_eq!(r.read().unwrap(), vec![0x22u8; 240], "at={at}: reuse reads back"); + assert_eq!( + r.read().unwrap(), + vec![0x22u8; 240], + "at={at}: reuse reads back" + ); alloc.dealloc(r).unwrap(); assert_arena_footers_match(&alloc, &format!("at={at} after reuse")); }