CNA-BUG-029: A copied Texture2D or TextureCube raises GraphicsDevice.ResourceDestroyed without a matching ResourceCreated, and a copy taken after its device died still reaches the device on disposal
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 c1c316b9; unless a sentence says otherwise, nothing here was built or executed. Nothing on this page was executed unless the Evidence section says so.
Texture copies are now registered with their device, but a copy never raises ResourceCreated while its disposal raises ResourceDestroyed, and a copy taken after the device was destroyed still touches the dead device's texture slots on disposal.
- Identifier
CNA-BUG-029- Category
- Bug
- Subsystem
- Graphics & renderers
- Status
- Narrowed (partially fixed; describes only what survives)
- Verified against
- CNA
c1c316b9(c1c316b9c7a846ce8002809c151fcd1af14942c9) - Severity
- Low (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
- GraphicsDevice.ResourceCreated / GraphicsDevice.ResourceDestroyed; Texture2D(const Texture2D&), TextureCube(const TextureCube&); GraphicsResource copy constructor; Texture::Dispose(bool)
Expected behaviour
Every wrapper a device tracks should produce a balanced event stream: a listener that counts ResourceCreated and ResourceDestroyed (a leak tracker, the Inspector, a game's own resource census) should see one destroy per create. And disposing a texture wrapper must never touch a GraphicsDevice that no longer exists: GraphicsDevice::AddResourceReference is documented as the registration that lets the device “dispose registered resources before its own renderer is destroyed, preventing use-after-free”.
Actual behaviour at TARGET
- Fixed since the finding was first recorded: moves (
TransferResourceReference,TransferMovedTexture, gated on the weakgraphicsDeviceLifetime_token) and, since MSR-027 (af3c5037c), copies.Texture2DandTextureCubenow define their copy constructor and copy assignment (Texture2D.cpp,TextureCube.cpp): a copy callsAddResourceReferenceon a live device, and a copy assignment across devices removes the wrapper from the old device's registry and adds it to the new one, soGraphicsDevice::Dispose()marks every live copy disposed and aContentManagercache hit is tracked. - Still present: events.
GraphicsResource(const GraphicsResource&)(GraphicsResource.cpp) raises noResourceCreated, and the texture copy constructors only callAddResourceReference, butGraphicsResource::Dispose(bool)callsOnResourceDestroyedfor every registered wrapper whose device is alive. Every texture copy (eachContentManager::Load<Texture2D>cache hit returns one) therefore adds aResourceDestroyedwith no matchingResourceCreated. - Still present: copies made after the device died. When the source's device lifetime token has expired, the copy constructor skips registration but
GraphicsResource(const&)still setsisDisposed_to false and keeps the rawgraphicsDevice_pointer. When that copy is disposed,Texture::Dispose(Texture.cpp) callsRemoveDisposedTextureongraphicsDevice_->getTexturesProperty()andgetVertexTexturesProperty()without checking the lifetime token, so it reads the storage of a destroyedGraphicsDevice. This needs a copy taken from a wrapper whose device is already gone, for example aContentManagercache hit after the device was disposed and destroyed.
Source locations
modules/graphics/src/Xna/GraphicsResource.cpp— GraphicsResource copy constructor raises no ResourceCreated; Dispose(bool) raises ResourceDestroyed for a registered wrapper on a live devicemodules/graphics/src/Xna/Texture2D.cpp— Texture2D copy constructor and copy assignment (MSR-027): register the copy, move the registration across devices; skip registration when the device lifetime has expiredmodules/graphics/src/Xna/TextureCube.cpp— TextureCube copy constructor and copy assignment (MSR-027), same rulesmodules/graphics/src/Xna/Texture.cpp— Texture::Dispose: RemoveDisposedTexture on the device's texture collections without a lifetime checkmodules/graphics/src/Xna/GraphicsDevice.cpp— AddResourceReference, OnResourceCreated, OnResourceDestroyed, Dispose(bool) drains resources_modules/content/include/Microsoft/Xna/Framework/Content/ContentManager.hpp— Load: the cache stores a copy and every cache hit returns another copy through std::any_cast
Evidence
Checked by reading GraphicsResource.cpp, GraphicsDevice.cpp, Texture2D.cpp, TextureCube.cpp, Texture.cpp and ContentManager.hpp at c1c316b9. Not executed: a focused run needs a GraphicsDevice with a real renderer, which is outside a pure-source probe.
Independently observed as a separate finding (merged): The GraphicsResource copy constructor neither registers the copy nor raises ResourceCreated, but Dispose(bool) (also run from the destructor) raises ResourceDestroyed for any live device, so every short-lived copy produces an unmatched destroy event.
Re-verified at c1c316b9: MSR-027 (af3c5037c) fixed the tracking half of this entry (the copy registration, cross-device copy assignment and device disposal of copies), pinned by GraphicsResourceTest.CopiedTexturesAreTrackedAndCanOutliveOwningDevice and GraphicsResourceTest.CopyAssignedTexturesTransferDeviceTracking; the event imbalance and the unchecked device access in Texture::Dispose survive, by reading; not executed.
Focused reproduction
Illustrative, not compiled:
GraphicsDevice device;
int created = 0, destroyed = 0;
device.ResourceCreated += [&](auto*, const auto&) { ++created; };
device.ResourceDestroyed += [&](auto*, const auto&) { ++destroyed; };
{
Texture2D original(device, 4, 4); // created == 1
Texture2D copy(original); // registered, but created stays 1
} // destroyed == 2: one unmatched destroy
Current tests
GraphicsResourceDisplayModeCollectionTests.cpp pins the tracking: CopiedTexturesAreTrackedAndCanOutliveOwningDevice (a copy raises GetTrackedResourceCount(), is disposed with its device and can be destroyed afterwards), CopyAssignedTexturesTransferDeviceTracking (copy assignment across two devices) and CrossDeviceMoveAssignmentTransfersTrackingAndDetachesOldBindings. No test counts ResourceCreated against ResourceDestroyed for a copied texture, and none copies a texture after its device was destroyed.
Regression test
Add to GraphicsResourceTest: (1) subscribe to both device events, copy a device-created Texture2D and a TextureCube, destroy everything and require equal create and destroy counts; (2) destroy a device, then copy a texture that outlived it and destroy the copy, run under AddressSanitizer.
Blast radius
Affected: listeners of GraphicsDevice.ResourceCreated/ResourceDestroyed that pair the two events (resource counters, leak reports), which over-count destroys whenever textures are copied, in particular for every ContentManager cache hit; and code that copies a texture wrapper after its device was destroyed (statics or caches that outlive the Game). Not affected: device tracking and disposal of copies, moves, move-only resources (VertexBuffer, IndexBuffer, RenderTarget2D, Texture3D) and the state objects, whose copies share one identity.
Workaround
Do not pair ResourceCreated and ResourceDestroyed counts for textures; use GetTrackedResourceCount() instead. Keep texture wrappers and content caches inside the lifetime of the Game, so no copy is taken after the device is gone.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Graphics architecture
- Internals
- Graphics resources: why registration is not ownership · Ownership and shutdown: the ownership tree
- Maintainer workflow
- Debug shutdown and lifetime behavior · Ownership and lifetime master map: graphics
- Known issues
- Bug index