Ownership and lifetime master map

CNA snapshot 009d40f5  ·  Development › Human Takeover  ·  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. 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

FormExamples at the snapshotWhat it guaranteesWhat it does not
Value memberGame::GraphicsDevice_, Content_, Window_, Components_, Services_Lifetime equals the owner's; destruction in reverse declaration orderAnything about other threads or about objects the member merely points at
std::unique_ptrGame::platform_, GraphicsDevice::renderer_, platformWindow_, surfacePresenter_Single owner; reset order is explicit in codeThat no raw pointer to the object survives elsewhere
Shared ownership / control blockGameComponentCollection::owned_, SoundEffect::Impl keep-alive, SensorOwnerControlBlock, C ABI handle slotsThe block outlives every holder of a referenceThat the pointee it names is still alive (control blocks carry a nullable owner)
Weak lifetime tokenGraphicsResource::graphicsDeviceLifetime_ watching GraphicsDevice::resourceDeviceLifetime_A late resource can ask whether its device still existsAny extension of the device's life
Raw registry (tracks, does not own)GraphicsDevice::resources_, SoundEffect::Impl::instances, FrameworkDispatcher::Streams, Game's ordered component listsThe owner can find and dispose or notify everything registeredThat an entry's object is alive; entries must unregister themselves
Borrowed service or back pointerGameServiceContainer::services_ (void*), GraphicsDevice::platform_, ContentManager::graphicsDevice_, StorageContainer::device_Nothing; the borrower relies on declaration order or documented useSafety if the lender dies first
CacheContentManager::loadedAssets_ (std::any values)Repeated loads return the same shared resource until Unload()Disposal of copies the game still holds
Ambient / process-staticCNA::Platform::GetCurrentPlatform(), Game's live-platform stack, VibrateController::getDefaultProperty(), the audio mixer globalsA process-wide answer, deliberately immortal where teardown order is unknowableOwnership of what they point at (the installed platform is borrowed)
Generation-checked identityCNA_Handle, the SDL3 mixer generation, sensor control-block generationsA stale identity is detected instead of dereferencedKeeping 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:

  1. sets isDisposed_ first, so re-entrant disposal is a no-op and a Disposing handler cannot issue work against objects being torn down;
  2. raises Disposing when disposing is true;
  3. moves resources_ into a local vector and clears it, then calls every resource's Dispose() while the renderer still exists (a resource's own RemoveResourceReference then finds nothing to erase);
  4. destroyNativeResources(): drops the capability profile, resets renderer_, then surfacePresenter_, clears the shared text-input and mouse window handles if they point at this window, and resets platformWindow_;
  5. 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. SoundEffect holds a shared_ptr to its Impl; every SoundEffectInstance copies that pointer as soundEffectKeepAlive_ at construction, so SoundEffect(path).CreateInstance() is safe even though the temporary effect dies at once. Impl::instances is a raw registry for the Dispose cascade; SoundEffect::Dispose iterates a snapshot because each instance unregisters itself. Moves re-point the registration (SoundEffectInstance.cpp).
  • Per-track DSP state. An instance's FilterState is heap-owned through a unique_ptr, so moving the instance moves ownership without moving the address the mixer callback was given.
  • Mixer generation (SDL3 only). AudioMixer::DestroyMixer in AudioMixer.cpp increments an atomic generation before stopping and closing the device and destroying the mixer; an instance compares its recorded generation in GetLiveTrackHandle and DestroyTrackSafe and treats a mismatch as “the track is already gone”. CNA's own mixer (the ALSA path) is never torn down and always reports generation 1 (CnaMixer MixerEngine.cpp).
  • Fire-and-forget playback. SoundEffect::Play allocates 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 next Play() 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 an atexit handler closes the device without holding the engine lock so no callback runs while other statics are destroyed.
  • Dynamic streams. FrameworkDispatcher::Streams is a raw registry; a playing DynamicSoundEffectInstance adds itself and StopInternal removes 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

  1. Game.hpp member order and comments, then Game.cpp: InstallPlatform, UninstallPlatform, PlatformInstallation, the constructor, ~Game and Dispose(bool).
  2. GraphicsDevice.cpp: constructor catch block, Dispose(bool), destroyNativeResources, discardOwnedWindow, setVideoSubsystemAcquired; then GraphicsResource.cpp.
  3. GameComponentCollection.cpp and GraphicsDeviceManager.cpp.
  4. SoundEffect.cpp, SoundEffectInstance.cpp and the selected mixer backend.
  5. Accelerometer.cpp, Compass.cpp, DevicesShutdownCoordinator.cpp.
  6. CnaCApiDetail.cpp and CnaCApiRuntime.cpp.

Tests that pin parts of the map (present in the snapshot; not executed for this page):

  • GamePlatformOwnershipTests.cpp: GamePlatformOwnershipTest.ExplicitPlatformIsOwnedAndInstalledBeforeGameMembers, AFailedConstructionLeavesNothingInstalled, AnInnerGameRestoresTheOuterOnesPlatformWhenItGoesAway, AnOuterGameOutlivingAnInnerOneDoesNotOverwriteTheInstallation, OutOfOrderDestructionLeavesNoDanglingInstallation and others.
  • GameComponentCollectionTest shared-ownership cases; GameTest.DisposingDeviceInvokesUnloadContent and GameTest.RepeatedDisposeDoesNotReinvokeUnloadContent.
  • GraphicsDeviceSubsystemLifecycleTest (video-reference balance across fallback, failed construction and repeated disposal), GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget and RendererFacingOperationsRejectUseAfterDeviceDisposal, GraphicsResourceTest.CrossDeviceMoveAssignmentTransfersTrackingAndDetachesOldBindings.
  • Audio callback barriers (AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers) and the standalone mixer-destroy harnesses built from cmake/Harnesses.cmake; sensor lifetime cases such as AccelerometerTests.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.

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

Tests and validation
What to test after changing X