[PATCH v4 1/4] mm/truncate: align truncation boundaries to mapping minimum folio order
From: Zhang Yi
Date: Tue Sep 22 2026 - 07:16:28 EST
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);
+
folio_batch_init(&fbatch);
index = start;
while (index < end && find_lock_entries(mapping, &index, end - 1,
--
2.52.0