Skip to content

Prune the component index with the scrollback buffer - #265

Merged
sproctor merged 4 commits into
mainfrom
prune-component-index
Aug 10, 2026
Merged

Prune the component index with the scrollback buffer#265
sproctor merged 4 commits into
mainfrom
prune-component-index

Conversation

@sproctor

@sproctor sproctor commented Aug 10, 2026

Copy link
Copy Markdown
Owner

The second leak from the memory-growth investigation. Independent of #264; they touch different files.

The leak

componentLocations maps a component name to the serial numbers of the lines referencing it, and removeLines deliberately left it alone:

// Intentionally leak components here. They don't exist in the main window,
// and no other windows get long enough

On any stream that does carry components, that index grew for the life of the connection — one entry per occurrence, never reclaimed. The consumer already skipped entries below the buffer (if (lineNumber >= 0)), so they were pure retention.

It is pruned against the oldest buffered line now, which ties it to a bound that already exists rather than inventing a new one.

Also quadratic

Entries were held in a Set<Long> rebuilt on every occurrence:

val existingLocations = componentLocations[name] ?: emptySet()
componentLocations[name] = existingLocations + serialNumber

So registering a line copied the entire set, and the copy grew as the leak did. A deque appends in O(1), and because serials are recorded in order, eviction is a prefix drop.

Measured with :compose:streamNetworkBenchmark (2 connections, 6 windows, 4000 lines/sec), append time per ~24k appends:

before after
append ~176-192ms ~153-156ms

Pruning costs less than the rebuild it replaces. Not a controlled A/B — same machine and knobs, but the baseline came from the earlier investigation run — though the direction matches the mechanism, since the old cost rose with the size of the set.

Testing

The test that pinned the old behaviour is inverted rather than deleted: memoryUsagePrunesComponentIndexWithTheBuffer asserts the index tracks bufferedLines (4) instead of the 40 lines appended.

Added componentUpdatesReachRemainingLinesAfterEviction, which is the risk in this change — that pruning disturbs the component-to-line mapping. It appends 10 component lines into a 3-line buffer, updates the component, and asserts all three surviving rows render the new value.

updateComponentRefreshesAllOccurrences still covers the unevicted path. Full suites, ktlint, and the benchmark above.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Prevented stale component references from accumulating after buffered lines are evicted.
    • Preserved component updates across remaining buffered lines after eviction.
    • Improved handling of repeated partial-line registrations.
    • Enforced a maximum buffer size of 1,000,000 lines, including for unbounded settings.
    • Improved memory safety when configuring buffer sizes.
  • Documentation

    • Clarified how component references are tracked and pruned during buffer management.
  • Tests

    • Added coverage for buffer limits, reference pruning, memory-safe sizing, and updates across retained lines.

componentLocations maps a component name to the serials of the lines
referencing it, and removeLines deliberately left it alone:

    // Intentionally leak components here. They don't exist in the main
    // window, and no other windows get long enough

On a stream that does carry components, that index grew for the life of
the connection - one entry per occurrence, never reclaimed. Prune it
against the oldest buffered line instead, which ties it to a bound that
already exists.

The entries were also a Set rebuilt per occurrence
(existingLocations + serialNumber), so registering a line copied the
whole set and the cost grew with it. A deque appends in O(1) and lets
eviction drop a prefix, since serials are recorded in order. The
benchmark's append time fell from ~176-192ms to ~153-156ms per ~24k
appends: pruning costs less than the quadratic rebuild it replaces.

The memory view's "component refs" column keeps its meaning, but now
reads as a check on the pruning rather than a record of the leak.

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: 3cf10e38-a8d0-4676-9d5f-0ab24db4cacd

📥 Commits

Reviewing files that changed from the base of the PR and between 5cde6f8 and 963784a.

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

📝 Walkthrough

Walkthrough

ComposeTextStream now stores component locations in ordered deques, removes stale locations after buffer eviction, and avoids duplicate partial-line registrations. Buffer sizes are capped at 1,000,000 lines. Tests and MemoryUsage documentation cover the updated behavior.

Changes

Component location pruning and buffer limits

Layer / File(s) Summary
Buffer limits and location tracking
compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/ComposeTextStream.kt
Component registrations use ordered serial-number deques and avoid duplicate partial-line entries. Configured buffer limits are capped at 1,000,000 lines.
Eviction pruning and memory reporting
compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/ComposeTextStream.kt, core/src/commonMain/kotlin/warlockfe/warlock3/core/window/MemoryUsage.kt
Evicted lines remove stale component locations. Empty component entries are deleted. Memory reporting uses the effective limit, and documentation describes pruning.
Regression coverage
compose/src/jvmTest/kotlin/ComposeTextStreamTest.kt
Tests cover effective limits, bounded capacities, component-index pruning, multiple references per line, and updates to retained lines.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • sproctor/warlock3#263: Introduced the related component-reference tracking, memory reporting, and tests.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 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 pruning the component index with the scrollback buffer, which is the main change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch prune-component-index

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: 2

🤖 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/ComposeTextStream.kt`:
- Around line 267-299: Update removeLines() and pruneComponentLocations() to
prune only when lines are actually evicted, avoiding a full scan of
componentLocations after every append. Track the evicted cached lines and remove
locations only for component names referenced by those lines, while preserving
cleanup of empty component-location entries.

In `@core/src/commonMain/kotlin/warlockfe/warlock3/core/window/MemoryUsage.kt`:
- Around line 25-27: Update the documentation for componentReferences in
MemoryUsage so it no longer treats counts above bufferedLines as evidence that
pruning failed. Explain that a buffered line may reference multiple distinct
server components, and describe anomalously high values relative to expected
component occurrences while preserving the existing buffer-pruning context.
🪄 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: 8b648c36-4d61-40a5-86ae-2d1d11248fe5

📥 Commits

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

📒 Files selected for processing (3)
  • compose/src/commonMain/kotlin/warlockfe/warlock3/compose/ui/window/ComposeTextStream.kt
  • compose/src/jvmTest/kotlin/ComposeTextStreamTest.kt
  • core/src/commonMain/kotlin/warlockfe/warlock3/core/window/MemoryUsage.kt

Comment thread core/src/commonMain/kotlin/warlockfe/warlock3/core/window/MemoryUsage.kt Outdated
A scrollback of zero or less meant an unbounded buffer, and every line
is held twice over - the rendered line plus the source it was rendered
from - for every window of every connection. Cap eviction at a million
lines, which is far past any use for scrollback, so a setting near it is
a mistake rather than a preference. Lines past it are dropped silently:
there is nothing useful to tell a user who will never reach it.

Kept to the pruning path. The setting is untouched, stores whatever it
stored before, and the settings screen says nothing new; the buffer
simply stops honouring a number it should never have honoured. The
memory view reports the cap in force rather than the raw setting, so its
"max" column cannot claim a bound the buffer would not keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sproctor added a commit that referenced this pull request Aug 10, 2026
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>

@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/ComposeTextStream.kt`:
- Around line 760-771: The ComposeTextStream buffer construction currently uses
raw maxLines, allowing negative capacities and excessive allocation. Update the
line-buffer deque initialization in ComposeTextStream to use
effectiveMaxLines(maxLines), or no-argument constructors where appropriate, for
both deques; add stream-construction tests covering negative and oversized
settings alongside the existing effectiveMaxLines tests.
🪄 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: 0a0f5960-3f2e-4d47-97b1-5d0cb75517b6

📥 Commits

Reviewing files that changed from the base of the PR and between 22497eb and 38cb86c.

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

sproctor and others added 2 commits August 10, 2026 08:22
Review points on the pruning path.

removeLines pruned on every append, including the ones that evicted
nothing. Guard it, which is free once the buffer is full and saves the
walk during warmup.

The suggested per-evicted-line prune is not taken: it re-derives that
line's components on eviction and rests on the front entry belonging to
that line, which partial lines - registered per increment, cached as the
accumulation - make harder to verify, in exchange for dropping a walk
over the small fixed set of component ids the server actually sends. The
benchmark could not separate the two; the spread between runs of
identical code was wider than the gap between them.

Also correct the memory view's doc: a line can reference several
components, so the count legitimately sits above bufferedLines, and the
comment said a count above it meant pruning had failed. Test added for
that, which also covers a line carrying two components being pruned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ArrayDeque allocates its initial capacity immediately and rejects a
negative one outright, and the setting reached it unfiltered: a negative
scrollback threw IllegalArgumentException while the stream was being
built, taking the window with it, and an oversized one reserved the
whole array before a single line arrived.

Capping eviction made negative values meaningful rather than a synonym
for unbounded, so the constructor became the one place that still could
not survive them. Size from initialBufferCapacity instead, which clamps
to the effective cap and then to something modest: a deque grows
amortized, so starting under an unusually long buffer costs a few
copies, while starting over it costs the whole allocation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sproctor
sproctor merged commit 10c80d3 into main Aug 10, 2026
2 checks passed
sproctor added a commit that referenced this pull request Aug 10, 2026
* Bound the lazy list's item key space

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>

* Keep the key fold correct at any span

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>

* Drop the guard for a serial span that cannot happen

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>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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