What happens
GetOrLoadTexture (ImGui.App/ImGuiApp.cs, lines 1630-1658) does an unsynchronized check-then-act on Textures (a ConcurrentDictionary): it checks Textures.TryGetValue(path, ...), and on a miss decodes the image and uploads a new GPU texture via UploadTextureRGBA (which marshals to the GL thread via Invoker.Invoke), then writes Textures[path] = textureInfo. There is no lock around the whole load-and-publish sequence.
Failure scenario
Two threads call GetOrLoadTexture(path) for the same not-yet-cached path at nearly the same time — plausible, since the Invoker/GL-thread marshaling machinery exists specifically to support calling into ImGuiApp from non-UI threads (e.g. background asset loading). Both threads miss the cache, both decode the image and upload a separate GPU texture, and both assign Textures[path]. Whichever assignment happens last wins; the other upload's GPU handle is never stored anywhere, so it isn't reachable by DeleteTexture(path), CleanupAllTextures(), or ReloadAllTextures() — it leaks for the life of the GL context.
Suggested fix
Guard the check-and-upload-and-publish sequence per path, e.g. ConcurrentDictionary<AbsoluteFilePath, Lazy<ImGuiAppTextureInfo>>, or a per-path lock/SemaphoreSlim, so only one load/upload happens per path even under concurrent first access.
Acceptance criteria
- Concurrent calls to
GetOrLoadTexture for the same uncached path result in exactly one GPU upload for that path.
- A test covers concurrent first-access calls to
GetOrLoadTexture with the same path.
What happens
GetOrLoadTexture(ImGui.App/ImGuiApp.cs, lines 1630-1658) does an unsynchronized check-then-act onTextures(aConcurrentDictionary): it checksTextures.TryGetValue(path, ...), and on a miss decodes the image and uploads a new GPU texture viaUploadTextureRGBA(which marshals to the GL thread viaInvoker.Invoke), then writesTextures[path] = textureInfo. There is no lock around the whole load-and-publish sequence.Failure scenario
Two threads call
GetOrLoadTexture(path)for the same not-yet-cached path at nearly the same time — plausible, since theInvoker/GL-thread marshaling machinery exists specifically to support calling intoImGuiAppfrom non-UI threads (e.g. background asset loading). Both threads miss the cache, both decode the image and upload a separate GPU texture, and both assignTextures[path]. Whichever assignment happens last wins; the other upload's GPU handle is never stored anywhere, so it isn't reachable byDeleteTexture(path),CleanupAllTextures(), orReloadAllTextures()— it leaks for the life of the GL context.Suggested fix
Guard the check-and-upload-and-publish sequence per path, e.g.
ConcurrentDictionary<AbsoluteFilePath, Lazy<ImGuiAppTextureInfo>>, or a per-path lock/SemaphoreSlim, so only one load/upload happens per path even under concurrent first access.Acceptance criteria
GetOrLoadTexturefor the same uncached path result in exactly one GPU upload for that path.GetOrLoadTexturewith the same path.