Skip to content

Bound the lazy list's item key space - #264

Merged
sproctor merged 3 commits into
mainfrom
bound-lazy-item-keys
Aug 10, 2026
Merged

Bound the lazy list's item key space#264
sproctor merged 3 commits into
mainfrom
bound-lazy-item-keys

Conversation

@sproctor

@sproctor sproctor commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Fixes the unbounded growth found while investigating the memory-growth report.

The leak

Compose caches one CachedItemContent per distinct item key in LazyLayoutItemContentFactory, and never evicts an entry — the map is only ever written to, and the DisposableEffect there clears the content lambda but leaves the entry behind. That is fine for a list with finite keys. Stream rows were keyed by line serial number, which only counts up, so every line ever displayed left an entry (plus its boxed Long key) for as long as the window stayed open.

Measured by replaying protocol through the real app and sampling jcmd GC.class_histogram, four samples about a minute apart:

sample 1 sample 2 sample 3 sample 4
CachedItemContent 3,876 51,782 97,206 140,031
java.lang.Long 8,311 56,309 101,613 144,509
StreamTextLine 14,684 14,665 10,115 13,138

Linear, no plateau, while the line buffers themselves stayed flat — the scrollback caps were working; the cache underneath them was not.

Not a regression from any recent change: v3.0.166 keyed rows the same way, and LazyLayoutItemContentFactory.kt is byte-identical between Compose 1.10.3 and 1.11.1. What varies is the rate, which scales with how many windows are composing lines.

The fix

Item keys only have to be distinct among the rows present at once — a key reused after its line is gone just reuses the cache entry, which is the point. So the serial numbers are folded into a bounded space.

Displayed rows occupy a contiguous serial range (a name filter only removes lines from inside it), so any modulus wider than that range keeps them distinct. lazyItemKeyModulus takes twice the range, floored at 4096 and rounded up to a power of two, so with the default 2000-line scrollback it settles during warmup and then never changes — a change would re-key every row, costing one full recomposition of the list.

Same replay after the fix: 2,261 → 4,082 → 4,086 → 4,086. Bounded by the modulus rather than growing ~45k entries a minute.

Why not just drop the key

Index keys are bounded too, but the keys are what anchor the view when lines are trimmed off the front of the buffer. Without them, a reader scrolled up in the scrollback drifts by a line every time one is evicted, and per-row remembered state gets reused for the wrong line.

Testing

LazyItemKeyTest covers the invariant the fix depends on — that no two rows on screen share a key:

  • the span measures the serial range, not the row count, so a filtered list is still covered
  • the modulus always exceeds the span it has to separate
  • keys are distinct across a full buffer (1 … 20,000 rows) starting from a high serial offset, not just from zero
  • keys do recycle, so the space stays bounded
  • the default scrollback maps to a single stable modulus, so rows are not re-keyed as the buffer fills

Plus the existing suites, ktlint, and the replay measurement above.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved scrolling performance by limiting retained list-item cache entries.
    • Preserved reliable row identification as items are displayed, recycled, and revisited during scrolling.
    • Improved behavior during extended scrolling and larger buffer configurations.
  • Tests

    • Added coverage for item-key uniqueness, stability, bounded key ranges, and recycling behavior across different scrolling conditions.

Compose caches one CachedItemContent per distinct item key in
LazyLayoutItemContentFactory and never evicts an entry - the map is only
ever written to; the DisposableEffect there clears the content lambda
but leaves the entry. Keying stream rows by serial number, which only
counts up, therefore retained one entry plus its boxed key per line ever
displayed, for as long as the window was open.

Measured by replaying protocol through the app and sampling
GC.class_histogram: CachedItemContent went 3,876 -> 51,782 -> 97,206 ->
140,031 over four samples a minute apart, climbing linearly, while
StreamTextLine stayed flat at ~13k. The buffers were doing their job;
the cache underneath them was not.

Keys only have to be distinct among the rows on screen at once, so fold
them into a bounded space and let a key be reused once its line is gone.
The rows occupy a contiguous serial range, so any modulus wider than
that range separates them; this takes twice the range, rounded up to a
power of two so it settles during warmup and then stops changing (a
change re-keys every row, which costs a full recomposition).

Same replay after: 2,261 -> 4,082 -> 4,086 -> 4,086. Bounded by the
modulus instead of growing about 45k entries a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dbc051c-950f-47f0-b04a-4d642c436896

📥 Commits

Reviewing files that changed from the base of the PR and between c46f446 and bad981a.

📒 Files selected for processing (2)
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt
  • compose/src/jvmTest/kotlin/LazyItemKeyTest.kt
🚧 Files skipped from review as they are similar to previous changes (2)
  • compose/src/jvmTest/kotlin/LazyItemKeyTest.kt
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt

📝 Walkthrough

Walkthrough

Lazy-list item keys now use a bounded modulus based on the displayed serial range. Helpers select power-of-two moduli. JVM tests cover modulus bounds, uniqueness, stability, and key reuse.

