Skip to content

fix: keep a path-tracked texture published across UpdateTexture's recreate [patch] - #451

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/imguiapp-450-updatetexture-cache-republish
Sep 24, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/imguiapp-450-updatetexture-cache-republish

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #450.

ImGuiApp.UpdateTexture falls back to delete-and-recreate when the size changes or the backend declines an in-place update. That fallback went through DeleteTexture(nint), which drops every cache entry whose handle matches:

Textures.Where(x => x.Value.TextureId == textureId).ToList().ForEach(x => Textures.Remove(x.Key, out ImGuiAppTextureInfo? _));

For a texture that came from GetOrLoadTexture, that is the entry published under its path. UpdateTexture then mutated textureInfo in place with the fresh handle and never restored the entry, so the texture stayed live on the GPU while the cache no longer knew about it.

The change

The fallback captures the keys this instance is published under, deletes, recreates, then restores them.

List<AbsoluteFilePath> publishedKeys = [.. Textures.Where(entry => ReferenceEquals(entry.Value, textureInfo)).Select(entry => entry.Key)];

Two decisions worth a look:

Reference equality, not a handle comparison. Only the entry this exact ImGuiAppTextureInfo is published under should come back. Matching on TextureId would republish an entry that a different info object happened to share a handle with, which is the same class of mistake as the over-broad removal in DeleteTexture that caused this.

The fallback now runs inside one Invoker.Invoke. This is load-bearing rather than tidying: between the delete and the republish the path is absent from the cache, so a concurrent GetOrLoadTexture for it would miss, re-decode the file and upload a second GPU texture — reintroducing the very leak by another route. GetOrLoadTexture already publishes inside its own Invoke for exactly this reason, and its comment spells the hazard out. Invoke bodies never overlap, so nothing can observe the gap. Nested Invoke runs inline on the invoker thread and is already exercised today (GetOrLoadTexture's body calls UploadTextureRGBA, which invokes again). The span copy is hoisted out of the lambda since a ReadOnlySpan<byte> cannot be captured; it is the same ToArray() the recreate path always made.

The <remarks> on UpdateTexture now states that a path-tracked texture stays published across the fallback, since that is now part of the contract.

The other option the issue offered

The issue also proposed throwing when UpdateTexture is handed a path-tracked texture. Not taken: hot-reloading a file-backed image at a new resolution is a reasonable thing to do and nothing in the public API discourages it, so making the two APIs compose is better than forbidding the combination. No public signature changes either way.

Tests

Three added to ImGuiAppTests, next to the existing UpdateTexture_* cases. Verified by stashing the ImGuiApp.cs change and re-running — two fail against the old implementation:

Test Old implementation
UpdateTexture_OnAPathTrackedTexture_KeepsItPublishedInTheCache fails — TryGetTexture returns false for a texture that is loaded and in use
UpdateTexture_OnAPathTrackedTexture_DoesNotUploadADuplicateOnTheNextLoad fails — the next GetOrLoadTexture returns a different instance, having uploaded a second GPU texture
UpdateTexture_OnAnUntrackedTexture_StaysOutOfTheCache passes — a regression guard that the republish restores only entries the instance already had, so a CreateTexture texture still never enters the path-keyed cache

The existing UpdateTexture_WithDifferentSize_RecreatesTexture and UpdateTexture_WhenBackendDeclines_FallsBackToRecreate missed this because both drive a CreateTexture-sourced texture, which was never in the cache to be evicted.

The second test also asserts the path is still among Textures.Keys, which is what CleanupAllTextures iterates — that is the property that makes the handle freeable. It asserts on the keys rather than calling CleanupAllTextures because that method early-returns when gl is null, and these tests register a renderer backend without a GL context.

ImGui.App.Tests: 449 passed, 0 failed, 0 skipped. Whole solution builds with 0 warnings, 0 errors.

ImGuiAppDemo.UITests could not be run here: examples/ImGuiAppDemo/icon.png is a Git LFS pointer and git-lfs is not installed in this container, so the harness fails to decode it during SetUp and every test cascades from that. It is unrelated to this diff — nothing here touches the demo or image decoding — and CI fetches LFS.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjdYTWFr5EVfbmSobYcw3G


Generated by Claude Code

…reate [patch]

UpdateTexture's delete-and-recreate fallback routed through DeleteTexture,
which drops every cache entry pointing at the deleted handle - including the
entry GetOrLoadTexture published for that path - and then never restored it.
The texture stayed live on the GPU while TryGetTexture reported it missing,
the next GetOrLoadTexture uploaded a duplicate, and CleanupAllTextures, which
walks Textures.Keys, could no longer reach either handle to free it.

The fallback now captures the keys the instance is published under, by
reference equality so only its own entries come back, and restores them after
the recreate. Capture, delete, recreate and republish run inside one
Invoker.Invoke for the same reason GetOrLoadTexture publishes inside its own:
between the delete and the republish the path is absent from the cache, so a
concurrent load would otherwise miss and upload a second GPU texture.

Fixes #450

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjdYTWFr5EVfbmSobYcw3G
@sonarqubecloud

Copy link
Copy Markdown

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.

UpdateTexture desyncs the path-tracked texture cache when a texture is resized, leaking GPU textures

1 participant