CNA-BUG-021: ContentManager::Unload() only clears the cache; it disposes none of the assets it loaded

CNA snapshot 009d40f5  ·  Known Issues › Current bugs  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.

Unload() is loadedAssets_.clear(): no IDisposable is tracked or disposed, so assets a game still references stay alive and undisposed, where XNA's Unload disposes everything the manager loaded.

Identifier
CNA-BUG-021
Category
Bug
Subsystem
Content & XNB/CNB/CNJ
Status
Open
Verified against
CNA 009d40f5 (009d40f5dd085c4e674d3479675fac84b12b3e0a)
Severity
Medium (a triage suggestion, not a project priority)
Evidence basis
Source-verified: read at TARGET, not executed
Tests touching this area
Yes: see Current tests
Affected contract
ContentManager::Unload(), ContentManager::Dispose() and the protected ContentManager::ReadAsset (its recordDisposableObject contract)

Expected behaviour

XNA 4.0's ContentManager.Unload "disposes all data that was loaded by this ContentManager": it calls Dispose on every tracked IDisposable and then clears its tables, and ReadAsset with a null recordDisposableObject records disposables in that list. CNA's own header says Unload "unloads all cached assets and frees the associated resources", and the ReadAsset<T> documentation says that with an empty callback "this manager records them itself, as Load<T>() does".

Actual behaviour at TARGET

ContentManager::Unload (ContentManager.cpp) is loadedAssets_.clear(), and Dispose(true) only calls it. No disposal list exists: ContentReader::RecordDisposable (ContentReader.hpp) forwards to an explicit callback when one is given and otherwise does nothing; its comment says the ContentManager fallback "is deferred to Phase B2 (XNB-17B), which is when ContentManager gains that tracking mechanism at all". Load<T> passes an empty callback, so nothing is ever recorded.

Because Load<T> returns shared-owned values, dropping the cache's copy frees a resource only when the game holds no other copy. A Texture2D the game still holds after Unload() keeps its renderer resource and reports getIsDisposedProperty() == false (each GraphicsResource copy carries its own disposal flag). XNA code that relies on Unload to release a level's textures while stale references linger, or that expects a use-after-unload to throw ObjectDisposedException, sees the memory retained and the use succeed.

Source locations

Evidence

Checked by reading at 009d40f5; not executed. XNA behaviour taken from the decompiled XNA 4.0 ContentManager (Unload, ReadAsset). The site's ContentManager guide already describes the CNA behaviour ("A copy you already hold stays valid"), and the content-runtime internals page warns not to assume a blanket disposal guarantee; neither page is contradicted here, but CNA's own header documentation is.

Independently observed as a separate finding (merged): ReadAsset documents that with an empty callback the manager records disposables itself, as Load does, and ContentReader that it falls back to its manager, but neither Load nor the manager records any disposable at all.

Focused reproduction

Illustrative; not compiled.

ContentManager content(&services, "Content");
content.setGraphicsDevice(device);
Graphics::Texture2D tiles = content.Load<Graphics::Texture2D>("level1/tiles");
content.Unload();
// XNA: tiles.IsDisposed is true, drawing with it throws ObjectDisposedException.
bool disposed = tiles.getIsDisposedProperty();   // false at 009d40f5; GPU memory still held

Current tests

ContentManagerXnbTests.cpp (UnloadClearsXnbCachedAssets) and ContentManagerTexture2DXnbTests.cpp (UnloadClearsTheTextureCache) prove that the cache is dropped and a reload produces a new renderer resource; neither checks the held copy's disposal. ContentRuntimeContractTests.cpp proves only that Dispose() routes through Dispose(bool).

Regression test

Load a Texture2D, keep the copy, call Unload(), and require copy.getIsDisposedProperty() to be true (and its Disposing event to have fired); repeat through ReadAsset<T>(name, {}). Because each copy owns its own disposal flag, the fix needs a shared resource identity between the cached value and the returned copies (the GraphicsResource::ShareResourceIdentityWith seam exists for that), not just a list of cached values.

Blast radius

Every IDisposable asset loaded through a ContentManager or ResourceContentManager: textures, effects, fonts and model buffers. Games that drop all of their copies still get the memory back through shared ownership; games that keep references past Unload() (per-level managers with long-lived caches, sprite lists) do not, and code that checks IsDisposed after an unload reads the wrong answer. SoundEffect is never cached by the manager and is not affected.

Workaround

Reset every holder of a loaded asset when unloading, or call Dispose() on the resources you still hold before Unload().

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Known issues
Bug index