[PATCH 6.6.y 2/2] jbd2: bound shrinker scans by examined checkpoint buffers

From: Artem Dinaburg

Date: Thu Oct 08 2026 - 15:24:06 EST


From: Max Kellermann <max.kellermann@xxxxxxxxx>

[ Upstream commit 15cb16496446b94e67f7abcb049b8e2c75cd3d02 ]

The jbd2 shrinker currently accounts only checkpoint buffers that it
successfully releases against nr_to_scan. Busy buffers therefore do not
consume the scan budget.

If a checkpoint transaction contains mostly busy buffers, the shrinker
can scan its entire checkpoint list while holding journal->j_list_lock.
Large checkpoint lists can result in excessive lock hold times and leave
other CPUs spinning on j_list_lock, causing soft lockups or RCU stalls.

Pass nr_to_scan into journal_shrink_one_cp_list() and decrement it for
every buffer examined, including busy buffers. Pass NULL from checkpoint
cleanup paths so their existing full-list behavior is preserved.

This restores the scan-budget semantics that existed before
journal_shrink_one_cp_list() was changed to always scan a complete
checkpoint list.

[ Backport to 6.6.y: use this tree's older shrink_type enumerator names;
the scan-budget accounting and caller changes are otherwise unchanged. ]

Fixes: b98dba273a0e ("jbd2: remove journal_clean_one_cp_list()")
Cc: stable@xxxxxxxxxxxxxxx
Signed-off-by: Max Kellermann <max.kellermann@xxxxxxxxx>
Reviewed-by: Zhang Yi <yi.zhang@xxxxxxxxxx>
Reviewed-by: Jan Kara <jack@xxxxxxx>
Link: https://patch.msgid.link/20260713102229.1598812-3-max.kellermann@xxxxxxxxx
Signed-off-by: Theodore Ts'o <tytso@xxxxxxx>
Assisted-by: LLM
Signed-off-by: Artem Dinaburg <artem@xxxxxxxxxxxxxxx>
---
This is patch 2 of 2 in the ordered 6.6.y backport series.
This change addresses CVE-2026-89567. Both jbd2 shrinkers decrement the
scan budget only for buffers actually freed, so busy checkpoint lists can
hold j_list_lock unboundedly; counting examined buffers restores shrinker
semantics.
This needed a target-specific adjustment; I called it out in the bracketed
backport note above.

The fix is already present in 6.12.y, 6.18.y, and 7.2.y, but not in 6.6.y.
This fix also affects 6.1.y, which will need a separate backport; this
submission contains only the 6.6.y patch.

fs/jbd2/checkpoint.c | 25 +++++++++++++------------
1 file changed, 13 insertions(+), 12 deletions(-)

diff --git a/fs/jbd2/checkpoint.c b/fs/jbd2/checkpoint.c
index e8a7186eb8c3..0462101f683c 100644
--- a/fs/jbd2/checkpoint.c
+++ b/fs/jbd2/checkpoint.c
@@ -360,15 +360,16 @@ enum shrink_type {SHRINK_DESTROY, SHRINK_BUSY_STOP, SHRINK_BUSY_SKIP};
/*
* journal_shrink_one_cp_list
*
- * Find all the written-back checkpoint buffers in the given list
- * and try to release them. If the whole transaction is released, set
- * the 'released' parameter. Return the number of released checkpointed
- * buffers.
+ * Find written-back checkpoint buffers in the given list and try to release
+ * them. If 'nr_to_scan' is set, scan at most that many buffers. If the whole
+ * transaction is released, set the 'released' parameter. Return the number of
+ * released checkpointed buffers.
*
* Called with j_list_lock held.
*/
static unsigned long journal_shrink_one_cp_list(struct journal_head *jh,
enum shrink_type type,
+ unsigned long *nr_to_scan,
bool *released)
{
struct journal_head *last_jh;
@@ -377,13 +378,15 @@ static unsigned long journal_shrink_one_cp_list(struct journal_head *jh,
int ret;

*released = false;
- if (!jh)
+ if (!jh || (nr_to_scan && !*nr_to_scan))
return 0;

last_jh = jh->b_cpprev;
do {
jh = next_jh;
next_jh = jh->b_cpnext;
+ if (nr_to_scan)
+ (*nr_to_scan)--;

if (type == SHRINK_DESTROY) {
ret = __jbd2_journal_remove_checkpoint(jh);
@@ -405,7 +408,7 @@ static unsigned long journal_shrink_one_cp_list(struct journal_head *jh,
next:
if (need_resched())
break;
- } while (jh != last_jh);
+ } while (jh != last_jh && (!nr_to_scan || *nr_to_scan));

return nr_freed;
}
@@ -427,7 +430,6 @@ unsigned long jbd2_journal_shrink_checkpoint_list(journal_t *journal,
tid_t first_tid = 0, last_tid = 0, next_tid = 0;
tid_t tid = 0;
unsigned long nr_freed = 0;
- unsigned long freed;
bool first_set = false;

again:
@@ -460,10 +462,9 @@ unsigned long jbd2_journal_shrink_checkpoint_list(journal_t *journal,
next_transaction = transaction->t_cpnext;
tid = transaction->t_tid;

- freed = journal_shrink_one_cp_list(transaction->t_checkpoint_list,
- SHRINK_BUSY_SKIP, &released);
- nr_freed += freed;
- (*nr_to_scan) -= min(*nr_to_scan, freed);
+ nr_freed += journal_shrink_one_cp_list(transaction->t_checkpoint_list,
+ SHRINK_BUSY_SKIP,
+ nr_to_scan, &released);
if (*nr_to_scan == 0)
break;
if (need_resched() || spin_needbreak(&journal->j_list_lock))
@@ -515,7 +516,7 @@ void __jbd2_journal_clean_checkpoint_list(journal_t *journal, bool destroy)
transaction = next_transaction;
next_transaction = transaction->t_cpnext;
journal_shrink_one_cp_list(transaction->t_checkpoint_list,
- type, &released);
+ type, NULL, &released);
/*
* This function only frees up some memory if possible so we
* dont have an obligation to finish processing. Bail out if
--
2.39.5