Skip to content

Instantly share code, notes, and snippets.

@patrickdk77
Last active July 28, 2026 22:33
Show Gist options
  • Select an option

  • Save patrickdk77/503c31f8dd206e65b635d6c1e06257a2 to your computer and use it in GitHub Desktop.

Select an option

Save patrickdk77/503c31f8dd206e65b635d6c1e06257a2 to your computer and use it in GitHub Desktop.
commit 92bd9d9772d851f71a6c9bc83f6825eba969744d (HEAD -> fulldedupe)
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Tue Jul 28 12:26:58 2026 -0400
Return a negative error from the writepage callback
zfs_putpage() reports failures as positive ZFS errnos: it assigns
EDQUOT directly, and passes on the values from
zfs_enter_verify_zp(), dmu_tx_assign() and zil_commit_flags().
zpl_putpage() handed that straight back to the kernel, but it is
the writepage callback for both .writepage and .writepages, and
those must return a negative error or zero.
The writeback loop notices and complains. Running the ENOSPC
soak in fstests generic/269 produced:
WARNING: CPU: 3 PID: 7320 at mm/page-writeback.c:2568
writeback_iter+0x267/0x2d0
__writeback_single_inode
writeback_single_inode
write_inode_now
zfs_clone_range
which is reached because zfs_clone_range() flushes the source
through write_inode_now() before cloning. Only tests that run the
filesystem out of space hit it, since that is the only way
zfs_putpage() returns non-zero. The bad value was also recorded as
the mapping's writeback error, so the error reported to userspace
was wrong too, not just the log message.
zpl_writepages() was already inconsistent with itself here: the
zil_commit_flags() failure it returns is negated, while the value
from write_cache_pages() was not. Negate in zpl_putpage(), as every
other wrapper in this file does.
generic/269 fails with that warning before this change and passes
without it after, and so does generic/083. Across a full fstests
auto run on five pool topologies (single disk ashift 9 and 12,
3-disk stripe, mirror, raidz1) both tests pass on every topology
and no kernel warning is reported anywhere, against five in the
same run before the fix.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit b472eba4e9465a567683200c5f59ae98b7f00650
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Tue Jul 28 00:11:25 2026 -0400
zvol: give up the clone txg wait when the pool suspends
zvol_clone_range() holds RL_WRITER on the destination range,
RL_READER on the source range, and zv_suspend_lock across its clone
loop. When dmu_read_l0_bps() reports EAGAIN for a block dirtied in
the open txg and zfs_bclone_wait_dirty is set, the loop waits for the
next txg with a plain txg_wait_synced(). If the pool suspends during
that wait it never returns, so the thread parks unkillably while
holding all three locks and blocks every other I/O to both zvols.
zfs_clone_range_locked() already handles this: it passes
TXG_WAIT_SUSPEND when the pool failmode is continue, then maps
ESHUTDOWN to EIO so the loop can break out and drop its locks. Do the
same here.
Found by inspection while auditing range lock use across the tree,
prompted by an unrelated clone hang. It was not reproduced, since
provoking it needs a pool suspend during a clone of a block dirtied
in the current txg. The change is build tested and the ZPL clone
tests (generic/733, 154, 430, 434) still pass, but note those cover
zfs_clone_range(), not this zvol path.
znode: assert the range lock is empty when a znode is recycled
zfs_rangelock_init() for z_rangelock runs in the znode slab
constructor, not per allocation, so the lock outlives any single
znode and is handed to whatever file next reuses the object. Nothing
checks that its tree is empty when the znode is freed. A range left
locked by any znode, an xattr znode, a directory, a since-deleted
file, therefore survives kmem_cache_free() and reappears in the tree
of an unrelated file, where it blocks that file forever with no
thread holding anything and no stack to point at.
Assert the tree is empty in zfs_inode_free() before the object goes
back to the cache, and again in zfs_inode_alloc() after it comes
out. Under ZFS_DEBUG this turns a leak into a failure at the znode
that leaked it, instead of an unexplained hang later on a different
file. The assertions compile out of release builds.
This was written while chasing exactly that symptom: an FICLONERANGE
on a file blocked in zfs_rangelock_enter() for hours while no task in
the system held a ZFS lock. An audit of every zfs_rangelock_enter()
call site found no unbalanced path, which is what pointed at the
recycled slab object rather than the clone path.
The assertions have not fired so far: 24 runs of the xattr, clone and
fiemap tests, plus a full xfstests auto sweep, all clean. They stand
as a net for the class rather than a fix for a known leak.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit b12826d169695c75a29227704282ec8ce3e549c4
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 21:12:26 2026 -0400
Cover holes and overridden BPs in the clone pending-free check
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 #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: https://github.com/openzfs/zfs/pull/18853
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 2e3f588b6df2153437a1232f220cabe2616d7ea6
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 20:08:44 2026 -0400
Don't assert a valid spill bp when the spill write failed
dbuf_write_ready() asserts that a DMU_SPILL_BLKID dbuf has a non-hole
block pointer matching the dnode's spill blkptr. When the write failed
-- e.g. DVA allocation returns ENOSPC because the pool's only vdev is
failing -- the spill bp is left a hole (no DVA) and the ZIO still runs
its ready callback carrying io_error. The regular-block path above
already tolerates a hole bp (it guards the type/level asserts on
BP_GET_BIRTH != 0), but the spill-only assert does not, so a debug
build panics.
generic/753 (log recovery under repeated dm-error disk failures + xattr
fsstress) reproduces it: the moment dm-error returns EIO, several
in-flight spill writes reach dbuf_write_ready with hole bps and
io_error == ENOSPC (pool not yet suspended), tripping the assert. The
failed txg is retried once the pool resumes, so the hole is transient;
on a non-debug build the assert is compiled out and the pool suspends
and recovers normally.
Only require a valid allocated spill bp when the write succeeded.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 9c653d9077d671b80c67f71db43723e7cb021d39
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 17:36:04 2026 -0400
Strip security capabilities on write
zpl_iter_write() cleared set-ID mode bits (via
zfs_clear_setid_bits_if_necessary()) but never removed the
security.capability xattr, so writing to a file left its file
capabilities intact (generic/093). The generic VFS write path drops
them in file_remove_privs(), which ZFS bypasses.
Call file_remove_privs() at the top of zpl_iter_write(). It needs
the inode lock only when it has something to remove, so set SB_NOSEC
on the superblock and gate the call on IS_NOSEC(): once a file is
known to carry no set-ID bits or capabilities the write path skips
both the lock and the call, leaving concurrent writes unserialized.
S_NOSEC is cleared by the VFS on any security.* setxattr and is never
set on a freshly instantiated inode, so a later setcap is still seen.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 63f44e0dc7f5b4e62128361a706bcc213e90bdbc
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 17:35:33 2026 -0400
Fix set-user-ID/set-group-ID clearing on write and chown
ZFS open-codes set-ID clearing rather than going through the VFS
file_remove_privs()/notify_change() path, and both copies diverged
from should_remove_suid():
- zfs_clear_setid_bits_if_necessary() (write path) only cleared a
set-ID bit when at least one execute bit was set, and cleared
S_ISUID and S_ISGID together. A direct write to a file with no
execute bits (-rwSr-Sr--) left set-user-ID in place (generic/355).
- secpolicy_setid_clear() (chown/truncate path) cleared both
S_ISUID and S_ISGID whenever either was set, stripping
set-group-ID from a file with no group-execute bit -- a
mandatory-locking marker that must be preserved (generic/193).
Match should_remove_suid(): always clear S_ISUID, and clear S_ISGID
only when S_IXGRP is set.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 962dbd343d10bd09710170aa85db5681db44ff35
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 16:21:52 2026 -0400
Zero the last partial page's tail past EOF when extending a file
The tail of a file's last partial page, past EOF, can hold stale bytes
that were never persisted, most easily via an mmap store past EOF,
which the kernel lets land in the page but which zfs_putpage() clamps
out of the writeback. Those bytes must read back as zero.
When the file is later extended to cover them, by a write past EOF
that leaves a gap, or by a truncate or fallocate that grows the file,
ZFS left the stale bytes in the cached page. A subsequent read then
returned them, and the next writeback (now clamped to the larger EOF)
persisted them to disk. generic/363 (fsx -e, "pollute EOF") hits
this; the bytes are visible past the file's real data in the page
cache and, after writeback, on disk (confirmed with zdb).
Zero the old partial-page tail before the file grows to expose it:
- zfs_extend() (truncate-up and fallocate) already holds the whole-
file range lock, so it calls zfs_zero_partial_page() directly.
- zfs_write() only locks the range it writes, which does not cover
the old EOF when the write leaves a gap; it takes a brief range
lock over just the old last page and re-reads its tail from the
DMU (a hole past the old EOF, hence zeros) before the main write.
- zfs_clone_range() extends the destination as well and had the
same gap. Its destination range lock starts at the clone offset,
so like zfs_write() it does not cover the old EOF page; it now
takes the same brief lock and re-read first. A clone placed past
a partial last page would otherwise expose that page's stale
tail.
All three are gated on the page being cached, so the common path is
unaffected.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit e58150f5e12bab92a6ae0edd9851eb42ae12b25e
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 15:01:21 2026 -0400
Honor RLIMIT_FSIZE and the maximum file size when extending a file
A mode 0 fallocate (no FALLOC_FL_KEEP_SIZE) that extends the file must
fail when the new size would exceed RLIMIT_FSIZE or the filesystem's
maximum file size, exactly as a write() that extends the file does.
zpl_fallocate_common() checked neither, so preallocating past
RLIMIT_FSIZE silently succeeded instead of raising SIGXFSZ and
returning EFBIG (generic/228).
Call inode_newsize_ok(), the standard VFS size-limit check, before
extending the file: it raises SIGXFSZ and returns EFBIG past
RLIMIT_FSIZE, and returns EFBIG past the maximum file size, matching
how ext4 and xfs gate a fallocate that grows the file.
Two more paths grow a file and need the same check. A
FALLOC_FL_ZERO_RANGE without FALLOC_FL_KEEP_SIZE reaches zfs_extend()
by way of zfs_space() with no check at all. FICLONE, FICLONERANGE and
copy_file_range() reach zfs_clone_range(), which calls
zn_rlimit_fsize(); that is the real check only on FreeBSD, and expands
to 0 on Linux. Nothing else covers either path: the VFS makes this
check in generic_remap_file_range_prep(), which ZFS does not call. So
ulimit -f did not bound a reflink on Linux at all, neither EFBIG nor
SIGXFSZ.
Apply inode_newsize_ok() to both, on the destination inode and under
its lock.
generic/514 covers the reflink case and passed over the bug. It fails
only when the destination reaches the full size, and a clone that
fails leaves the destination at zero bytes, which the test accepts.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 294f360792b25085f780b85a9adabf9506217018
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Mon Jul 27 14:47:08 2026 -0400
Fix data loss when fallocate races an O_DIRECT append write
zpl_fallocate_common() extends a file (mode 0, no FALLOC_FL_KEEP_SIZE)
by sampling the current i_size once and, when offset+len is larger,
calling zfs_freesp(zp, offset+len, 0, ...), the len==0 "set the file
size to off" idiom.
An O_DIRECT append write updates the file size outside the inode lock
(ZFS serializes direct writes with the range lock, not i_rwsem or
inode_dio_wait), so it can grow the file past offset+len after
fallocate sampled the old size but before zfs_freesp() runs.
zfs_freesp() then treats the now-smaller offset+len as a truncation:
it either clamps i_size back down in truncate_setsize(), hiding the
write's data past the reported EOF, or, when the size check races the
other way, calls zfs_trunc() and frees the block the write just
added. generic/586 reproduces this: the appended block is on disk
(zdb reports the larger dnode size) while stat() reports the smaller.
Extend the file with zfs_extend() instead, which is grow-only: it does
nothing when the file already reached the target size, so a concurrent
write that grew it further is never truncated. zfs_extend() gains a
'log' argument so the fallocate path keeps the mtime/ctime and z_seq
updates and the TX_TRUNCATE ZIL record that zfs_freesp() emitted (a
following fsync stays crash-durable); the two existing callers in
zfs_freesp() pass B_FALSE and log as before.
The FALLOC_FL_KEEP_SIZE path samples the same size and had a second
instance of the bug. It skips the request when offset is past EOF and
otherwise clamps len to olen - offset, so at exactly EOF len becomes
zero. zfs_space() reads a zero length as "to the end of the file" and
zfs_freesp() turns that into zfs_trunc(): a size-changing operation on
the path that promised to keep the size, and one that discards an
O_DIRECT append that grew the file after olen was read. Skip the
request at or past EOF instead.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 94e3ad71114a301a502d6398aefdc758a900efa0
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Sun Jul 26 22:10:23 2026 -0400
Fix self-deadlock when cloning a range within the same file
zfs_clone_range() takes an RL_READER range lock on the source range
and an RL_WRITER range lock on the destination range. When the
source and destination are the same file (inzp == outzp) both locks
are on the same znode's rangelock. If the destination write has to
grow the file's block size, zfs_rangelock_enter() grows the writer
lock to cover the whole file (see zfs_rlock.c), which then conflicts
with the source reader held by the same thread. The result is an
unrecoverable self-deadlock (the thread waits forever in
zfs_rangelock_enter_impl(), D state, freed only by a reboot).
Reproduced by fstests generic/564, whose copy_range probe does a
sub-recordsize copy_file_range() within one file that extends it (so
the destination block size grows).
For a same-file clone, take a single writer lock spanning both ranges
instead of a separate reader and writer. The source block pointers
are read before zfs_clone_range_locked() reduces the lock, so the
source stays covered, and a single lock cannot conflict with itself.
Clones between different files are unchanged.
zfs_dedupe_range() takes two locks on one znode the same way and does
not need the same treatment, but only because it never extends the
file. Both ranges are checked to lie within their file before the
locks are taken, so end_size in zfs_rangelock_cb() is just z_size and
its block growth test can never hold. A concurrent shrink cannot
invalidate that, because the caller holds the inode lock, exclusively
when the two files are the same, and the only size update outside it
(an O_DIRECT append) only grows the file. Record the invariant next
to the locks, so a later change that lets dedupe extend the file does
not quietly reintroduce this deadlock.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit c5361000aec8edde6d8e75094a80d85f19d1d197
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Sun Jul 26 15:30:39 2026 -0400
Fix double free when cloning a block with a pending free
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 #18842
commit 490936de578753f20c95d1bbb26fb35abc2a6c40
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Sun Jul 26 14:10:33 2026 -0400
Fix Direct I/O handling of blocks with a pending free
Commit 9bf75b4b13 ("Fix reads for blocks freed after being cloned")
taught the buffered read path, dbuf_read_hole(), to treat a block
with a pending free as a hole: it consults dnode_block_freed(), or
dnode_block_freed_after() when the block was overridden by a block
clone or Direct I/O write so that only frees from TXGs after the
override count.
The Direct I/O read path in dmu_read_abd() was never updated to
match. It issues a read from the block pointer whenever the bp is
valid and the dbuf is not DB_CACHED, so a block freed by a not yet
synced truncate (whose bp is still valid) returns stale data from
the old block instead of zeros.
Apply the same pending-free check to dmu_read_abd(). The DB_CACHED
case is now handled first and always copies the dbuf's data, which
already reflects any pending write or free, exactly as the buffered
path returns db_buf for a cached dbuf. The pending-free check is
consulted only on the uncached path that would otherwise read the
stale bp. This is the Direct I/O counterpart to 9bf75b4b13.
The Direct I/O write path had the matching gap. dmu_sync() disables
nopwrite when the current block pointer could change before the TXG
syncs, and it can change in two ways: the dbuf is dirty again, or
the block is being freed (dnode_block_freed()). dmu_write_direct()
tested only the first. A nopwrite that keeps the old bp because the
data compares equal, while a pending truncate frees that very block,
leaves the dnode pointing at freed space. Test both, as dmu_sync()
does.
fsx with O_DIRECT reproduced the read corruption within ~100k
operations and now runs millions of operations cleanly.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 991b25565794297a3bb87a3c87e639629b1abb78
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Fri Jul 24 19:00:32 2026 -0400
zpl: report a short clone's real error instead of EINVAL
FICLONE and FICLONERANGE cannot shorten a clone; the whole
requested range must be cloned. zfs_clone_range() can still
return a short clone: on partial progress it clears the
underlying error and returns the bytes already cloned, which is
correct for copy_file_range(). For FICLONE,
zpl_remap_file_range() then turned that short result into EINVAL,
discarding the real reason the clone stopped.
The most visible case is ENOSPC. A clone that fills the pool
partway through reported EINVAL ("Invalid argument") to userspace
instead of ENOSPC ("No space left on device"); EDQUOT and EIO
were hidden the same way. generic/333 and generic/334 expose it:
they clone a file in a loop while writing to it, expect ENOSPC as
the pool fills, and instead fail on the masked EINVAL.
For the no-shorten path, retry the remaining range rather than
converting the first short clone to EINVAL. Each retry either
makes further progress or returns the real hard error, which then
propagates to the caller; EINVAL is returned only when the clone
genuinely cannot make progress.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 466bfa04fbb9c2e51318f225570bebe054a4b397
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Fri Jul 24 15:41:12 2026 -0400
zfs_clone_range: allow a partial-tail clone past the dest EOF
A clone (FICLONE/FICLONERANGE) whose length is not a multiple of
the block size is only valid when the range reaches the end of
both the source and the destination. zfs_clone_range() enforced
that by comparing the length against z_size minus the offset for
each file.
When the destination offset is beyond the destination's current
size (the clone extends the file, so the trailing partial block
lands at the new EOF), that subtraction underflows (z_size and the
offset are both uint64_t), so the term is always true and the
clone is wrongly rejected with EINVAL.
generic/144 hits this: it reflinks a partial-tail range into a
file at an offset past its current EOF and expects it to succeed.
Bounds-check each side (offset < size) before subtracting, so a
clone that extends the destination past its current EOF is
allowed while a genuine mid-file partial block is still rejected.
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
commit 1c158bede18d1eed2fe5eb970dfe7e34d8f79a4e
Author: Patrick Domack <patrickdk@patrickdk.com>
Date: Wed Jul 22 03:27:55 2026 -0400
fideduperange: reject dedupe crossing destination EOF
Dedupe silently shortened a request crossing the destination EOF
under REMAP_FILE_CAN_SHORTEN, and the ioctl layer then reported the
full requested length as deduped. xfs and btrfs reject this case
with EINVAL via generic_remap_file_range_prep(); match them
(caught by fstests generic/158).
Signed-off-by: Patrick Domack <patrickdk@patrickdk.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment