Case study: Game component lifetime

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. 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

FactValue
Commit3faa1930aaf0d6585a67faafe9208dfd18f6031c, 2026-09-06, “fix(SAMPLE-065): keep Game's components valid across the loading-screen pattern”
FilesGame.hpp (+11), Game.cpp (+64/−12), GameTests.cpp (+156): 219 insertions, 12 deletions
Position in historyAfter 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 TARGETThe locking and snapshot-nulling code is present verbatim in Game.cpp; both regression tests are in GameTests.cpp
Same-day follow-up43748c88 “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:

  1. Game::Update and Game::Draw in Game.cpp: how each frame copies an ordered list into currentlyUpdatingComponents_ / currentlyDrawingComponents_ and then calls every component.
  2. Game::DoInitialize: the initial categorization and the ComponentAdded / ComponentRemoved subscriptions that route collection changes to OnComponentAdded / OnComponentRemoved.
  3. CategorizeComponent, SortUpdateable, SortDrawable and the order-changed handlers: the other writers of the ordered lists and of the two token maps.
  4. The private member block of Game.hpp: the four vectors of raw IUpdateable* / IDrawable* and the comment on componentListsMutex_.
  5. GameComponentCollection.cpp: InsertItem and RemoveItem, which raise the events that reach Game.

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.cppUnder componentListsMutex_?Note
Snapshot copy in Update / DrawYesReleased before the loop that calls components
The loop calling Update/Draw on each entryNoSkips nullptr and disabled/invisible entries
Clearing the ordered lists in DoInitializeYes
CategorizeComponent and the two order-changed token mapsYesThe comment notes a loader thread writes the maps while the game thread reads and erases them
SortUpdateable / SortDrawableYes (re-entered)Also reached from OnUpdateOrderChanged / OnDrawOrderChanged
OnComponentRemoved, including nulling snapshot entries and removing the order-changed tokenYesRemoving the token calls the component's event Remove under the lock
OnComponentAdded's component->Initialize()NoRuns on the adding thread before CategorizeComponent takes the lock
GameComponentCollection's own items_ and owned_No lock at allThe 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. OnComponentRemoved writes 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::Add from a loader thread and Remove on the game thread both modify items_ without a lock. The regression test only adds from one thread while the other thread calls Game::Update/Game::Draw, which never touch items_.
  • Initialization happens on the adding thread. Because OnComponentAdded calls Initialize() directly, a DrawableGameComponent added from a loader thread runs LoadContent() there (DrawableGameComponent.cpp). GPU work in that LoadContent is 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. ContentManager itself 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: a RemovingComponent with update and draw order −1 removes a CountingComponent from inside its own Update() and Draw() and then frees it with unique_ptr::reset(), exactly as a screen manager does. The test asserts the frame completes and the victim is gone.
  • GameTest.ComponentsAddedFromALoadingThreadSurviveTheFrameThatIsIteratingThem: a std::thread adds 200 components while the main thread loops Update/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. OnComponentAdded initializes on the adding thread; that single fact decides where a component's LoadContent runs.
  • 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

  1. Read the diff: git show 3faa1930aa -- modules/runtime/src/Game.cpp modules/runtime/include/Microsoft/Xna/Framework/Game.hpp, then git show 43748c883.
  2. Confirm both commits are ancestors of the snapshot: git merge-base --is-ancestor 3faa1930aa 009d40f5 && echo yes.
  3. List every writer of currentlyUpdatingComponents_ in Game.cpp at the snapshot and write the thread each runs on.
  4. Explain in two sentences why the mid-frame test passes without any second thread, and why the threaded test would still pass if OnComponentRemoved did not null snapshot entries.
  5. 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.

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

Architecture
Runtime lifecycle