stripe_alloc balance-reclaim zero-fill — forensic record

Bug found 2026-08-07 on kernel #115, fixed and validated on #116.

Root cause

The growable-frontier path in btrfs_alloc_from_open_stripe() handed out [r->offset, r->offset + num_bytes) without calling run_live_set(). The other two allocation sites (fast path, new-run install) both set the bits.

Grow fires when an allocation exceeds the run's remainder, so the allocation begins at the old tail — usually mid-stripe — and spills across the full-stripe boundary. The liveness map therefore called exactly those spill sectors dead. When a later allocation filled the rest of the boundary row and its write went down as a separate partial rbio, rmw_try_pad_full() consulted the map, believed the spill was dead, zero-filled it and computed parity over the zeros.

The failure is asymmetric: a map that under-reports liveness destroys committed data silently, while one that over-reports only forces an RMW fallback. That is why the missing set was catastrophic and why the live_nbits overflow guard (which zeroes the map, making pad decline) is harmless.

Fix: run_live_set(r, r->offset, num_bytes, true) in the grow path.

Scope

Forensic signature (preserved array, fsid ed90a27f, gen 59)

Why other suites missed it: fill-edge has no per-file fsync, so boundary rows merge into single full-stripe writes. The window needs a separate final writer on the boundary row, which balance-reclaim's rolling fsync-per-file oracle supplies.

Files here

Reproducing without the evidence images

/boot/vmlinuz.prev on the test VM is #115 (pre-fix), and balance-reclaim-test.sh reproduces the damage deterministically at either profile in well under an hour. Revert the one-line fix on any later kernel for the same effect. The raw images are therefore a convenience, not the only path back to a damaged array.