Ownership and lifetime master map
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. Renderer-specific native teardown and binding-side finalizer behaviour were not traced family by family.
CNA mixes value members, unique ownership, shared control blocks, registries of borrowed pointers, ambient process-wide references, caches, callbacks and opaque C handles. This map joins those lifetimes across Game, GraphicsDevice, graphics resources, content, audio, devices and the C ABI so that a maintainer can answer one question before touching a destructor: who may still use this object when it dies? Never infer ownership from a getter or a “manager” name; for a lifetime bug, draw both the C++ destruction order and every asynchronous user of the object.
Ownership vocabulary used in CNA
| Form | Examples at the snapshot | What it guarantees | What it does not |
|---|---|---|---|
| Value member | Game::GraphicsDevice_, Content_, Window_, Components_, Services_ | Lifetime equals the owner's; destruction in reverse declaration order | Anything about other threads or about objects the member merely points at |
std::unique_ptr | Game::platform_, GraphicsDevice::renderer_, platformWindow_, surfacePresenter_ | Single owner; reset order is explicit in code | That no raw pointer to the object survives elsewhere |
| Shared ownership / control block | GameComponentCollection::owned_, SoundEffect::Impl keep-alive, SensorOwnerControlBlock, C ABI handle slots | The block outlives every holder of a reference | That the pointee it names is still alive (control blocks carry a nullable owner) |
| Weak lifetime token | GraphicsResource::graphicsDeviceLifetime_ watching GraphicsDevice::resourceDeviceLifetime_ | A late resource can ask whether its device still exists | Any extension of the device's life |
| Raw registry (tracks, does not own) | GraphicsDevice::resources_, SoundEffect::Impl::instances, FrameworkDispatcher::Streams, Game's ordered component lists | The owner can find and dispose or notify everything registered | That an entry's object is alive; entries must unregister themselves |
| Borrowed service or back pointer | GameServiceContainer::services_ (void*), GraphicsDevice::platform_, ContentManager::graphicsDevice_, StorageContainer::device_ | Nothing; the borrower relies on declaration order or documented use | Safety if the lender dies first |
| Cache | ContentManager::loadedAssets_ (std::any values) | Repeated loads return the same shared resource until Unload() | Disposal of copies the game still holds |
| Ambient / process-static | CNA::Platform::GetCurrentPlatform(), Game's live-platform stack, VibrateController::getDefaultProperty(), the audio mixer globals | A process-wide answer, deliberately immortal where teardown order is unknowable | Ownership of what they point at (the installed platform is borrowed) |
| Generation-checked identity | CNA_Handle, the SDL3 mixer generation, sensor control-block generations | A stale identity is detected instead of dereferenced | Keeping the object alive |
Game is the physical lifetime envelope
caller owns Game (stack, heap, or a C ABI handle slot)
|- platform_ unique_ptr<IPlatform> declared first, destroyed last
|- platformInstallation_ scope guard: undoes the ambient install if construction throws
|- platformCapabilities_, eventBatch_
|- Components_ GameComponentCollection: raw pointers, plus owned_ shared_ptrs
| for components added through the shared_ptr overloads
|- GraphicsDevice_ value
| |- platform_ borrowed IPlatform* (the Game's, or the ambient lazy default)
| |- platformWindow_ unique_ptr wrapper: owns a created window, borrows an adopted one
| |- surfacePresenter_ unique_ptr, declared before renderer_ (outlives it)
| |- renderer_ unique_ptr<IGraphicsRenderer>
| |- resources_ GraphicsResource* registry: tracks disposal, deletes nothing
| '- resourceDeviceLifetime_ shared token that resources watch weakly
|- Content_ ContentManager value; borrowed GraphicsDevice*; loadedAssets_ cache
|- Window_ GameWindow value; borrows the device's platform window
|- LaunchParameters_
'- Services_ type -> void* map of borrowed providers
(usually a caller-owned GraphicsDeviceManager)
ambient CurrentPlatform borrowed pointer to the live Game's platform (not an extra owner)
Game::Game(std::unique_ptr<IPlatform>) installs the owned platform from platform_'s own member initializer, so every later member (the device, the window, the content manager) that reaches for the ambient platform during construction finds this game's instance instead of lazily creating a second one. The comment in Game.hpp states the rule: member order is the only thing that guarantees the platform's lifetime strictly contains theirs.
If a later member throws (a renderer refusing the platform, for example), ~Game never runs. platformInstallation_ is constructed immediately after the installation and disarmed at the end of the constructor, so exactly the failed-construction path uninstalls. Nested or out-of-order games use a process-wide stack of live platforms in Game.cpp; a game can remove itself from the middle, and the ambient accessor is re-aimed only if it currently points at the departing game. The stack and its mutex are deliberately immortal (allocated once, never destroyed): the C ABI's handle registry is a function-local static constructed during the first handle-creating call, before these statics, so at process exit the stack would otherwise be destroyed first and ~Game would read a freed vector. CNA's source records that AddressSanitizer found exactly that for C API smoke tests (task CSS-2).
The ambient side lives in CurrentPlatform.cpp: an installed pointer (borrowed), a lazily created default (owned, created only when the static API is used with nothing installed, destroyed by ResetCurrentPlatform), and a list of ambient subsystem pins that SetCurrentPlatform transfers to the replacement platform. All of it is guarded by one mutex and held in immortal storage for the same teardown reason.
GraphicsDeviceManager is usually a caller-owned object (often a member of the derived game). Its constructor registers itself as IGraphicsDeviceManager and IGraphicsDeviceService in Game::Services_; CreateDevice applies settings to the Game-owned device and records ownsGraphicsDevice_ = false, so its Dispose never deletes that device (GraphicsDeviceManager.cpp). The exact construction sequence is traced in Startup source trace and the teardown in Ownership and shutdown.
Graphics resources and native owners
A GraphicsResource registers with its device for orderly disposal, but a registry entry is a raw pointer, not an allocation claim. Each resource copies a std::weak_ptr<void> of the device's resourceDeviceLifetime_ token at construction (GraphicsResource.cpp); Dispose(bool), moves and move-assignments consult it before touching the device, so a resource destroyed after its device does not dereference a dead one. ~GraphicsDevice resets the token after its own disposal. State objects (BlendState, DepthStencilState, RasterizerState, SamplerState, VertexDeclaration) can additionally share one identity through a SharedIdentity block with an alias list; disposing any alias disposes the shared identity.
GraphicsDevice::Dispose(bool) in GraphicsDevice.cpp does, in order:
- sets
isDisposed_first, so re-entrant disposal is a no-op and aDisposinghandler cannot issue work against objects being torn down; - raises
Disposingwhendisposingis true; - moves
resources_into a local vector and clears it, then calls every resource'sDispose()while the renderer still exists (a resource's ownRemoveResourceReferencethen finds nothing to erase); destroyNativeResources(): drops the capability profile, resetsrenderer_, thensurfacePresenter_, clears the shared text-input and mouse window handles if they point at this window, and resetsplatformWindow_;- releases its single video-subsystem reference through
setVideoSubsystemAcquired(false).
Unlike Game, the device's destructor calls Dispose(), i.e. Dispose(true): the header explains that C++ has no separate finalizer and every member is still alive in a destructor body. So the device's Disposing event is raised on destruction as well. Backend-native texture and buffer handles live in renderer-side allocation objects; the neutral resource is not the OS or GPU handle. The review question for a teardown change is whether any backend deferred command or callback can still refer to an allocation after step 3.
Window ownership has a second branch. A CNA-created platform window is owned by its wrapper and may be discarded and recreated for a fallback renderer that needs another window kind (discardOwnedWindow). A caller window adopted through PresentationParameters.DeviceWindowHandle is borrowed: ownsWindow_ stays false, the fallback chain records WindowKindConflict instead of recreating it, and resetting the borrowed wrapper cannot destroy the caller's window. The device's platform_ is borrowed from the Game-owned platform (or the ambient lazy default for a bare device), so any platform surface or GL-context service the renderer uses must outlive the renderer. A resize or reset patch should draw these edges, not just the pixel-size state; see Textures and render targets and GraphicsDevice internals.
Caches and asynchronous audio
Content
ContentManager keeps loaded objects in loadedAssets_, keyed by requested type and normalized name. Unload() clears that map and nothing else, and Dispose(true) calls Unload() (ContentManager.cpp). Because Load<T> returns a value that shares the underlying resource, a copy the game still holds survives Unload() with its renderer resource intact; ContentManagerTexture2DXnbTest.UnloadClearsTheTextureCache asserts that a load after Unload() produces a different renderer texture even while the caller keeps its first copy (a Texture2D holds a shared_ptr to its renderer texture, so that copy keeps its own texture alive). ContentReader::RecordDisposable forwards disposables only to an explicitly supplied callback; its comment marks the fallback to the owning manager as deferred (ContentReader.hpp). Do not assume that removing a cache key, or Unload(), disposes every retained resource immediately. Content readers create graphics resources, so review content lifetime together with device teardown; the cache and format route is in Content runtime internals.
Audio
- Effect and instances.
SoundEffectholds ashared_ptrto itsImpl; everySoundEffectInstancecopies that pointer assoundEffectKeepAlive_at construction, soSoundEffect(path).CreateInstance()is safe even though the temporary effect dies at once.Impl::instancesis a raw registry for theDisposecascade;SoundEffect::Disposeiterates a snapshot because each instance unregisters itself. Moves re-point the registration (SoundEffectInstance.cpp). - Per-track DSP state. An instance's
FilterStateis heap-owned through aunique_ptr, so moving the instance moves ownership without moving the address the mixer callback was given. - Mixer generation (SDL3 only).
AudioMixer::DestroyMixerinAudioMixer.cppincrements an atomic generation before stopping and closing the device and destroying the mixer; an instance compares its recorded generation inGetLiveTrackHandleandDestroyTrackSafeand treats a mismatch as “the track is already gone”. CNA's own mixer (theALSApath) is never torn down and always reports generation 1 (CnaMixer MixerEngine.cpp). - Fire-and-forget playback.
SoundEffect::Playallocates a pan state as mixer-callback userdata. The stopped callback cannot free it, because the mixer may still deliver the final already-pulled buffer to the cooked callback afterwards (an ASan-reproduced use-after-free, per the source); it pushes the state onto a lock-free list that the nextPlay()drains and a static destructor drains at exit (SoundEffect.cpp). This code is backend-neutral and applies to both mixers. - CNA mixer engine. The ALSA pairing keeps its engine in immortal storage;
Stop()/Close()join the ALSA worker thread before the device's callback and buffers are released, and anatexithandler closes the device without holding the engine lock so no callback runs while other statics are destroyed. - Dynamic streams.
FrameworkDispatcher::Streamsis a raw registry; a playingDynamicSoundEffectInstanceadds itself andStopInternalremoves it.
These mechanisms are not equivalent to “let shared_ptr clean up”. Follow Audio engine internals before changing playback or callback fields.
Sensor sessions and the late singleton
An Accelerometer or Gyroscope acquires a reference to the selected platform's Sensor subsystem on its first successful Start() and holds it (with the platform pointer) until Dispose(true) releases it. Per sensor class, one PlatformSensorSubsystem<T> (PlatformSensorSubsystem.hpp) owns a single shared native session and a vector of shared_ptr<DispatchRegistration> whose owner is nulled on unregistration. Stop() unregisters under the class mutex and moves the session out when the last registration goes; the session is destroyed after the lock is released, because its destructor is a callback barrier and an in-flight callback may be waiting for that mutex (Accelerometer.cpp). Dispose(true) then waits until no other thread is dispatching into the instance and releases the platform reference.
Compass and Motion own a backend and a shared_ptr<SensorOwnerControlBlock>; each callback captures a copy of the block and the generation active when it was registered, never this. Dispose(true) nulls the block's owner under its mutex before stopping, so later callbacks no-op. The header of SensorOwnerControlBlock.hpp documents the accepted boundary: a callback already past its check on another thread when a different thread completes destruction remains unsupported.
VibrateController::getDefaultProperty() is a function-local static whose backend borrows the platform's haptic service. DevicesShutdownCoordinator.hpp states the contract: applications must call Microsoft::Devices::Detail::DevicesShutdownCoordinator::Shutdown() before their own platform shutdown whenever the default controller may have been used; it destroys the backend while the platform is still valid. Nothing in Game or the runtime calls it at this snapshot. If it is omitted, an atexit fallback marks shutdown before the singleton's destructor, and PlatformVibrateBackend::ReleaseService releases the haptic reference only if its captured platform is still the installed one. The full chain is in Devices and sensor lifetime.
Foreign handles are a different ownership language
A CNA_Handle is a slot index plus a 32-bit generation in HandleRegistry (CnaCApiDetail.hpp), not a native pointer. Each slot holds a std::shared_ptr<void>, the object kind, the creation thread and a user tag. What that pointer means depends on the route: it can own the object (a C-created game is a shared_ptr<CGame>), borrow a Game member for one callback (BorrowedGraphicsDevice), or keep a parent alive through a retentionOwner/owner field. Release checks the thread, moves the object out, bumps the generation and lets the last reference die after the registry lock is released (CnaCApiDetail.cpp).
cna_game_destroy (CnaCApiRuntime.cpp) refuses with CNA_RESULT_INVALID_STATE while owned graphics resources, content managers, audio resources or game components exist, and refuses from inside a lifecycle callback. Event registrations are the deliberate exception: they do not block destruction, are invalidated after the disposal event is raised, and remain the caller's to release once. A foreign garbage-collector finalizer running on another thread would violate the creation-thread check (CNA_RESULT_THREAD), so binding layers must not rely on GC order or GC threads for child-before-parent destruction. The C ABI's own statement of these rules is docs/c-api/CALLBACKS_AND_THREADING.md; the layer-by-layer policies are in C API internals and the C#, Java and Python binding pages. The user-level handle model is in the C API guide.
Teardown order to preserve
Two paths reach teardown, and a review must test both.
Explicit: game.Dispose() -> Game::Dispose(true), then the Disposed event
1. every component that is IDisposable -> Dispose()
2. Content_.Dispose() -> Unload(): cache cleared
3. the registered IGraphicsDeviceService, if IDisposable
(a GraphicsDeviceManager): unregister services, release the frame's
renderer-context lease, unsubscribe device events, raise DeviceDisposing
(Game::UnloadContent runs here), never delete the Game-owned device
4. isDisposed_ = true (GraphicsDevice_ itself is NOT disposed here)
Destruction: ~DerivedGame body, then the derived members (reverse order),
then ~Game:
1. [Emscripten] stop the browser loop if this game drives it
2. Game::Dispose(false) -> only marks disposed
3. UninstallPlatform(platform_) -- the ambient accessor stops pointing here
4. Game members in reverse declaration order:
Services_, LaunchParameters_, Window_, Content_ (cache entries released),
GraphicsDevice_ -> Dispose(true): Disposing raised, registry drained while
the renderer lives, renderer -> presenter -> window,
video reference released, lifetime token reset,
Components_ (owned shared components destroyed after the device),
eventBatch_, platformInstallation_ (disarmed), platform_ (last)
Audio: stop device callbacks/workers before mixer state and callback userdata go
C ABI: destroy owned children before the game handle, on the creation thread
This is a composite of several lifecycles, not a claim that every application calls Dispose(). Game::Exit() only requests loop exit (and suppresses the next draw); Run() returning does not destroy the game. Three consequences are easy to miss. First, explicit Game::Dispose(true) does not dispose the Game-owned device; it is disposed when the device member is destroyed, unless game code disposed it earlier. Second, the ambient platform is uninstalled in the ~Game body, before any member is destroyed, so code that consults GetCurrentPlatform() during device teardown sees the successor game's platform or the lazy default, while the device's own borrowed platform_ is still valid. Third, derived-class members (textures, a GraphicsDeviceManager) are destroyed before any Game member; the manager's Dispose(false) unsubscribes from the device, so the device's later Disposing event does not reach it.
For an unfamiliar object, write down whether its owner is a value member, a unique or shared owner, a raw registry, a cache, a borrowed service or a native callback before touching its destructor. The function-level order, including constructor rollback, is in Ownership and shutdown; the user-level hooks are in the Game loop guide; a debugging procedure is in Debug shutdown and lifetime behavior.
What to read to verify the map
Game.hppmember order and comments, thenGame.cpp:InstallPlatform,UninstallPlatform,PlatformInstallation, the constructor,~GameandDispose(bool).GraphicsDevice.cpp: constructor catch block,Dispose(bool),destroyNativeResources,discardOwnedWindow,setVideoSubsystemAcquired; thenGraphicsResource.cpp.GameComponentCollection.cppandGraphicsDeviceManager.cpp.SoundEffect.cpp,SoundEffectInstance.cppand the selected mixer backend.Accelerometer.cpp,Compass.cpp,DevicesShutdownCoordinator.cpp.CnaCApiDetail.cppandCnaCApiRuntime.cpp.
Tests that pin parts of the map (present in the snapshot; not executed for this page):
GamePlatformOwnershipTests.cpp:GamePlatformOwnershipTest.ExplicitPlatformIsOwnedAndInstalledBeforeGameMembers,AFailedConstructionLeavesNothingInstalled,AnInnerGameRestoresTheOuterOnesPlatformWhenItGoesAway,AnOuterGameOutlivingAnInnerOneDoesNotOverwriteTheInstallation,OutOfOrderDestructionLeavesNoDanglingInstallationand others.GameComponentCollectionTestshared-ownership cases;GameTest.DisposingDeviceInvokesUnloadContentandGameTest.RepeatedDisposeDoesNotReinvokeUnloadContent.GraphicsDeviceSubsystemLifecycleTest(video-reference balance across fallback, failed construction and repeated disposal),GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTargetandRendererFacingOperationsRejectUseAfterDeviceDisposal,GraphicsResourceTest.CrossDeviceMoveAssignmentTransfersTrackingAndDetachesOldBindings.- Audio callback barriers (
AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers) and the standalone mixer-destroy harnesses built fromcmake/Harnesses.cmake; sensor lifetime cases such asAccelerometerTests.NoDispatchAfterDispose;DevicesShutdownOrderingTest.*.
Each suite proves a narrow contract in its own configuration. No single suite proves every renderer's native teardown, and the renderer-specific allocation paths are only as verified as that family's own tests. The thread side of the same objects is mapped in Thread and callback map; the historical case of a snapshot outliving its referent is Case study: Game component lifetime.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-029: Copying a GraphicsResource bypasses GraphicsDevice resource tracking (copies of Texture2D and TextureCube escape device disposal) — A copy-constructed Texture2D or TextureCube is never registered with its GraphicsDevice, so device disposal neither disposes it nor releases the renderer texture it shares, and a cross-device copy assignment leaves a sta
- CNA-BUG-082: GraphicsDeviceManager leaves dangling references when its lifetime is not nested inside its Game — Game never clears its cached manager and service pointers, the manager never removes its ClientSizeChanged handler, and ~GraphicsDeviceManager dereferences its Game, so a manager that dies before or after its game is a u
- CNA-BUG-083: PhoneApplicationService::getCurrentProperty()'s static instance detaches from an already-destroyed Game during static destruction — The process-wide PhoneApplicationService is a function-local static holding a borrowed Game*; attached to a Game that is destroyed first, its destructor calls DetachEXT on the dead game's events.
- CNA-BUG-166: GamerServicesDispatcher::Initialize deletes every SignedInGamer in the installed collection, including gamers the application created — Initialize frees whatever gamers Gamer::getSignedInGamersProperty() currently holds before creating its four stub gamers, so gamers a game published with setSignedInGamersProperty, the pattern leaderboard reads require,
- CNA-BUG-220: cna_game_destroy's comment says Shutdown has disposed the canonical graphics device; it has not — A comment in cna_game_destroy claims CGame::Shutdown already disposed the game's canonical GraphicsDevice so C subscribers saw its Disposing event, but Game::Dispose(true) never disposes the Game-owned device.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Game loop guide: Dispose(bool) · ContentManager: caching and lifetime · C API guide: handle model
- Architecture
- Runtime lifecycle · Architecture: layers
- Internals
- Ownership and shutdown · Textures and render targets · Audio engine internals · Devices and sensor lifetime
- Maintainer workflow
- Debug shutdown and lifetime behavior · Thread and callback map
- Tests and validation
- What to test after changing X
- Reference
- Test target index