GetOrLoadTexture checked the texture cache, decoded the image and uploaded
a new GPU texture without any synchronisation around the sequence. Two
threads reaching the same uncached path together both missed the cache,
both uploaded a separate texture, and both assigned Textures[path]. Only
the last assignment was reachable, so the other handle could not be found
by DeleteTexture, CleanupAllTextures or ReloadAllTextures and leaked for
the life of the GL context. Background asset loading makes this reachable:
the invoker exists to marshal exactly those calls onto the render thread.
Move the cache re-check and the publish inside the same Invoker.Invoke
that already performs the upload. Invoke bodies never overlap - they run
inline on the invoker thread and are drained one at a time from every
other thread - so the invoker thread becomes the single place a texture
for a path can be created. Decoding deliberately stays outside, since it
touches no GPU state and moving it in would drag every background load
onto the render thread; a racing thread may decode twice, which costs CPU
but leaks nothing.
Locking around the sequence instead would deadlock: a caller holding the
lock blocks inside Invoke waiting for the render thread to pump, and the
render thread would then block on that same lock instead of pumping.
Fixes #405
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyFvutjJ2Po2WmjiL7z9iP
Fixes #405
The bug
GetOrLoadTexturedid an unsynchronised check-then-act: it checkedTextures, and on a miss decoded the image, uploaded a GPU texture viaUploadTextureRGBA, then assignedTextures[path].Two threads reaching the same uncached path together both miss the cache, both upload a separate texture, and both assign
Textures[path]. Only the last assignment is reachable — the other handle can't be found byDeleteTexture,CleanupAllTexturesorReloadAllTextures, and leaks for the life of the GL context. Background asset loading makes this reachable in practice: theInvoker/GL-thread marshaling exists precisely to support calling intoImGuiAppfrom non-UI threads.The fix
Move the cache re-check and the publish inside the same
Invoker.Invokethat already performs the upload.Invokebodies never overlap — they run inline on the invoker thread, and from every other thread they are queued and drained one at a time — so the invoker thread becomes the single place a texture for a given path can be created. The re-check inside that body therefore cannot be raced.Decoding deliberately stays outside the
Invoke: it touches no GPU state, and moving it in would drag every background load onto the render thread. A racing thread may decode the image twice, which costs CPU but leaks nothing.Why not a lock
Guarding the sequence with a per-path lock or
Lazy<T>(as the issue suggests) would introduce a deadlock that doesn't exist today: a caller holding the gate blocks insideInvokewaiting for the render thread to pumpDoInvokes, and the render thread — which callsGetOrLoadTexturefrom application draw code — would then block on that same gate instead of pumping. Serialising on the invoker thread, which the upload already marshals to, gets single-flight semantics with no new blocking primitive.Test
GetOrLoadTexture_WithConcurrentFirstAccessToOnePath_UploadsExactlyOneTexturedrives four threads at one uncached path, with the test thread standing in for the render thread and holding back the pump so every caller lands on the uncached side of the check. It reuses the existingFakeRendererBackendseam to count uploads without a GL context.Verified by reverting the
ImGuiApp.cschange and re-running: fails withexpected: 1, actual: 4, passes with the fix.Checks run
tests/ImGui.App.Tests— 428/428 passtests/ImGui.App.Testing.Tests— 82/82 passtests/ImGuiAppDemo.UITestsfails 26/26 in my container, but it fails identically on the base commit — the failure is an embedded-resource lookup inBuildConfig()duringSetUp, unrelated to this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyFvutjJ2Po2WmjiL7z9iP
Generated by Claude Code