Skip to content

Wire ImageCache size-variant LRU eviction to fix unbounded memory growth - #15170

Draft
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-3877-image-cache-size-eviction
Draft

Wire ImageCache size-variant LRU eviction to fix unbounded memory growth#15170
warp-agent-staging[bot] wants to merge 2 commits into
masterfrom
factory/app-3877-image-cache-size-eviction

Conversation

@warp-agent-staging

@warp-agent-staging warp-agent-staging Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes unbounded ImageCache growth from unevicted resized/decoded image size variants (APP-3877).

Root cause (confirmed via heap profile analysis of a production Sentry event, ~9.2 GB sampled heap, 77% attributed to zune_jpeg::upsampler::scalar::upsample_vertical): ImageCache stores a RenderedImageCache keyed by (asset hash, RenderedImageCacheKey{bounds, fit_type, animated_image_behavior}). Every time an image is rendered at a new size/fit (e.g. dragging to resize a window with an image in the Markdown viewer, or any UI that renders the same asset at varying dimensions), a fresh decoded+resized Arc<StaticImage> is inserted into this map, but nothing ever evicted old size variants, so the map grew unbounded for the lifetime of the app.

Prep 1 (already merged) added a private evict_size() method, marked #[allow(dead_code)] with a TODO(APP-3877), because the "main changeset" that wires it into ImageCache::image() had not landed. This PR implements that wiring.

Eviction policy

Bounded per-asset LRU cap (MAX_CACHED_SIZES_PER_ASSET = 8): each asset retains at most 8 distinct rendered size variants. Every cached entry now tracks a last_accessed sequence number, bumped on every cache hit or insert. When a cache miss occurs and the asset is already at capacity, image()'s lazy eviction pass finds the least-recently-used variant among that asset's (at most 8) entries and evicts it via evict_size() before inserting the new one.

Why this policy: it directly bounds memory (at most 8 resized copies per live asset, regardless of how many distinct sizes have ever been requested over the asset's lifetime — e.g. during a long window-resize drag), requires no new per-frame hooks (no end_frame/tick plumbing through core/app.rs), and only ever scans the handful of entries belonging to one asset, never the whole cache. I considered a frame-based approach (mirroring TextureCache::end_frame(), evicting anything unused for N frames) but rejected it here: it requires wiring a new per-frame callback through core/app.rs/windowing, and does an eviction scan over the entire cache every frame rather than a bounded, lazy, per-asset check only when a genuinely new size is requested. (I reviewed the two stale, unmerged community attempts at this — warp-external#556 "frame-based pruning" and warp-external#554 "LRU cache cap" — for ideas; both are spec-only with no code. The LRU cap approach here follows warp-external#554's spec.)

Memory bound guaranteed: the per-asset size-variant map can never hold more than 8 entries, so worst-case retained memory for size variants of one asset is bounded by 8 * (largest requested size in bytes), instead of growing with the number of distinct sizes ever requested.

