I need to debug shutdown and lifetime behavior
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. Read at 009d40f5 from the runtime, graphics, audio, devices, platform, C API and CMake sources and from CNA's plan records; no sanitizer, test or harness was run, and gaps marked open are source-read, not observed.
Use this recipe for a crash, hang, leak or wrong callback that appears when an object dies: at Dispose, at the end of a scope, at process exit, when a device is torn down under live resources, or when a native or foreign callback fires late. The order in which CNA destroys things is written down once, on Ownership and shutdown, and every owner is mapped on the ownership master map; this page is the procedure: classify the failure shape, pick the tool that can actually see it, reproduce it in the right kind of process, and fix it without merely silencing a sanitizer. Everything was read from TARGET 009d40f5; no test, sanitizer or harness was run for it.
Find the owner: which lifetime is it?
Start by naming the lifetime that ended, not the crash site. The same use-after-free can look identical from a renderer, an audio mixer and a C API handle.
| Symptom | Suspect lifetime | Read first | Sees it best |
|---|---|---|---|
Crash after main returns, in a static destructor, or only when a game handle or platform is left alive | Process-exit static destruction order | Game.cpp (PlatformStack, UninstallPlatform), CurrentPlatform.cpp | A subprocess whose exit status is the assertion, under AddressSanitizer |
Crash in ~Game, ~GraphicsDevice or a resource destructor | Member destruction order, borrowed platform, weak device token | Game.hpp member order, GraphicsDevice.cpp Dispose(bool) | ASan; the first report, not the cascade |
| Virtual call on a half-destroyed object (“pure virtual method called”), or a component called after removal | Raw-pointer snapshot outliving its referent | Case study: Game component lifetime | ASan for the free, TSan for the race, a same-frame regression test |
Late audio or sensor callback reading freed state; deadlock in Stop/Dispose | Callback barrier | Thread and callback map, Audio engine internals | TSan and ASan on the barrier conformance tests |
CNA_RESULT_THREAD, INVALID_STATE on destroy, a leaked handle, a binding aborting at exit | C API child-before-parent rule, creation-thread affinity | Update the C API, TeardownLifetimeSmoke.c | The seven teardown modes, ASan |
| Native validation error or crash after disposing a texture or buffer that was just drawn | In-flight native lifetime on a deferred renderer | ADR 0001 section 4, cnaext-ownership.md | The renderer's own error count, then ASan |
Browser page keeps calling a dead Game | Emscripten main-loop lifetime | emscripten-mainloop-game-lifetime.md, Game::~Game | A browser run that calls Exit() and returns from Run() |
The destruction order to hold in your head
Declaration order in Game.hpp is construction order and its reverse is the teardown script: platform_ (destroyed last), platformInstallation_ (undoes the ambient install if construction throws), platformCapabilities_, eventBatch_, Components_, GraphicsDevice_, Content_, Window_, LaunchParameters_, Services_. Five consequences drive most bugs; the full trace is on the shutdown page.
- The platform outlives everything that borrows it.
~Gameuninstalls the ambient accessor in its body, before any member dies, so teardown code must use its stored platform, neverGetCurrentPlatform(), which would lazily create a fresh default when nothing is installed (code that must not do that asksHasCurrentPlatform()). Components_is declared beforeGraphicsDevice_, so a component held through theshared_ptroverload ofAddis destroyed after the device, content manager, window and services and must not call back through itsGame.Game::Dispose(true)does not dispose the Game-owned device. It disposesIDisposablecomponents, thenContent_, then the registered device service (which raisesDeviceDisposing, whereUnloadContentruns). The device is disposed by~GraphicsDeviceor by game code;~GamecallsDispose(false), which only marks the game disposed.- The registry is drained while the renderer lives.
GraphicsDevice::Dispose(bool)setsisDisposed_first, raisesDisposing, movesresources_into a local and disposes each resource, then runsdestroyNativeResources()(renderer, presenter, window) and releases its one video-subsystem reference. The registry tracks and never deletes. - A weak token protects late resources. Each
GraphicsResourceholds astd::weak_ptr<void>of the device'sresourceDeviceLifetime_and checksexpired()before calling back into the device;~GraphicsDeviceresets it afterDispose(). It extends nothing: aCNA::Graphicsobject must not outlive its device, and public resources must not outlive theirs (ADR 0001).
Borrowed and owned pointers
Never infer ownership from a getter or a “manager” name. For each pointer you touch, write down the lender, the borrower and what stops the lender dying first.
| Pointer | Lender and borrower | What protects it | What does not |
|---|---|---|---|
GraphicsDevice::platform_ | Game (or the ambient lazy default) → device | Declaration order: platform_ is destroyed last | A device constructed on its own must not outlive the ambient default; not a token |
Game::Services_ entries, graphicsDeviceService_, graphicsDeviceManager_ | Caller-owned GraphicsDeviceManager → game | The manager being nested inside the game's lifetime (usually a member of the derived game) | A manager destroyed while its game runs, or outliving an undisposed game: the game keeps raw pointers that unregisterServices does not clear, and ~GraphicsDeviceManager dereferences its Game. No test covers those shapes |
GraphicsDevice::resources_ | Resource → device registry | Each resource unregisters itself; the device drains it at disposal | An entry whose object died without unregistering |
ContentManager to device | Game-owned device → content | Members declared in that order; Unload() only clears the cache | Copies the caller still holds survive Unload(); Unload() is not disposal of every retained resource |
| Ordered component vectors and frame snapshots | Collection → Game raw IUpdateable*/IDrawable* | componentListsMutex_ for list changes; snapshot entries nulled on removal | Removal from another thread during a frame; the collection's own storage has no lock |
VideoPlayer ↔ Video | Raw pointers in both directions | Nothing stated in the header | Destroying a Video before its player is stopped, disposed or destroyed (source-read, no test) |
| C API borrowed handles | Game → C caller | Generation invalidation when the callback returns; owner token | Retaining one past the callback; destroying the game first |
Callback barriers
A callback bug is usually decided in creation, generation change or teardown, not in the callback body. Name the barrier the code relies on and keep it on the normal and the failure path:
| Callback source | Barrier | Pinned by |
|---|---|---|
Audio device (IAudioDevice) | Stop waits for an in-flight callback; after Close none runs again. SDL3/SDL2 lock and unlock SDL's stream lock; the NULL and ALSA devices join their worker thread. The callback must not block, allocate, throw or call device lifecycle methods | AudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers, Sdl2AudioDeviceTests.OpenIsPausedAndStopIsACallbackBarrier |
| SDL3 mixer | Generation counter bumped before device stop and close; deferred free list for tracks destroyed from their own stopped callback | CnaMixer.ATrackDestroyedFromItsOwnStoppedCallbackLeavesTheMixAndIsFreedLater (CNA mixer), the mixer-destroy harnesses |
| Dynamic streams | FrameworkDispatcher::Update copies the stream list under a non-recursive mutex and updates unlocked, because a BufferNeeded handler may dispose its own stream | FrameworkDispatcherTest.UpdateDoesNotDeadlockWhenBufferNeededDisposesTheInstance |
| Accelerometer, Gyroscope | Per-class mutex, snapshot of registrations, in-flight dispatch token that Dispose waits on | AccelerometerTests.NoDispatchAfterDispose, DisposeFromWithinOwnCallbackDoesNotDeadlock |
| Compass, Motion | Generation-checked shared control block; user code runs outside the lock | Accepted, documented gap: a callback already past its check while another thread finishes destroying the owner |
| C API lifecycle callbacks and registrations | Synchronous on the creation thread; non-reentrant calls refused; registrations survive game destroy but are invalidated after the disposal event; contexts stay caller-owned | CALLBACKS_AND_THREADING.md, CApi_StressSmoke, CApi_AudioSmoke |
| Foreign finalizers and garbage collectors | Creation-thread check: an off-thread destroy answers CNA_RESULT_THREAD and the handle stays live | The bindings queue releases for the owner thread at their pinned revisions (C#, Java) |
Before changing any callback, write five answers in the patch: who invokes it and on which thread; which locks are held; whether it can re-enter CNA; who owns its userdata and for how long; and what Stop, Close or Dispose does to in-flight calls. A moved-out object destroyed after the lock scope ends (as the C API registry does), and callbacks run with no lock held (as the sensor and dispatcher code does), are the two patterns that keep barriers deadlock-free.
Catalogue of real failure shapes
Each row is a shape that has occurred in CNA and the current code's answer. “Fixed” rows are teaching material and name the test or harness that pins the answer; “open” rows are source-read gaps recorded for maintainers, not verified defects.
| Shape | Concrete example | Status at TARGET |
|---|---|---|
| Static destruction order | The C API handle registry (a function-local static owning games) was constructed before the platform stack that ~Game unregisters from, so at exit the stack died first and AddressSanitizer reported a heap-use-after-free at UninstallPlatform for every smoke test leaving a game handle alive. Ordering cannot fix it: the registry must exist before the object it stores (plan_capi_smoke_stability.md). | Fixed: PlatformStack, its mutex and the CurrentPlatform holders are deliberately immortal (allocated once, never destroyed). Pinned by CApi_TeardownLifetime_* and GamePlatformOwnershipTest.OutOfOrderDestructionLeavesNoDanglingInstallation |
| Same, native platform | An X11 connection destroyed after its error-policy display list and mutex | Fixed by the same idiom in X11Error.cpp; X11Live.AProcessExitingWithTheCurrentPlatformOpenTearsDownCleanly checks a harness's exit status |
| Static destruction versus a library shutdown call | VibrateController's default singleton borrows the platform's haptic service and is destroyed at exit, possibly after SDL_Quit() | Mitigated: an atexit fallback and a check that the captured platform is still the installed one. No production code calls DevicesShutdownCoordinator::Shutdown(); the ordering harness covers SDL3/POSIX only |
| Native owner never destroyed | The SDL3 mixer's DestroyMixer has no production caller: at exit a namespace-scope static closes the device and the native mixer pointer is never destroyed (the ALSA engine is immortal with an atexit close) | Open (as read); a real shutdown needs every audio object idle first |
| Foreign process exit | The Java binding records at its pinned revision that, on the identities served by EasyGL, a process exiting with a vertex buffer alive after its creating thread ended aborts in a static destructor (a C probe reproduces it without Java; the renderer keeps thread_local context-lease state in EasyGLRenderer.cpp) | Not examined at TARGET; evidence for cna-java@6661173 only |
| Snapshot of raw pointers | A loading screen dropped a screen from inside the update iterating its 87 components, then aborted on a pure virtual call. Two mechanisms: concurrent list mutation during the snapshot copy, and removal freeing a component that the in-flight snapshot still names | Fixed: list lock plus nulling snapshot entries; GameTest.AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame and GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem. Open: removal from another thread |
| Resource outliving its device | A shared-owned component (destroyed after the device) or a late copy of a texture | Guarded by the weak token. Tests found cover use after device disposal (GraphicsDeviceLifecycleTest.RendererFacingOperationsRejectUseAfterDeviceDisposal) and cross-device moves (GraphicsResourceTest.CrossDeviceMoveAssignmentTransfersTrackingAndDetachesOldBindings); none was found that destroys a wrapper after its device object is gone |
| Re-entrant disposal | A Disposing handler disposing again, or a resource unregistering while the registry is walked | Guarded by isDisposed_ first and moving the registry out; GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget |
| Failed-constructor leak | A throwing member constructor never reaches ~Game, leaving the ambient pointer aimed at a dead platform | Guarded by platformInstallation_; GamePlatformOwnershipTest.AFailedConstructionLeavesNothingInstalled |
| Reference-count imbalance | Two independent campaigns each acquired the video subsystem while one release stayed in Dispose; the subsystem stayed up for the process. Nothing observable failed | Fixed by a single owned flag (setVideoSubsystemAcquired); GraphicsDeviceSubsystemLifecycleTest counts acquisitions on a decorated real platform |
| Deferred-record lifetime | On a deferred renderer a texture disposed right after SpriteBatch.End() is still referenced by a queued command, so each queued command holds a shared keep-alive. The oracle is the renderer's own uncaptured-error count, because ASan did not catch the removed keep-alive (the native handle table asserted first) | Pinned by the WebGPU lifetime stress program (webgpu_resource_lifetime_stress_test.cpp); the general rule is ADR 0001 section 4 |
| Browser main loop | An older implementation unwound the caller of Run(), destroying a local Game while the registered frame callback still held its address | Fixed: the loop runs on the existing Wasm stack; ~Game stops the loop and logs an error if destroyed while Run() is active |
| Borrowed pointer to a queue-owned object | MediaPlayer::Play(Song*) clears the queue (which owns its songs through unique_ptr) and then reads the argument, so passing a queue-owned song reads a destroyed object | Open (source-read, no test) |
| Singleton bound to a shorter-lived owner | PhoneApplicationService::getCurrentProperty() is a function-static; attached to a Game destroyed earlier, its destructor calls DetachEXT on a dangling pointer | Open (source-read, no test) |
| Operations after disposal | StorageContainer operations keep working after Dispose; streams outlive the container and device; the same holds through the C route | Open |
| Comment versus code | cna_game_destroy's comment says shutdown already disposed the canonical device; Game::Dispose(true) disposes only the registered service | Open (documentation) |
Tooling that exists at TARGET
Sanitizer configurations
CNA_SANITIZE takes a comma-separated list (for example address,undefined), adds -fsanitize=… -fno-omit-frame-pointer -g to every CNA-owned target and to the enabled Sharp Runtime closure, and links the same flag; CNA_SANITIZE_OPTIMIZATION accepts DEFAULT, O0…O3. The configure step (BuildPerformance.cmake) refuses Emscripten, non-GNU/Clang compilers, AddressSanitizer with ThreadSanitizer, ThreadSanitizer with MemorySanitizer, CNA_ENABLE_IPO, and non-full CNA_DEBUG_INFO. Vendored SDL and ENet sub-builds stay uninstrumented (build-performance.md).
The shipped presets are devices-asan (address, O0), devices-tsan (thread, O1) and devices-ubsan (undefined, O1) in CMakePresets.json: Debug, OPENGLES3, tests on and CNA_DEVICES=ON. They are named for the Devices module, and each builds the whole CnaTests aggregate; use them for runtime lifetime work only if the host has a display. There is no runtime-specific sanitizer preset, so a lifetime investigation on another renderer or platform is -DCNA_SANITIZE=address in a separate build tree for that configuration. ASan finds the free, TSan finds the race, UBSan does not find lifetime bugs; run the two that match the hypothesis. CNA's own workflows show the option strings in use: input-ci.yml runs address,undefined with detect_leaks=0:halt_on_error=1, and gltf-sanitizers-ci.yml runs the glTF path with detect_leaks=1 and UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=0:exitcode=1 (without exitcode UBSan prints and the process still exits 0).
- Keep the first report: under ASan preserve the first use-after-free, not the destructor cascade.
- LeakSanitizer noise. CNA's integration notes record leak stacks rooted in the Mesa GLX driver with no CNA frames (
integration/lanes/opengl4.md), and a WebGPU stress program's 368 bytes from adapter enumeration, told apart from a leak because the total did not scale when the churn loop was made sixteen times longer. LeakSanitizer swallows buffered stdout (CNA reads such programs' PASS lines in adetect_leaks=0control run) and cannot run under ptrace. - No Valgrind guidance was found in the TARGET documentation.
Processes whose exit status is the assertion
What happens after main returns cannot be tested inside a long-running CnaTests process. CNA's answer is a small standalone program spawned by a test, declared in Harnesses.cmake:
| Harness | Hazard it isolates | Spawned or registered by |
|---|---|---|
TeardownLifetimeSmoke.c: modes explicit, game-alive, game-alive-no-frame, children-alive, partial, cycles, device-alive | C game and children left alive at exit | CApi_TeardownLifetime_<mode> (needs CNA_BUILD_C_API=ON) |
x11_exit_harness.cpp | Native X11 platform still open at exit | X11PlatformIntegrationTests (X11 selection only) |
shutdown_ordering_harness.cpp (--skip-shutdown-call) | Vibrate singleton destroyed after the real SDL_Quit() | DevicesShutdownOrderingTest.* (SDL3 only) |
static and dynamic voice harnesses | Destroying the SDL3 mixer while an instance is alive, then using the orphaned instance | AudioMixerTests (SDL3 audio only) |
cnaext_leak_loop_test.cpp | 1,000 frames and 50 resizes repeated, for LeakSanitizer to watch; asserts only that the memory estimate does not grow | Run under CNA_SANITIZE=address with CNA_CNAEXT=ON |
Diagnostics resource records and events
With -DCNA_DIAGNOSTICS=STATS or FULL (the default is OFF; the level is a compile definition and changes class layout, so reconfigure and rebuild everything), every GraphicsResource registers a resource record, and GetProvider().CaptureSnapshot() returns them with stable, never-reused ids. Use them to correlate explicit create and dispose cycles with what the registry still holds; do not read them as a GPU allocation census, since state objects and effects appear as zero-byte Unknown records and a resource created during a runtime Off window is never registered. The XNA GraphicsDevice::ResourceCreated and ResourceDestroyed events are the level-independent alternative. See Diagnostics internals.
Step-by-step triage
- Record the configuration: platform, audio and renderer axes, sanitizer, display, build type, and whether the game was disposed explicitly, destroyed by scope exit or left alive at process exit. Those are different paths and a change must be checked against each.
- Get the first error under ASan (free) or TSan (race), with its three stacks (access, free, allocation). A bad access in a destructor at exit suggests static destruction; in a member destructor, order or a borrowed pointer.
- Classify with the owner table. Draw the destruction order and every asynchronous user of the object (device callbacks, sensor sessions, deferred renderer records, foreign threads); an unnamed lender is the finding.
- Reduce: one game with one resource, then a bare device, then no window (
HEADLESS). Use the exit-status harness pattern when the failure needs process teardown. - Repeat. One pass does not show a race away: use
--gtest_repeatand TSan, and read the skip count, since a window-needing test skips where none can be created. - Prefer removing the dependency over ordering it. CNA's fixes for teardown-order bugs were an immortal holder (the registry cannot be ordered), a weak token (a late resource asks whether its device lives), moving state out before iterating, setting the disposed flag before raising events, running callbacks with no lock held, and generation counters that turn a stale pointer into “already gone”. A fix that only reorders declarations needs a test that would fail under the old order.
- Do not quiet the sanitizer. Several barriers exist because one found a real free or race (the pan-state list, the dynamic-stream flag, the platform stack); a suppression or
detect_leaks=0describes the environment, not a fix. - Write the regression in the shape of the failure. Same-frame semantics for component removal; a subprocess exit status for static destruction; a counted-acquisition fake on a real decorated platform for reference balance (a fake that answers every call would have passed before the fix). See Add a regression test.
- Check the blast radius. The C API child-before-parent gates, the bindings' deferred releases, Emscripten
Run(), a second platform backend, and both mixer implementations when a shared contract moves (Blast radius and readiness).
Review checklist
- Which lifetime ended, who lent the pointer, and what stops the lender dying first, written in the description.
- Both teardown paths (explicit dispose and destruction) and the process-exit path considered; the ambient platform not assumed to name this game.
- Callback barrier named, kept on the failure path, and no user code run under a non-recursive lock.
- A regression test whose failure mode matches the bug, with the sanitizer configuration and skip counts stated, and anything not run said plainly.
- No new process-lifetime static that a destructor of a shorter-lived object touches, unless it follows the immortal-holder idiom.
Related: Architectural invariants, Known uncertainty and history, Debugging cookbook, Ownership and shutdown and Ownership and lifetime master map.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Device reset, disposal, adapters and format queries — Order and failure boundaries of GraphicsDevice::Reset, the manager's hooks, device-loss status, resource registration and disposal, plus CNA's adapter, format and profile queries.
- Exit, Exiting, Dispose and destruction — What each way of ending a CNA game runs: Exit versus Exiting, explicit Dispose versus destruction, the XNA 4.0 disposal order, repeated, re-entrant and throwing disposal, and component lifetimes at shutdown.
- Graphics resource lifetime: tracking, copies, moves and disposal — Which graphics resources a CNA GraphicsDevice tracks, how copies and moves of each resource type behave, what disposal unbinds, and which resources report lost content.
- SDL_GPU uploads, render-target lifetime and swapchain recovery — What SDL_gpu validation exposed in CNA's SDL_GPU renderer, when uploads may cycle, how render targets outlive their wrappers, what MRT writes, and how a failed swapchain acquisition keeps the frame.
- Vulkan draw-time state, ordered clears, occlusion queries and descriptor pools — How CNA's deferred VULKAN renderer carries blend, stencil, viewport and scissor state, orders clears, counts occlusion queries and grows descriptor pools, with the defects behind each rule.
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-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-096: VulkanRenderer's constructor leaks the Vulkan handles it created when a later construction step throws — VulkanRenderer::VulkanRenderer creates the instance, debug messenger, device, swapchain and other raw handles in sequence with no cleanup path, so a throw from a later step such as PickPhysicalDevice leaves them undestro
- CNA-BUG-098: A throwing shadow-caster or transparent-phase callback leaves RenderPipeline and its ShadowMap in an unrecoverable state — RenderPipeline::begin() raises frameOpen_ before running the shadow-caster callback between ShadowMap::begin and end, and end() lowers it before the transparent callback; an exception from either skips the cleanup that p
- CNA-BUG-141: MediaPlayer::ProgramExit is documented as called at application exit, but nothing in CNA calls it — Unlike FNA, which hooks ProcessExit, CNA never calls MediaPlayer::ProgramExit outside an explicit C API export, so the music track is not released by CNA at exit and the header's statement is false.
- CNA-BUG-143: VideoPlayer and Video keep raw pointers to each other, so destroying a Video before its player is stopped writes to freed memory — VideoPlayer stores a borrowed Video* and Video a borrowed VideoPlayer* parent; CloseDecoder, run by Stop, Dispose and the destructor, writes through the stored Video*, and no header states the ordering rule.
- CNA-BUG-157: HttpNotificationChannel::Close and the channel destructor block indefinitely while a connected peer sends nothing — The single listener thread reads each accepted connection with a blocking recv and no timeout; Close shuts down only the listening socket and then joins that thread, so a silent peer stalls Close and the destructor.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Building CNA · Diagnostics
- Architecture
- Runtime lifecycle
- Maintainer workflow
- Ownership and lifetime master map · Thread and callback map · Case study: Game component lifetime · Architectural invariants · Debugging cookbook
- Tests and validation
- Test architecture
- Reference
- CMake option index