diff mbox series

[v3,2/3] mm/truncate: align truncation boundaries to mapping minimum folio order

Message ID 20260916092450.654408-3-yi.zhang@huaweicloud.com
State New
Headers show
Series mm/truncate: fix data loss when truncating straddling large folios | expand

Commit Message

Zhang Yi Sept. 16, 2026, 9:24 a.m. UTC
From: Zhang Yi <yi.zhang@huawei.com>

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@gmail.com>
Link: https://lore.kernel.org/linux-mm/CAJnrk1bQYUe6+1ryyJur5EEnZYrC+_5AYsy=OWzVRgD4202y1g@mail.gmail.com/
Fixes: e220917fa5077 ("mm: split a folio in minimum folio order chunks")
Signed-off-by: Zhang Yi <yi.zhang@huawei.com>
---
 mm/truncate.c | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

Comments

sashiko-bot@kernel.org Sept. 16, 2026, 9:42 a.m. UTC | #1
> From: Zhang Yi <yi.zhang@huawei.com>
> 
> 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
> [ ... ]
> 
> Reported-by: Joanne Koong <joannelkoong@gmail.com>
> Link: https://lore.kernel.org/linux-mm/CAJnrk1bQYUe6+1ryyJur5EEnZYrC+_5AYsy=OWzVRgD4202y1g@mail.gmail.com/
> Fixes: e220917fa5077 ("mm: split a folio in minimum folio order chunks")
> Signed-off-by: Zhang Yi <yi.zhang@huawei.com>

Sashiko has reviewed this patch and found no issues. It looks great!
Zi Yan Sept. 16, 2026, 9:14 p.m. UTC | #2
On Wed Sep 16, 2026 at 5:24 AM EDT, Zhang Yi wrote:
> From: Zhang Yi <yi.zhang@huawei.com>
>
> 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@gmail.com>
> Link: https://lore.kernel.org/linux-mm/CAJnrk1bQYUe6+1ryyJur5EEnZYrC+_5AYsy=OWzVRgD4202y1g@mail.gmail.com/
> Fixes: e220917fa5077 ("mm: split a folio in minimum folio order chunks")
> Signed-off-by: Zhang Yi <yi.zhang@huawei.com>
> ---
>  mm/truncate.c | 14 ++++++++------
>  1 file changed, 8 insertions(+), 6 deletions(-)
>

This should be the first patch, since Fixes is older than the one in
patch 1.

It can adjust start and end in truncate_inode_pages_range() instead,
since shmem's min_order is always 0.

Something like below.

Am I missing anything? Thanks.

diff --git a/mm/truncate.c b/mm/truncate.c
index b58ba940be474..803ee61ebf624 100644
--- a/mm/truncate.c
+++ b/mm/truncate.c
@@ -374,6 +374,7 @@ 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;
@@ -395,6 +396,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,
Zhang Yi Sept. 17, 2026, 12:32 p.m. UTC | #3
On 9/17/2026 5:14 AM, Zi Yan wrote:
> On Wed Sep 16, 2026 at 5:24 AM EDT, Zhang Yi wrote:
>> From: Zhang Yi <yi.zhang@huawei.com>
>>
>> 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@gmail.com>
>> Link: https://lore.kernel.org/linux-mm/CAJnrk1bQYUe6+1ryyJur5EEnZYrC+_5AYsy=OWzVRgD4202y1g@mail.gmail.com/
>> Fixes: e220917fa5077 ("mm: split a folio in minimum folio order chunks")
>> Signed-off-by: Zhang Yi <yi.zhang@huawei.com>
>> ---
>>   mm/truncate.c | 14 ++++++++------
>>   1 file changed, 8 insertions(+), 6 deletions(-)
>>
> 
> This should be the first patch, since Fixes is older than the one in
> patch 1.
> 
> It can adjust start and end in truncate_inode_pages_range() instead,
> since shmem's min_order is always 0.
> 
> Something like below.
> 
> Am I missing anything? Thanks.

Yeah, this makes sense to me, thanks for your suggestion.

Yi.

> 
> diff --git a/mm/truncate.c b/mm/truncate.c
> index b58ba940be474..803ee61ebf624 100644
> --- a/mm/truncate.c
> +++ b/mm/truncate.c
> @@ -374,6 +374,7 @@ 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;
> @@ -395,6 +396,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,
>
diff mbox series

Patch

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;