Skip to content

Fix double free when cloning a block with a pending free - #18853

Open
patrickdk77 wants to merge 2 commits into
openzfs:masterfrom
patrickdk77:generic-733
Open

Fix double free when cloning a block with a pending free#18853
patrickdk77 wants to merge 2 commits into
openzfs:masterfrom
patrickdk77:generic-733

Conversation

@patrickdk77

Copy link
Copy Markdown

Motivation and Context

generic/733 reliably panics an --enable-debug build within a minute: a
BRT-cloned block is double-freed, tripping the metaslab verifier in the
free-bpobj drain (panic: segment already in tree, via
metaslab_check_free <- zio_free_sync <- dsl_scan_free_block_cb). On a
release build this silently corrupts the space map, and the damage persists
on disk. Reproduced on unmodified master d98fa72ca05a.

Closes #18842

Description

dmu_read_l0_bps() captured a clone's source block pointers from
db->db_blkptr without checking dnode_block_freed(). A block with a
pending free (a truncate/hole-punch not yet synced) still has a valid BP,
so it could be cloned -- registering a BRT reference to a DVA that is then
returned to the allocator and freed a second time. Deferring the free
doesn't help: the free bpobj drains in the same TXG, before the clone's
brt_pending_apply(), so the fix is on the clone side.

Mirror the existing dbuf_read_hole() guard: return EAGAIN from
dmu_read_l0_bps() when the source block has a pending free.
zfs_clone_range() already retries on the next TXG, by which point the
block reads as a hole. One file, module/zfs/dmu.c (+24).

How Has This Been Tested?

  • generic/733 on a debug build: panicked within a minute before, clean
    across repeated runs after.
  • Full ZFS Test Suite (incl. block_cloning / bclone) and full fstests
    -g auto across single-disk (ashift 9/12), stripe, and raidz1 -- no new
    failures.
  • Clone data compares identical, bclonesaved correct, scrub clean.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • Quality assurance (non-breaking change which makes the code more robust against bugs)

Checklist:

  • My code follows the OpenZFS code style requirements.
  • I have read the contributing document.
  • I have run tests to cover my changes (existing fstests generic/733).
  • I have run the ZFS Test Suite with this change applied.
  • All commit messages are properly formatted and contain Signed-off-by.

dmu_read_l0_bps() captures the source block pointers for a clone
(FICLONE / copy_file_range). For a block with no dirty record it
used db->db_blkptr directly, without checking dnode_block_freed().
A block with a pending free -- e.g. a truncate that has recorded the
block in the dnode's free ranges but has not yet synced -- still has
a valid on-disk block pointer, so it could be cloned. The clone then
registers a BRT reference to a block that is about to be returned to
the allocator, and the block is freed twice: once for the pending
free, and again when the clone reference is later released.

On a debug build this trips the metaslab double-free verifier in the
free bpobj drain (panic: "segment already in tree"); on a release
build the space map is silently corrupted.

The buffered read path already guards against this: dbuf_read_hole()
treats a block with a pending free as a hole via dnode_block_freed().
Apply the same check in dmu_read_l0_bps() and return EAGAIN when the
source block has a pending free. zfs_clone_range() already handles
EAGAIN by waiting for the next TXG and retrying (or falling back to a
copy), by which point the free has synced and the block reads as a
hole.

Reproduced with fstests generic/733, which panicked within a minute
on a debug build and now runs cleanly across repeated runs; block
cloning and BRT space accounting are unaffected (clone data compares
identical, bclonesaved is correct, scrub is clean).

Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
Closes openzfs#18842
@mkhllr

mkhllr commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Nice work tracking this one down, and thanks for the detailed report on #18842
that made it followable, diagnostic patches and all. Your fstests sweep across
the different pool configs is more coverage than I'd have thought to run.

I'd been chasing the same issue from your repro and had ended up at the same
clone-side conclusion, so I've got a regression test for it already written, and
I'd rather it land with your fix than sit in a branch. It punches a block,
clones the file while the punch is still syncing, and checks that the clone
matches the source instead of showing the block's old contents. On an Ubuntu
24.04 debug build it fails ten out of ten runs on unfixed master, and it passes
four out of four against this branch.

