CNA-BUG-021: ContentManager::Unload() only clears the cache; it disposes none of the assets it loaded
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
modules/content/src/Xna/ContentManager.cpp— ContentManager::Unload and ContentManager::Dispose(bool)modules/content/include/Microsoft/Xna/Framework/Content/ContentReader.hpp— ContentReader::RecordDisposable - no ContentManager fallbackmodules/content/include/Microsoft/Xna/Framework/Content/ContentManager.hpp— ContentManager::ReadAsset and Unload documentationmodules/graphics/include/Microsoft/Xna/Framework/Graphics/GraphicsResource.hpp— GraphicsResource copy semantics - per-copy disposal state
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().
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- ContentManager: caching and lifetime
- Architecture
- Content architecture: the cache
- Known issues
- Bug index