Re: [PATCH v4 1/4] mm/truncate: align truncation boundaries to mapping minimum folio order
From: Zhang Yi
Date: Thu Sep 24 2026 - 05:33:45 EST
On 9/22/2026 7:07 PM, Zhang Yi wrote:
> From: Zhang Yi <yi.zhang@xxxxxxxxxx>
>
> When the mapping has a non-zero minimum folio order (min_order),
> folio_split() in truncate_inode_partial_folio() stops at min_order
> instead of order 0, so the sub-folio containing a split point stays
> aligned to 1 << min_order rather than to a single page. The original
> boundaries in truncate_inode_pages_range() were based on page
> granularity, so either boundary could land inside the min_order chunk at
> its edge, and the truncation loop would drop that whole chunk, valid
> out-of-range tail included.
>
> For example, a 64K (order-4) folio with min_order = 2 (16K) punched from
> offset 0 to 36K:
>
> split @p0 -> [p0-p3, p4-p7, p8-p15] # non-uniform, min_order
> folio2 = p8-p15 # straddles: p8 in range, p9-p15 tail valid
> 2nd split of folio2 -> [p8-p11, p12-p15] # success
> end(old) = p9 # BUG: p9 inside [p8-p11]
> loop truncates ... p8-p11 # p9-p11's valid tail is lost
>
> It has gone unnoticed so far for two reasons. A non-zero min_order is
> only used by filesystems with a block or sector size larger than the
> page size, and those either always write back the affected range before
> punching a hole or truncating, or they carry filesystem private data on
> dirty folios (e.g. buffer_head), which makes filemap_release_folio()
> fail and folio_split() abort with -EBUSY, so the folio is never split
> and the old start/end boundaries remain valid. The bug only becomes
> reachable on paths that truncate dirty large folios without prior
> writeback and without filesystem private data, such as the upcoming ext4
> iomap buffered I/O path.
>
> Align both start (rounded up) and end (rounded down) to the mapping
> minimum folio order so they always fall on a folio boundary.
>
> Reported-by: Joanne Koong <joannelkoong@xxxxxxxxx>
> Link: https://lore.kernel.org/linux-mm/CAJnrk1bQYUe6+1ryyJur5EEnZYrC+_5AYsy=OWzVRgD4202y1g@xxxxxxxxxxxxxx/
> Fixes: e220917fa5077 ("mm: split a folio in minimum folio order chunks")
> Suggested-by: Zi Yan <ziy@xxxxxxxxxx>
> Signed-off-by: Zhang Yi <yi.zhang@xxxxxxxxxx>
> ---
> mm/truncate.c | 18 ++++++++++++------
> 1 file changed, 12 insertions(+), 6 deletions(-)
>
> diff --git a/mm/truncate.c b/mm/truncate.c
> index b58ba940be47..f9625bb4916f 100644
> --- a/mm/truncate.c
> +++ b/mm/truncate.c
> @@ -345,9 +345,11 @@ long mapping_evict_folio(struct address_space *mapping, struct folio *folio)
> * @lstart: offset from which to truncate
> * @lend: offset to which to truncate (inclusive)
> *
> - * Truncate the page cache, removing the pages that are between
> - * specified offsets (and zeroing out partial pages
> - * if lstart or lend + 1 is not page aligned).
> + * Truncate the page cache, removing the folios that are between specified
> + * offsets (and zeroing out partial folios if lstart or lend + 1 is not
> + * folio aligned). For mappings with a non-zero minimum folio order, the
> + * boundaries are aligned inwards to 1 << min_order so the edge sub-folio
> + * straddling the range is kept.
> *
> * Truncate takes two passes - the first pass is nonblocking. It will not
> * block on page locks and it will not block on writeback. The second pass
> @@ -374,14 +376,14 @@ void truncate_inode_pages_range(struct address_space *mapping,
> int i;
> struct folio *folio;
> bool same_folio;
> + pgoff_t min_nrpages = mapping_min_folio_nrpages(mapping);
>
> if (mapping_empty(mapping))
> return;
>
> /*
> - * 'start' and 'end' always covers the range of pages to be fully
> - * truncated. Partial pages are covered with 'partial_start' at the
> - * start of the range and 'partial_end' at the end of the range.
> + * 'start' and 'end' always covers the range of folios to be fully
> + * truncated, with both boundaries aligned inwards to 1 << min_order.
> * Note that 'end' is exclusive while 'lend' is inclusive.
> */
> start = (lstart + PAGE_SIZE - 1) >> PAGE_SHIFT;
> @@ -395,6 +397,10 @@ void truncate_inode_pages_range(struct address_space *mapping,
> else
> end = (lend + 1) >> PAGE_SHIFT;
>
> + start = round_up(start, min_nrpages);
> + if (end != (pgoff_t)-1)
> + end = round_down(end, min_nrpages);
> +
Hi, all
The sashiko pointed out an issue on this patch:
> [Severity: Critical]
> Can this integer overflow on 32-bit architectures?
>
> In truncate_inode_pages_range(), pgoff_t is a 32-bit integer on 32-bit
> systems. If a hole punch or truncate operation occurs at an offset near
> MAX_LFS_FILESIZE, the start page index will be close to 0xFFFFFFFF.
>
> When min_nrpages > 1 (e.g., on filesystems supporting min_order > 0 like
> Ext4 with bigalloc), rounding up this index wraps it to 0.
>
> Would this wrap-around cause the entire page cache for the file to be
> truncated instead of just the intended tail, leading to data loss?
This is a pre-existing issue, and it is not specific to the truncate
path.
On 32-bit systems, for a mapping with a non-zero minimum folio order,
the last min_order-aligned block of a file of MAX_LFS_FILESIZE is also
the last block of the page index space, so the folio covering it ends at
or past the last representable page index. All places that do offset
calculations on such end folios carry an overflow risk, e.g.,
folio_next_index() wraps to 0 for that folio, and the values derived
from it are wrong.
For example, on a 32-bit environment I create an ext4 filesystem with a
16KB blocksize, where the maximum file size is 0xFFFFFFFF000
(MAX_LFS_FILESIZE). If we run:
xfs_io -f -c "pwrite -b 0x10000 0xFFFFFFFC000 0x3000" /mnt/foo
The last folio starts at 0xFFFFFFFC000, but its length is 0x4000,
so calling folio_next_index() on it will overflow, leading to
unpredictable errors.
I think the root cause is that sb->s_maxbytes is set to
MAX_LFS_FILESIZE, which on 32-bit is (loff_t)ULONG_MAX << PAGE_SHIFT. A
folio is at least min_order pages and min_order aligned, so the file
must have at most ULONG_MAX + 1 - (1 << min_order) pages for the last
folio's next index to stay representable.
I suppose filesystems that use a non-zero minimum folio order should cap
sb->s_maxbytes instead of using MAX_LFS_FILESIZE directly, something
like this:
static inline loff_t max_lfs_filesize(unsigned int min_order)
{
#if BITS_PER_LONG == 32
return ((loff_t)ULONG_MAX + 1 - (1UL << min_order)) << PAGE_SHIFT;
#else
return MAX_LFS_FILESIZE;
#endif
}
Any thoughts?
Besides, I understand that since this is a pre-existing issue, it
shouldn't block the merging of this series?
Thanks,
Yi.