recursion: capture native_stack tooling + working doc

Snapshot of the uncommitted native_stack capture tool (crash_trace.c,
append_native_stack) that recovers the true compiled<->dirty-interp
recursion cycle the recent_fn ring can't show, plus docs/RECURSION_BUG.md
documenting the freeze root cause, the 4-instance soak reproduction
harness, and the overworld-load regression now blocking repro.

This is the build that currently insta-crashes soaks on overworld load;
preserved as-is before hardening so the regression stays reproducible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-06-17 16:02:14 -07:00
parent 3ba40b02ae
commit d9b6ae5e16
2 changed files with 298 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
# Tomba long-run recursion freeze — working doc
Branch: `bug/recursion`. This is the single source of truth for the long-run
freeze investigation and the soak-harness regression that is currently blocking
reproduction. Update it as we learn.
---
## 1. The bug we are actually chasing (the freeze)
**Symptom.** Tomba runs correctly for a long time, then hard-freezes. Intermittent;
*often sooner than 15 minutes* but **not reliable**. The captured specimen is
`_freeze_specimens/tomba_4393_freeze_46440.dmp` (frame 46440).
**Root cause (from the minidump).** A **runaway guest recursion that never unwinds.**
The per-frame game-flow state machine re-enters *itself* across the
**compiled ↔ dirty-RAM-interpreter boundary**. Validated, symbolized cycle:
```
exec_one (main loop 0x8001A51C)
-> psx_dispatch_game_compiled(0x8001A954)
-> 0x8001A954 -> 0x8001AC00 -> 0x8001B2B4 -> 0x8001B5A8 -> 0x80046264
-> dirty_ram_dispatch
-> psx_dispatch_game_compiled(0x8001A954) <-- back to the top
```
Repeats ~1443 times -> native **fiber stack overflows** -> stack-depth guard halts
the process (`psx_fatal_halt`, which parks or exits). The state-machine fields
`[+0x4a]/[+0x4c]/[+0x4e]` on the controller object at `0x801FD800` are pinned at
`1/1/1` and never advance — that is the cycle that won't terminate. The ~1443
frames overflow nearly instantly *once the cycle starts*; the "~15m / intermittent"
is how long until the **trigger condition** is met, not how long the recursion runs.
**What it is NOT** (all prior hypotheses, refuted):
- NOT data corruption — `0x8001a954` is not a wild pointer; it exists nowhere in
RAM as data.
- NOT nested interrupt delivery — `in_exception=0`, exception counts normal.
- NOT a fiber deadlock/livelock.
- Scene id intact, table `DAT_8007c640` intact.
**Why it's slippery to capture.** `g_psx_dispatch_depth` reads only 5 — it is blind
to this, because the 1443 frames are direct `func_X(cpu)` calls it does not count.
The `recent_fn` ring is time-ordered, so it shows only the leaf churning at the trip,
**not** the recursing cycle. **This is the entire reason the native_stack tool
exists** (see §3).
**Suspected trigger.** CD-DMA dirty-page over-marking: `dma.c:695` marks every
DMA'd word executable/dirty and never clears it (463/512 RAM pages dirty at the
freeze), time-correlated with the onset of boundary re-entry.
**Fix territory (NOT yet done).** The interp<->compiled call/return contract
(`dirty_ram_dispatch` / `psx_dispatch_game_compiled`) must **unwind** at the
boundary instead of **nesting**; and/or stop `dma.c:695` marking non-code CD-DMA
data as executable/dirty. Same family as the "wild call contract" recursion bugs
(Bug A/C/D). The fix is in the **recompiler**, never in generated C.
---
## 2. The reproduction harness ("soaks") — and the regression blocking it
**Workflow.** Run **4 instances** in parallel ("Soak A/B/C/D"). Claude launches
them; the user navigates **2 to a New Game** and **2 into Dwarf Forest** (overworld),
then lets all four **idle**. Running 4x in parallel is how we hit the intermittent
freeze fast. Per-instance memcard dirs (`saves_a..d`), **software renderer**, 4:3,
ports 4393-4396 (`tomba_soak_{a,b,c,d}.toml`). Stay off heavy host work while
soaking (a wall-clock starvation watchdog, 4s no-heartbeat -> exit(2), false-trips
under host load).
**The regression (currently blocking everything).** On the current
uncommitted/debug build, **Soaks A-D crash the instant anything loads to the
overworld** — before idle, before any chance to reproduce the freeze. SIGSEGV,
**no `psx_last_run_report.json`**. This did not happen on the build that used to
soak faithfully for ~15m.
---
## 3. The native_stack tool (the thing that may be causing the regression)
Uncommitted tooling added to `runtime/src/crash_trace.c` (129 lines): a
host-stack walker `append_native_stack()` that recovers the true recursion cycle
(the `recent_fn` ring can't — see §1). It is called **only** from
`psx_crash_trace_dump()`, i.e. only while a crash/fatal/SEH dump is already in
progress. It walks from the faulting/the current `rsp` up to the fiber `StackBase`,
keeps only validated return addresses (value in `.text` preceded by a `call`),
run-length-collapses them, and emits module-relative RVAs + a histogram. Every
stack read is bounded by `VirtualQuery`; symbolize offline with `nm`
(`_freeze_specimens/analyze_named.py`, `decode_report.py`).
**Known hazard (handoff issue #3).** Both `psx_signal_handler` and
`psx_seh_handler` run **on the faulting stack** and call the dump. If the original
fault is the recursion's stack overflow, `append_native_stack` runs on the
already-exhausted stack and can **re-fault** — taking the whole report with it
(silent SIGSEGV, no JSON). As written, the tool meant to capture the overflow can
**destroy the very report it was built for.**
---
## 4. Evidence collected this session (artifact-level)
Compared `TombaRecomp/build-soak` (A, has tooling, crashes) vs
`TombaRecomp/build-soak-e` (B, pure master, healthy). MD5'd all 49 objects in each:
**only two differ** — `crash_trace.c.obj` and `main.cpp.obj`. `main.cpp` is an
AppImage `getenv("APPIMAGE")` change, **inert on Windows**. All generated BIOS/game
code (`SCUS_942.36_*.c.obj`, `SCPH1001_*.c.obj`) is **byte-identical MD5**. The
psxrecomp reflog shows HEAD parked at master `3ba40b0` since 09:34 that day, so A
and B were regen'd off the **same** master tip — **not** a flavor trap, **not**
stale generated code. (Those were the handoff's two hypotheses; both refuted.)
**Open logical tension.** `crash_trace.c`'s only new code runs inside the dump
path, and **every dump caller terminates**. So if a dump fired on overworld-load,
build B (no tooling) would stop too — contradicting "B gets into game." Two ways
out, to be settled empirically (§5):
1. B was never actually driven through the exact "New Game -> overworld" path the
soaks use; OR
2. **the overworld-load regression is in `master` itself** (this morning's merges:
ws-backdrop-coverage @09:27, controller change @09:34), not the tooling. The A-vs-B
diff can't see a master regression because both A and B are post-09:34 master.
---
## 5. Plan
1. **(done)** Branch `bug/recursion`, write this down.
2. **Unravel the overworld-load regression — decisive A/B from source.** Build two
clean builds from current master: **X = master + native_stack tooling +
"flush report before the walk" hardening**; **Y = pure master, no tooling**
(the existing `build-soak-e` already IS Y). Run 2 soaks on X + 2 on Y, navigate
all to overworld:
- Y also crashes -> it's a **master regression**; bisect master against the
last-known-good faithful-soak commit.
- Only X crashes -> it's the **tooling**; X's report (now flushed before the
walk) shows the real trigger — recursion-fired-immediately vs a separate fault.
- X crashes with **no** report even after the early flush -> the fault is
**outside** `psx_crash_trace_dump` -> not `append_native_stack`; look elsewhere.
3. **Return to the soak workflow** on whichever build survives overworld-load,
reproduce the freeze, and capture it with native_stack (the early-flush makes the
report survive the overflow walk).
4. **Fix the recompiler** (interp<->compiled unwind contract and/or `dma.c:695`
over-marking). Regen, rebuild, re-soak to confirm. No stubs, no HLE, never edit
generated C.
---
## 6. Build & run (this project)
```
cd /f/Projects/psxrecomp/TombaRecomp
export PATH=/c/msys64/mingw64/bin:$PATH TMP=/c/msys64/tmp TEMP=/c/msys64/tmp
# regen tools + BIOS + game (generated/ is gitignored; off master tip):
cmake --build ../psxrecomp/recompiler/build --target psxrecomp-game psxrecomp-bios -j8
(cd ../psxrecomp && ./recompiler/build/psxrecomp-bios.exe --config bios/SCPH1001.toml)
../psxrecomp/recompiler/build/psxrecomp-game.exe --config tomba_soak_e.toml
# build:
cmake -S . -B <build-dir> -G Ninja -DCMAKE_BUILD_TYPE=Release -DPSX_DEBUG_TOOLS=ON -DPSX_LAUNCHER=OFF \
-DCMAKE_C_COMPILER=C:/msys64/mingw64/bin/gcc.exe -DCMAKE_CXX_COMPILER=C:/msys64/mingw64/bin/g++.exe
cmake --build <build-dir> --target psx-runtime -j8
# run (per-instance card; taskkill first):
taskkill //F //IM psx-runtime.exe 2>/dev/null
nohup ./<build-dir>/psx-runtime.exe --game tomba_soak_a.toml > _soak_a.log 2>&1 &
# decode a freeze report's native_stack:
nm -n <build-dir>/psx-runtime.exe | awk '$2~/^[Tt]$/{print $1,$3}' > ../_freeze_specimens/allsyms.txt
python ../_freeze_specimens/decode_report.py psx_last_run_report.json ../_freeze_specimens/allsyms.txt
```
**Hard rules:** fix the recompiler, never generated C; no printf/log files (TCP
debug server + always-on rings; read `psx_last_run_report.json` for fatals);
per-instance memcard dir per concurrent instance (sharing corrupts the card ->
fake "regression"); software renderer for soaks; `taskkill //F //IM psx-runtime.exe`
before any launch.
+129
View File
@@ -21,6 +21,7 @@
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <intrin.h> /* __readgsqword — fiber TEB stack bounds for native_stack walk */
#endif
#include <stdint.h>
@@ -132,6 +133,123 @@ void psx_crash_trace_set_exit_origin(const char *origin) {
if (origin) s_exit_origin = origin;
}
/* ── Native call-stack snapshot ──────────────────────────────────────────
*
* The recent_fn ring is TIME-ORDERED (function entries over time), so for a
* runaway recursion its tail is dominated by whatever leaf was churning at the
* trip — it does NOT show the actual recursion CYCLE. This walks the running
* fiber's native stack at the fatal instant and recovers the true cycle:
*
* - Walk from the current/faulting RSP up to the fiber StackBase (TEB GS:[8]).
* - Keep only qwords that are genuine return addresses: a value pointing into
* this module's .text whose immediately-preceding bytes are a `call`
* instruction (filters spilled function pointers / stale data that merely
* look like code addresses).
* - Run-length-collapse consecutive equal frames and emit module-relative
* RVAs, plus a small frequency histogram (the recursion participants each
* appear ~depth times and dominate it).
*
* Emitted RVAs are build-relative (module base + RVA). Symbolize offline against
* the exact binary with nm (see _freeze_specimens/analyze_named.py, which reads
* this `native_stack` block directly). Works on BOTH the SEH path (uses the
* faulting ContextRecord->Rsp) and the graceful stack-guard halt path (walks the
* current frame), in debug and release — no minidump required. */
#ifdef _WIN32
static int crash_is_retaddr(uintptr_t v, uintptr_t text_lo, uintptr_t text_hi) {
if (v < text_lo + 7u || v >= text_hi) return 0;
const unsigned char *p = (const unsigned char *)(v - 7);
if (p[2] == 0xE8) return 1; /* call rel32 -> E8 at v-5 */
unsigned char m = p[5]; /* byte at v-2 */
if (p[4] == 0xFF && ((m >= 0xD0 && m <= 0xD7) || /* call reg */
(m >= 0x10 && m <= 0x17))) /* call [reg] */
return 1;
if (p[1] == 0xFF && p[2] == 0x15) return 1; /* call [rip+disp32] */
if (p[3] == 0xFF && p[4] >= 0x50 && p[4] <= 0x57) /* call [reg+disp8] */
return 1;
return 0;
}
static void append_native_stack(char *buf, size_t cap, size_t *pos, uintptr_t start_sp) {
HMODULE h = GetModuleHandleW(NULL);
uintptr_t mb = (uintptr_t)h;
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)h;
IMAGE_NT_HEADERS *nt = (IMAGE_NT_HEADERS *)((BYTE *)h + dos->e_lfanew);
IMAGE_SECTION_HEADER *sec = IMAGE_FIRST_SECTION(nt);
uintptr_t text_lo = 0, text_hi = 0;
for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (!memcmp(sec[i].Name, ".text", 5)) {
text_lo = mb + sec[i].VirtualAddress;
text_hi = text_lo + sec[i].Misc.VirtualSize;
break;
}
}
uintptr_t base = (uintptr_t)__readgsqword(0x08); /* fiber StackBase (high) */
char probe;
if (start_sp == 0) start_sp = (uintptr_t)&probe;
start_sp &= ~(uintptr_t)7;
append_fmt(buf, cap, pos,
" \"native_stack\": {\n"
" \"module_base\": \"0x%llX\",\n"
" \"text_lo_rva\": \"0x%llX\",\n"
" \"text_hi_rva\": \"0x%llX\",\n"
" \"frames\": [",
(unsigned long long)mb,
(unsigned long long)(text_lo - mb),
(unsigned long long)(text_hi - mb));
enum { HCAP = 24, MAX_RUNS = 256 };
uint32_t hk[HCAP]; uint32_t hc[HCAP]; int hn = 0;
uint32_t prev_rva = 0; int run = 0, emitted = 0, total = 0;
/* This runs in the crash/halt path, so it MUST never fault: bound every
* read with VirtualQuery and stop at the first non-committed / non-readable
* page or the PAGE_GUARD page (touching it would re-arm the overflow). The
* fiber StackBase (base) is only a hint — if it's stale/bogus the region
* walk terminates safely at the real committed-stack top anyway. */
if (text_lo && start_sp) {
MEMORY_BASIC_INFORMATION mbi;
uintptr_t region_end = 0;
for (uintptr_t a = start_sp; total < 300000; a += 8) {
if (base > start_sp && a + 8 > base) break; /* don't pass a good StackBase */
if (a + 8 > region_end) { /* (re)validate the page region */
if (!VirtualQuery((void *)a, &mbi, sizeof(mbi))) break;
if (!(mbi.State & MEM_COMMIT)) break;
if (mbi.Protect & PAGE_GUARD) break;
DWORD pr = mbi.Protect & 0xFFu;
if (!(pr & (PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY |
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)))
break;
region_end = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
}
uintptr_t v = *(uintptr_t *)a;
if (!crash_is_retaddr(v, text_lo, text_hi)) continue;
uint32_t rva = (uint32_t)(v - mb);
total++;
int found = 0;
for (int k = 0; k < hn; k++) { if (hk[k] == rva) { hc[k]++; found = 1; break; } }
if (!found && hn < HCAP) { hk[hn] = rva; hc[hn] = 1; hn++; }
if (total > 1 && rva == prev_rva) { run++; continue; }
if (run > 0 && emitted < MAX_RUNS) {
append_fmt(buf, cap, pos, "%s{\"rva\":\"0x%X\",\"n\":%d}",
emitted ? "," : "", prev_rva, run);
emitted++;
}
prev_rva = rva; run = 1;
}
if (run > 0 && emitted < MAX_RUNS)
append_fmt(buf, cap, pos, "%s{\"rva\":\"0x%X\",\"n\":%d}",
emitted ? "," : "", prev_rva, run);
}
append_fmt(buf, cap, pos,
"],\n \"total_frames\": %d,\n \"runs_emitted\": %d,\n \"histogram\": [",
total, emitted);
for (int k = 0; k < hn; k++)
append_fmt(buf, cap, pos, "%s{\"rva\":\"0x%X\",\"n\":%u}", k ? "," : "", hk[k], hc[k]);
append_str(buf, cap, pos, "]\n },\n");
}
#endif /* _WIN32 */
/* ── Main entry ──────────────────────────────────────────────────────── */
void psx_crash_trace_dump(const char *reason, void *seh_info) {
@@ -291,6 +409,17 @@ void psx_crash_trace_dump(const char *reason, void *seh_info) {
append_str(buf, sizeof(buf), &pos, "]\n },\n");
}
/* Native call-stack snapshot — the TRUE recursion cycle (recent_fn above is
* time-ordered and shows leaf churn, not the recursing frames). */
#ifdef _WIN32
{
uintptr_t sp = 0;
if (seh_info)
sp = (uintptr_t)((EXCEPTION_POINTERS *)seh_info)->ContextRecord->Rsp;
append_native_stack(buf, sizeof(buf), &pos, sp);
}
#endif
/* dispatch_ring tail (last 256) */
{
uint64_t total = crash_trace_dispatch_seq_get();