Keep 32 KiB of every thread's and fiber's stack back for a stack overflow's crash report.

A stack overflow leaves the faulting thread only a guard page's worth of stack for the handler, and the crash
report needs more: main.c crash_handler's rt_log buffers are 2 KiB each, it walks the frames, and while the boot
builds pipelines its first call writes a file (plat_file_write's frame is about 4 KiB). Nothing in the runtime set
a stack guarantee, so the report faulted again; the verifier's probe ended with 0xC0000005 and no line once the
report's first call was the file write. The launcher starts the game with stderr on DEVNULL, so raw_note's stderr
line was all there was, and nobody saw it.

The new platform plat_stack_guarantee calls SetThreadStackGuarantee with PLAT_STACK_GUARANTEE (32 KiB) and runs
at the start of every thread win32.c thread_main starts, in fiber_start (a guest fiber's stack is its own and the
guarantee belongs to the running fiber), and on the main thread in main. Each thread and fiber commits that much
more of its stack. Linux's crash handling already runs on its signal stack (linux.c alt_stack), so it is empty
there.

Reproduced before the change by the new tests/crash_stack_check.py: a plat_thread_spawn thread and a plat_fiber_new
fiber recurse until their stacks run out, and the handler writes a file and three 2 KiB log lines. Without the
guarantee both ended with 0xC0000005 and no line; with it both write every line and end with 71.
thread_end_check, fatal_exit_check and the compile checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 17:18:05 -05:00
parent 790d201a59
commit 722a5dd1b0
6 changed files with 168 additions and 0 deletions

View File

@@ -641,6 +641,7 @@ static int main_run(int argc, char **argv);
// A game run never comes back here: it ends in plat_exit (boot.c, gfx_present.c). Every other mode returns its code.
int main(int argc, char **argv) {
plat_stack_guarantee(); // a stack overflow on the main thread still gets its crash report (platform.h)
mode_rc = main_run(argc, argv);
__atomic_store_n(&mode_done, 1, __ATOMIC_RELEASE); // from here on only the exit runs (mode_done, above)
return mode_rc;

View File

@@ -1929,6 +1929,8 @@ void plat_exit(int code) {
void plat_terminate(int code) { _exit(code); }
void plat_stack_guarantee(void) {} // the crash report runs on the signal stack crash handling sets up
int plat_setenv(const char *name, const char *value) {
return (value ? setenv(name, value, 1) : unsetenv(name)) == 0;
}

View File

@@ -17,6 +17,10 @@ typedef struct PlatThread PlatThread;
typedef void (*PlatThreadFn)(void *arg);
// Starts fn(arg) on a new thread named name (shown in debuggers and profilers; may be NULL). NULL on failure.
// Keeps PLAT_STACK_GUARANTEE bytes of the calling thread's (or fiber's) stack for its own stack overflow: the crash
// report then runs on them. The threads and fibers made here get it at their start; main calls it for its thread.
#define PLAT_STACK_GUARANTEE 32768
void plat_stack_guarantee(void);
PlatThread *plat_thread_new(PlatThreadFn fn, void *arg, const char *name);
// The same with the stack size given: stack bytes reserved for the thread (0: PLAT_STACK_DEFAULT). The default is the
// same on every OS, so a thread that fits on Windows fits elsewhere.

View File

@@ -44,8 +44,20 @@ struct PlatThread {
// ExitThread jumps back to the thread's start).
static void thread_handles_close(void);
// A stack overflow leaves the faulting thread a guard page's worth of stack, about 12 KiB of which a crash handler
// gets, and the report (main.c crash_handler) needs more: rt_log's 2 KiB buffers, the frame walk, and during the
// boot's builds a file write first (plat_file_write's frame is about 4 KiB). It faulted again inside the report, and
// the process ended with no FATAL line (the audit's probe: a 16 MiB-reserve thread recursing in 1 KiB frames). With
// 32 KiB kept back, the probe's report ran whole with room to spare. The guarantee belongs to the running fiber, so
// fiber_start asks for it too. Each thread and fiber commits that much more of its stack.
void plat_stack_guarantee(void) {
ULONG g = PLAT_STACK_GUARANTEE;
SetThreadStackGuarantee(&g);
}
static DWORD WINAPI thread_main(LPVOID p) {
PlatThread *t = p;
plat_stack_guarantee();
t->fn(t->arg);
plat_thread_end_run();
thread_handles_close();
@@ -336,6 +348,7 @@ void plat_fiber_unself(void) {
static void WINAPI fiber_start(void *p) {
PlatFiber *f = p;
plat_stack_guarantee(); // a guest fiber's stack is its own (kernel.c kern_fiber_init)
f->fn(f->arg);
}

80
tests/crash_stack_check.c Normal file
View File

@@ -0,0 +1,80 @@
// A stack overflow's crash report (runtime/platform/win32.c plat_stack_guarantee) with the platform layer linked: a
// thread made by plat_thread_spawn, or a fiber made by plat_fiber_new, recurses until its stack runs out, and the crash
// handler then writes its lines as main.c crash_handler does (2 KiB log buffers, a file write first, as during the
// boot's builds, and a frame walk). Built and run by tests/crash_stack_check.py, which wants every line and code 71.
// crash_stack_check thread <file> the overflow on a thread
// crash_stack_check fiber <file> the overflow on a fiber
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
#include <windows.h>
#include "platform/platform.h"
void rt_log(const char *fmt, ...) { (void)fmt; }
static void out(const char *s) {
DWORD w;
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), s, (DWORD)strlen(s), &w, NULL);
}
// main.c rt_log: a 2 KiB buffer on the stack and vsnprintf
__attribute__((noinline)) static void log2k(const char *fmt, ...) {
char buf[2048];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
out(buf);
}
static const char *file;
static void crash(const PlatCrash *x) {
// main.c crash_handler's first call while the boot builds pipelines: vk_pipeline_crash_note writes a file
static const char text[] = "the process faulted while the boot built pipelines\n";
int wrote = plat_file_write(file, text, sizeof text - 1);
log2k("report line 1: %s (%#x), the file written: %d\n", x->what, x->code, wrote);
log2k("report line 2: %d host frames\n", x->nframes);
void *f[62];
USHORT n = RtlCaptureStackBackTrace(0, 62, f, NULL);
log2k("report line 3: %u frames captured; the end\n", (unsigned)n);
TerminateProcess(GetCurrentProcess(), 71);
}
static volatile int never; // keeps the recursion from looking endless to the compiler
__attribute__((noinline)) static int recurse(int d) {
if (never) return 0;
volatile char pad[1024];
pad[0] = (char)d;
return recurse(d + 1) + pad[0];
}
static void overflower(void *arg) {
(void)arg;
recurse(0);
}
static PlatFiber *main_fiber;
static void fiber_body(void *arg) {
(void)arg;
recurse(0);
plat_fiber_switch(main_fiber);
}
int main(int argc, char **argv) {
if (argc < 3) return 2;
file = argv[2];
plat_crash_hook(crash);
if (!strcmp(argv[1], "thread")) {
plat_thread_spawn(overflower, NULL, "deep", 0);
} else {
main_fiber = plat_fiber_self();
PlatFiber *f = plat_fiber_new(1 << 20, fiber_body, NULL);
if (!main_fiber || !f) return 3;
plat_fiber_switch(f);
}
Sleep(10000);
out("main: no crash ended the process in 10 s\n");
return 0;
}

View File

@@ -0,0 +1,68 @@
"""Build and run tests/crash_stack_check.c: a stack overflow on a thread and on a fiber made by the platform layer
(runtime/platform/win32.c thread_main, fiber_start, plat_stack_guarantee) still gets its whole crash report, a file
write and three 2 KiB log lines, and ends with the report's code 71. Without the stack kept back the report faulted
again and the process ended with an access violation and no line. No game files.
Usage: crash_stack_check.py
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
LAYOUT = '''// A stand-in for the generated layout.h (tests/crash_stack_check.py).
#pragma once
#define MOD_MAIN_BASE 0x7000000000ull
#define MOD_MAIN_SIZE 0x4ea0000ull
'''
def compiler():
if os.environ.get('CC'):
return os.environ['CC']
if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists():
return str(MSYS_BIN / 'clang.exe')
return shutil.which('clang') or 'clang'
def main():
if os.name != 'nt':
print('crash_stack_check: Windows only')
return 0
work = ROOT / 'build' / 'test' / 'crash_stack'
work.mkdir(parents=True, exist_ok=True)
(work / 'layout.h').write_text(LAYOUT)
exe = work / 'crash_stack_check.exe'
env = dict(os.environ)
if MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
rt = ROOT / 'runtime'
plat = [rt / 'platform' / 'win32.c', rt / 'platform' / 'common.c']
libs = ['-lbcrypt', '-lws2_32', '-lpsapi', '-lwinmm', '-ldbghelp', '-lshell32', '-lole32', '-luser32', '-ladvapi32']
subprocess.check_call([compiler(), '-O1', '-g', '-Wall', '-Wno-unused-function', '-fno-strict-aliasing',
'-march=x86-64-v3', '-I', str(rt), '-I', str(work), str(ROOT / 'tests' / 'crash_stack_check.c'),
*map(str, plat), '-o', str(exe), '-lm', *libs], env=env)
bad = 0
for mode in ('thread', 'fiber'):
note = work / f'note_{mode}.txt'
note.unlink(missing_ok=True)
p = subprocess.run([str(exe), mode, str(note)], env=env, capture_output=True, text=True, errors='replace',
timeout=60)
lines = [f'report line {i}' for i in (1, 2, 3)]
missing = [x for x in lines if x not in p.stdout]
ok = p.returncode == 71 and not missing and 'stack overflow' in p.stdout and 'the file written: 1' in p.stdout
print(f'a stack overflow on a {mode}: exit code {p.returncode & 0xffffffff:#x} (want 0x47), '
f'{3 - len(missing)} of 3 report lines: {"ok" if ok else "FAIL"}', flush=True)
if not ok:
print(p.stdout[-2000:], p.stderr[-2000:])
bad += not ok
print(f'crash_stack_check: {"FAILED" if bad else "ok"}')
return 1 if bad else 0
if __name__ == '__main__':
sys.exit(main())