Ownership and shutdown

CNA snapshot 009d40f5  ·  Development › Runtime internals  ·  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. Order and ownership were read from Game.hpp, Game.cpp, GraphicsDeviceManager.cpp, GraphicsDevice.cpp, GraphicsResource.cpp and CurrentPlatform.cpp at this snapshot; the named tests exist and were not executed. Backend-specific native destruction paths, mobile and browser hosts and the C API's own teardown are outside what was checked here.

The dangerous boundary in CNA is not simply “call Dispose”. A game has explicit disposal, C++ member destruction, borrowed service pointers, a device resource registry, a process-wide ambient platform and, for browsers, a loop that may still be running. Their order is visible in the code and differs depending on how the game ends. This page traces each path from the declaration order in Game.hpp to the last member destructor, names what each step guarantees, and records the bugs that order exists to prevent. It is for anyone touching a destructor, Dispose, a service registration or a device teardown step.

Ownership tree and borrowed references

Declaration order is construction order and its reverse is destruction order, so the diagram below is also the teardown script read from the bottom up (Game.hpp, GraphicsDevice.hpp).

Game object                                     the caller owns it: stack, heap, or a C API handle slot
├─ Activated, Deactivated, Disposed, Exiting, RunApplication     public members, declared first
├─ platform_               unique_ptr<IPlatform>      physical lifetime encloses every later member
├─ platformInstallation_   unique_ptr<PlatformInstallation>    undoes the ambient install if construction throws
├─ platformCapabilities_   value, cached once
├─ eventBatch_             unique_ptr<PlatformEventBatch>      reused vector for PollEvents
├─ Components_             GameComponentCollection: raw items_ + owned_ shared_ptrs (shared overload only)
├─ GraphicsDevice_         value member
│    ├─ platform_          borrowed IPlatform* (the Game's, or the ambient lazy default for a bare device)
│    ├─ platformWindow_    unique_ptr<IPlatformWindow>: owns a CNA-created window, borrows an adopted one
│    ├─ surfacePresenter_  unique_ptr, declared before renderer_ so it outlives it
│    ├─ renderer_          unique_ptr<IGraphicsRenderer>
│    ├─ resourceDeviceLifetime_   shared_ptr<void> token that resources hold weakly
│    ├─ state objects, texture and sampler collections     value members
│    └─ resources_         vector<GraphicsResource*>: tracking only, deletes nothing
├─ Content_                ContentManager value: borrowed device, loadedAssets_ cache
├─ Window_                 GameWindow value: borrows the device's IPlatformWindow
├─ LaunchParameters_
├─ Services_               type_index -> borrowed void*
│    └─ GraphicsDeviceManager*     caller-owned, usually a member of the derived game
├─ clock, flags, four ordered raw-pointer component vectors, componentListsMutex_, order-token maps
└─ graphicsDeviceService_, graphicsDeviceManager_, currentAdapter_   cached raw pointers
ℹ

Member order to remember. Components_ is declared before GraphicsDevice_, not after the window and content manager. Reverse order therefore destroys the device first and the component collection after it, and any component held through the shared_ptr overload of Add is destroyed after the device has gone. The header comment calls platform_ the first member; strictly it is the first private data member, with the four events and RunApplication ahead of it.

The device's resource vector records which objects must be disposed while the native device still exists. It does not mean GraphicsDevice allocated every GraphicsResource or may delete one. A resource keeps a raw device pointer plus a std::weak_ptr<void> copy of resourceDeviceLifetime_ (GraphicsResource.cpp). Dispose(bool), moves and move-assignments consult that token (!graphicsDeviceLifetime_.expired()) before calling RemoveResourceReference or TransferResourceReference, so a resource whose C++ destructor runs after its device does not dereference a dead one. ~GraphicsDevice resets the token in its body, after Dispose() and before the device's own members are destroyed, so even those members see it expired. A texture or vertex buffer additionally owns a renderer-side allocation object (ITextureRenderer, IVertexBufferRenderer) that its own Dispose releases; whether every backend's allocation destructor tolerates the renderer being gone is a per-backend question these pages do not verify.

State wrappers (BlendState, DepthStencilState, RasterizerState, SamplerState, VertexDeclaration) are copied and assigned freely; copy assignment (and VertexDeclaration's copy constructor) then makes the target an alias of one SharedIdentity by ShareResourceIdentityWith, while the other four states' copy constructors produce an independent copy; that block holds the device pointer, the weak token, name, tag, the disposed flag and an alias list, and disposing any alias disposes the identity and raises Disposing on every alias. This complicates the ordinary one-object-one-registration assumption, so read GraphicsResource.cpp before changing copy, move or disposal behaviour.

Exit versus dispose versus destructor

ActionWhat it does at this snapshot
Game::Exit()Sets RunApplication false and suppressDraw_ true. Nothing is disposed.
Game::Run() returnsAfter the loop: OnExiting, EndRun, AfterLoop. The caller still owns the Game; nothing is disposed.
Game::Dispose()Calls Dispose(true), then raises Disposed — on every call, including repeated ones. Dispose(true) (first call only) disposes each component that is IDisposable, then Content_.Dispose(), then the registered graphics device service if it is IDisposable, then sets isDisposed_. Afterwards Run, DoInitialize and a following Update throw std::runtime_error.
~Game()On Emscripten, first stops the browser loop if this game is driving it (with an error log line). Then Dispose(false), which only marks the game disposed (no component, content or service disposal), then UninstallPlatform. Members are destroyed after the body, in reverse declaration order.

What actually disposes the device. Game::Dispose(true) does not call GraphicsDevice_.Dispose() for the usual Game-owned value member. A registered manager's Dispose(true) unregisters its two services, releases its frame lease, unsubscribes its device events, raises its own DeviceDisposing and, because ownsGraphicsDevice_ is false for every Game-attached manager, neither disposes nor deletes the device before clearing its pointer. The device is disposed by GraphicsDevice::~GraphicsDevice(), or earlier if game code disposes it. (The gate matters: the raise of DeviceDisposing is deliberately not conditional on ownership, because gating it left the event permanently dead for the only configuration the code base constructs.)

What that means for UnloadContent. Game::Initialize subscribes UnloadContent to the graphics device service's DeviceDisposing event, and only when such a service is registered. So UnloadContent runs when (a) game code or a host disposes explicitly — Game::Dispose(), or the registered service's own Dispose() — and (b) a GraphicsDeviceManager (or another service) is registered. It runs after Content_.Dispose() has cleared the asset cache, so it is a place to release resources the game owns, not to touch content. It never runs from ~Game, from Run() returning, or in a game that has no manager, such as the demo_2d executable. GameTest.DisposingDeviceInvokesUnloadContent and RepeatedDisposeDoesNotReinvokeUnloadContent pin the explicit path; the second holds because the manager's disposed_ guard makes DeviceDisposing a one-time event even though Game::Dispose() re-raises Disposed.

Controllers are not released here: Game no longer acquires that subsystem (the startup page records why), so the platform releases whatever it acquired for the gamepad or joystick services when it is destroyed, which is the last member destructor.

Teardown order, step by step

Two paths reach teardown, and a change must be checked against both.

Explicit:  game.Dispose()  → Game::Dispose(true), then the Disposed event
  1. every IDisposable component                 → Dispose()
  2. Content_.Dispose()                          → Unload(): cached assets released
  3. the registered graphics device service (a GraphicsDeviceManager):
       unregister both services; drop the frame's renderer-context lease;
       unsubscribe device events; raise DeviceDisposing  (Game::UnloadContent runs here);
       never delete or dispose the Game-owned device
  4. isDisposed_ = true                          (GraphicsDevice_ is NOT disposed here)

Destruction (delete game, or scope exit):
  0. the derived class first: its ~Derived body, then its members in reverse order
     (a GraphicsDeviceManager member, textures, SpriteBatch ...) - all before any Game member
  1. ~Game body: [Emscripten: stop the loop]; Dispose(false); UninstallPlatform(platform_)
  2. Game members, reverse declaration order:
       cached pointers, order-token maps, vectors, mutex, clock state
       Services_ (drops borrowed pointers), LaunchParameters_, Window_ (borrows only)
       Content_        (asset cache released; the device is still alive)
       GraphicsDevice_ (~GraphicsDevice: Dispose() = Dispose(true) → Disposing raised,
                        registry drained while the renderer lives, renderer → presenter → window,
                        video reference released, lifetime token reset, then its own members)
       Components_     (owned_ shared components destroyed - after the device)
       eventBatch_, platformCapabilities_, platformInstallation_ (disarmed), platform_
       finally the public events and RunApplication

Four consequences are easy to miss. First, the ambient platform is uninstalled in the ~Game body, before any member is destroyed: during device teardown GetCurrentPlatform() names the successor game's platform, or, when none is installed, would lazily create a brand-new default platform (see the next section). The device's own borrowed platform_ is still valid, which is why teardown code must use the stored dependency. Second, a GraphicsDeviceManager that is a member of the derived game is destroyed before every Game member: if the game was not explicitly disposed, its Dispose(false) unregisters the services (the Game and its container are still alive) and unsubscribes from the device, so the device's later Disposing event does not reach it, and DeviceDisposing is not raised at all. Third, shared-owned components outlive everything declared after Components_ — the device, content manager, window and services — so a component destructor must not call back into any of them through its Game reference; its GraphicsResource members are safe only because of the weak token. Fourth, ~GameComponentCollection is defaulted: it releases the owning references without raising ComponentRemoved and disposes nothing, so components (raw entries) are the caller's to destroy or, for IDisposable, to have disposed by an explicit Game::Dispose().

This is a composite of several lifecycles, not a claim that every application calls Dispose(). The same map with the thread side is in the Ownership and lifetime master map and the Thread and callback map; the user-level hooks are on the Game loop guide.

Inside GraphicsDevice disposal

GraphicsDevice.cpp's Dispose(bool) does, in this order:

  1. Returns if already disposed, otherwise sets isDisposed_ first, before any callback or owned-resource work, so a re-entrant Dispose is a no-op and a Disposing handler cannot issue work against objects that are being torn down (the source ties this to FNA's lifecycle boundary).
  2. Raises Disposing when disposing is true. XNA's finalizer would pass false; C++ has no separate finalizer, so the destructor is the only other caller and it passes true because every member is still alive in a destructor body. The device's Disposing event is therefore raised during destruction as well. The manager's subscription uses it to release a still-active frame context lease so EndDraw cannot present on a destroyed renderer.
  3. Moves resources_ into a local vector and clears the member, so a resource's re-entrant RemoveResourceReference finds an empty registry instead of mutating the iteration, then calls Dispose() on each tracked resource while renderer_ still exists.
  4. destroyNativeResources(): invalidates the cached capability profile, resets renderer_, then surfacePresenter_, clears the shared TextInputEXT and Mouse window handles if they still name this device's window (for owned and adopted windows alike), and resets platformWindow_ — which destroys a CNA-created window and merely lets go of an adopted one.
  5. setVideoSubsystemAcquired(false) releases this device's single video-subsystem reference. The source states why the window goes first: the video subsystem is what backs it. The platform object itself is destroyed later, by Game.

The constructor has a mirror rollback: if resolving a renderer or applying the initial state throws, its catch calls destroyNativeResources(), balances the video reference and restores the caller's GL binding before rethrowing (see the startup page's failure table). This matters for testing fallback candidates and partial initialization. For a fallback chain, discardOwnedWindow() is the per-candidate variant: it drops the presenter, clears the shared input handles when they name the window, and resets the wrapper and the stored window handle; the attempt loop calls it only for a window this device owns, and resetting a borrowed wrapper could not destroy a caller's window in any case.

The video reference is a bool, not a counter. This is a worked historical case, fixed in the current code. Two independent campaigns each added an AcquireSubsystem(Video) (one where a window is created, one per fallback candidate) while a single release stayed in Dispose, so every windowed device took two references and returned one and the platform's video subsystem stayed up for the rest of the process; nothing observable failed. The repair is ownership rather than another release call: the device's count is videoSubsystemAcquired_, changed only by setVideoSubsystemAcquired, which acquires before setting the flag (an acquire can throw when there is no display server) and clears the flag before releasing, so every path — success, creation failure, fallback A to B to C, window failure, Dispose, the constructor's catch — balances by construction. GraphicsDeviceSubsystemLifecycleTests.cpp counts the acquisitions on a decorated real platform instead of faking one, because a fake that answers every call would have passed before the fix as well.

Ambient platform and out-of-order games

Some static XNA APIs (Keyboard::GetState, StorageDevice, TitleContainer) cannot be handed a Game, so CurrentPlatform.cpp keeps an ambient pointer, a lazily created owned default and a list of subsystem pins; Game installs its own platform for those APIs. A process-wide stack in Game.cpp records the live games' platforms, in the order they installed, and the back entry is the one the accessor aims at. A per-game “the platform I displaced” pointer looks simpler and is wrong, because games are not guaranteed to be destroyed in reverse construction order: a game that saved a predecessor destroyed first would restore a dangling pointer on its way out.

UninstallPlatform removes the departing platform wherever it sits in the stack (searching from the back, so the middle case works), takes the new back as the successor, and re-aims the accessor only if it currently points at the departing platform — a test may have installed its own, and a game going away is no reason to take that over. SetCurrentPlatform then moves the ambient pins: for each pin held on the departing platform it acquires the subsystem on the replacement and releases it on the old one; if there is no replacement, or the acquire fails, the pin is released and dropped. The pin in practice is the GraphicsAdapter video pin taken by the first adapter enumeration (the startup page's device section); when it could not be transferred, the next use of the adapter cache re-raises it. When nothing is left installed, GetCurrentPlatform() would create a default platform on demand, so code that must not do that uses HasCurrentPlatform() (this is why Game.cpp wraps it in InstalledPlatformOrNull, and why DestroyingAGameUninstallsItsPlatform can assert that the accessor is empty afterwards).

The stack, its mutex and every CurrentPlatform holder that has a destructor are deliberately immortal: allocated once, never destroyed, so unregistering is valid at any point of process teardown. The case that proved it is the C API. Its handle registry owns each C-created Game through a shared_ptr in a function-local static that is constructed on the first handle-creating call — necessarily before this file's statics, which are constructed inside that same call by the Game it is creating. Reverse-order destruction therefore destroys the stack first and the registry that still holds the Game second, and ~Game read a freed vector at exit; the source records that AddressSanitizer reported a heap-use-after-free at UninstallPlatform for every C API smoke test that left a game handle alive. It is a fixed, always-wrong ordering, not a race, and reordering cannot fix it because the registry has to exist before the object it is about to store.

~Game unregisters the ambient pointer before the platform member is destroyed, so the accessor is never left aimed at a dead platform, while the platform object's physical lifetime still encloses the device and window members because it was declared first. Code added to a later member destructor should use its stored platform dependency and never assume the ambient accessor still names this game.

Bugs this order prevents

  • Video reference leak — two acquires and one release kept a native subsystem alive after the device (above). setVideoSubsystemAcquired centralizes a single owned reference.
  • Use-after-free at exit — an immortal C API handle registry can destroy a Game after ordinary statics, so the ambient platform stack and its mutex must still exist.
  • Dangling service — Services_ stores borrowed providers; the manager's destructor unregisters its entries before the game next queries them. The registration is not symmetric, see the maintainer note below.
  • Dead renderer during resource disposal — the registry is drained before renderer_ is reset, so a resource's Dispose runs while the backend that created its allocation still exists; reversing the two would leave resources unable to release native handles.
  • Invalid in-flight present — disposing the device from inside Draw needs the manager's Disposing listener to release the frame lease while the renderer's context still exists.
  • Failed-constructor leak — a throwing GraphicsDevice_ constructor never reaches ~Game; the installation guard must undo the ambient pointer before the platform argument is destroyed. AFailedConstructionLeavesNothingInstalled asserts the accessor is empty afterwards.
  • Resource outliving its device — the weak lifetime token keeps a late C++ destructor from calling into a destroyed device, including the device's own state members.
  • Re-entrant disposal — isDisposed_ is set first and the registry is moved out before iteration. GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget disposes from inside a Disposing handler and expects exactly one event and a released bound render target.
✎

Maintainer note — shapes the order does not protect. Read from source, not observed: (1) Game caches graphicsDeviceService_ and graphicsDeviceManager_ as raw pointers that unregisterServices does not clear, and the manager's ClientSizeChanged subscription is never removed, so a manager destroyed while its game keeps running leaves both dangling; (2) ~GraphicsDeviceManager runs unregisterServices, which dereferences its Game, so a manager that outlives an undisposed game reads a destroyed object; (3) UnloadContent runs after Content_.Dispose() on the explicit path and never on the destructor path. The supported shape is a manager nested inside its game's lifetime, typically a member of the derived class. No test covers the two unsupported shapes.

Validation and source reading order

These tests exist at this snapshot; none was executed for this page. Each pins a narrow contract in its own configuration, and none proves every renderer's native destruction path.

SuitePins
GamePlatformOwnershipTests.cpp (13)Install before members (ExplicitPlatformIsOwnedAndInstalledBeforeGameMembers), a failed construction leaves nothing installed, null rejected, install and uninstall, AnInnerGameRestoresTheOuterOnesPlatformWhenItGoesAway, AnOuterGameOutlivingAnInnerOneDoesNotOverwriteTheInstallation, OutOfOrderDestructionLeavesNoDanglingInstallation, one platform per game, repeated construct and destroy. The nested cases are guarded by a constant that is true at this snapshot.
GameTests.cppDisposingDeviceInvokesUnloadContent, RepeatedDisposeDoesNotReinvokeUnloadContent, UnloadContentWorksAcrossRepeatedGameInstancesInOneProcess.
GraphicsDeviceManagerTests.cppRepeatedDisposeDoesNotReraiseDeviceDisposing, reset-event forwarding.
GameComponentCollectionTests.cppShared ownership: an added shared component outlives the caller's reference, removal releases it after the event, Clear releases all, a rejected add leaves nothing behind, and the collection releases what it still owns when it goes away.
modules/graphics/testsGraphicsDeviceSubsystemLifecycleTests.cpp (7 cases: video-reference balance across normal disposal, failed construction, fallback, initialization failure then success, repeated lifetimes and double disposal); GraphicsDeviceLifecycleTest.RendererFacingOperationsRejectUseAfterDeviceDisposal and DisposalIsReentrantAndReleasesABoundRenderTarget; GraphicsResourceTest.CrossDeviceMoveAssignmentTransfersTrackingAndDetachesOldBindings.

For a resource-specific change also inspect the graphics resource lifetime tests of that resource type. Audio callback barriers, sensor lifetimes and the C API's own teardown rules are mapped on the neighbouring pages; the debugging procedure is in Debug shutdown and lifetime behavior.

  1. Game.hpp — declaration order and the borrowed manager and service members.
  2. Game.cpp — InstallPlatform, the PlatformInstallation guard, Dispose, the destructor and the uninstall stack.
  3. GraphicsDeviceManager.cpp — service unregister, DeviceDisposing and frame-lease cleanup.
  4. GraphicsDevice.cpp — Dispose(bool), registry drain, destroyNativeResources, discardOwnedWindow, setVideoSubsystemAcquired.
  5. GraphicsResource.cpp — registration, the weak lifetime token and shared-identity semantics.
  6. CurrentPlatform.cpp — the installed pointer, lazy default and pin transfer.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.

Architecture
Runtime lifecycle
Tests and validation
Test architecture