Changes

Lazy-list key management

Layer / File(s) Summary
Bounded key calculation
compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt
Row keys fold serial numbers with a bounded modulus. Helper functions calculate the serial span and select power-of-two modulus values.
Key behavior validation
compose/src/jvmTest/kotlin/LazyItemKeyTest.kt
Tests verify serial-span measurement, modulus bounds, stability, key uniqueness, and key-space reuse.

Estimated code review effort: 2 (Simple) | ~15 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: bounding the lazy list item key space.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bound-lazy-item-keys

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt`:
- Around line 655-658: Update the modulus calculation around the visible while
loop to prevent duplicate LazyColumn keys when serialSpan exceeds
MAX_LAZY_ITEM_KEY_MODULUS: enforce a displayed-span limit before calculating the
modulus or otherwise ensure the active key range remains below the cap, and
avoid serialSpan * 2 overflow by using overflow-safe bounds. Preserve unique
keys for spans of MAX_LAZY_ITEM_KEY_MODULUS + 1 and overflow-sized spans, and
add regression tests covering both cases.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 93e36265-ec1a-48df-a5bb-6e318e05d25c

📥 Commits

Reviewing files that changed from the base of the PR and between 0d4eb90 and da7d306.

📒 Files selected for processing (2)
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt
  • compose/src/jvmTest/kotlin/LazyItemKeyTest.kt

Review catch: the modulus cap could return a value that collides. Above
MAX_LAZY_ITEM_KEY_MODULUS the loop stopped at the cap even though the
span exceeded it, and `serialSpan * 2` overflowed negative past
Long.MAX_VALUE / 2, ending the loop immediately and handing back the
4096 floor. Either way the fold returned a modulus that maps two
on-screen rows to one key, which is the one thing it must not do.

Neither is reachable - both need more lines than fit in memory - but a
function whose job is distinct keys should not have a size at which it
quietly stops. Compare by halving the modulus instead of doubling the
span, so nothing overflows, and past the point where doubling would
overflow return Long.MAX_VALUE, where the modulo is the identity: no
folding, but no collision either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
compose/src/jvmTest/kotlin/LazyItemKeyTest.kt (1)

35-39: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the required key reuse distance.

Lines 35-39 only require modulus > span. A regression that returns a modulus where span < modulus <= 2 * span will pass this test, but it will recycle keys earlier than lazyItemKeyModulus permits.

Assert exact boundary values, such as 2_047L -> 4_096L and 2_048L -> 8_192L. Also assert modulus / 2 > span when the modulus is not Long.MAX_VALUE.

Proposed test change
-        for (span in listOf(0L, 1L, 100L, 2_000L, 4_095L, 4_096L, 10_000L, 250_000L)) {
+        for ((span, expectedModulus) in listOf(
+            0L to 4_096L,
+            2_000L to 4_096L,
+            2_047L to 4_096L,
+            2_048L to 8_192L,
+            4_095L to 8_192L,
+            4_096L to 16_384L,
+        )) {
             val modulus = lazyItemKeyModulus(span)
-            assertTrue(modulus > span, "modulus $modulus must exceed span $span to keep keys distinct")
+            assertEquals(expectedModulus, modulus)
+            assertTrue(modulus / 2 > span)
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@compose/src/jvmTest/kotlin/LazyItemKeyTest.kt` around lines 35 - 39,
Strengthen modulus assertions in modulusExceedsTheSpanItHasToSeparate by
verifying exact boundary mappings such as 2_047L to 4_096L and 2_048L to 8_192L,
and assert modulus / 2 > span whenever the result is not Long.MAX_VALUE. Retain
coverage of the existing span values while checking the required key reuse
distance rather than only modulus > span.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@compose/src/jvmTest/kotlin/LazyItemKeyTest.kt`:
- Around line 35-39: Strengthen modulus assertions in
modulusExceedsTheSpanItHasToSeparate by verifying exact boundary mappings such
as 2_047L to 4_096L and 2_048L to 8_192L, and assert modulus / 2 > span whenever
the result is not Long.MAX_VALUE. Retain coverage of the existing span values
while checking the required key reuse distance rather than only modulus > span.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 80ebef49-7c4f-472e-bf35-49ef28447481

📥 Commits

Reviewing files that changed from the base of the PR and between da7d306 and c46f446.

📒 Files selected for processing (2)
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt
  • compose/src/jvmTest/kotlin/LazyItemKeyTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/WindowViewScaffold.kt

removeLines now caps the buffer, so the displayed rows come out of at
most about a million lines and the span they occupy is bounded with
them. The escape hatch for a span too wide to fold - and the reasoning
about overflowing a doubled span - was defending a case the buffer no
longer allows.

The comparison keeps halving the modulus instead of doubling the span:
same result, and it stays that way whatever the cap becomes.

Note this now leans on the buffer cap in #265. Merged the other way
round, an unbounded scrollback would grow the span until the doubling
overflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sproctor
sproctor merged commit e7b56a4 into main Aug 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant