fix(graphics): evict GPU textures when their pixels are freed#1755
Open
cantona wants to merge 2 commits into
Open
fix(graphics): evict GPU textures when their pixels are freed#1755cantona wants to merge 2 commits into
cantona wants to merge 2 commits into
Conversation
The window-level image stores (Sugarloaf::image_data and the renderer's
per-image GPU texture cache) were keyed by a bare numeric id: kitty
images by their protocol id verbatim, atlas graphics (sixel/iTerm2) by
their GraphicId shifted above the u32 range. One window hosts many
terminals (tabs and splits) that all share these stores, but both id
spaces restart per terminal -- a kitty client in each tab numbers its
images from 1, and each terminal's atlas GraphicIds start over too. So
two tabs displaying images collided: tab B's image id 1 overwrote tab
A's, and removals could free the wrong tab's texture.
Replace the flat u64 key with ImageKey { owner, source, id }, where
owner is the originating route id and source distinguishes the atlas
and kitty id spaces. Disjointness now comes from the source
discriminant rather than a numeric shift, so atlas keys carry the raw
GraphicId. The removal queue changes from Vec<u64> to
Vec<GraphicRemoval> (Atlas(GraphicId) | Kitty(u32)) so the frontend,
which owns the route id, builds the matching key; removals are applied
before inserts so an evict-then-retransmit of the same id in one batch
keeps the fresh pixels.
The per-image GPU texture cache was only ever pruned by the byte-budget LRU or an explicit kitty eviction. A texture whose backing image_data entry is gone -- the graphic was freed because an atlas graphic's last referencing cell dropped, or a kitty image was deleted -- stayed resident for the life of the window (upstream raphamorim#1591). Atlas ids are monotonic, so a long session that renders many sixel/iTerm2 images grows the texture cache without bound. Sweep the texture map at the start of prepare against image_data, which is the source of truth: a texture is only inserted for a key present in image_data, and rio drops that entry when the graphic is freed. An off-screen-but-scrollable image keeps its image_data entry and so keeps its texture; only genuinely-freed textures are dropped. The sweep is O(live textures) -- a set this eviction itself keeps small -- so it adds no meaningful per-frame cost.
cantona
force-pushed
the
fix/gpu-texture-leak-sweep
branch
from
July 23, 2026 09:06
4ac295c to
b5819b2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(graphics): evict GPU textures when their pixels are freed
Problem (fixes #1591)
The renderer keeps one GPU texture per standalone image in
Renderer::image_textures. That map is only ever pruned two ways:render_images(evicts the least-recently-drawntextures once the cache exceeds its budget), and
evict_image_texture).Neither reacts to an image being freed. When a graphic goes away — an
atlas graphic's last referencing cell scrolls off the grid, or a kitty
image is deleted — its CPU-side pixels are dropped from
Sugarloaf::image_data, but the GPU texture stays resident for the lifeof the window.
Because atlas ids are monotonic (every sixel/iTerm2 image gets a fresh
GraphicId), a long-running session that renders many images — shellprompts with inline thumbnails, image-heavy TUIs, repeated
icat— growsimage_textureswithout bound. The LRU only caps it at the byte budget;below that ceiling, dead textures accumulate indefinitely.
Fix
Sweep
image_texturesat the start ofprepare, keeping only keys thatstill exist in
image_data:image_datais the source of truth for what pixels exist:image_dataentry (the upload path reads fromimage_data).image_dataentry exactly when the graphic is freed(atlas graphic's last cell dropped, or kitty image deleted).
So a texture with no matching
image_datakey can never be drawn again —it is safe to drop, and doing so is the only thing that reclaims its
VRAM. An image that is merely scrolled off-screen keeps its
image_dataentry (so it can re-upload when scrolled back), and therefore keeps its
texture; only genuinely-freed textures are swept.
Cost
O(live GPU textures) per frame — and this sweep is exactly what keeps
that set small, so it is bounded by real on-screen/scrollback image
count, not by session history. No measurable per-frame overhead.
Testing
cargo fmt --all -- --check— cleancargo clippy -p rioterm --all-features— no new warningscargo test --features wgpu— all suites pass (0 failed)Note
This PR is independent of, but shares files with, my per-terminal image
key change (#1752). It is branched on top of it to avoid a textual
conflict in
renderer/mod.rs; the fix itself does not depend on thatchange and can be rebased onto
maindirectly if #1752 is not taken.