From: Zygo Blaxell Date: Wed, 29 Jul 2026 14:35:41 +0000 (-0400) Subject: btrfs: stripe_alloc: never claim stripes covered by a live run X-Git-Url: http://git.hungrycats.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=3e7195589e7efaccec1715ea2328bbfa159f05e8;p=linux btrfs: stripe_alloc: never claim stripes covered by a live run The range-to-run lookups -- attaching an ordered extent to its stripe run and reporting completed data IO by bytenr -- assume that at most one run on a block group's list covers any given address. Nothing enforced that. A reservation that is discarded before anything references it (the cow_file_range error path under ENOSPC, the find_free_extent backout paths) returns its bytes to the free space cache immediately, with no pinning: there is no committed state to protect. When an ENOSPC failure storm discards every allocation in a stripe, the stripe is fully free again and the claim rule -- correctly, by its own lights -- hands it out as part of a new run while the old run object is still on the list draining its other stripes' IO. Full-stripe write batching widened a run's post-close drain from microseconds to the parked-write timeout, and the soak test hit the overlap within minutes: new allocations' ordered extents attached to the old run (first match by range), their completions drained the old run's inflight accounting into an assertion failure, and the new run's accounting never drained, wedging the commit's retire wait. The overlap is harmless to data -- a stripe can only be re-claimed if every byte of it is free, and discarded reservations never issued bios -- but the accounting corruption is fatal. Freed committed extents cannot reproduce this: they return to the free cache only in the unpin phase at the tail of a commit, and the same commit's retirement already drained -- and freed -- every run opened before it. Only the unpinned immediate-free paths race with a draining run. Rather than enumerate those paths, enforce the lookups' assumption at the claim site: btrfs_claim_free_stripe_run() now trims a candidate to end before the first live run overlapping it, or rejects it if its head overlaps. Re-claiming such stripes just waits until the old run drains off the list, which only comes up inside ENOSPC failure storms. Reproduced with concurrent fill-to-ENOSPC/delete cycles, balance, and fsstress on a 4-device raid5: the assertion fired within ~15 minutes unpatched, and ~10 hours of the same load ran clean with this fix. Assisted-by: Claude:claude-fable-5 --- diff --git a/fs/btrfs/block-group.c b/fs/btrfs/block-group.c index 3b9c7fdd1391e..7bb032bd383a9 100644 --- a/fs/btrfs/block-group.c +++ b/fs/btrfs/block-group.c @@ -770,6 +770,40 @@ out: return ret; } +/* + * A live stripe run's address range must not be re-claimed while the run + * object still exists: an ENOSPC discard storm can return every byte of a + * stripe to the free cache while its run is still draining other stripes' + * IO, and a second run claimed over the same range would make the + * range-to-run lookups (ordered extent attach, IO reporting) ambiguous and + * corrupt the inflight accounting. Trim the candidate to end before the + * first overlapping run; reject it outright if its head overlaps. Caller + * holds ctl->tree_lock; stripe_run_lock nests inside it. + */ +bool btrfs_stripe_run_range_usable(struct btrfs_block_group *bg, + u64 run_start, u64 *run_len) +{ + struct btrfs_open_stripe_run *run; + unsigned long flags; + u64 len = *run_len; + + spin_lock_irqsave(&bg->stripe_run_lock, flags); + list_for_each_entry(run, &bg->open_stripe_runs, list) { + if (run->end <= run_start || run->start >= run_start + len) + continue; + if (run->start <= run_start) { + len = 0; + break; + } + len = run->start - run_start; + } + spin_unlock_irqrestore(&bg->stripe_run_lock, flags); + if (len < bg->full_stripe_len) + return false; + *run_len = len; + return true; +} + /* * Does @logical lie within a stripe run (open or draining)? Used by the * raid56 layer to decide whether a partial write to this stripe may be diff --git a/fs/btrfs/block-group.h b/fs/btrfs/block-group.h index dc02b1392ace6..47447ff67aed3 100644 --- a/fs/btrfs/block-group.h +++ b/fs/btrfs/block-group.h @@ -403,6 +403,8 @@ struct btrfs_open_stripe_run *btrfs_get_open_stripe_run( void btrfs_close_bg_open_stripes(struct btrfs_block_group *bg); void btrfs_retire_block_group_stripes(struct btrfs_block_group *bg); bool btrfs_stripe_in_open_run(struct btrfs_fs_info *fs_info, u64 logical); +bool btrfs_stripe_run_range_usable(struct btrfs_block_group *bg, + u64 run_start, u64 *run_len); void btrfs_retire_open_stripes(struct btrfs_fs_info *fs_info, struct btrfs_transaction *trans); void btrfs_clear_data_reloc_bg(struct btrfs_block_group *bg); diff --git a/fs/btrfs/free-space-cache.c b/fs/btrfs/free-space-cache.c index b85084f8c8698..6c086dbe1bd41 100644 --- a/fs/btrfs/free-space-cache.c +++ b/fs/btrfs/free-space-cache.c @@ -2201,7 +2201,9 @@ int btrfs_claim_free_stripe_run(struct btrfs_block_group *block_group, if (entry->bitmap) { if (find_stripe_run_in_bitmap(ctl, entry, want_bytes, - &run_start, &run_len)) + &run_start, &run_len) && + btrfs_stripe_run_range_usable(block_group, + run_start, &run_len)) goto found; continue; } @@ -2212,6 +2214,9 @@ int btrfs_claim_free_stripe_run(struct btrfs_block_group *block_group, run_len = min(want_bytes, div64_u64(entry->offset + entry->bytes - run_start, fsl) * fsl); + if (!btrfs_stripe_run_range_usable(block_group, run_start, + &run_len)) + continue; goto found; } goto out;