commit 0142bdbd455d90d7c280c7e25131e45857a5a639
parent a0d3f7beb9a75d03bd13d3b65ffb49b1f5deb17e
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 9 Jun 2026 10:53:20 -0700
codegen: stack-probe large frames on Windows
kit's prologue reserves the whole frame in one `sub sp, sp, #N`, but Windows
grows a thread stack one guard page at a time: a sub that jumps SP more than a
page past the guard page leaves the skipped pages uncommitted, and the first
store into them faults with 0xC0000005 — and since SP is then in uncommitted
memory the kernel can't deliver the exception (no VEH/WER), so it presented as
a silent crash. This was the cause of the ~17 toy-case self-host compile
crashes; only Windows is affected (other OSes auto-grow on any access below SP).
Add an `abi_stack_probe_interval` ABI capability (4096 on the aapcs64_windows
and win64_x64 vtables, 0 elsewhere). The aarch64 prologue emits an inline
page-probe loop (aa_words_stack_probe — mirrors the linker's aa64_coff_chkstk
body but with no external symbol / reloc in the patched prologue region; clobbers
only x16/x17) before the sub when frame_size > interval; AA_PROLOGUE_WORDS grows
24->32 to fit it. The x64 prologue routes its existing __chkstk call through the
same capability (drops the redundant X64_WIN64_CHKSTK_THRESHOLD). Fully abstract
over the ABI — no os==WINDOWS checks.
Verified on the ARM64 Win11 VM with a cross-built kit.exe (269 inline probes):
Toy AOT compile+link+run is 166/166 correct exit codes (was 147/166 with 17
crashes); kit run (JIT) is 164/166 (the 2 misses are thread-local cases, a
pre-existing Windows-JIT TLS gap, not a crash).
Diffstat:
9 files changed, 128 insertions(+), 60 deletions(-)
diff --git a/doc/plan/windows.md b/doc/plan/windows.md
@@ -31,10 +31,29 @@ follow-on (rt path resolution).
`libkit_rt` on demand.
- **`kit run` (JIT)**: executes self-contained programs (compute, data/globals),
external calls (`rand`), and **libc I/O via dlsym** (`puts`).
-- **Toy AOT corpus on the VM** (native `kit cc` + execute, compare `.expected`):
- **147 pass / 0 miscompile / 17 crash / 2 skip** of 166 — see task 1.
+- **Toy corpus on the VM** (native `kit.exe`, compare `.expected`):
+ - AOT (`kit cc` compile + link + execute): **166 / 166 pass**, 0 miscompile,
+ 0 crash, at `-O0`.
+ - JIT (`kit run`): **164 / 166** — the two misses are the thread-local cases
+ (§1), which return a clean error, not a crash.
Fixes the bring-up required (all in tree):
+- **Large-frame stack probes in codegen** — *the fix that closed the ~17
+ silent self-host compile crashes.* kit's prologue reserves the whole frame in
+ one `sub sp, #N`, but Windows grows a thread stack one guard page at a time:
+ a `sub` that moves SP more than a page past the guard page leaves the skipped
+ pages uncommitted, and the first store into them faults with `0xC0000005` —
+ and because SP itself is then in uncommitted memory the kernel can't deliver
+ the exception to user mode, so it presented as a silent crash (no
+ VEH / `SetUnhandledExceptionFilter` / WER fired). Other OSes auto-grow the
+ stack on any access below SP, which is why only Windows was affected. Fix: a
+ new `abi_stack_probe_interval` ABI capability (4096 on the two Windows
+ vtables, 0 elsewhere); the aarch64 prologue emits an inline page-probe loop
+ (`aa_words_stack_probe`, mirroring the linker's `__chkstk` body but with no
+ external symbol / reloc in the patched prologue region) before the `sub` when
+ `frame_size > interval`, and the x64 prologue routes its existing `__chkstk`
+ call through the same capability. Verified on the VM: the full Toy AOT corpus
+ (was 147/166 with 17 crashes) is now 166/166.
- C frontend honors GCC asm-label renames on declarations (`Decl.asm_name`;
mingw `time`→`_time64`).
- COFF short-import `NameType` (NOPREFIX/UNDECORATE/**EXPORTAS**): the PE
@@ -58,44 +77,16 @@ Fixes the bring-up required (all in tree):
The items below are what is **not** yet done.
-## 1. Self-host miscompilation: kit.exe segfaults compiling ~17 toy cases (HIGH)
-
-The Toy AOT corpus on the VM is 147/166 with **zero exit-code mismatches** (no
-miscompiles of the toy programs themselves), but **17 cases deterministically
-crash kit.exe itself** with `0xC0000005` (silent access violation) during
-compilation.
-
-What's known:
-- The crash is in the **frontend/codegen** stage: `kit.exe cc -c case.toy` and
- `kit.exe run case.toy` both crash; the linker is not involved.
-- **Deterministic per case**: `01_return_const` (`return 7;`) crashes 12/12;
- `03_bitwise_shift` / `08_recursion_fib` always succeed. Not correlated with
- input size — the trivial case crashes, complex ones pass.
-- The crashers (from a clean run): `01_return_const`, `02_arith_precedence`,
- `05_if_else`, `06_while_sum`, `33_musttail_void`, `50_switch_statement`,
- `70_labeled_switch_break`, `90_continue_through_switch`,
- `96_data_relocations`, `97_let_pointer_pointee_assignment`,
- `100_record_data_relocation`, `101_extern_threadlocal_decls`,
- `117_many_enum_values`, `120_data_symdiff`, `122_data_entsize`, … (~17).
-- **It is a self-host miscompilation, not a frontend bug**: the *same* inputs
- cross-compile cleanly via the host `build/kit -target aarch64-windows`
- (valid PE32+). So `build/kit`'s aarch64-windows `-O0` codegen miscompiled
- some libkit frontend/CG function, and that miscompiled code in kit.exe faults
- on specific control/data flow.
-
-Next steps: get the faulting function. Either run kit.exe under a Windows
-debugger (WinDbg / `cdb`, or `Add-MpPreference` + a crash-dump) to get the
-faulting address → map to a libkit symbol; or bisect by building kit.exe with
-subsets at `-O0` vs a known-good path. A debug-instrumented cross build (asserts
-already on; add `kit_debug_printf` tracing around the toy compile pipeline)
-narrows the stage. This is the gating bug for a Windows 3-stage bootstrap
-(task 6).
-
-## 2. printf-family via `kit run` (JIT) needs libucrt static helpers
-
-`kit run` of a printf program fails: `undefined reference to 'printf'`. mingw's
-`<stdio.h>` printf wrapper resolves to the static `printf` (and
-`__local_stdio_printf_options`) that live only in `libucrt.a` — not DLL
+## 1. JIT runtime gaps: printf-family and thread-locals
+
+Two things an AOT `kit cc` link resolves but the in-process JIT (`kit run`) does
+not, on Windows specifically. Neither is a codegen bug — the same programs
+compile+link+run correctly under AOT, and run under the host JIT on
+macOS/Linux.
+
+**printf-family.** `kit run` of a printf program fails: `undefined reference to
+'printf'`. mingw's `<stdio.h>` printf wrapper resolves to the static `printf`
+(and `__local_stdio_printf_options`) that live only in `libucrt.a` — not DLL
exports — and the JIT links no static archives (it resolves externs via dlsym
over loaded DLLs + in-image symbols). AOT `kit cc` printf works fully (it links
`libucrt.a`); `puts` and other direct ucrtbase exports work under JIT.
@@ -107,9 +98,18 @@ routes the short-import members to dlsym) — the general fix; or (b) extend the
`__local_stdio_*_options` helpers) to the small set of libucrt-static stdio
entry points, handing JIT'd code kit.exe's own statically-linked copies.
-## 3. x86_64-windows parity
+**Thread-local local-exec.** `kit run` of a `threadlocal`-using program returns
+a clean error (rc 1), not the program's value — local-exec TLS isn't resolved
+by the in-process JIT on Windows. The same cases compile+link+run correctly via
+AOT `kit cc` (PE TLS directory) and under the host JIT, so it's a Windows-JIT
+TLS-resolution gap. Repro: `141_threadlocal_mutate` (expect 43),
+`142_threadlocal_multi` (expect 134) — the only two Toy JIT misses.
-aarch64-windows is the reference. For x64:
+## 2. x86_64-windows parity
+
+aarch64-windows is the reference. x64 codegen now shares the large-frame
+stack-probe gate (§Baseline) via `abi_stack_probe_interval`, but the self-host
+path isn't built/verified end to end:
- Build `rt-x86_64-pc-windows` (the variant exists in `mk/rt.mk` /
`driver/lib/runtime.c`; not built by default here).
- `scripts/windows_cross.sh x64` should produce an x64 `kit.exe`; verify it runs
@@ -118,23 +118,26 @@ aarch64-windows is the reference. For x64:
`*sret` tail-call crash at **-O1**, and `118_decl_extra_attrs` ADRP-range link
is aarch64-only. Re-confirm against a native x64 `kit.exe`.
-## 4. A committed "compile-on-VM" test lane
+## 3. A committed "compile-on-VM" test lane
`test/toy/vm.sh windows` today cross-compiles the toy cases on the *host* and
-only executes on the VM. The native self-host path (kit.exe compiling on the
-VM) was exercised ad hoc this session. Generalize it into a committed lane
-(e.g. `test/toy/vm.sh windows --native` or a new harness) that ships the case
-sources + a `<bindir>/support/rt` + the mingw sysroot to the VM, compiles with
-`kit.exe` there, runs, and compares `.expected`. Gotchas to bake in:
+only executes on the VM, so it cannot catch self-host compile bugs (the §Baseline
+stack-probe crash, for one, was invisible to it). The native self-host path
+(kit.exe compiling on the VM) has been exercised ad hoc. Generalize it into a
+committed lane (e.g. `test/toy/vm.sh windows --native` or a new harness) that
+ships the case sources + a `<bindir>/support/rt` + the mingw sysroot to the VM,
+compiles with `kit.exe` there, runs, and compares `.expected`. Gotchas to bake
+in:
- PowerShell `Start-Process -PassThru` *without* `-Wait` reports `.ExitCode = 0`
always — use a `[Diagnostics.Process]` with `WaitForExit(ms)` + `.ExitCode`,
- or `& exe; $LASTEXITCODE` with a Defender path exclusion.
+ or `& exe; $LASTEXITCODE` with a Defender path exclusion. Likewise `cmd`
+ `%ERRORLEVEL%` expands at parse time — use `cmd /v:on` + `!ERRORLEVEL!`.
- macOS `tar` adds AppleDouble `._*` sidecars — ship case sources with
`COPYFILE_DISABLE=1` (same trap as the FreeBSD bootstrap).
- The mingw sysroot's `<arch>-w64-mingw32/include` is a symlink to
`generic-w64-mingw32/include`; dereference (`cp -RL`) before shipping.
-## 5. Distribution + default sysroot
+## 4. Distribution + default sysroot
- Ship the Windows distribution as `<bindir>/support/rt` (what `kit install`
produces); rt now resolves from the real image path, so no cwd dependence.
@@ -149,17 +152,19 @@ sources + a `<bindir>/support/rt` + the mingw sysroot to the VM, compiles with
`__local_stdio_printf_options`, … — still have no DLL home and would have to
move into `libkit_rt` for Windows).
-## 6. Windows 3-stage bootstrap
+## 5. Windows 3-stage bootstrap
The self-host milestone: use the cross-built `kit.exe` on the VM to compile
kit's own sources into a stage-2 `kit.exe`, then stage-3, and assert stage-2 ==
stage-3 byte-for-byte (cf. [BOOTSTRAP.md](BOOTSTRAP.md), and the
-native-bootstrap analogs `scripts/{linux,freebsd}_bootstrap.sh`). **Blocked on
-task 1** — kit.exe crashes compiling some inputs, so it cannot yet compile the
-full libkit/driver source set. A `scripts/windows_bootstrap.sh` would drive the
-VM-side stages once task 1 is resolved.
-
-## 7. Debugger fault-guard / SEH on Windows
+native-bootstrap analogs `scripts/{linux,freebsd}_bootstrap.sh`). **Unblocked**
+now that the large-frame stack-probe crash is fixed — kit.exe compiles + links +
+runs the full Toy AOT corpus (166/166) on the VM. Next: confirm kit.exe can
+compile the full libkit/driver source set on the VM, then add a
+`scripts/windows_bootstrap.sh` to drive the VM-side stages and the
+stage-2==stage-3 byte-identity check.
+
+## 6. Debugger fault-guard / SEH on Windows
`driver/env/windows.c`'s `driver_run_with_crash_guard` is a no-op on Windows
(the POSIX path uses `sigaction` + `sigsetjmp`); a crashing `kit run` program
diff --git a/src/abi/abi.c b/src/abi/abi.c
@@ -171,6 +171,10 @@ const ABIFuncInfo* abi_cg_func_info(TargetABI* a, KitCgTypeId fn_type) {
return info;
}
+u32 abi_stack_probe_interval(TargetABI* a) {
+ return a->vt->stack_probe_interval;
+}
+
ABITypeInfo abi_va_list_info(TargetABI* a) { return a->vt->va_list_info; }
ABIVaListInfo abi_va_list_layout(TargetABI* a) {
diff --git a/src/abi/abi.h b/src/abi/abi.h
@@ -166,5 +166,8 @@ const ABIRecordLayout* abi_cg_record_layout(TargetABI*, KitCgTypeId);
const ABIFuncInfo* abi_cg_func_info(TargetABI*, KitCgTypeId fn_type);
ABITypeInfo abi_va_list_info(TargetABI*);
ABIVaListInfo abi_va_list_layout(TargetABI*);
+/* Page granularity for Windows-style large-frame stack probing; 0 when the
+ * target auto-grows its stack and needs no probe. See ABIVtable. */
+u32 abi_stack_probe_interval(TargetABI*);
#endif
diff --git a/src/abi/abi_aapcs64_windows.c b/src/abi/abi_aapcs64_windows.c
@@ -66,6 +66,10 @@ static ABIFuncInfo* aapcs64_windows_compute_func_info(TargetABI* a,
const ABIVtable aapcs64_windows_vtable = {
.compute_func_info = aapcs64_windows_compute_func_info,
+ /* Windows commits stack one guard page at a time; large frames must be
+ * probed page-by-page before the single `sub sp`. See aa_words_stack_probe
+ * in src/arch/aa64/native.c. */
+ .stack_probe_interval = 4096,
.va_list_info = {8, 8, ABI_SC_PTR, 0, 0, 0},
.va_list_layout = {.type = {8, 8, ABI_SC_PTR, 0, 0, 0},
.kind = ABI_VA_LIST_POINTER,
diff --git a/src/abi/abi_internal.h b/src/abi/abi_internal.h
@@ -19,6 +19,11 @@ typedef struct ABIVtable {
u32 (*scalar_split_lane_size)(TargetABI*, KitCgTypeId);
ABITypeInfo va_list_info;
ABIVaListInfo va_list_layout;
+ /* Stack-probe granularity. 0 = the target OS auto-grows the stack on any
+ * access below SP, so no probe is needed (Linux/macOS/FreeBSD). Non-zero =
+ * the page size the prologue must touch, in descending order, before a
+ * single large `sub sp` jumps past uncommitted guard pages (Windows). */
+ u32 stack_probe_interval;
} ABIVtable;
/* Per-ABI vtables exposed by their TUs. */
diff --git a/src/abi/abi_win64_x64.c b/src/abi/abi_win64_x64.c
@@ -176,6 +176,9 @@ static ABIFuncInfo* win64_x64_compute_func_info(TargetABI* a, KitCgTypeId fn) {
const ABIVtable win64_x64_vtable = {
.compute_func_info = win64_x64_compute_func_info,
+ /* Windows commits stack one guard page at a time; the x64 backend gates its
+ * __chkstk call on this (frame_size > one page). See x64_build_prologue. */
+ .stack_probe_interval = 4096,
.va_list_info = {8, 8, ABI_SC_PTR, 0, 0, 0},
.va_list_layout = {.type = {8, 8, ABI_SC_PTR, 0, 0, 0},
.kind = ABI_VA_LIST_POINTER},
diff --git a/src/arch/aa64/native.c b/src/arch/aa64/native.c
@@ -63,7 +63,11 @@ enum {
AA_LR = 30u,
AA_SP = 31u,
AA_FRAME_SAVE_SIZE = 16u,
- AA_PROLOGUE_WORDS = 24u,
+ /* Worst-case reserved prologue region (NDT single-pass path patches it in
+ * place; the optimizer path reserves exactly what it emits). Sized to hold
+ * the fat prologue plus the Windows large-frame stack probe (≤7 words, see
+ * aa_words_stack_probe). */
+ AA_PROLOGUE_WORDS = 32u,
AA_TAIL_WORDS = 32u,
};
@@ -1264,6 +1268,34 @@ static void aa_words_load_imm(AANativeTarget* a, u32* words, u32 cap, u32* n,
for (u32 i = 0; i < m; ++i) words[(*n)++] = tmp[i];
}
+/* Windows large-frame stack probe. kit's prologue reserves the whole frame in
+ * one `sub sp, sp, #N`, but Windows grows a thread stack one guard page at a
+ * time: a sub that jumps SP more than a page past the guard page leaves the
+ * skipped pages uncommitted, and the first store into them faults (and, since
+ * SP itself is then in uncommitted memory, the fault can't even be delivered).
+ * Touch every page the frame spans, top-down, so each guard page commits in
+ * turn before the sub. Inlined (no external __chkstk symbol / no reloc in the
+ * patched prologue region); mirrors the linker's aa64_coff_chkstk body. Only
+ * x16/x17 are clobbered — the following sub-sp / saved-pair material re-derives
+ * both. Emitted only when frame_size > interval (one page). */
+static void aa_words_stack_probe(AANativeTarget* a, u32* words, u32 cap, u32* n,
+ u32 frame_size, u32 interval) {
+ u32 imm12, sh;
+ if (!aa64_addsub_imm_fits(interval, &imm12, &sh))
+ aa_panic(a, "stack-probe interval not an addsub immediate");
+ /* x16 = frame_size ; x17 = sp */
+ aa_words_load_imm(a, words, cap, n, AA_TMP0, frame_size);
+ if (*n + 5u > cap) aa_panic(a, "instruction patch too small");
+ words[(*n)++] = aa64_add_imm(1, AA_TMP1, AA_SP, 0, 0); /* mov x17, sp */
+ /* loop: x17 -= page ; x16 -= page (sets flags) ; touch [x17] ; b.gt loop */
+ words[(*n)++] = aa64_sub_imm(1, AA_TMP1, AA_TMP1, imm12, sh);
+ words[(*n)++] = aa64_subs_imm12(1, AA_TMP0, AA_TMP0, imm12, sh);
+ words[(*n)++] = aa64_ldr64_uimm12(31, AA_TMP1, 0); /* ldr xzr, [x17] */
+ /* branch back to the `sub x17` three words above while x16 stays positive */
+ words[(*n)++] =
+ aa64_brcond_pack((AA64BrCond){.imm19 = (u32)(-3), .cond = 0xcu /* GT */});
+}
+
static void aa_words_sub_sp_frame(AANativeTarget* a, u32* words, u32 cap,
u32* n, u32 frame_size) {
u32 imm12, sh;
@@ -1438,6 +1470,15 @@ static u32 aa_build_prologue_words(AANativeTarget* a, const AAFrameLayout* L,
aa_words_callee_saves(a, 1, words, cap, &n);
return n;
}
+ /* On targets that don't auto-grow the stack (Windows), probe each page the
+ * frame spans before the single large `sub sp` jumps past the guard page.
+ * slim_prologue/fp_at_bottom returned above — their frames are bounded to
+ * one page (≤16 / ≤504 bytes), so only this path can exceed `interval`. */
+ {
+ u32 interval = abi_stack_probe_interval(a->base.c->abi);
+ if (interval && L->frame_size > interval)
+ aa_words_stack_probe(a, words, cap, &n, L->frame_size, interval);
+ }
aa_words_sub_sp_frame(a, words, cap, &n, L->frame_size);
if (a->slim_small_frame) {
/* `stp x29, x30, [sp, #saved_pair_off]` — skip the `add x17, sp, #...`
diff --git a/src/arch/x64/emit.h b/src/arch/x64/emit.h
@@ -24,7 +24,6 @@
#define X64_PROLOGUE_XMM_SAVE_BYTES 8u
#define X64_PROLOGUE_CHKSTK_DELTA 6u
#define X64_WIN64_SHADOW_SPACE 32u
-#define X64_WIN64_CHKSTK_THRESHOLD 4096u
#define X64_MAX_CS_INT_REGS 7u
/* ---- per-OS ABI register layout (SysV vs Win64) ---- */
diff --git a/src/arch/x64/native.c b/src/arch/x64/native.c
@@ -1623,6 +1623,10 @@ static u32 x64_build_prologue(X64NativeTarget* a, u8* buf, u32 cap,
u32 wi = 0;
u32 xmm_base = x64_xmm_base(a, n_fp);
u32 i;
+ /* Page granularity for Windows large-frame probing (0 = no probe needed).
+ * Win64 reserves >1-page frames through __chkstk; the same ABI capability
+ * the aarch64 backend reads for its inline probe. */
+ u32 probe = abi_stack_probe_interval(a->base.c->abi);
*chkstk_disp_pos_out = (u32)-1;
if (cap < X64_PROLOGUE_BASE_BYTES)
x64_panic(a, "prologue placeholder overflow");
@@ -1635,7 +1639,7 @@ static u32 x64_build_prologue(X64NativeTarget* a, u8* buf, u32 cap,
* red-zone tiers, which reserve no stack. */
if (skip_sub) {
/* no reservation */
- } else if (a->abi->shadow_space && frame_size > X64_WIN64_CHKSTK_THRESHOLD) {
+ } else if (probe && frame_size > probe) {
if (wi + 13u > cap) x64_panic(a, "prologue placeholder overflow");
buf[wi++] = (u8)(X64_OPC_MOV_RI | (X64_RAX & 7u)); /* mov eax, imm32 */
wr_u32_le(buf + wi, frame_size);