Exit, Exiting, Dispose and destruction

CNA snapshot 3b43bd86  ·  Deep Dives › Framework core  ·  source links pinned to 3b43bd86

✓

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 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. The explicit-disposal path, its repetition and the component disposal rules have tests; destruction and throwing or re-entrant game-level handlers are source readings. XNA 4.0 statements come from a disassembly of its Game assembly.

A CNA game can end in three different ways — Run() returning, an explicit Dispose(), and C++ destruction — and they do different amounts of work. This page states what each one runs, in which order, how that compares with XNA 4.0, what happens on repeated, re-entrant and throwing disposal, and which component lifetimes are unsafe at shutdown. It is for game authors who rely on UnloadContent(), Exiting or Disposed, and for porting engineers whose XNA code assumes XNA's disposal order. The member-by-member teardown trace is Ownership and shutdown; the debugging procedure is Debug shutdown and lifetime behavior.

Exit() requests a stop; Exiting reports that the loop ended

The names invite a wrong model: that calling Exit() raises Exiting. In Game.cpp, Game::Exit() does exactly two things: it sets the public RunApplication flag to false and sets the private suppressDraw_ flag. It is idempotent, and it is also what the framework calls for a platform quit event, a window close request (CNA acts on the request itself rather than relying on a synthesized quit, because XNA's close button always ends the game) and a mobile Terminating event.

  • An Exit() from Update stops the current tick, as XNA's Tick does: no further catch-up update of that tick runs, and the tick's draw is skipped. A quit event that PollEvents handles at the start of a tick likewise prevents that tick's update, and every later Tick() returns at once while RunApplication is false. FNA keeps running the remaining catch-up updates, so code ported from FNA that counted on them gets fewer updates here.
  • The desktop loop checks RunApplication between ticks. When it is false the loop ends, and only then does RunLoop() call OnExiting(), which raises Exiting. After Exiting returns, Run() calls EndRun() and AfterLoop(). An Exiting handler therefore runs before an EndRun() override.
  • Exiting is a loop-boundary notification, not a cancellable request: the event carries plain EventArgs (why).
  • An Exit() from Draw sets suppressDraw_ after that tick already consumed the flag. The loop ends before the flag is looked at again, but if application code later sets RunApplication back to true and calls Run() again, the first tick of that run draws nothing.
  • RunOneFrame() never raises Exiting; a host that drives single frames must watch RunApplication itself, and after Exit() its further calls neither update nor draw. A host that wants the full run sequence one frame at a time calls the CNAEXT Game::RunFrameEXT() instead: its first call initializes and begins the run, and the call that finds the game exited raises Exiting, calls EndRun and returns false.

Three endings, three amounts of work

EndingWhat runsWhat does not
Run() returnsOnExiting (the Exiting event), EndRun, AfterLoopNothing is disposed; the caller still owns a complete game
game.Dispose()Dispose(true) — components, content manager, device service, and through the service's DeviceDisposing the game's UnloadContent() — then the Disposed eventThe game-owned GraphicsDevice is not disposed here; its destructor does that later
Destruction (scope exit, delete)Dispose(false), which only marks the game disposed; the ambient platform is uninstalled; members are destroyed in reverse declaration orderNo component disposal, no ContentManager::Dispose(), no device-service disposal, no UnloadContent(), no Disposed event

A typical MyGame game; game.Run(); program that never calls Dispose() therefore never sees UnloadContent(). That matches XNA's own split between Dispose() and the finalizer, but C++ makes the finalizer path the default, because every scope exit takes it.

What Game::Dispose() does, step by step

The public Dispose() is Dispose(true) followed by Disposed.Raise(this, EventArgs::Empty). The protected Dispose(bool) returns immediately if the game is already disposed; otherwise, when disposing is true, it:

  1. takes a snapshot of Components and calls Dispose() on every entry of it that is System::IDisposable, keeping each alive even if an earlier callback removes it — a GameComponent removes itself from the collection and raises its Disposed, and a DrawableGameComponent first runs its own UnloadContent() through DrawableGameComponent::Dispose(bool);
  2. calls Content.Dispose(), which unloads the content manager — disposing every asset it loaded, including copies the game still holds — clears the cache and marks the manager disposed;
  3. disposes the cached graphics-device service if it is IDisposable — for a GraphicsDeviceManager this unregisters its two services, releases the frame's renderer-context lease, unsubscribes from the device and raises DeviceDisposing, and the subscription that base Game::Initialize() installed calls the game's UnloadContent() there;
  4. sets the private disposed flag, last.

So an explicit disposal produces two distinct UnloadContent() calls on two distinct objects: the drawable component's in step 1 and the game's in step 3. By the time the game's runs, the content manager and its assets are already disposed, so it is a place to release resources the game created itself; calling Content.Unload() or Load there throws System::ObjectDisposedException. Without a registered device service (a game with no GraphicsDeviceManager, such as the demo_2d executable) step 3 does nothing and the game's UnloadContent() is never called.

Compared with XNA 4.0

XNA 4.0's Game (read from a disassembly of the Microsoft.Xna.Framework.Game assembly) hooks the device service's DeviceDisposing to a private handler that calls Content.Unload() and then UnloadContent(). Its Dispose(bool), when disposing, copies the component collection into an array, disposes every IDisposable entry of that array, disposes the graphics device manager, unhooks the device events and raises Disposed — from inside Dispose(bool), with no disposed guard.

AspectXNA 4.0CNA at this snapshot
Component iterationOver a snapshot arrayOver a snapshot of the original components, each kept alive through the pass
Content managerUnloaded (still usable) by the device-disposing handler, just before UnloadContent()Disposed, with its assets, before the device service; a later Load or Unload throws System::ObjectDisposedException
Who raises DisposedDispose(bool), so an override that skips the base suppresses itThe public Dispose() wrapper, whatever the override does
Second Dispose()Disposes the components and the manager again and raises Disposed againCleanup skipped by the disposed flag; Disposed raised again
A component's own Dispose(true)Removes the component from Game.Components, then raises its DisposedThe same, on every explicit call

Consequences for a port: an XNA UnloadContent() that calls Content.Unload() is redundant in XNA, whose device-disposing handler has just unloaded the manager, but in CNA it throws System::ObjectDisposedException during Game::Dispose(), because the manager is already disposed; the exception leaves the game undisposed (see throwing disposal), so remove that call or guard it. Loading anything there throws for the same reason. Components follow XNA: each one leaves Components when it is disposed, during play as at shutdown (components page).

What destruction does and does not do

~Game first stops the browser loop if this game is still driving it (Emscripten only, with an error log line), then calls Dispose(false), then uninstalls its platform from the ambient accessor; members are destroyed after the body. The false path only sets the disposed flag. At this snapshot there is no controller-subsystem or audio shutdown call in the destructor: Game no longer acquires the controller subsystem at all (the platform starts it lazily and releases it when the platform member is destroyed, last), and older descriptions of a gamepad shutdown on the disposing == true branch or an audio shutdown after Dispose(false) describe removed code.

  • The content manager's destructor is defaulted (ContentManager.hpp): it does not call its public Dispose(); the cache is simply destroyed with the member.
  • DrawableGameComponent has no destructor of its own. ~GameComponent calls Dispose(false), and a virtual call made from a base-class destructor resolves to the base's own override, so it runs GameComponent::Dispose(false), not DrawableGameComponent::Dispose(false). Destruction alone therefore never dispatches a drawable component's UnloadContent(), and a component's Disposed event is only raised on the disposing == true path.
  • A GraphicsDeviceManager that is a member of the derived game is destroyed before any Game member. Its Dispose(false) unregisters the services (the game is still alive), releases the frame lease and unsubscribes from the device; it does not raise DeviceDisposing, but it does raise the manager's own Disposed event, which is not gated on disposing.
int main()
{
    MyGame game;      // derived game owns its GraphicsDeviceManager as a member
    game.Run();       // Exiting, EndRun, AfterLoop
    game.Dispose();   // components, content, manager -> UnloadContent, then Disposed
    return 0;         // ~MyGame and its members, then ~Game and its members in reverse order
}

This is the order the regression tests use (GameTest.DisposingDeviceInvokesUnloadContent in GameTests.cpp). Keep the manager nested inside the game's lifetime: the manager dereferences its game when it unregisters, and the game keeps raw pointers to the manager that nothing clears.

Disposed: repeated, re-entrant and throwing disposal

  • Repeated. A second Dispose() skips all cleanup but raises Disposed again, to every subscriber. The comment on GameTest.RepeatedDisposeDoesNotReinvokeUnloadContent calls this FNA-faithful, and XNA 4.0 raises it on every disposing call as well. UnloadContent() is not repeated, because the manager's own disposed flag makes DeviceDisposing a one-time event; that test and GraphicsDeviceManagerTest.RepeatedDisposeDoesNotReraiseDeviceDisposing pin it.
  • Re-entrant. System::EventHandler::Raise invokes a snapshot of its handler list. A Disposed handler that calls game.Dispose() re-enters the wrapper, which raises Disposed again, which calls the handler again: unbounded recursion until the stack is exhausted. XNA 4.0 has no guard against this shape either. A handler should release only its own state and never dispose the game that is notifying it.
  • Throwing. The disposed flag is written only after all true-path cleanup succeeded. An exception from a component's Dispose, from the content manager or from the device service (including an UnloadContent() override that throws inside DeviceDisposing) propagates out of Dispose(), skips the remaining steps, leaves the game not marked disposed and suppresses the outer Disposed event. A later Dispose() takes a new snapshot: components disposed before the failure have already left the collection, while one that threw may still be listed and is disposed again (a component's explicit disposal is not idempotent: it repeats its UnloadContent(), its removal and its Disposed). The content manager checks its flag, but the manager marks itself disposed only after raising DeviceDisposing, so an UnloadContent() that threw is called again — one that always throws, such as one calling Content.Unload(), keeps the game from ever completing its disposal — and a component whose Dispose always throws blocks every later step for good.

