Re: [PATCH] ext4: fix extent status cache clearing when truncating inline data
From: Zhang Yi
Date: Wed Oct 14 2026 - 22:22:04 EST
On 10/15/2026 12:27 AM, Jan Kara wrote:
> On Wed 14-10-26 10:17:14, Zhang Yi wrote:
>> From: Zhang Yi <yi.zhang@xxxxxxxxxx>
>>
>> ext4_inline_data_truncate() clears the whole extent status tree of the
>> inode when it truncates the inline data, with
>>
>> ext4_es_remove_extent(inode, 0, EXT_MAX_BLOCKS);
>>
>> It does that when the inode still carries inline data while the
>> EXT4_STATE_MAY_INLINE_DATA state bit is already cleared, which is the
>> case once an inline to extent conversion has been initiated, for
>> example a delalloc buffered write larger than the inline space.
>>
>> The clear is meant to drop stale extent status entries of such an inode
>> to prevent stale extents and stale reserved space after the conversion,
>> but the range is too wide. It also drops the entries of the blocks
>> below the new end of file, that is the blocks that stay in the file.
>>
>> When writeback of these blocks starts later, the lookup of such a block
>> misses the extent status tree, falls back to the extent tree and caches
>> a hole entry for it. Those blocks then have dirty, mapped and delayed
>> buffers together with a hole extent status entry. This is an
>> unexpected inconsistency.
>>
>> This problem has been hidden so far because we allocate blocks and new
>> extents for them during writeback. But once [1] is merged during the
>> iomap conversion, the writeback behavior will be normalized, and it will
>> no longer allocate blocks for holes, thus exposing this problem.
>
> Hum, I don't understand. So we have inode with inline data. We truncate it
> (i.e. further reduce i_size). At this point we hold i_rwsem,
> invalidate_lock, i_data_sem. So how can race with inline data
> conversion?
>
Hi Jan,
It is not a race, the state is left behind by an ordinary delalloc
buffered write that ran before the truncate started. Please take a look
at ext4_da_convert_inline_data_to_extent().
A buffered write into an inline file that does not fit the inline space
goes there. It copies the inline data into the page cache, creates
delayed allocation mappings for it, marks the folio dirty and clears
EXT4_STATE_MAY_INLINE_DATA. The non-delalloc variant
ext4_convert_inline_data_to_extent() destroys the inline data with
ext4_destroy_inline_data_nolock() right there, but it can do that
because it allocates the blocks in the same transaction. The delalloc
path deliberately keeps the inline data, it is the only copy of the data
until the blocks are created. It is destroyed later by
ext4_do_writepages() during writeback, which calls
ext4_destroy_inline_data() when the inode still has inline data and it
is about to create the blocks, see the "we will soon create the block
for the 1st page, so we'd better clear the inline data here" comment.
So until that writeback runs, the inode has inline data, the
EXT4_STATE_MAY_INLINE_DATA bit cleared, and a dirty page whose blocks
are mapped as delayed allocations in the extent status tree. A truncate
that comes later is fully serialized and still sees
ext4_has_inline_data() true, which is why it enters
ext4_inline_data_truncate().
Those delayed allocation entries below the new end of file are what the
unconditional ext4_es_remove_extent(inode, 0, EXT_MAX_BLOCKS) drops,
even though their dirty buffers are still in the page cache.
Cheers,
Yi.
>
>>
>> Therefore, restrict the range to the blocks that are actually truncated
>> away, and pass EXT_MAX_BLOCKS minus lblk as the length.
>>
>> [1] https://lore.kernel.org/linux-ext4/20261009103033.2920530-9-yi.zhang@xxxxxxxxxxxxxxx/
>>
>> Reported-by: syzbot ci <syzbot+ci46f64f7b6a2a188e@xxxxxxxxxxxxxxxxxxxxxxxxx>
>> Closes: https://lore.kernel.org/linux-ext4/6ac92a4c.70ed5d86.2ac85.0072.GAE@xxxxxxxxxx/
>> Fixes: 0add491df4e5 ("ext4: remove extent cache entries when truncating inline data")
>> Signed-off-by: Zhang Yi <yi.zhang@xxxxxxxxxx>
>> ---
>> fs/ext4/inline.c | 8 ++++++--
>> 1 file changed, 6 insertions(+), 2 deletions(-)
>>
>> diff --git a/fs/ext4/inline.c b/fs/ext4/inline.c
>> index ceee69a66482b..b630e56815bb5 100644
>> --- a/fs/ext4/inline.c
>> +++ b/fs/ext4/inline.c
>> @@ -1905,8 +1905,12 @@ int ext4_inline_data_truncate(struct inode *inode, int *has_inline)
>> * the extent status cache must be cleared to avoid leaving
>> * behind stale delayed allocated extent entries
>> */
>> - if (!ext4_test_inode_state(inode, EXT4_STATE_MAY_INLINE_DATA))
>> - ext4_es_remove_extent(inode, 0, EXT_MAX_BLOCKS);
>> + if (!ext4_test_inode_state(inode, EXT4_STATE_MAY_INLINE_DATA)) {
>> + ext4_lblk_t lblk = EXT4_B_TO_LBLK(inode, i_size);
>> +
>> + ext4_es_remove_extent(inode, lblk,
>> + EXT_MAX_BLOCKS - lblk);
>> + }
>>
>> /* Clear the content in the xattr space. */
>> if (inline_size > EXT4_MIN_INLINE_DATA_SIZE) {
>> --
>> 2.52.0
>>