Skip to content

Stop the C++ GSDK's threads cleanly at process exit - #208

Merged
Dimitris Gkanatsios (dgkanatsios) merged 2 commits into
mainfrom
digkanat-microsoft-pr-178-triage
Oct 3, 2026
Merged

Dimitris Gkanatsios (dgkanatsios) merged 2 commits into
mainfrom
digkanat-microsoft-pr-178-triage

Conversation

@dgkanatsios

@dgkanatsios Dimitris Gkanatsios (dgkanatsios) commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Supersedes #178, without adding public API.

Problem

The C++ GSDK only stopped its heartbeat thread in ~GSDKInternal, which runs during static destruction, after the game's own exit-time cleanup and after GSDK's own static m_logFile is destroyed. As a result:

  • Every exit sent a heartbeat after teardown. ~GSDKInternal didn't wake the thread, so it waited out the heartbeat interval (delaying exit by up to one interval), then sent one more heartbeat. That heartbeat calls the game's health callback and may log into the destroyed m_logFile.
  • Custom allocators crashed at exit (Add a way to reset GSDK in cppsdk  #178). The heartbeat thread allocates, and ~GSDKInternal frees, after a game's allocator may already be gone.
  • Calling std::exit() from the shutdown callback aborted the process. Static destruction then runs on the std::async thread, so ~GSDKInternal destroys the future it's running on (libstdc++: std::terminate, "Resource deadlock avoided"). Exiting from the health callback had the same problem, because the heartbeat thread tried to join itself.
  • Destroying GSDK while the shutdown callback was starting re-created it. runShutdownCallback looked the instance up with get(), so the game's callback was skipped. Only tests destroy the instance, so this only affected tests.

Fix

No public API changes.

  • get() registers an atexit handler when the first instance is created (normally from GSDK::start() in main). Handlers and static destructors run in reverse order, so GSDK's threads now stop before anything set up earlier, GSDK's or the game's, is torn down.

  • Stopping signals the heartbeat event, so the thread wakes immediately and sends nothing more. The existing path where the shutdown callback returns still sends its last heartbeat, as before.

  • The shutdown callback runs on a std::thread with this. A thread is never joined from itself; it's detached instead. Exceptions from the callback are caught and logged; the std::future used to swallow them silently.

  • m_instance and m_logFile are never destroyed at exit; the OS reclaims them. Nothing gets freed after a game's allocator is gone, and game threads that still call GSDK while the process exits stay safe.

  • Test apps and docs. Without GSDK's crash, std::exit() from the shutdown callback now runs the whole exit sequence, and that deadlocked in two of our test apps under LocalMultiplayerAgent:

    • cppLinuxTestApp: glibc's exit-time stdio cleanup waits for stdin, which main() holds while blocked in getchar().
    • cppLinuxRunnerGame: destroying its static processRequestsThread future waits for a request loop that never ends.

    The test apps now flush stdout and call std::quick_exit(0); the Windows apps use the same patterns, so they get the same change. registerShutdownCallback now documents the pitfall.

Testing

I tested with LocalMultiplayerAgent v0.12.0-beta and its real heartbeat protocol, comparing main with this PR. That's about 190 runs in two setups:

  • the Linux build in process mode, where the game reads LMA's GSDK_CONFIG_FILE;
  • the Windows build running the game in Linux containers (-lcow, Docker Desktop).
Scenario main This PR
cppLinuxTestApp terminated by LMA SIGABRT (3/3) exit 0 (13/13)
cppLinuxRunnerGame terminated by LMA, with HTTP checks on its port SIGABRT (5/5) exit 0 (15/15); HTTP OK
Game calls std::exit(0) from the shutdown callback SIGABRT (12/12), 70–160 ms after exit exit 0 (12/12), 6–26 ms after; Terminating still reported (12/12)
Game calls std::exit(0) from the health callback SIGABRT (5/5) exit 0 (18/18)
Game returns from main while allocated exit delayed ~530 ms; 1 heartbeat and a health callback after the game's teardown (5/5) 1–7 ms; nothing after (18/18)
Same, with an allocator that fails after the game's teardown free after teardown, exit 42 (5/5) exit 0 (18/18)
Whole public API plus a maintenance event (callback sets a flag, main returns) exit 0 exit 0 (18/18); API outputs identical to main
  • Unit tests: two new tests (ShutdownCallbackThatThrowsDoesNotCrash, DestroyingGsdkWaitsForShutdownCallback). Equivalent Linux runs fail on main (the callback is skipped because GSDK gets re-created) and pass here. Windows CI passes all 17 tests.
  • AddressSanitizer: no new reports. main additionally reports the curl handle as leaked; it's now still reachable.
  • Windows: not run locally (no MSVC here). This relies on the Windows CI build and unit tests; the test apps aren't built in CI.
  • Timing note: the test apps now exit as soon as their shutdown callback runs, so they can exit before GSDK reports Terminating. That race exists for any game that exits immediately in the callback, including on main, and the C# GSDK has it too. LMA handles it without errors.

The heartbeat thread was only stopped during static destruction, after the
game's own exit-time cleanup. It then waited out the heartbeat interval
(delaying exit), sent one more heartbeat, called the game's health callback
and could log into the already-destroyed log file, and could allocate or
free through a custom allocator that was already gone (#178). Calling
exit() from the shutdown callback, as all C++ test apps do, aborted the
process: the std::async future destroyed itself on its own thread.

- Stop our threads from an atexit handler registered when GSDK starts, and
  wake the heartbeat thread instead of waiting out the interval.
- Run the shutdown callback on a std::thread using `this` rather than get(),
  and never join a thread from itself.
- Never destroy the singleton or the log stream at exit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
With GSDK no longer aborting during exit, calling std::exit() from the
shutdown callback runs the full exit sequence, which deadlocked in these
apps (found by running them under LocalMultiplayerAgent):
- cppLinuxTestApp: main() is blocked in getchar(), and glibc's exit-time
  stdio cleanup waits for it to release stdin.
- cppLinuxRunnerGame: destroying the static processRequestsThread future
  waits for processRequests(), which never returns.
The Windows apps follow the same patterns. Document the pitfall on
registerShutdownCallback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@dgkanatsios
Dimitris Gkanatsios (dgkanatsios) merged commit 0a60585 into main Oct 3, 2026
2 checks passed
@dgkanatsios
Dimitris Gkanatsios (dgkanatsios) deleted the digkanat-microsoft-pr-178-triage branch October 3, 2026 00:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant