I need to debug shutdown and lifetime behavior

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

SymptomSuspect lifetimeRead firstSees it best
Crash after main returns, in a static destructor, or only when a game handle or platform is left aliveProcess-exit static destruction orderGame.cpp (PlatformStack, UninstallPlatform), CurrentPlatform.cppA subprocess whose exit status is the assertion, under AddressSanitizer
Crash in ~Game, ~GraphicsDevice or a resource destructorMember destruction order, borrowed platform, weak device tokenGame.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 removalRaw-pointer snapshot outliving its referentCase study: Game component lifetimeASan for the free, TSan for the race, a same-frame regression test
Late audio or sensor callback reading freed state; deadlock in Stop/DisposeCallback barrierThread and callback map, Audio engine internalsTSan and ASan on the barrier conformance tests
CNA_RESULT_THREAD, INVALID_STATE on destroy, a leaked handle, a binding aborting at exitC API child-before-parent rule, creation-thread affinityUpdate the C API, TeardownLifetimeSmoke.cThe seven teardown modes, ASan
Native validation error or crash after disposing a texture or buffer that was just drawnIn-flight native lifetime on a deferred rendererADR 0001 section 4, cnaext-ownership.mdThe renderer's own error count, then ASan
Browser page keeps calling a dead GameEmscripten main-loop lifetimeemscripten-mainloop-game-lifetime.md, Game::~GameA 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. ~Game uninstalls the ambient accessor in its body, before any member dies, so teardown code must use its stored platform, never GetCurrentPlatform(), which would lazily create a fresh default when nothing is installed (code that must not do that asks HasCurrentPlatform()).
  • Components_ is declared before GraphicsDevice_, so a component held through the shared_ptr overload of Add is destroyed after the device, content manager, window and services and must not call back through its Game.
  • Game::Dispose(true) does not dispose the Game-owned device. It disposes IDisposable components, then Content_, then the registered device service (which raises DeviceDisposing, where UnloadContent runs). The device is disposed by ~GraphicsDevice or by game code; ~Game calls Dispose(false), which only marks the game disposed.
  • The registry is drained while the renderer lives. GraphicsDevice::Dispose(bool) sets isDisposed_ first, raises Disposing, moves resources_ into a local and disposes each resource, then runs destroyNativeResources() (renderer, presenter, window) and releases its one video-subsystem reference. The registry tracks and never deletes.
  • A weak token protects late resources. Each GraphicsResource holds a std::weak_ptr<void> of the device's resourceDeviceLifetime_ and checks expired() before calling back into the device; ~GraphicsDevice resets it after Dispose(). It extends nothing: a CNA::Graphics object 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.

PointerLender and borrowerWhat protects itWhat does not
GraphicsDevice::platform_Game (or the ambient lazy default) → deviceDeclaration order: platform_ is destroyed lastA device constructed on its own must not outlive the ambient default; not a token
Game::Services_ entries, graphicsDeviceService_, graphicsDeviceManager_Caller-owned GraphicsDeviceManager → gameThe 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 registryEach resource unregisters itself; the device drains it at disposalAn entry whose object died without unregistering
ContentManager to deviceGame-owned device → contentMembers declared in that order; Unload() only clears the cacheCopies the caller still holds survive Unload(); Unload() is not disposal of every retained resource
Ordered component vectors and frame snapshotsCollection → Game raw IUpdateable*/IDrawable*componentListsMutex_ for list changes; snapshot entries nulled on removalRemoval from another thread during a frame; the collection's own storage has no lock
VideoPlayer ↔ VideoRaw pointers in both directionsNothing stated in the headerDestroying a Video before its player is stopped, disposed or destroyed (source-read, no test)
C API borrowed handlesGame → C callerGeneration invalidation when the callback returns; owner tokenRetaining 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 sourceBarrierPinned 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 methodsAudioDeviceConformanceTests.StartStopAndCloseFormCallbackBarriers, Sdl2AudioDeviceTests.OpenIsPausedAndStopIsACallbackBarrier
SDL3 mixerGeneration counter bumped before device stop and close; deferred free list for tracks destroyed from their own stopped callbackCnaMixer.ATrackDestroyedFromItsOwnStoppedCallbackLeavesTheMixAndIsFreedLater (CNA mixer), the mixer-destroy harnesses
Dynamic streamsFrameworkDispatcher::Update copies the stream list under a non-recursive mutex and updates unlocked, because a BufferNeeded handler may dispose its own streamFrameworkDispatcherTest.UpdateDoesNotDeadlockWhenBufferNeededDisposesTheInstance
Accelerometer, GyroscopePer-class mutex, snapshot of registrations, in-flight dispatch token that Dispose waits onAccelerometerTests.NoDispatchAfterDispose, DisposeFromWithinOwnCallbackDoesNotDeadlock
Compass, MotionGeneration-checked shared control block; user code runs outside the lockAccepted, documented gap: a callback already past its check while another thread finishes destroying the owner
C API lifecycle callbacks and registrationsSynchronous on the creation thread; non-reentrant calls refused; registrations survive game destroy but are invalidated after the disposal event; contexts stay caller-ownedCALLBACKS_AND_THREADING.md, CApi_StressSmoke, CApi_AudioSmoke
Foreign finalizers and garbage collectorsCreation-thread check: an off-thread destroy answers CNA_RESULT_THREAD and the handle stays liveThe 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.

