Skip to content

BaseImageCache never evicts, and holds readable textures for the process lifetime #43

Description

@Laumania

SDK version: Unity plugin v2026.7
Impact: unbounded memory growth in any title that lets players browse mods
Severity: measured 190 MB retained after a single Workshop visit, never reclaimed

The problem

Modio/Images/BaseImageCache.cs:

readonly Dictionary<ImageReference, (Error, T)> _cache = new Dictionary<ImageReference, (Error, T)>();

It is written in two places (DownloadImageInternal, LoadFromDiskCache) and read in two. There is
no Remove, no Clear, no capacity bound, no WeakReference, no Dispose anywhere in the type
or its subclasses. Nothing evicts on scene change, on leaving the browser, or under memory pressure.

Notably, every other cache in the SDK does clean up — Caching/BaseCache.cs and
Caching/ModCache.cs both subscribe to ModioClient.OnShutdown. BaseImageCache does not. That
looks like an oversight rather than a decision.

Two things compound it:

  1. The textures are created readable. Unity/ImageCacheTexture2D.cs:

    var texture = new Texture2D(0, 0);
    bool success = texture.LoadImage(rawBytes, false);   // markNonReadable: false

    So every image costs twice its decoded size — a CPU pixel copy plus the GPU copy — even though
    nothing reads the pixels back except the optional disk-cache path.

  2. ModContent_ModImageGallery.prefab ships with ModioUIModGallery._resolution set to
    GalleryResolution.Original
    , so the details gallery downloads full-size uploads rather than the
    1280x720 thumbnail. The component's own C# default is X1280_Y720; the shipped prefab overrides
    it upward. Per-image cost then scales with whatever creators upload — a 4K screenshot costs a
    viewer ~63 MB, permanently.

Measured

Unity 6 (6000.3.11f1), editor, real authenticated session. Census over runtime-created Texture2Ds
via Resources.FindObjectsOfTypeAll<Texture2D>(), sized with Profiler.GetRuntimeMemorySizeLong:

stage textures retained
browser never opened 0 0 MB
browser landing screen, no scrolling 53 87.9 MB
+ one mod's 10 screenshots viewed 63 190.0 MB
browser closed, GC.Collect() + Resources.UnloadUnusedAssets() 63 190.0 MB, unchanged

GetRuntimeMemorySizeLong reports exactly 2× the raw w × h × bpp for each, which is the readable
double-cost above. mipmapCount is 1, so this is not mip overhead.

Breakdown of the 87.9 MB landing screen: 43 row thumbnails at 320×180 = 18.4 MB, and 10 featured
tiles at 1280×720 = 69.4 MB
. The 102 MB from ten screenshots is the Original gallery setting.

Reproduction

  1. Open the mod browser.
  2. Open one mod's details and page through its screenshots.
  3. Close the browser, force a GC and Resources.UnloadUnusedAssets().
  4. Enumerate Texture2Ds — every image is still resident, and stays resident until the process exits.

Suggested fix

The minimal, source-compatible change is an eviction entry point. Modio.asmdef sets
noEngineReferences: true, so the base assembly cannot destroy a Texture2D itself — returning the
images for the caller to release keeps that boundary intact:

// in BaseImageCache<T>
public List<T> ClearCachedImages()
{
    var clearedImages = new List<T>(_cache.Count);

    foreach ((Error, T) cached in _cache.Values)
    {
        if (cached.Item2 != null)
            clearedImages.Add(cached.Item2);
    }

    _cache.Clear();

    return clearedImages;
}
// in ImageCacheTexture2D, or wherever the host application drives it
foreach (Texture2D texture in ImageCacheTexture2D.Instance.ClearCachedImages())
    UnityEngine.Object.Destroy(texture);

Removing the dictionary entries is the load-bearing half, not the destroy. T is unconstrained, so
every image != null in GetFirstCachedImage and in LazyImage is a plain reference comparison,
not UnityEngine.Object's overload — a texture destroyed while still mapped would pass those guards
and be assigned onto a live RawImage. Any eviction you add must remove the entry.

Three things we found that a fix should be careful about:

  • Don't clear _ongoingDownloads. Dropping an in-flight entry lets the next DownloadImage start
    a second download for the same URL; both completions write _cache[uri] and one texture is orphaned
    with nothing left holding it.
  • Don't clear PendingDiskSaves. ModioImageSource latches _isCachingLowestResolution, so a
    dropped entry is never re-queued and that mod loses its offline logo for the session. Clearing
    _cache alone merely defers the save: CacheToDiskInternal returns false, the reference goes
    back into PendingDiskSaves, and the next download redeems it.
  • A time- or count-based LRU would be unsafe as-is, because the cache is never told when a
    consumer stops displaying an image — there is no release, no ref count, and none of the UI
    components tear down. Eviction cannot currently know whether the texture it is dropping is on
    screen. An explicit entry point that the host application calls at a known-safe moment (we use
    LoadSceneMode.Single) is the safe shape until a release API exists.

Also worth considering: markNonReadable: true in ImageCacheTexture2D.Convert would halve the cost
outright. The blocker is ConvertToBytesTexture2D.EncodeToPNG(), which needs a readable texture.
That path is only reached when CacheToDisk is called for an image already in memory — the download
path already writes the original bytes straight to disk. Keeping the source bytes for the
disk-cache candidates (or re-fetching them) would let every texture be non-readable.

And the shipped ModContent_ModImageGallery.prefab should arguably default to X1280_Y720 to match
the component's own C# default, rather than Original.

Unrelated bug found while investigating

DownloadImageInternal removes from _ongoingDownloads on its success and error paths, but has no
try/finally. If ModioClient.Api.DownloadFile, stream.CopyToAsync or Convert throws — a
network drop mid-transfer is the realistic case — the faulted Task stays in _ongoingDownloads
forever, and every later DownloadImage for that URL returns the same faulted task, rethrowing into
LazyImage.SetImage's async void. That image can never load again for the rest of the session. A
try/finally around the removal would fix it.

Relatedly, neither the Stream from DownloadFile nor the MemoryStream is disposed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions