Skip to content

Fix Image memory leak in map preview cache via ref-counted leases#1014

Open
Copilot wants to merge 3 commits into
developfrom
copilot/fix-image-dispose-issue
Open

Fix Image memory leak in map preview cache via ref-counted leases#1014
Copilot wants to merge 3 commits into
developfrom
copilot/fix-image-dispose-issue

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 15, 2026

Summary

Map preview Image objects were leaking — never disposed on LRU eviction — and a naive fix would introduce use-after-free: a caller could hold a reference to an Image while the cache evicts and disposes it concurrently on the worker thread.

This PR solves both problems with a ref-counted lease pattern and reorganises the cache infrastructure into the shared ClientCore project.


Problem

MapPreviewCacheManager cached SixLabors.ImageSharp.Image objects in an LRU dictionary. When an entry was evicted it was simply removed from the dictionary without calling Image.Dispose(), leaking native pixel memory. Callers held a direct reference to the Image from the cache — there was no mechanism to delay disposal until all callers were done.


Solution: ClientCore.Caching — reusable ref-counted cache infrastructure

RefCountedValue<T> (internal)

Thread-safe ref-counted wrapper around a cached value. Starts at count = 1 (the cache's own reference). AcquireLease() atomically increments the count and returns a CacheLease<T> for the caller. Release() atomically decrements; when the count reaches zero the disposeAction is invoked. Finalizer is intentionally absent — the type holds only managed references and T (e.g. Image) has its own GC backstop.

CacheLease<T> (public, IDisposable)

The caller-facing handle returned by ICacheManager.Request(...). Designed for using blocks. Dispose() is idempotent via Interlocked.Exchange. Two creation paths:

  • Ref-counted (CacheLease(RefCountedValue<T>)) — for items managed by the cache; Dispose calls RefCountedValue<T>.Release().
  • Directly-owned (CacheLease<T>.CreateOwned(value, onRelease)) — for values the caller owns outright (e.g. an immediately-loaded preview image not stored in the LRU cache).

The two classes have distinct roles and cannot be collapsed into one: RefCountedValue<T> is the single shared ownership tracker (one per cached entry); CacheLease<T> is the per-caller handle (one per AcquireLease() call).

CacheManagerBase<TInput, TOutput> (public abstract)

Thread-safe LRU cache with a background worker thread that computes outputs without blocking callers. No TOutput constraint — works for any type. Eviction releases the cache's own reference; if a caller still holds a lease the value stays alive until that lease is disposed. A protected virtual Action? GetDisposeAction(TOutput?) hook (returning null by default) lets subclasses inject cleanup without the base class knowing about IDisposable.

DisposableCacheManagerBase<TInput, TOutput> (public abstract)

Thin subclass where TOutput : IDisposable. Overrides GetDisposeAction to return value.Dispose, ensuring each Image (or other IDisposable output) is disposed exactly once when its ref count reaches zero.

Class hierarchy

ClientCore.Caching.ICacheManager<TInput, TOutput>              // no TOutput constraint
    └─ CacheManagerBase<TInput, TOutput>                       // virtual GetDisposeAction() => null
           └─ DisposableCacheManagerBase<TInput, TOutput>      // where TOutput : IDisposable
                  └─ DTAClient.Domain.Multiplayer.MapPreviewCacheManager

Namespace assignment

Type Namespace Project
ICacheManager<TInput, TOutput> ClientCore.Caching ClientCore
CacheManagerBase<TInput, TOutput> ClientCore.Caching ClientCore
DisposableCacheManagerBase<TInput, TOutput> ClientCore.Caching ClientCore
CacheLease<T> ClientCore.Caching ClientCore
RefCountedValue<T> ClientCore.Caching ClientCore
IMapPreviewCacheManager DTAClient.Domain.Multiplayer DXMainClient
MapPreviewCacheManager DTAClient.Domain.Multiplayer DXMainClient

ClientCore.Caching follows the existing sub-namespace convention (ClientCore.Statistics, ClientCore.Extensions, etc.) and clearly scopes the folder's purpose. Map-specific types stay in DXMainClient because they depend on Map, Image, and other DXMainClient-only types.


Thread-safety guarantee

Leases are acquired inside cacheLock. This means the caller's ref-count increment is visible before any concurrent eviction can call Release() on the same entry — eliminating the use-after-free window that would exist if the lease were acquired after releasing the lock.


Files changed

Added to ClientCore/Caching/

  • ICacheManager.cs
  • CacheManagerBase.cs
  • DisposableCacheManagerBase.cs
  • CacheLease.cs
  • RefCountedValue.cs

Removed from DXMainClient/Domain/Multiplayer/

  • ICacheManager.cs, CacheManagerBase.cs, DisposableCacheManagerBase.cs, CacheLease.cs, RefCountedValue.cs

Updated in DXMainClient/Domain/Multiplayer/

  • IMapPreviewCacheManager.csusing ClientCore.Caching;
  • MapPreviewCacheManager.csusing ClientCore.Caching;
  • MapLoader.csusing ClientCore.Caching;; use CacheLease<Image>.CreateOwned for directly-owned images

@SadPencil SadPencil marked this pull request as ready for review May 15, 2026 20:58
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.

2 participants