Case study: Game component lifetime
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. The pre-fix and post-fix failure counts are the commit author's records; the two regression tests skip when no window can be created.
CNA commit 3faa1930 fixed a real crash: a sample aborted with “pure virtual method called” while its loading screen swapped game screens. The defect is historical and fixed; this page reconstructs it from the commit and then states what Game does in the TARGET snapshot, because the lesson generalizes to every raw-pointer snapshot in CNA. Nothing on this page was re-run: the failure counts quoted below are the commit author's records.
The historical commit
| Fact | Value |
|---|---|
| Commit | 3faa1930aaf0d6585a67faafe9208dfd18f6031c, 2026-09-06, “fix(SAMPLE-065): keep Game's components valid across the loading-screen pattern” |
| Files | Game.hpp (+11), Game.cpp (+64/−12), GameTests.cpp (+156): 219 insertions, 12 deletions |
| Position in history | After the v0.1.0-alpha.1 tag (alpha.1 still had the defect); an ancestor of snapshot 009d40f5 (checked with git merge-base --is-ancestor) |
| State at TARGET | The locking and snapshot-nulling code is present verbatim in Game.cpp; both regression tests are in GameTests.cpp |
| Same-day follow-up | 43748c88 “let GameComponentCollection own what XNA's owns” (see below) |
This is teaching material about a fixed defect, not a bug report. The point is the method: follow a pointer from the thread that inserted it, through the copy that captured it, to the code that may free its referent during a callback.
From symptom to owning subsystem
The commit message records the scenario. The XNA loading-screen pattern builds the next screen's components on a background thread and adds them to Game.Components while the game keeps drawing the loading art; NinjAcademy's and MarbleMaze's loading screens both do it. In one NinjAcademy transition the menu, faithful to the original game, rebuilds its loading screen on every frame it is still transitioning off, so a GameplayScreen that had already registered 87 components was dropped from inside the very update that was iterating them. The process aborted on “pure virtual method called”.
A pure-virtual abort is what a call through an object whose derived part has already been destroyed looks like. It is not evidence of a renderer fault just because a game was on screen at the time. The comment above the regression tests adds the second mechanism: before the lists were guarded, the frame's snapshot copy was reading updateableComponents_ while the loader thread's insert reallocated it.
Read in this order:
Game::UpdateandGame::DrawinGame.cpp: how each frame copies an ordered list intocurrentlyUpdatingComponents_/currentlyDrawingComponents_and then calls every component.Game::DoInitialize: the initial categorization and theComponentAdded/ComponentRemovedsubscriptions that route collection changes toOnComponentAdded/OnComponentRemoved.CategorizeComponent,SortUpdateable,SortDrawableand the order-changed handlers: the other writers of the ordered lists and of the two token maps.- The private member block of
Game.hpp: the four vectors of rawIUpdateable*/IDrawable*and the comment oncomponentListsMutex_. GameComponentCollection.cpp:InsertItemandRemoveItem, which raise the events that reachGame.
Two distinct hazards, not one mutex problem
Background loader -> Game.Components.Add -> ComponentAdded -> OnComponentAdded
(Initialize on the loader thread,
then CategorizeComponent under the lock)
Game loop -> copy ordered list into frame snapshot (under the lock)
-> release the lock -> call each component's Update/Draw
Removal inside a callback -> ComponentRemoved -> OnComponentRemoved
-> owner may free the component immediately afterwards
Frame snapshot holds raw pointers -> the next call would target a freed object
The first hazard is concurrent mutation while a frame copies an ordered list. The fix adds mutable std::recursive_mutex componentListsMutex_ and takes it around every list modification and around the snapshot copy. The lock is released before any component's Update or Draw runs, so game code never executes under it.
The second hazard survives a perfect lock. CNA's in-flight snapshots are vectors of raw pointers. In XNA the snapshot holds strong references, so a removed component stays alive until the frame ends; in CNA whoever removes a component usually frees it in the same breath. OnComponentRemoved therefore removes the component from the ordered list and sets any matching entry in the in-flight snapshot to nullptr. It cannot erase the entry, because the range-for in Update/Draw is still walking that vector; both loops already skip null entries. These are two separate invariants: a safe list copy, and no later callback into a removed component.
The mutex is recursive for a concrete reason recorded in the header comment: categorizing a component sorts it, and CategorizeComponent already holds the lock when it calls SortUpdateable/SortDrawable, which take it again; a component's own order-changed event re-enters through the same path. “Recursive” is a property of this implementation, not an invitation to hold the lock across arbitrary user code.
What the lock covers in the TARGET snapshot
Code path in Game.cpp | Under componentListsMutex_? | Note |
|---|---|---|
Snapshot copy in Update / Draw | Yes | Released before the loop that calls components |
The loop calling Update/Draw on each entry | No | Skips nullptr and disabled/invisible entries |
Clearing the ordered lists in DoInitialize | Yes | |
CategorizeComponent and the two order-changed token maps | Yes | The comment notes a loader thread writes the maps while the game thread reads and erases them |
SortUpdateable / SortDrawable | Yes (re-entered) | Also reached from OnUpdateOrderChanged / OnDrawOrderChanged |
OnComponentRemoved, including nulling snapshot entries and removing the order-changed token | Yes | Removing the token calls the component's event Remove under the lock |
OnComponentAdded's component->Initialize() | No | Runs on the adding thread before CategorizeComponent takes the lock |
GameComponentCollection's own items_ and owned_ | No lock at all | The collection is a plain vector plus a map |
What the fix does not establish
The following follow from reading the source; no test in the snapshot exercises them, so treat them as open questions for any change here, not as supported patterns:
- Removal from another thread while a frame iterates.
OnComponentRemovedwrites the snapshot entry under the lock, but the game thread reads that entry without it. If a loader thread removes (and frees) a component while the game thread is inside the loop, nothing orders the two. The fix is designed for removal on the game thread, from inside a callback. - Concurrent collection mutation.
GameComponentCollection::Addfrom a loader thread andRemoveon the game thread both modifyitems_without a lock. The regression test only adds from one thread while the other thread callsGame::Update/Game::Draw, which never touchitems_. - Initialization happens on the adding thread. Because
OnComponentAddedcallsInitialize()directly, aDrawableGameComponentadded from a loader thread runsLoadContent()there (DrawableGameComponent.cpp). GPU work in thatLoadContentis serialized against the frame only where a renderer implements a thread-context lease and only for the operations that take one; see the thread and callback map.ContentManageritself has no lock.
The same-day ownership follow-up
Commit 43748c88 (also 2026-09-06, also SAMPLE-065) attacked the ownership half of the problem. XNA's GameComponentCollection is a Collection<IGameComponent> and so holds a strong reference; a game that drops its own last reference leaves a live component registered rather than a dangling one. CNA held raw pointers only, which forced a faithful port to track and unregister everything it owned. The follow-up added Add(std::shared_ptr<IGameComponent>) and the matching Insert; the raw-pointer overloads are unchanged and remain right for a component the game holds as a member.
In GameComponentCollection.cpp at TARGET, the shared reference is stored in owned_ before the insert (so a ComponentAdded handler that removes the component again finds a reference to release) and erased in RemoveItem after ComponentRemoved is raised. That ordering is what makes the snapshot nulling above sufficient for shared-owned components: Game nulls the in-flight entry while the component is still alive, and only then can dropping the collection's reference destroy it. The five tests are GameComponentCollectionTest.AnAddedSharedComponentOutlivesTheCallersOwnReference, RemovingASharedComponentReleasesItAfterTheEvent, ClearReleasesEverySharedComponent, ARejectedSharedAddLeavesNoOwnershipBehind and TheCollectionReleasesWhatItStillOwnsWhenItGoesAway in GameComponentCollectionTests.cpp.
One more ownership edge matters at teardown: Components_ is declared before GraphicsDevice_ in Game.hpp, so a shared-owned component is destroyed after the device during ~Game. Its graphics resources then rely on the weak device-lifetime token described in the ownership map.
How the repair was proved
GameTests.cpp contains the two regression tests:
GameTest.AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame: aRemovingComponentwith update and draw order −1 removes aCountingComponentfrom inside its ownUpdate()andDraw()and then frees it withunique_ptr::reset(), exactly as a screen manager does. The test asserts the frame completes and the victim is gone.GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem: astd::threadadds 200 components while the main thread loopsUpdate/Draw; afterwards every component must have been updated and drawn at least once.
The commit reports that the threaded case aborted in 3 of 8 runs before the fix and 0 of 8 after, and that the mid-frame case aborts in 4 of 4 runs with the fix removed. Those are the commit author's recorded results, not a run made for these pages.
Both tests begin with CNA::Runtime::Testing::DefaultPlatformCanCreateWindow() (RuntimePlatformTestSupport.hpp) and call GTEST_SKIP() when the selected platform cannot create a hidden window. On a machine without a usable display they report skipped, not passed, so read the skip count. The threaded test's components use the default no-op LoadContent(), so it does not exercise GPU resource creation on the loader thread.
On a prepared checkout of the snapshot, focused evidence looks like this (commands checked against CMakePresets.json and cmake/UnitTests.cmake; not executed here):
# from the CNA checkout root (discovered tests also run from the source root)
cmake --preset unit
cmake --build --preset unit # builds the aggregate CnaTests
./cmake-build-unit/CnaTests \
--gtest_filter='GameTest.AComponentRemovedMidFrameIsNotCalledLaterInThatSameFrame:GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem' \
--gtest_repeat=50
# or the focused runtime binary, which is EXCLUDE_FROM_ALL and has no build preset of its own
cmake --build cmake-build-unit --target CnaRuntimeTests
./cmake-build-unit/CnaRuntimeTests --gtest_filter='GameTest.*:GameComponentCollectionTest.*'
Repeat the threaded case many times; one pass cannot establish the absence of a race. AddressSanitizer detects a dereference after destruction; ThreadSanitizer detects the data race itself; neither replaces a test that asserts same-frame semantics. There is no runtime-specific sanitizer preset, but CNA_SANITIZE=address or CNA_SANITIZE=thread in a separate build directory instruments every CNA-owned target, and the devices-asan / devices-tsan presets build the whole CnaTests aggregate (on the OPENGLES3 renderer), so GameTest.* can run there too if the host has a display. Before merging a change to this path, also run GameComponentCollectionTest.*, GameEventSemanticsGoldenTest.*, GamePlatformOwnershipTest.* and the unfiltered aggregate (note that GameComponentTests.cpp and DrawableGameComponentTests.cpp exist but contain no tests at this snapshot); the validation matrix lists the broader gate.
What this teaches a human reviewer
- Do not stop at the crash site. Trace who inserted a pointer, who copied it, and who may destroy its referent during a callback.
- Separate concurrency protection from ownership. The mutex protects list and snapshot transitions; it does not extend any object's lifetime after removal. Lifetime came from nulling the snapshot and, for shared-owned components, from releasing the collection's reference only after the event.
- Compare compatibility expectations carefully. A similar XNA control flow does not imply XNA's strong-reference guarantees in CNA's C++ representation; check which overload a port uses.
- Write one test per failure mechanism. Background mutation and in-frame removal are different mechanisms and got different tests.
- Preserve callback placement relative to locks. A “more synchronized” patch that runs component code under
componentListsMutex_invites deadlock and re-entrancy failures, because components add, remove and reorder components. - Write down the thread of every call you rely on.
OnComponentAddedinitializes on the adding thread; that single fact decides where a component'sLoadContentruns. - Use Git history as evidence of symptom and intent, then verify against current source. The follow-up commit changed the ownership model the same day; the source could have moved again.
Exercise: repeat the reconstruction
- Read the diff:
git show 3faa1930aa -- modules/runtime/src/Game.cpp modules/runtime/include/Microsoft/Xna/Framework/Game.hpp, thengit show 43748c883. - Confirm both commits are ancestors of the snapshot:
git merge-base --is-ancestor 3faa1930aa 009d40f5 && echo yes. - List every writer of
currentlyUpdatingComponents_inGame.cppat the snapshot and write the thread each runs on. - Explain in two sentences why the mid-frame test passes without any second thread, and why the threaded test would still pass if
OnComponentRemoveddid not null snapshot entries. - Sketch the test that would be needed before claiming cross-thread removal is safe, and name the assertion that would distinguish a pass from a lucky schedule.
This case applies the method in How to understand code you did not write. Its ownership edges also appear in the ownership and lifetime master map, its thread and callback constraints in the thread and callback map, and the runtime frame itself in One frame source trace. The user-facing component model is described in the Game loop guide.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- 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.
- Game components and the service container — Exact contracts of GameComponent, DrawableGameComponent, GameComponentCollection and GameServiceContainer in CNA: ordering, content loading, disposal without unregistration, events, iterators and type-keyed services.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-086: GameComponent::Dispose(true) does not remove the component from Game.Components — XNA 4.0's GameComponent.Dispose(bool) removes the component from its Game's Components before raising Disposed; CNA only raises Disposed, so a component disposed to retire it keeps being updated and drawn.
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- User guide
- Game loop guide: components
- Architecture
- Runtime lifecycle
- Internals
- One frame source trace · Ownership and shutdown
- Maintainer workflow
- How to understand code you did not write · Debug shutdown and lifetime behavior
- Tests and validation
- What to test after changing X · Test architecture
- Reference
- Test target index