kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

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:
Mdoc/plan/windows.md | 119+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/abi/abi.c | 4++++
Msrc/abi/abi.h | 3+++
Msrc/abi/abi_aapcs64_windows.c | 4++++
Msrc/abi/abi_internal.h | 5+++++
Msrc/abi/abi_win64_x64.c | 3+++
Msrc/arch/aa64/native.c | 43++++++++++++++++++++++++++++++++++++++++++-
Msrc/arch/x64/emit.h | 1-
Msrc/arch/x64/native.c | 6+++++-
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);