Repository navigation
Stop the C++ GSDK's threads cleanly at process exit - #208
Merged
Dimitris Gkanatsios (dgkanatsios) merged 2 commits intoOct 3, 2026
Merged
Conversation
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>
Dimitris Gkanatsios (dgkanatsios)
deleted the
digkanat-microsoft-pr-178-triage
branch
October 3, 2026 00:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 staticm_logFileis destroyed. As a result:~GSDKInternaldidn'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 destroyedm_logFile.~GSDKInternalfrees, after a game's allocator may already be gone.std::exit()from the shutdown callback aborted the process. Static destruction then runs on thestd::asyncthread, so~GSDKInternaldestroys 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.runShutdownCallbacklooked the instance up withget(), so the game's callback was skipped. Only tests destroy the instance, so this only affected tests.Fix
No public API changes.
get()registers anatexithandler when the first instance is created (normally fromGSDK::start()inmain). 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::threadwiththis. A thread is never joined from itself; it's detached instead. Exceptions from the callback are caught and logged; thestd::futureused to swallow them silently.m_instanceandm_logFileare 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, whichmain()holds while blocked ingetchar().cppLinuxRunnerGame: destroying its staticprocessRequestsThreadfuture 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.registerShutdownCallbacknow documents the pitfall.Testing
I tested with LocalMultiplayerAgent v0.12.0-beta and its real heartbeat protocol, comparing
mainwith this PR. That's about 190 runs in two setups:GSDK_CONFIG_FILE;-lcow, Docker Desktop).maincppLinuxTestAppterminated by LMASIGABRT(3/3)cppLinuxRunnerGameterminated by LMA, with HTTP checks on its portSIGABRT(5/5)std::exit(0)from the shutdown callbackSIGABRT(12/12), 70–160 ms afterexitTerminatingstill reported (12/12)std::exit(0)from the health callbackSIGABRT(5/5)mainwhile allocatedmainreturns)mainShutdownCallbackThatThrowsDoesNotCrash,DestroyingGsdkWaitsForShutdownCallback). Equivalent Linux runs fail onmain(the callback is skipped because GSDK gets re-created) and pass here. Windows CI passes all 17 tests.mainadditionally reports the curl handle as leaked; it's now still reachable.Terminating. That race exists for any game that exits immediately in the callback, including onmain, and the C# GSDK has it too. LMA handles it without errors.