It's necessarily probabilistic: the clone has to be registered in a later txg
than the free, and it also has to read the block pointer before the free is
issued within that syncing group. A clone made in the same txg is applied to the
BRT before frees are issued and is handled correctly, so an iteration that
doesn't straddle proves nothing. The test reads back the txg the punch synced in
and the txg the clone was made in from the block birth times, and fails instead
of reporting a pass if no iteration straddled, so it can't quietly stop
exercising the bug. Happy to open it against your branch, or just paste the file
here for you to take.

Two things in the diff I'd look at.

The override branch isn't covered. When the head dirty record is a brtwrite the
BP comes from dr_overridden_by with no check at all. A punch landing in the txg
after a still-unsynced clone leaves that record in place, since
dbuf_free_range() only unoverrides records from its own txg and skips cloned
dbufs as DB_NOFILL, so cloning the clone captures the overridden pointer with the
newer free pending against it. That's the same double free through the other
branch. dbuf_read_hole() handles this with dnode_block_freed_after(dn, db->db_blkid, dr->dr_txg) so that frees older than the override aren't counted
against it, and the same call works here.

The other is holes. The check sits inside else { bp = db->db_blkptr; }, which
runs before the bp == NULL handling further down, and dnode_free_range()
records a range whether or not the blocks in it are holes. So a block that was
never written inside a pending punch range, or a BP that is already a hole, now
returns EAGAIN where master reported a hole. With zfs_bclone_wait_dirty=1, the
default, that costs an extra txg wait and resolves on the retry. With it unset,
copy_file_range falls back to a plain copy, but FICLONE has nowhere to fall
back to and fails outright where master cloned the hole. Guarding the new check
with !BP_IS_HOLE(bp), and letting the NULL case reach its existing handling,
avoids both.

@patrickdk77

Copy link
Copy Markdown
Author

I was hyper focused on only fixing the panic I observed and didn't branch out
from there.

On the fstests sweep across pool configs, that was reuse rather than foresight:
I have a FIEMAP PR I plan to follow up with where testing across vdev types
genuinely matters, so the harness already existed and this just used the same
method.

patrickdk77 added a commit to patrickdk77/zfs that referenced this pull request Jul 28, 2026
f74a384ae2 made dmu_read_l0_bps() return EAGAIN for a source block with
a pending free, so a clone cannot add a BRT reference to a block about
to be freed. Review on openzfs#18853 pointed out two cases that check missed;
handle both the way dbuf_read_hole() already does.

  - A hole (or absent BP) has nothing to free. The check ran
    unconditionally, so a hole lying inside a pending free range
    returned EAGAIN where the block should just be cloned as a hole.
    dnode_free_range() records a range whether or not the blocks in it
    are holes, so this also covers a block never written inside a
    pending punch range. With zfs_bclone_wait_dirty=1 that costs an
    extra txg wait and resolves on the retry; with it unset
    copy_file_range falls back to a plain copy, but FICLONE has nowhere
    to fall back to and fails outright where master cloned the hole.
    Guard the check with !BP_IS_HOLE(bp) and let a NULL bp reach the
    existing hole handling below.

  - When the head dirty record already overrode the BP (dr_brtwrite --
    cloning a clone made in the same txg), the overridden pointer was
    returned with no check at all. A punch landing in a txg after a
    still-unsynced clone leaves that override in place: dbuf_free_range()
    only unoverrides records from its own txg and skips cloned dbufs as
    DB_NOFILL, so cloning the clone captures the overridden pointer with
    the newer free pending against it and double frees it through that
    branch. Only frees after the override count against it, so use
    dnode_block_freed_after(dn, db->db_blkid, dr->dr_txg).

Verified on a debug build with fstests generic/733 and with a FICLONE of
a sparse file in a pending free range, which fails before this change
(EAGAIN) and succeeds after; block cloning and BRT space accounting are
unaffected.

