[PATCH v3 2/3] mm/truncate: align truncation boundaries to mapping minimum folio order
From: Zhang Yi
Date: Wed Sep 16 2026 - 05:35:01 EST
From: Zhang Yi <yi.zhang@xxxxxxxxxx>
When the mapping has a non-zero minimum folio order (min_order),
folio_split() 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 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")
Signed-off-by: Zhang Yi <yi.zhang@xxxxxxxxxx>
---
mm/truncate.c | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/mm/truncate.c b/mm/truncate.c
index bec6d881d022..5ab7a40b1e25 100644
--- a/mm/truncate.c
+++ b/mm/truncate.c
@@ -213,8 +213,8 @@ static int folio_split_or_unmap(struct folio *folio, struct page *split_at,
*
* When @pstart and/or @pend are non-NULL they receive the indexes of the
* page range fully covered by [lstart, lend] after any split (or none),
- * i.e. the range of pages wholly within [lstart, lend] and so safe to
- * discard.
+ * aligned inwards to min_order, i.e. the range of folios wholly within
+ * [lstart, lend] and so safe to discard.
*
* Returns false if splitting failed so the caller can avoid
* discarding the entire folio which is stubbornly unsplit.
@@ -226,6 +226,7 @@ bool truncate_inode_partial_folio(struct folio *folio, loff_t lstart,
size_t size = folio_size(folio);
unsigned int offset, length;
struct page *split_at, *split_at2;
+ unsigned long min_nrbytes;
unsigned int min_order;
if (pos < lstart)
@@ -263,6 +264,7 @@ bool truncate_inode_partial_folio(struct folio *folio, loff_t lstart,
return true;
min_order = mapping_min_folio_order(folio->mapping);
+ min_nrbytes = mapping_min_folio_nrbytes(folio->mapping);
split_at = folio_page(folio, PAGE_ALIGN_DOWN(offset) / PAGE_SIZE);
if (!folio_split_or_unmap(folio, split_at, min_order)) {
/*
@@ -271,12 +273,12 @@ bool truncate_inode_partial_folio(struct folio *folio, loff_t lstart,
* for shmem truncate
*/
struct folio *folio2;
- pgoff_t end, aligned_end = (pos + offset + length) >>
- PAGE_SHIFT;
+ pgoff_t end, aligned_end = round_down(pos + offset + length,
+ min_nrbytes) >> PAGE_SHIFT;
if (pstart)
- *pstart = round_up(pos + offset, PAGE_SIZE) >>
- PAGE_SHIFT;
+ *pstart = round_up(pos + offset,
+ min_nrbytes) >> PAGE_SHIFT;
if (offset + length == size) {
end = aligned_end;
--
2.52.0