Skip to content

fix(gameserver): hand shutdown unregister to the game thread (#44) - #118

Merged
thesprockee merged 3 commits into
mainfrom
fix/44-shutdown-thread-registry-violation
Oct 6, 2026
Merged

thesprockee merged 3 commits into
mainfrom
fix/44-shutdown-thread-registry-violation

Conversation

@thesprockee

Copy link
Copy Markdown
Member

Summary

  • Closes gameserver: shutdown thread calls Unregister(), which touches a callback registry documented as main-thread-only #44: the shutdown thread called EndSession()+Unregister() directly, reaching the callback registry (documented main-thread-only, server_context.h:116-119) from off-thread. Confirmed via ReVault: EchoVR::BroadcasterUnlisten (0x140f8df20) takes no lock and only guards same-thread re-entry with a non-atomic bit — nothing protects it against a second thread.
  • Fix: new main_thread_handoff.{h,cpp} (mutex+condvar, no Sleep/polling). The shutdown thread hands EndSession+Unregister to Update() (serviced first thing each frame) and waits up to 30s; on timeout/cancel/busy it falls back to an off-thread path that still ends the session and tells ServerDB we're gone, but never touches the registry.
  • New lifecycle-invariant sensor in just verify: test_shutdown_thread_never_touches_the_callback_registry, fails against the pre-fix code.

Found, not fixed here

Test plan

  • New test_main_thread_handoff.cpp (9 tests), 200/200 repeat runs under Wine
  • Mutation check: forcing the pre-fix inline-run shape fails 7/9 tests
  • New lifecycle invariant sensor fails against pre-fix gameserver.cpp, passes after
  • just verify green (450 GTest declarations, capped CMAKE_BUILD_PARALLEL_LEVEL=4)

Not done

  • No live server run confirming the game thread services the handoff within the 30s bound in practice — the new handoff=<outcome> log line will show this on the first real shutdown.

🤖 Generated with Claude Code

BeginGracefulShutdown's thread called self->EndSession() and
self->Unregister() itself. Unregister reaches UnregisterAllCallbacks, which
reads and clears ServerContext's callback registry ("Not synchronized — only
safe from the game's main thread", server_context.cpp) and calls
EchoVR::BroadcasterUnlisten. ReVault BroadcasterUnlisten @ 0x140f8df20 takes
no lock: it unlinks the handle from the broadcaster's listener hash chain
(+0x5e0/+0x5f8/+0x648) and only defers the delete when a plain
dispatch-in-progress bit (+0x448 & 0x20000) is set, a same-thread
reentrancy guard for SBroadcasterData's dispatch loop. From another thread
that check races the main thread walking the chain.

The shutdown thread now hands EndSession + Unregister to
GameServerLib::Update() through GameServer::MainThreadHandoff and blocks
until the game thread has run it. The game can stop calling Update(), so
the wait is bounded (30 s, a chosen bound, not a measured one). On timeout
or cancel the shutdown thread ends the session and unregisters from
ServerDB itself (UnregisterFromServerDb(false)) and leaves the registry
alone; ForceFatalExit follows and the process takes the listeners with it.
A request the game thread has already started is never abandoned, so the
fallback never runs at the same time as the task. The destructor cancels a
pending request so its join does not wait out the timeout.

Logging: the shutdown thread records handoff=<ran|task_threw|timed_out|
cancelled|busy> with the game and shutdown thread ids; Initialize logs the
game thread id; UnregisterAllCallbacks warns if it is ever reached off the
thread that registered the callbacks.

Tests: test_main_thread_handoff.cpp (in test_callback_unregistration, run
by test-auth-unit), 9 tests, 200/200 repeats under Wine. With
RunOnServicingThread forced to run the task inline (the pre-fix shape),
7 of 9 fail, including "the request never reached the game thread". A
lifecycle invariant in tools/tests/test_runtime_lifecycle_invariants.py
fails on the pre-fix gameserver.cpp (matches self->Unregister() in
BeginGracefulShutdown) and passes now.

Not covered: no live server run. Today production never reaches
BroadcasterUnlisten: merge 033b303 dropped RegisterBroadcasterCallbacks'
cb.broadcasterOwner assignment (still present in parent d339898), so
UnregisterBroadcasterCallbacks only Clear()s the registry. The race fixed
here is on the registry fields now. It would also cover the lockless
unlisten once that wiring comes back.

Co-Authored-By: nevr-runtime <agents@sprock.io>
thesprockee and others added 2 commits October 5, 2026 20:16
…d-registry-violation

Resolve src/runtime/CMakeLists.txt list conflict from #103/#104/#111/#113
landing on main: keep both the test_main_thread_handoff comment and the
test_winhttp_stub target block.

Co-Authored-By: nevr-runtime <agents@sprock.io>
…d-registry-violation

Resolve src/runtime/CMakeLists.txt list conflicts from #109 landing on
main: keep both main_thread_handoff and serverdb_uri in the source/header
lists, SKIP_PRECOMPILE_HEADERS list, and the test_serverdb_uri target.

Co-Authored-By: nevr-runtime <agents@sprock.io>
@thesprockee
thesprockee merged commit dfca641 into main Oct 6, 2026
1 check passed
@thesprockee
thesprockee deleted the fix/44-shutdown-thread-registry-violation branch October 6, 2026 01:22
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.

gameserver: shutdown thread calls Unregister(), which touches a callback registry documented as main-thread-only

1 participant