No test at this snapshot exercises ordinary destruction of a game, a throwing UnloadContent() or re-entrant game disposal; those statements are read from the source. Component-level re-entry and exceptions are pinned by ComponentLifecycleCompatibilityTests.cpp.

Components that outlive the game

GameComponentCollection::Add(IGameComponent*) stores a raw, non-owning pointer, and Game subscribes a handler that captures the game to each registered component's UpdateOrderChanged / DrawOrderChanged event. Four facts combine:

  • Game::Dispose() disposes components, and a component derived from GameComponent removes itself from the collection as it is disposed, which makes Game drop its order-change subscriptions; a component that implements the interfaces without deriving from GameComponent stays registered and subscribed, and so does every component of a game that is only destroyed.
  • The collection's destructor is defaulted: it raises no ComponentRemoved event, so nothing unsubscribes during destruction either.
  • The component setters have no disposed guard: a disposed component still raises its order-change events.
  • A derived class's members are destroyed before its Game base, and before the base's Components member.

A component still registered in an explicitly disposed game can still re-sort itself inside that game (harmless). A component that survives the game's destruction — held by a screen manager outside the game, by another shared_ptr owner, or in a static — still carries a callback into the destroyed game, and its next setUpdateOrderProperty or setDrawOrderProperty is a use-after-free. Removing the component while both objects are alive closes both routes: Remove() raises ComponentRemoved, and Game then drops the component from its update and draw lists, nulls it in any in-flight frame snapshot and removes its order-change subscriptions. For a component that is a member of the derived game, removing it in the derived destructor is the robust pairing (example); nothing in ~Game dereferences a stale entry at this snapshot, but that is an accident of the current destructor, not a contract. A component added through the shared_ptr overload is owned by the collection and is destroyed with it — after the graphics device, so its graphics resources rely on the device-lifetime token described on Ownership and shutdown.

Evidence and limits

Read at this snapshot from Game::Exit, RunLoop, Dispose, Dispose(bool), Initialize, ~Game and the component callbacks in Game.cpp; GameComponent.cpp, DrawableGameComponent.cpp, GameComponentCollection.cpp, GraphicsDeviceManager.cpp and ContentManager.cpp; the event invocation rule in Sharp Runtime's System/EventHandler.hpp (next @ 41b918c9). The tests named above exist and were not executed; they cover the explicit-disposal path and its repetition, and ComponentLifecycleCompatibilityTests.cpp covers component disposal, removal and the disposal snapshot. The stopped tick is covered by GameTest.ExitStopsTheRemainingCatchUpUpdatesOfItsFrame and GameTest.AQuitHandledByPollEventsPreventsThatFramesUpdate in the same file, also not executed for this page. XNA 4.0 behaviour comes from a disassembly of its assembly, not from running XNA. Whether the content cache clear releases every asset's native resources depends on each asset type's ownership rules and is not traced here. The main() shape was syntax-checked with g++ -std=c++23 -fsyntax-only against the headers of the earlier evidence commit, whose declarations used here are unchanged at 3b43bd86; it was not built or run.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.

Tests and validation
Test architecture