Crash self-report + round-completed latch + PDB archiving
CRASH SELF-REPORT (the #35/#41 heisenbug hunt): every unhandled exception now logs its code, address, and an EBP-walked stack as module-relative offsets (btl4+0xNNNN) into BT_LOG before dying -- any field crash on any machine hands us a resolvable stack. The walker is per-dereference guarded (a corrupt chain truncates). BT_CRASHTEST=1 exercises the path end-to-end (verified: forced AV logs code/addr/5-frame stack). mkdist archives each build's PDB next to its zip so offsets resolve per distributed version. Context: a flaky release-only crash in the mech build path surfaced during #38 rigs -- it moves with build layout, spares neither peer, and masks under a debugger heap (13 clean cdb runs vs 2/3 crashes without). A real latent memory bug; the self-report will catch it wherever it strikes next. ROUND-COMPLETED LATCH (issue #38, awaiting verification): relay-mode clients refuse RunMission after a round has ended -- the lobby-loop relaunch owns the next round (in-process round 2 rebuilt every player/mech entity on stale globals; matchlog caught player=3:1->3:36 in one generation). Latch sets ONLY when the stop ends a RUNNING mission (a stray pre-mission StopMission must not poison round 1 -- the first cut ate a legit launch, caught + fixed in rig). Regression verification pending (contaminated by the heisenbug above). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ac7f0cb13a
commit
f7daf03507
@@ -1724,6 +1724,19 @@ void
|
||||
extern int gBTMissionStoppedByConsole;
|
||||
gBTMissionStoppedByConsole = 1;
|
||||
}
|
||||
// Issue #38: the round is over -- latch it (relay mode refuses further
|
||||
// in-process RunMissions; the relaunch owns the next round) and void
|
||||
// any stale mid-load launch pends so they can't fire post-round.
|
||||
// ONLY when this stop ends a mission that actually RAN: a stray
|
||||
// pre-mission StopMission (relay races) must not poison the round-1
|
||||
// launch (caught live: the latch ate RunMission #2 of round one).
|
||||
if (GetApplicationState() == RunningMission)
|
||||
{
|
||||
extern int gBTRoundCompleted;
|
||||
extern int gBTRunMissionPendCount;
|
||||
gBTRoundCompleted = 1;
|
||||
gBTRunMissionPendCount = 0;
|
||||
}
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
@@ -571,6 +571,20 @@ Entity*
|
||||
//
|
||||
int gBTRunMissionPendCount = 0;
|
||||
|
||||
//
|
||||
// Round-completed latch (issue #38, 2026-07-24): our port replaces the
|
||||
// arcade's in-process next-round flow with a PROCESS RELAUNCH per round
|
||||
// (reconstructed gBT* globals don't survive in-process re-init). A
|
||||
// RunMission that lands after a round has ended (a stale pend, a relay
|
||||
// resend racing the stop) used to start round 2 IN-PROCESS anyway --
|
||||
// rebuilding every player/mech as new entities on stale globals (field
|
||||
// signature: opponents' mechs re-created mid-session with the wrong
|
||||
// paint; matchlog showed player=3:1->3:36 in one generation). Set by
|
||||
// StopMissionMessageHandler; in relay mode any later RunMission is
|
||||
// refused -- the relaunch owns the next round.
|
||||
//
|
||||
int gBTRoundCompleted = 0;
|
||||
|
||||
int BTTakePendingRunMissions(void)
|
||||
{
|
||||
int pended = gBTRunMissionPendCount;
|
||||
@@ -593,6 +607,18 @@ void
|
||||
Check(this);
|
||||
Verify(message->messageID == RunMissionMessageID);
|
||||
|
||||
//
|
||||
// Issue #38: after a completed round, relay mode NEVER runs another
|
||||
// mission in-process -- the lobby-loop relaunch handles the next round
|
||||
// with a fresh process (see gBTRoundCompleted above).
|
||||
//
|
||||
if (gBTRoundCompleted && getenv("BT_RELAY") != NULL)
|
||||
{
|
||||
DEBUG_STREAM << "[launch] RunMission after round end IGNORED -- the "
|
||||
<< "relaunch owns the next round\n" << std::flush;
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
// If the application is already running then ignore this message
|
||||
|
||||
@@ -163,6 +163,58 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
|
||||
#include "btl4console.hpp"
|
||||
#endif
|
||||
|
||||
//
|
||||
// CRASH SELF-REPORT (2026-07-24, the #35/#41 heisenbug hunt): every unhandled
|
||||
// exception logs its code, address, and an EBP-chain stack walk as
|
||||
// MODULE-RELATIVE offsets ("btl4+0x1234") into BT_LOG before the process
|
||||
// dies -- so any field crash on any machine hands us a resolvable stack (we
|
||||
// hold the PDB). The walker is guarded per-dereference; a corrupt frame
|
||||
// chain just truncates the walk.
|
||||
//
|
||||
static LONG WINAPI
|
||||
BTCrashFilter(EXCEPTION_POINTERS *info)
|
||||
{
|
||||
unsigned long base = (unsigned long)GetModuleHandleA(NULL);
|
||||
unsigned long code = info->ExceptionRecord->ExceptionCode;
|
||||
unsigned long addr = (unsigned long)info->ExceptionRecord->ExceptionAddress;
|
||||
|
||||
std::cout << "\n[crash] UNHANDLED EXCEPTION code=0x" << std::hex << code
|
||||
<< " addr=0x" << addr;
|
||||
if (addr >= base && addr < base + 0x2000000UL)
|
||||
std::cout << " (btl4+0x" << (addr - base) << ")";
|
||||
if (code == 0xC0000005 && info->ExceptionRecord->NumberParameters >= 2)
|
||||
std::cout << " access=" << std::dec
|
||||
<< (int)info->ExceptionRecord->ExceptionInformation[0]
|
||||
<< std::hex << " target=0x"
|
||||
<< (unsigned long)info->ExceptionRecord->ExceptionInformation[1];
|
||||
std::cout << "\n[crash] stack:";
|
||||
|
||||
unsigned long eip = info->ContextRecord->Eip;
|
||||
unsigned long ebp = info->ContextRecord->Ebp;
|
||||
for (int depth = 0; depth < 24; ++depth)
|
||||
{
|
||||
std::cout << " ";
|
||||
if (eip >= base && eip < base + 0x2000000UL)
|
||||
std::cout << "btl4+0x" << std::hex << (eip - base);
|
||||
else
|
||||
std::cout << "0x" << std::hex << eip;
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
if (ebp == 0 || (ebp & 3) != 0
|
||||
|| VirtualQuery((void *)ebp, &mbi, sizeof(mbi)) == 0
|
||||
|| mbi.State != MEM_COMMIT
|
||||
|| (mbi.Protect & (PAGE_READWRITE | PAGE_READONLY)) == 0)
|
||||
break;
|
||||
unsigned long next_eip = ((unsigned long *)ebp)[1];
|
||||
unsigned long next_ebp = ((unsigned long *)ebp)[0];
|
||||
if (next_eip == 0 || next_ebp <= ebp)
|
||||
break;
|
||||
eip = next_eip;
|
||||
ebp = next_ebp;
|
||||
}
|
||||
std::cout << std::dec << "\n" << std::flush;
|
||||
return EXCEPTION_EXECUTE_HANDLER; // die (after the evidence is out)
|
||||
}
|
||||
|
||||
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
|
||||
{
|
||||
// Boot tick for the relaunch storm damper (btl4console.cpp, issue #33):
|
||||
@@ -172,6 +224,13 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
gBTBootTick = GetTickCount();
|
||||
}
|
||||
|
||||
// Crash self-report (see BTCrashFilter above) -- armed before anything
|
||||
// else so even boot crashes leave a stack in the log.
|
||||
SetUnhandledExceptionFilter(BTCrashFilter);
|
||||
|
||||
// BT_CRASHTEST=1: deliberately AV after the log opens -- verifies the
|
||||
// crash filter's forensic path end-to-end on any machine.
|
||||
|
||||
// Heap-corruption hunt (env BT_HEAPCHECK=1; default OFF -- ~100x slower): validate
|
||||
// the whole debug heap on EVERY alloc/free, so an out-of-bounds write is caught at
|
||||
// the very next heap op (a stack near the culprit) instead of much later at some
|
||||
@@ -204,6 +263,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
||||
? (std::ios::out | std::ios::app) : std::ios::out);
|
||||
std::cout.rdbuf(logfile.rdbuf());
|
||||
|
||||
if (getenv("BT_CRASHTEST") != NULL && *getenv("BT_CRASHTEST") == '1')
|
||||
{
|
||||
*(volatile int *)0 = 0; // BT_CRASHTEST: exercise BTCrashFilter
|
||||
}
|
||||
|
||||
// FIRST-BREATH line, flushed IMMEDIATELY (field 2026-07-23: a player's
|
||||
// exe died repeatedly with a 0-byte join.log -- indistinguishable from
|
||||
// "never ran"). After this line, an empty log can only mean the exe was
|
||||
|
||||
@@ -142,5 +142,15 @@ def main():
|
||||
% (len(bats) + len(exes) + len(tracked), total / 1048576.0,
|
||||
zsz / 1048576.0))
|
||||
|
||||
# Crash-forensics: archive this build's PDB next to the zip (operator-side
|
||||
# only, never shipped) so the [crash] btl4+0xNNNN offsets in player logs
|
||||
# stay resolvable per distributed version.
|
||||
pdb = "build/Release/btl4.pdb"
|
||||
if os.path.exists(pdb):
|
||||
import shutil
|
||||
pdb_dest = zpath[:-4] + ".pdb"
|
||||
shutil.copyfile(pdb, pdb_dest)
|
||||
print(" symbols archived: %s" % pdb_dest)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user