Suggested-by: mkhllr
Refs: openzfs#18853
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
f74a384ae2 made dmu_read_l0_bps() return EAGAIN for a source block with
a pending free, so a clone cannot add a BRT reference to a block about
to be freed. Review on openzfs#18853 pointed out two cases that check missed;
handle both the way dbuf_read_hole() already does.

  - A hole (or absent BP) has nothing to free. The check ran
    unconditionally, so a hole lying inside a pending free range
    returned EAGAIN where the block should just be cloned as a hole.
    dnode_free_range() records a range whether or not the blocks in it
    are holes, so this also covers a block never written inside a
    pending punch range. With zfs_bclone_wait_dirty=1 that costs an
    extra txg wait and resolves on the retry; with it unset
    copy_file_range falls back to a plain copy, but FICLONE has nowhere
    to fall back to and fails outright where master cloned the hole.
    Guard the check with !BP_IS_HOLE(bp) and let a NULL bp reach the
    existing hole handling below.

  - When the head dirty record already overrode the BP (dr_brtwrite,
    cloning a clone made in the same txg), the overridden pointer was
    returned with no check at all. A punch landing in a txg after a
    still-unsynced clone leaves that override in place:
    dbuf_free_range() only unoverrides records from its own txg and
    skips cloned dbufs as DB_NOFILL, so cloning the clone captures
    the overridden pointer with the newer free pending against it and
    double frees it through that branch. Only frees after the override
    count against it, so use dnode_block_freed_after(dn, db->db_blkid,
    dr->dr_txg).

Verified on a debug build with fstests generic/733 and with a FICLONE of
a sparse file in a pending free range, which fails before this change
(EAGAIN) and succeeds after; block cloning and BRT space accounting are
unaffected.

Suggested-by: mkhllr
Refs: openzfs#18853
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
patrickdk77 added a commit to patrickdk77/xfstests that referenced this pull request Jul 28, 2026
The reasons in this file had accumulated from one-off observations,
some of them stale and at least one simply wrong. Replace them with
verdicts from a full -g auto run on five pool topologies (single
disk at ashift 9 and 12, a 3-disk stripe, a 2-way mirror, raidz1)
made with only the five block-layer cases excluded, so every test
previously listed here has a result from all five.

State what the list is calibrated against, which was missing before.
Our branch carries 34 commits that are not in upstream OpenZFS, and
only one of them has been submitted (the block cloning double free
fix behind generic/733, openzfs/zfs#18853). Tests that are absent
from this file because they now pass may well fail on stock
OpenZFS, so the header says so rather than letting the omissions
read as upstream behaviour.

Drop fifteen entries the run showed are no longer needed here:

  013, 139, 224, 269, 310, 476, 591 and 650 pass on all five. 013,
  310, 476 and 650 were gated for a rangelock livelock and an ARC
  OOM panic, neither of which reproduces. 269 passes only because of
  our local writepage error commit, and 139 exercises direct I/O
  over cloned blocks that several local commits touch; both notes
  say so.

  118, 119, 144, 517, 563, 616 and 617 self-skip on all five. Those
  capability checks are upstream, so these removals do not depend on
  our commits.

Correct the dm-error group. The note claimed 266, 281, 338 and 475
needed pool redundancy, but _zfs_dmerror_init builds the pool on a
single dm device whatever the topology, so redundancy never applies
and all five wedge identically.

Record what 083 actually does: it passes on the four 4-CPU
topologies, and only with our writepage commit applied, but panics
raidz1 (6 CPUs, 6 GiB) with "System is deadlocked on memory",
reproduced twice. It stays excluded, described as a memory deadlock
a zfs_arc_max cap avoids rather than the vague OOM it was filed as.

Nothing was added. generic/271 and 272 fail on all five in the same
way as the sync-write-error group but are left running, with a note
saying so, rather than hidden behind a new entry.

27 entries remain, down from 42.

Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
@behlendorf behlendorf added the Status: Code Review Needed Ready for review and testing label Jul 31, 2026
@behlendorf
behlendorf self-requested a review August 4, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Code Review Needed Ready for review and testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PANIC: BRT-cloned block double-freed via free bpobj (fstests generic/733)

3 participants