Exit, Exiting, Dispose and destruction

CNA snapshot 009d40f5  ·  Deep Dives › Framework core  ·  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. Only the explicit-disposal path and its repetition have tests; destruction, throwing or re-entrant handlers and the component index-shift case 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 does not stop the current tick. The remaining catch-up updates of that tick still run; only the tick's draw is skipped.
  • 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.

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. walks Components by index and calls Dispose() on every entry that is System::IDisposable — for a DrawableGameComponent that runs its own UnloadContent() through DrawableGameComponent::Dispose(bool);
  2. calls Content.Dispose(), which unloads the asset cache and marks the content 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 is already disposed, so it is a place to release resources the game created itself. 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 the live collection by index
Content managerUnloaded (still usable) by the device-disposing handler, just before UnloadContent()Disposed before the device service; a later Load throws std::runtime_error ("ContentManager has been disposed.")
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 DisposedRaises its Disposed; the component stays registered

Consequences for a port: Content.Unload() inside UnloadContent() is harmless in CNA (the cache is already empty), but loading anything there throws. Because CNA walks the live collection, a component whose Dispose removes itself (or another component) from Components shifts the later entries down one index and the next component is skipped by that disposal pass. And components are not unregistered by being disposed, so a component disposed during play keeps being updated and drawn until it is removed (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() starts again from the first component. Most steps are idempotent there — GameComponent sets its own disposed flag before raising its Disposed, and the content manager checks its flag — but the manager marks itself disposed only after raising DeviceDisposing, so an UnloadContent() that threw is called again, and a component whose Dispose always throws blocks every later step for good.

No test at this snapshot exercises ordinary destruction, a throwing component or handler, re-entrant disposal or the index-shift skip; those statements are read from the source.

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 but neither removes them from the collection nor removes those order-change subscriptions.
  • 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 that survives 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 only. 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 TARGET headers; 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