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

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

✓

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 weak graphicsDeviceLifetime_ token) and, since MSR-027 (af3c5037c), copies. Texture2D and TextureCube now define their copy constructor and copy assignment (Texture2D.cpp, TextureCube.cpp): a copy calls AddResourceReference on 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, so GraphicsDevice::Dispose() marks every live copy disposed and a ContentManager cache hit is tracked.
  • Still present: events. GraphicsResource(const GraphicsResource&) (GraphicsResource.cpp) raises no ResourceCreated, and the texture copy constructors only call AddResourceReference, but GraphicsResource::Dispose(bool) calls OnResourceDestroyed for every registered wrapper whose device is alive. Every texture copy (each ContentManager::Load<Texture2D> cache hit returns one) therefore adds a ResourceDestroyed with no matching ResourceCreated.
  • 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 sets isDisposed_ to false and keeps the raw graphicsDevice_ pointer. When that copy is disposed, Texture::Dispose (Texture.cpp) calls RemoveDisposedTexture on graphicsDevice_->getTexturesProperty() and getVertexTexturesProperty() without checking the lifetime token, so it reads the storage of a destroyed GraphicsDevice. This needs a copy taken from a wrapper whose device is already gone, for example a ContentManager cache hit after the device was disposed and destroyed.

Source locations

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.

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

Known issues
Bug index