ShapeConcrete exampleStatus at TARGET
Static destruction orderThe 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 platformAn X11 connection destroyed after its error-policy display list and mutexFixed by the same idiom in X11Error.cpp; X11Live.AProcessExitingWithTheCurrentPlatformOpenTearsDownCleanly checks a harness's exit status
Static destruction versus a library shutdown callVibrateController'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 destroyedThe 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 exitThe 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 pointersA 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 namesFixed: list lock plus nulling snapshot entries; GameTest.AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame and GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem. Open: removal from another thread
Resource outliving its deviceA shared-owned component (destroyed after the device) or a late copy of a textureGuarded 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 disposalA Disposing handler disposing again, or a resource unregistering while the registry is walkedGuarded by isDisposed_ first and moving the registry out; GraphicsDeviceLifecycleTest.DisposalIsReentrantAndReleasesABoundRenderTarget
Failed-constructor leakA throwing member constructor never reaches ~Game, leaving the ambient pointer aimed at a dead platformGuarded by platformInstallation_; GamePlatformOwnershipTest.AFailedConstructionLeavesNothingInstalled
Reference-count imbalanceTwo independent campaigns each acquired the video subsystem while one release stayed in Dispose; the subsystem stayed up for the process. Nothing observable failedFixed by a single owned flag (setVideoSubsystemAcquired); GraphicsDeviceSubsystemLifecycleTest counts acquisitions on a decorated real platform
Deferred-record lifetimeOn 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 loopAn older implementation unwound the caller of Run(), destroying a local Game while the registered frame callback still held its addressFixed: 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 objectMediaPlayer::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 objectOpen (source-read, no test)
Singleton bound to a shorter-lived ownerPhoneApplicationService::getCurrentProperty() is a function-static; attached to a Game destroyed earlier, its destructor calls DetachEXT on a dangling pointerOpen (source-read, no test)
Operations after disposalStorageContainer operations keep working after Dispose; streams outlive the container and device; the same holds through the C routeOpen
Comment versus codecna_game_destroy's comment says shutdown already disposed the canonical device; Game::Dispose(true) disposes only the registered serviceOpen (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 a detect_leaks=0 control 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:

HarnessHazard it isolatesSpawned or registered by
TeardownLifetimeSmoke.c: modes explicit, game-alive, game-alive-no-frame, children-alive, partial, cycles, device-aliveC game and children left alive at exitCApi_TeardownLifetime_<mode> (needs CNA_BUILD_C_API=ON)
x11_exit_harness.cppNative X11 platform still open at exitX11PlatformIntegrationTests (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 harnessesDestroying the SDL3 mixer while an instance is alive, then using the orphaned instanceAudioMixerTests (SDL3 audio only)
cnaext_leak_loop_test.cpp1,000 frames and 50 resizes repeated, for LeakSanitizer to watch; asserts only that the memory estimate does not growRun 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Repeat. One pass does not show a race away: use --gtest_repeat and TSan, and read the skip count, since a window-needing test skips where none can be created.
  6. 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.
  7. 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=0 describes the environment, not a fix.
  8. 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.
  9. 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.

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

Architecture
Runtime lifecycle
Tests and validation
Test architecture