#170 root-caused + fixed: the #163 SteamAPI_Shutdown was the crash -- at round end every peer's close events are ALWAYS in flight, and Shutdown tears client state down while steamclient's own service thread dispatches them (the field stack: a steamclient worker calling literal NULL, zero btl4 frames, 7 hits / 3 machines / one module-relative stack). BTSteamNet_ShutdownAll is now a QUIESCE: steamActive gated FIRST, linger closes ('round over' not timeouts), one SNS drain, 60ms grace, NO SteamAPI_Shutdown -- and every exit path dies via TerminateProcess (menu-quit no longer returns through the CRT; it uses BTOrderlyDie like everything else, so the 883 detach deadlock stays dead too). Status callback guarded for the dying window. Crash filter now names foreign DLL frames (module+offset via VirtualQuery/GetModuleFileName) so the next foreign-thread crash arrives pre-symbolized. Exit contract: steam-networking.md

This commit is contained in:
Joe DiPrima
2026-08-12 23:23:23 -05:00
parent c3258ee2e1
commit 2fe58b8b89
5 changed files with 106 additions and 18 deletions
+5
View File
@@ -1208,6 +1208,11 @@ consistent with internals-only, see #103). Logs: `scratchpad/night16/` + Oracle
menu-relaunch boundary. Intermittent (10 relaunches -> 3 crashes in one log). [T4 hypothesis:
callback left registered across teardown; possibly a #163/#156 teardown-ORDER regression.]
Players report it as "crash while joining lobby" -- it is not the join.
**ROOT-CAUSED + FIXED same night [T2]:** the first #163 fix's `SteamAPI_Shutdown()` inside
`BTSteamNet_ShutdownAll` raced steamclient's service thread (round-end = a burst of peer
closes always in flight). ShutdownAll is now a QUIESCE (no Shutdown; linger closes; seam
gated first) and every exit path dies via TerminateProcess -- see [[steam-networking]]
§THE EXIT CONTRACT. Field verify Friday; the crash filter now names foreign DLL frames.
- **#171 -- ballistic/missile hitscan era question (HOLD until group consensus).** Era witnesses
(Oracle, Lynx, Sauron; Dplotta softer; Draco DISSENTS) recall AFC/Gauss as instant-hit,
no lead, no flight animation, single-panel damage, no splash. 913 field observations: locked
+26
View File
@@ -60,6 +60,32 @@ reset on death), #69 (Sunder V1 → Denkou naming).
token / transport layer. Everything above the seam is the same arcade mesh — the three replication
symptoms this night produced would all reproduce on the relay.
## THE EXIT CONTRACT (#163 -> #170, settled 2026-08-12) [T2 field-proven]
Process-exit topology for every glass/steam process: **flush -> quiesce -> TerminateProcess.**
`BTSteamNet_ShutdownAll` (L4STEAMNET.cpp) is a QUIESCE: gate `steamActive` FIRST (stops
marshal/game-thread seam re-entry), `CloseConnection(..., linger=true)` on every connection
("round over", not a peer-side timeout), close listeners, one `sockets->RunCallbacks()` drain,
`Sleep(60)` grace. **It deliberately never calls `SteamAPI_Shutdown()`, and no exit path may
return through the CRT** (menu-quit included -- it dies via `BTOrderlyDie`, btl4console.cpp).
The two-failure history, because each fix's reason constrains the other:
- **883 (no teardown at all):** bare `ExitProcess` ran DLL_PROCESS_DETACH with live SNS threads
-> intermittent loader-lock deadlock -> "frozen view of how the game ended" + the menu child
hung in `SteamAPI_Init` against the wedged half-dead sibling (#163, night15).
- **913 (full `SteamAPI_Shutdown` before TerminateProcess):** at round end every peer closes
simultaneously, so status events are ALWAYS in flight; Shutdown tears client state down while
steamclient's own service thread dispatches them -> that thread calls literal NULL
(0xc0000005 access=8 target=0x0, BaseThreadInitThunk root, ZERO btl4 frames) in the window
before TerminateProcess (#170, night16: 7 hits / 3 machines / one module-relative stack).
- **The stable point:** TerminateProcess kills all threads atomically with every pointer still
valid (no detach, no dispatch window); the Steam client GCs the session on IPC pipe break --
the standard crashed-game path (883 relaunched all night with no Shutdown ever called). The
child-hang needed a WEDGED sibling, which TerminateProcess makes structurally impossible.
Forensics rider: the crash filter (btl4main.cpp `BTCrashFilter`) now prints foreign frames as
`<module.dll>+0xOFFSET` (VirtualQuery AllocationBase + GetModuleFileName), so a future
foreign-thread crash names its DLL instead of an unresolvable ASLR address.
## Key Relationships
- Rides: [[glass-cockpit]] miniconsole (marshal has a Steam-wire branch) · Extends:
[[multiplayer]] (the arcade mesh, unchanged above the seam)
+38 -5
View File
@@ -155,6 +155,10 @@ static void
static void __cdecl
OnConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t *status)
{
if (!steamActive)
{
return; // #170: the quiesce window -- the process is dying;
} // no accepts, no table writes, just survive the call
ISteamNetworkingSockets *sockets = SteamNetworkingSockets();
switch (status->m_info.m_eState)
@@ -634,7 +638,8 @@ unsigned long long
}
//
// #163 -- the ORDERED transport shutdown for process exit (2026-08-11).
// #163/#170 -- the ORDERED transport QUIESCE for process exit
// (2026-08-11, corrected 2026-08-12 -- see the #170 block at the tail).
//
// The port never called SteamAPI_Shutdown(): every round-end died via bare
// ExitProcess with live SNS connections and Steam callback threads, and the
@@ -655,6 +660,10 @@ void
{
return;
}
steamActive = 0; // #170 FIRST: gate every seam entry
// (Pump/Send/Recv/Accept) against
// re-entry from the marshal or game
// thread while the table is torn down
ISteamNetworkingSockets *sockets = SteamNetworkingSockets();
for (int i = 0; i < MaxConnections; ++i)
{
@@ -662,7 +671,9 @@ void
&& connections[i].connection != k_HSteamNetConnection_Invalid)
{
sockets->CloseConnection(connections[i].connection, 0,
"round over", false);
"round over", true); // #170 linger: let the close packet
// actually transmit -- peers should
// read "round over", not a timeout
}
connections[i].inUse = 0;
}
@@ -674,7 +685,29 @@ void
listenSockets[p] = k_HSteamListenSocket_Invalid;
}
}
SteamAPI_RunCallbacks(); // let the closes flush client-side
steamActive = 0;
SteamAPI_Shutdown();
sockets->RunCallbacks(); // one SNS drain; our status callback
// stays REGISTERED and this module
// stays LOADED, so any late dispatch
// lands in live (guarded) code
Sleep(60); // bounded grace for the lingered closes
//
// #170 -- DELIBERATELY NO SteamAPI_Shutdown(). The first #163 fix called
// it here, and that OVER-CORRECTED into tonight's field crash: at round
// end every peer closes simultaneously, so ClosedByPeer/ProblemDetected
// status events are ALWAYS in flight when this runs -- and Shutdown tears
// the client state down while steamclient's own service thread is still
// dispatching them. Field signature (night16, 7 hits / 3 machines): a
// steamclient-internal thread (BaseThreadInitThunk root, ZERO btl4
// frames) calling literal NULL (0xc0000005 access=8 target=0x0) in the
// window between Shutdown and TerminateProcess. The contract now
// [T2 field-proven]: quiesce only; EVERY caller must die via
// TerminateProcess (never a CRT return -- that re-opens the DLL-detach
// deadlock this function no longer prevents). TerminateProcess kills
// all threads atomically with every pointer still valid; the Steam
// client GCs the session on IPC pipe break (the standard crashed-game
// path -- build 883 relaunched all night without a single Shutdown
// call). The 883 child-hang-in-SteamAPI_Init needed a WEDGED
// (detach-deadlocked, half-dead) sibling; TerminateProcess makes wedges
// structurally impossible, so the child boots clean without Shutdown.
//
}
+34 -10
View File
@@ -216,7 +216,28 @@ static LONG WINAPI
if (eip >= base && eip < base + 0x2000000UL)
std::cout << "btl4+0x" << std::hex << (eip - base);
else
std::cout << "0x" << std::hex << eip;
{
//
// #170: name the owning MODULE -- a foreign-DLL stack is
// unresolvable after the fact without its (ASLR'd) base; the
// steam-worker NULL-call needed this and didn't have it.
//
MEMORY_BASIC_INFORMATION fmbi;
char dll_path[MAX_PATH];
if (VirtualQuery((void *)eip, &fmbi, sizeof(fmbi)) != 0
&& fmbi.AllocationBase != NULL
&& GetModuleFileNameA((HMODULE)fmbi.AllocationBase,
dll_path, sizeof(dll_path)) != 0)
{
const char *dll_name = dll_path;
for (const char *s = dll_path; *s; ++s)
if (*s == '\\' || *s == '/') dll_name = s + 1;
std::cout << dll_name << "+0x" << std::hex
<< (eip - (unsigned long)fmbi.AllocationBase);
}
else
std::cout << "0x" << std::hex << eip;
}
MEMORY_BASIC_INFORMATION mbi;
if (ebp == 0 || (ebp & 3) != 0
|| VirtualQuery((void *)ebp, &mbi, sizeof(mbi)) == 0
@@ -1241,17 +1262,20 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (BTFrontEnd_Run(&fe_spec) != 0 || fe_spec.mode == BTFeLaunchNone)
{
BTLastrunNote("clean exit: player quit from the menu");
#ifdef BT_STEAM
// #163: the menu keeps the Steam transport up for the lobby;
// returning through the CRT would run DLL detach with live
// Steam threads -- the same intermittent deadlock the
// round-end relaunch hit. Tear it down deliberately first.
//
// #163/#170: the menu keeps the Steam transport up for the
// lobby, and a CRT return would run DLL detach with live
// Steam threads (the 883 wedge). ShutdownAll is now a
// QUIESCE -- it deliberately does NOT stop those threads
// (SteamAPI_Shutdown mid-dispatch was the #170 NULL-call
// crash) -- so this path must die exactly like every other
// exit: flush, quiesce, TerminateProcess. One topology.
//
{
extern void BTSteamNet_ShutdownAll();
BTSteamNet_ShutdownAll();
extern void BTOrderlyDie(void);
BTOrderlyDie(); // never returns
}
#endif
return 0; // quit from the menu
return 0; // unreachable (TerminateProcess)
}
char fe_arguments[192];
char fe_value[64];
+3 -3
View File
@@ -181,9 +181,9 @@ static BOOL CALLBACK
return TRUE;
}
static void
BTOrderlyDie(void)
{
void
BTOrderlyDie(void) // #170: non-static -- the menu-quit
{ // path in btl4main dies through here
{
extern void BTMatchLogClose();
BTMatchLogClose(); // complete receipts on disk (the