Locking: the hot hit-path and the common miss-below-capacity path are unchanged from before — a single upgradable_readupgrade lock cycle, no extra acquisitions, and no O(n) scan of the whole cache. Only the rare "asset at capacity, new size requested" branch pays one extra lock cycle (evict_size acquires its own write lock, since it can't be called while already holding the write guard from a would-be upgradeparking_lot::RwLock is not reentrant). That branch already pays for a full CPU image decode/resize, so the extra lock cycle is negligible in comparison.

No-thrash verification: legitimate multi-size use (icons rendered at several fixed sizes at once, theme picker previews at different form factors) is unaffected as long as the number of concurrently-displayed sizes for one asset stays at or under 8 — every steady-state frame is a cache hit, so the LRU pass never observes a miss and never evicts. This is covered by test_image_does_not_evict_active_size_variants_at_capacity, which repeatedly re-requests the same 8 sizes across simulated paint frames and asserts every result is Rc::ptr_eq to the original (i.e., served from cache, not recomputed).

Linked Issue

  • Linear: APP-3877
  • Tech spec: specs/APP-3877/TECH.md (Prep 1; this PR implements the "main changeset" it defers to)
  • Not applicable — no GitHub issue is linked for this Linear-tracked bug; no UI changes, so no screenshots.

Changes

  • crates/warpui_core/src/image_cache.rs: added RenderedCacheEntry (wraps the cached Rc<Image> with a last_accessed: Cell<u64> sequence number) and MAX_CACHED_SIZES_PER_ASSET; added ImageCache::next_access_sequence(); wired a lazy LRU eviction pass into ImageCache::image() that calls evict_size() when an asset's cache is at capacity; removed #[allow(dead_code)] and the TODO(APP-3877) from evict_size().
  • crates/warpui_core/src/image_cache_tests.rs: added test_image_evicts_lru_size_variant_when_cache_exceeds_capacity (drives real eviction through image() across more distinct sizes than the cap, and asserts evicted variants' Arc<StaticImage> strong counts reach zero) and test_image_does_not_evict_active_size_variants_at_capacity (asserts no thrash for a steady-state multi-size workload, and proves that cache hits refresh LRU recency by re-hitting the oldest entry, forcing an eviction with a new size, and asserting the refreshed entry survives while the true next-oldest entry is evicted — this fails if the hit-path recency bump is removed).

Testing

  • ./script/format — no changes.

  • cargo clippy -p warpui_core --all-targets --all-features --tests -- -D warnings — clean (scoped to the crate touched; this change does not modify any public API surface of warpui_core consumed elsewhere, so no other crate's clippy pass is affected).

  • cargo nextest run -p warpui_core — 315 tests run, 315 passed, 7 skipped (including the two new tests and the existing evict_size/evict_image direct-call tests, which continue to pass unmodified).

  • No UI/visual changes; this is a pure memory-management fix in ImageCache's internals, so no computer-use verification was performed.

  • I have manually tested my changes locally with ./script/run — not run in this sandboxed environment; verification relied on the unit tests above, which exercise the exact code path (ImageCache::image()) that the affected UI calls every frame.

Screenshots / Videos

Not applicable — no user-visible or UI changes.

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent Mode

CHANGELOG-BUG-FIX: Fixed a memory leak where Warp's image cache would grow without bound when the same image was repeatedly rendered at different sizes (for example, while resizing a window with an image visible).

Co-Authored-By: Warp agent@warp.dev

Fixes unbounded ImageCache growth from unevicted resized/decoded image
size variants. Previously, every render of an asset at a new size/fit
(e.g. dragging to resize a window with an image in the Markdown
viewer) inserted a fresh decoded+resized Arc<StaticImage> into the
per-asset RenderedImageCache map, and nothing ever evicted old size
variants.

evict_size() already existed (Prep 1, APP-3877) but was never called
from production code, so it carried #[allow(dead_code)]. This wires
it into a lazy LRU eviction pass inside ImageCache::image():

- Each cached size variant now tracks a last_accessed sequence number,
  bumped on every hit or insert (RenderedCacheEntry).
- Each asset retains at most MAX_CACHED_SIZES_PER_ASSET (8) size
  variants. On a cache miss once an asset is at capacity, the
  least-recently-used variant is evicted via evict_size() before the
  new one is inserted.
- The eviction check only scans the handful of entries for the one
  asset being requested, not the whole cache, and the common
  (hit / miss-below-capacity) paths still use a single
  upgradable-read-then-upgrade lock cycle, matching the prior
  behavior. Only the rare at-capacity path pays an extra lock cycle,
  which is negligible next to the CPU decode/resize it's paired with.

Removed the #[allow(dead_code)] and TODO(APP-3877) from evict_size now
that it's genuinely exercised by production code.

Added tests verifying: the per-asset cache stays bounded at
MAX_CACHED_SIZES_PER_ASSET under a simulated continuous resize (with
evicted variants' Arc<StaticImage> strong counts reaching zero), and
that legitimately rendering an asset at several fixed sizes at once
does not thrash (no eviction/recompute) as long as the count stays at
or under the cap.

Co-Authored-By: Warp <agent@warp.dev>
@cla-bot cla-bot Bot added the cla-signed label Aug 15, 2026
@warp-agent-staging warp-agent-staging Bot added area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. factory:wilson labels Aug 15, 2026
@warp-agent-staging

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

View run View conversation

- test_image_does_not_evict_active_size_variants_at_capacity now proves
  that cache hits refresh LRU recency, not just that a steady set of
  MAX_CACHED_SIZES_PER_ASSET sizes doesn't thrash: it re-hits the oldest
  entry, forces an eviction with a new size, and asserts the refreshed
  entry survives while the true next-oldest entry is evicted. Verified
  this fails if the hit-path recency bump in image() is removed.
- evict_size's doc comment no longer enumerates its caller; the
  behavior/cascade contract above it already carries that meaning.

Co-Authored-By: Warp <agent@warp.dev>

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Overview

Wires the per-asset LRU eviction pass into ImageCache::image(), bounding rendered size variants at MAX_CACHED_SIZES_PER_ASSET and activating the evict_size() that Prep 1 left dead. The bound holds on every insertion path traced (resized BySize, SVG intrinsic rasterization, and Original when max_dimension forces a resize), the evict_size() call passes resolved cache-key values, and the extra unlock/relock is not a race or deadlock hazard because ImageCache is Rc-backed and foreground-owned; one open question on the chosen cap is left inline for a human decision.

Verdict

Checks: build pass, tests pass (315 passed, 7 skipped in warpui_core), CI green (no failing checks; heavy jobs skipped while draft), visual proof n/a

Found: 0 critical, 0 important, 0 suggestions, 0 nits, 1 question

Responding as wilson: Open session · View factory task

/// UI, or theme picker previews rendered at different form factors). Once an
/// asset has this many cached variants, requesting a new size evicts the
/// least-recently-used one first (see the lazy eviction pass in `image()`).
const MAX_CACHED_SIZES_PER_ASSET: usize = 8;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

question: A cap of 8 turns any surface that renders 9+ concurrent sizes of one asset into a decode-per-frame treadmill. With a stable 9-size set requested each frame, the 9th request evicts the size needed first on the next frame, and every request from then on misses and re-decodes — worse than the leak this fixes. Either name the largest active cardinality you want supported and raise the cap to match, or make the pass frame-aware so it never evicts a variant used in the current frame; a regression test at N = cap + 1 should follow whichever you pick.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. cla-signed factory:wilson

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants