kit

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

commit b499c2351e3bc118a107a8693d35413c6e8e0db3
parent 21ac717e705ac204fb507e83fa04f467397a494a
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Fri,  5 Jun 2026 14:45:54 -0700

Implement tool-side auto-backtrace for run & dbg (BACKTRACE L3c/WS5)

`kit run` and `kit dbg` now print a symbolized backtrace automatically when a
program faults or traps, reusing the hosted DWARF reader and never crossing into
rt/.

The CFI stepper kit_dwarf_unwind_step takes no memory provider, so it returns
pc=0 once a return address is spilled to the stack (the common case) — the old
`dbg bt` was effectively single-frame. Replace the unwind with a frame-pointer
chain walk: kit keeps a frame pointer on every backend with a uniform record
(fp[0]=caller fp, fp[1]=saved ra), the same walk __kit_backtrace does, lifted
tool-side with a memory-read callback.

- driver/lib/backtrace.{c,h}: shared FP-step kernel (with __kit_backtrace's
  guards), arch FP-reg/ptr-size helpers, and a PC-list symbolized printer. The
  walk stops at the kit-image boundary (kit_jit_runtime_to_image==0) so output
  ends at main and is host-independent (no libc/dyld trampoline noise).
- dbg (driver/cmd/dbg.c): dbg_cmd_bt advances via the FP kernel over
  kit_jit_session_read_mem (walks the whole stack), and dbg_render_stop
  auto-invokes it on KIT_STOP_SIGNAL (faults + __builtin_trap/assert, not
  breakpoints/steps).
- run (driver/cmd/run.c + driver/env/posix_dbg.c): a lightweight crash guard
  installs SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT/SIGTRAP handlers around the
  in-process entry_fn call, reusing dbg_ucontext_to_frame. Because run shares
  its stack with the program, the chain is captured inside the handler (before
  the post-siglongjmp stack is reused) and symbolized afterward in normal
  context; the process exits 128+signo. Windows has a no-op stub.
- backtrace.c is compiled unconditionally: posix_dbg.c's crash guard references
  it on every POSIX build regardless of tool selection.

Tests: test/dbg/cases/toy-trap-backtrace (multi-frame trap -> auto-bt + bt),
updated toy-trap-stop golden, and run-backtrace-* checks in test/driver/run.sh.

kit emu auto-backtrace is out of scope (it doesn't retain the guest DWARF);
deferred alongside L3b.

Diffstat:
Mdoc/plan/BACKTRACE.md | 54+++++++++++++++++++++++++++++++++++++++++++++++-------
Mdriver/cmd/dbg.c | 47++++++++++++++++++++++++++++++++++-------------
Mdriver/cmd/run.c | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdriver/env.h | 25+++++++++++++++++++++++++
Mdriver/env/posix_dbg.c | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdriver/env/windows.c | 19++++++++++++++++---
Adriver/lib/backtrace.c | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adriver/lib/backtrace.h | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmk/driver_srcs.mk | 6++++++
Atest/dbg/cases/toy-trap-backtrace/args | 2++
Atest/dbg/cases/toy-trap-backtrace/expected | 11+++++++++++
Atest/dbg/cases/toy-trap-backtrace/stdin | 4++++
Mtest/dbg/cases/toy-trap-stop/expected | 1+
Mtest/driver/run.sh | 41+++++++++++++++++++++++++++++++++++++++++
14 files changed, 573 insertions(+), 25 deletions(-)

diff --git a/doc/plan/BACKTRACE.md b/doc/plan/BACKTRACE.md @@ -1,6 +1,43 @@ # Plan: stack-trace builtins & runtime backtrace -## Status — 2026-06-05 — L1 + L2 + L3a + `kit symbolize` shipped (WS1–WS4 + WS5 symbolizer); WS5/L3c + L3b remaining +## Status — 2026-06-05 — L1 + L2 + L3a + `kit symbolize` + L3c (tool-side auto-backtrace) shipped (WS1–WS5); L3b remaining + +L3c (WS5) — **tool-side auto-backtrace for `kit run` and `kit dbg`** — is now +shipped. Both tools print a symbolized frame-pointer-chain backtrace at a +fault/trap, reusing the DWARF reader they already own and never crossing into +`rt/`. The decisive finding: the CFI stepper `kit_dwarf_unwind_step` +(`src/debug/dwarf_cfi.c:213`) takes **no memory provider**, so when the return +address is spilled to the stack (the normal case) it returns pc=0 and the walk +dies after the leaf — the existing `dbg bt` was effectively single-frame. The +fix is to walk the **frame-pointer chain** (kit's uniform `fp[0]`=caller fp / +`fp[1]`=saved ra record, no `.eh_frame` needed), the same walk `__kit_backtrace` +does, lifted tool-side with a memory-read callback. + +- **Shared module** `driver/lib/backtrace.c` + `.h`: the FP-step kernel + (`driver_bt_fp_step`, with `__kit_backtrace`'s guards) + arch FP-reg/ptr-size + helpers + a PC-list symbolizer (`driver_backtrace_print_pcs`). Gated into the + DBG/RUN tool builds (`mk/driver_srcs.mk`). Walks stop at the **kit-image + boundary** (`kit_jit_runtime_to_image` == 0) so output ends at `main` and is + host-independent (no libc/dyld trampoline noise). +- **`kit dbg`** (`driver/cmd/dbg.c`): `dbg_cmd_bt` now advances via the FP-step + kernel over `kit_jit_session_read_mem` (so it walks the whole stack, not just + the leaf), and `dbg_render_stop` auto-invokes it on `KIT_STOP_SIGNAL` + (faults + `__builtin_trap`/assert; not breakpoints/steps). +- **`kit run`** (`driver/cmd/run.c` + `driver/env/posix_dbg.c`): a lightweight + in-process crash guard (`driver_run_with_crash_guard`) installs + SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT/SIGTRAP handlers around the direct + `entry_fn` call, reusing the existing `dbg_ucontext_to_frame` marshalling. + Because `kit run` shares its stack with the program, the chain is captured + **inside the handler** (before the post-`siglongjmp` stack is reused) and + symbolized afterward in normal context; the process exits `128 + signo`. + Windows has a no-op stub (vectored-handler port is a follow-up). +- **Tests:** `test/dbg/cases/toy-trap-backtrace` (multi-frame trap → auto-bt + + `bt`), updated `toy-trap-stop` golden, and a `kit run` crash lane in + `test/driver/run.sh` (`run-backtrace-*`: non-zero exit + symbolized + `bt_leaf/bt_mid/bt_root` + source file). + +Scope note: `kit emu` auto-backtrace is **out of scope** (the emulator doesn't +retain the guest's DWARF after load); left as a follow-up alongside L3b. L3a (WS4) is now shipped on top of L1/L2: @@ -60,8 +97,8 @@ by `print_backtrace`/`backtrace_capture` (no asm); (2) **setjmp/longjmp is miscompiled at `-O1`** on every arch (`setjmp_runtime/O1` returns 1, not 42 — the second-return value isn't observed), failing `test-rt-runtime`. -Remaining: **WS5 (L3c)** tool-side auto-backtrace and **L3b** in-process -self-symbolization. +Remaining: **L3b** in-process self-symbolization (and the deferred `kit emu` +auto-backtrace). WS5/L3c (tool-side auto-backtrace) is **done** — see Status. Implemented and tested through L2: @@ -107,9 +144,11 @@ Nothing in L1/L2/L3a is outstanding. What's left is the rest of L3: symbolizer that reads the `#N 0x<hex>` stream and annotates it in place, sharing `driver/lib/dwarfsym.c` with `addr2line`. Tested by the second lane of `test/rt/addr2line.sh`. -- **WS5 — L3c (tool-side auto-backtrace, recommended next):** auto-print a - symbolized trace from `kit run`/`kit emu`/`dbg` fault handlers (reuses the - existing DWARF reader + `dbg bt`; never crosses into rt). +- ~~**WS5 — L3c (tool-side auto-backtrace):**~~ **done** (see Status) — + `kit run` + `kit dbg` auto-print a symbolized FP-chain backtrace at a + fault/trap via the shared `driver/lib/backtrace.c`; truncated at the kit-image + boundary; never crosses into rt. `kit emu` auto-backtrace remains deferred (it + doesn't retain the guest DWARF). - **L3b:** in-process self-symbolization (hosted-only `libkit_bt.a`); deferred until a concrete consumer needs in-binary symbolized panics. @@ -350,7 +389,8 @@ shipping **L3a now**, leaving L3b/L3c as documented extensions. 5. **WS5 — `kit symbolize`** hosted batching symbolizer over the `#N 0x<hex>` stream, sharing `driver/lib/dwarfsym.c` with `addr2line`; second lane of `test/rt/addr2line.sh`. ✅ done. -6. **WS5 — L3c** tool-side auto-backtrace (optional, parallelizable). ⏳ remaining (next). +6. **WS5 — L3c** tool-side auto-backtrace for `kit run` + `kit dbg`. ✅ done + (`kit emu` deferred — no retained guest DWARF). 7. **L3b** deferred until a consumer needs in-binary symbolized panics. ## Open questions diff --git a/driver/cmd/dbg.c b/driver/cmd/dbg.c @@ -11,6 +11,7 @@ #include <stdint.h> #include <string.h> +#include "backtrace.h" #include "cflags.h" #include "driver.h" #include "inputs.h" @@ -831,6 +832,10 @@ static KitSlice dbg_step_stop_label(KitStopReason reason) { return KIT_SLICE_LIT("Internal breakpoint hit at "); } +static void dbg_cmd_bt(DbgState* s); +static KitStatus dbg_dwarf_read_mem(void* user, uint64_t addr, void* dst, + size_t n); + static void dbg_render_stop(DbgState* s, const KitStopInfo* st) { KitSlice file = {0}; uint32_t line = 0; @@ -872,6 +877,11 @@ static void dbg_render_stop(DbgState* s, const KitStopInfo* st) { driver_printf("Stopped on signal %d at ", st->signal); dbg_print_pc(s, st->regs.pc); driver_printf("\n"); + /* A fault/trap is a crash, not a planned stop: print the backtrace + * automatically (the user would type `bt` next anyway). Breakpoints and + * step completions are separate stop kinds, so this only fires on a real + * signal or __builtin_trap/assert. */ + if (s->dwarf) dbg_cmd_bt(s); break; case KIT_STOP_INTERRUPT: driver_printf("Interrupted at "); @@ -1019,7 +1029,6 @@ static void dbg_cmd_bt(DbgState* s) { KitDwarfSubprogram sp; KitUnwindFrame img_frame; int have_sp; - KitStatus step; driver_printf("#%-2d ", level); driver_printf("0x%llx", (unsigned long long)frame.pc); @@ -1088,19 +1097,31 @@ static void dbg_cmd_bt(DbgState* s) { } driver_printf("\n"); - /* unwind_step keys off frame.pc as an image vaddr (FDE lookup) and - * writes the caller's return PC — read straight from registers or - * the stack — which is already a runtime address. Feed the image - * frame in, then copy back to `frame` so the next iteration's - * lookups translate from the new runtime PC. */ - step = kit_dwarf_unwind_step(s->dwarf, &img_frame); - if (step == KIT_NOT_FOUND) break; /* bottom of stack */ - if (step != KIT_OK) { - driver_errf(DBG_TOOL, "unwind step failed"); - break; + /* Advance to the caller by following the frame-pointer chain. kit keeps a + * frame pointer on every backend with a uniform record (fp[0] = caller fp, + * fp[1] = saved return address), so a memory-reading FP walk is reliable + * where kit_dwarf_unwind_step is not — the CFI stepper takes no memory + * provider and so cannot recover a return address spilled to the stack + * (the common case), terminating after the leaf frame. Reads go through the + * session; pc/fp/cfa stay runtime addresses, translated per DWARF query. */ + { + KitArchKind arch = driver_host_target().arch; + int fpreg = driver_bt_fp_dwarf_reg(arch); + int ptr = driver_bt_ptr_size(arch); + uint64_t ra = 0, next_fp = 0; + if (fpreg < 0) break; + if (!driver_bt_fp_step(arch, dbg_dwarf_read_mem, s->session, + frame.regs[fpreg], &ra, &next_fp)) + break; /* bottom of stack */ + /* Stop at the kit-image boundary: past `main` the chain runs into the + * session/JIT trampoline and host libc, which carry no symbols and whose + * depth is host-dependent. */ + if (kit_jit_runtime_to_image(s->jit, ra) == 0) break; + frame.pc = ra; + frame.regs[fpreg] = next_fp; + /* Caller CFA in kit's layout: the address just above the saved pair. */ + frame.cfa = next_fp + 2u * (uint64_t)ptr; } - frame.cfa = img_frame.cfa; - frame.pc = img_frame.pc; /* runtime PC from the stack */ if (++level > 256) { driver_errf(DBG_TOOL, "backtrace truncated at 256 frames"); break; diff --git a/driver/cmd/run.c b/driver/cmd/run.c @@ -7,6 +7,7 @@ #include <stdlib.h> #include <string.h> +#include "backtrace.h" #include "cflags.h" #include "driver.h" #include "hosted.h" @@ -762,11 +763,54 @@ static int run_compile_and_jit(RunOptions* o, KitCompiler* compiler, driver_wasm_run_options_used(&o->wasm)) extern_resolver = NULL; return driver_inputs_compile_and_jit(&o->inputs, compiler, host, &copts, &pp, - o->entry, extern_resolver, NULL, out_jit); + o->entry, extern_resolver, NULL, + out_jit); } typedef int (*MainFn)(int, char**); +/* --- crash backtrace ------------------------------------------------------ + * When the JITed program faults, driver_run_with_crash_guard captures the + * return-address chain (innermost first) inside its fault handler and hands it + * here; we open the image's DWARF (best effort) and symbolize each frame to + * stderr. */ + +typedef struct RunCrashCtx { + DriverEnv* env; + KitJit* jit; + int signo; /* filled in run_on_crash so the caller can derive the exit code */ +} RunCrashCtx; + +static void run_bt_emit(void* user, const char* line) { + (void)user; + driver_logf("%s", line); /* driver_logf appends the newline */ +} + +static void run_on_crash(void* user, int signo, const uint64_t* pcs, int npcs) { + RunCrashCtx* c = (RunCrashCtx*)user; + KitContext ctx = driver_env_to_context(c->env); + KitDebugInfo* dwarf = NULL; + DriverBtCtx btc; + + c->signo = signo; + driver_logf("kit: program received signal %d; backtrace:", signo); + + /* DWARF is best effort: without it we still print addresses + JIT symbols. */ + if (c->jit) { + const KitObjFile* view = kit_jit_view(c->jit); + if (view) (void)kit_dwarf_open(&ctx, view, &dwarf); + } + + memset(&btc, 0, sizeof btc); + btc.jit = c->jit; + btc.dwarf = dwarf; + btc.emit = run_bt_emit; + btc.emit_user = NULL; + driver_backtrace_print_pcs(&btc, pcs, npcs); + + if (dwarf) kit_dwarf_free(dwarf); +} + /* Host-identity symbol resolver for the interpreter. * * The interpreter holds symbol names as they appear in the object/image symbol @@ -1014,7 +1058,14 @@ int driver_run(int argc, char** argv) { driver_errf(RUN_TOOL, "wasm sandbox flags require a wasm input"); rc = 1; } else { - rc = entry_fn((int)ro.prog_argc, ro.prog_argv); + RunCrashCtx cc; + cc.env = &env; + cc.jit = jit; + cc.signo = 0; + if (driver_run_with_crash_guard(&env, ro.target.arch, entry_fn, + (int)ro.prog_argc, ro.prog_argv, &rc, + run_on_crash, &cc)) + rc = 128 + cc.signo; /* program faulted; shell convention */ } } if (ro.bench_time) bench_exec_end = driver_now_ns(); diff --git a/driver/env.h b/driver/env.h @@ -323,6 +323,31 @@ void driver_flush_stdout(void); int driver_install_sigint(void (*cb)(void*), void* user); void driver_restore_sigint(void); +/* Crash-guarded execution for `kit run`. + * + * `entry` is the JITed program entry, invoked as `entry(argc, argv)`. On a + * clean return, *ret_out receives the program's status and the call returns 0. + * + * On a fatal fault raised by the program (SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT/ + * SIGTRAP — the last covers __builtin_trap / failed asserts), the guard walks + * the frame-pointer chain *inside the signal handler* (where the faulting stack + * is still intact, since `kit run` shares its stack with the program), captures + * the return addresses innermost-first, and invokes `on_crash(user, signo, pcs, + * npcs)` from normal context — so symbolization (DWARF/malloc/printf) never + * runs async-signal. `arch` selects the FP register / pointer width for the + * walk. The call then returns 1; the program's own return value is undefined on + * this path, so the caller should treat the run as failed. + * + * Hosts without a fault guard (Windows) run `entry` directly, set *ret_out, and + * return 0 without ever calling on_crash. */ +typedef int (*DriverRunEntryFn)(int argc, char** argv); +typedef void (*DriverRunCrashFn)(void* user, int signo, const uint64_t* pcs, + int npcs); +int driver_run_with_crash_guard(DriverEnv* env, KitArchKind arch, + DriverRunEntryFn entry, int argc, char** argv, + int* ret_out, DriverRunCrashFn on_crash, + void* user); + /* Host-symbol resolver for JIT extern_resolver. Looks up `name` via * dlsym(RTLD_DEFAULT, ...) on POSIX hosts, returning NULL on miss. Stateless; * `user` is ignored and may be NULL. Wired into `kit run` so JITed code diff --git a/driver/env/posix_dbg.c b/driver/env/posix_dbg.c @@ -16,6 +16,7 @@ #include <stdlib.h> #include <string.h> +#include "backtrace.h" #include "env_posix.h" /* Single-session process model (one debug target at a time). The signal @@ -280,6 +281,127 @@ static void dbg_thread_abort(void* user) { siglongjmp(g_dbg_abort_buf, 1); } +/* --- kit run crash guard -------------------------------------------------- + * Independent of the session machinery above (no worker thread, no KitDbgOs): + * `kit run` calls the JITed entry directly in-process, sharing its stack with + * the program. So we catch a fatal fault, marshal the faulting frame via the + * same per-(arch,OS) ucontext path the session uses, and walk the frame-pointer + * chain *here in the handler* — where those frames are still intact. After the + * siglongjmp the guard's own call frames reuse that stack, so the walk must + * finish first; only the captured return-address list survives, to be + * symbolized later in normal context (DWARF/malloc/printf never run async). */ + +#define RUN_BT_MAX 256 +static const int g_run_signos[6] = {SIGSEGV, SIGBUS, SIGILL, + SIGFPE, SIGABRT, SIGTRAP}; +#define RUN_NSIGS ((int)(sizeof(g_run_signos) / sizeof(g_run_signos[0]))) +static struct sigaction g_run_prev_sa[RUN_NSIGS]; +static int g_run_installed; + +static sigjmp_buf g_run_crash_buf; /* fault -> back into the guard */ +static volatile sig_atomic_t g_run_crash_armed; +static KitArchKind g_run_crash_arch; +static int g_run_crash_signo; +static uint64_t g_run_crash_pcs[RUN_BT_MAX]; +static int g_run_crash_npcs; + +static void run_crash_restore(void) { + int i; + if (!g_run_installed) return; + for (i = 0; i < RUN_NSIGS; ++i) + sigaction(g_run_signos[i], &g_run_prev_sa[i], NULL); + g_run_installed = 0; +} + +/* Raw in-handler memory read. The FP-walk guards (alignment, strictly + * increasing fp, bounded depth) keep this from running away on a corrupt + * chain, mirroring rt's __kit_backtrace. */ +static KitStatus run_raw_read(void* user, uint64_t addr, void* dst, size_t n) { + (void)user; + memcpy(dst, (const void*)(uintptr_t)addr, n); + return KIT_OK; +} + +static void run_crash_handler(int signo, siginfo_t* si, void* ucv) { + ucontext_t* uc = (ucontext_t*)ucv; + (void)si; + if (g_run_crash_armed) { + KitUnwindFrame fr; + int fpreg, n = 0; + g_run_crash_armed = 0; + g_run_crash_signo = signo; + dbg_ucontext_to_frame(uc, &fr); + g_run_crash_pcs[n++] = fr.pc; /* frame #0 = the faulting PC */ + fpreg = driver_bt_fp_dwarf_reg(g_run_crash_arch); + if (fpreg >= 0) { + uint64_t fp = fr.regs[fpreg], ra = 0, next_fp = 0; + while (n < RUN_BT_MAX && driver_bt_fp_step(g_run_crash_arch, run_raw_read, + NULL, fp, &ra, &next_fp)) { + g_run_crash_pcs[n++] = ra; + fp = next_fp; + } + } + g_run_crash_npcs = n; + siglongjmp(g_run_crash_buf, 1); + } + /* Not ours (or a fault after disarm): restore the default disposition for + * this signal and re-raise so the process dies as it would have. */ + { + int i; + for (i = 0; i < RUN_NSIGS; ++i) + if (g_run_signos[i] == signo) { + sigaction(signo, &g_run_prev_sa[i], NULL); + break; + } + } + raise(signo); +} + +int driver_run_with_crash_guard(DriverEnv* env, KitArchKind arch, + DriverRunEntryFn entry, int argc, char** argv, + int* ret_out, DriverRunCrashFn on_crash, + void* user) { + struct sigaction sa; + int i; + (void)env; + + g_run_crash_arch = arch; + + memset(&sa, 0, sizeof sa); + sa.sa_sigaction = run_crash_handler; + sa.sa_flags = SA_SIGINFO; + sigemptyset(&sa.sa_mask); + for (i = 0; i < RUN_NSIGS; ++i) sigaddset(&sa.sa_mask, g_run_signos[i]); + + g_run_installed = 1; + for (i = 0; i < RUN_NSIGS; ++i) { + if (sigaction(g_run_signos[i], &sa, &g_run_prev_sa[i]) != 0) { + int j; + for (j = 0; j < i; ++j) + sigaction(g_run_signos[j], &g_run_prev_sa[j], NULL); + g_run_installed = 0; + *ret_out = entry(argc, argv); /* could not guard — run unguarded */ + return 0; + } + } + + if (sigsetjmp(g_run_crash_buf, 1) != 0) { + /* Reached via a fault. Restore default dispositions first so symbolization + * (which touches libkit, not the corrupt stack) faults cleanly if it ever + * goes wrong, then hand the captured chain to the caller. */ + run_crash_restore(); + if (on_crash) + on_crash(user, g_run_crash_signo, g_run_crash_pcs, g_run_crash_npcs); + return 1; + } + + g_run_crash_armed = 1; + *ret_out = entry(argc, argv); + g_run_crash_armed = 0; + run_crash_restore(); + return 0; +} + /* --- vtable --- */ KitDbgOs g_dbg_os_posix = { diff --git a/driver/env/windows.c b/driver/env/windows.c @@ -417,9 +417,7 @@ KitWriter* driver_stderr_writer(DriverEnv* e) { return driver_stdio_writer(e, stderr); } -const char* const* driver_environ(void) { - return (const char* const*)_environ; -} +const char* const* driver_environ(void) { return (const char* const*)_environ; } /* ============================================================ * file_io (CreateFileW + ReadFile/WriteFile) @@ -1232,6 +1230,21 @@ void driver_restore_sigint(void) { g_ctrlc_cb_user = NULL; } +/* No fault-guard on Windows yet (the POSIX path uses sigaction + sigsetjmp; a + * vectored-exception-handler port is a follow-up). Run the entry directly so + * `kit run` still executes the program; on_crash never fires. */ +int driver_run_with_crash_guard(DriverEnv* env, KitArchKind arch, + DriverRunEntryFn entry, int argc, char** argv, + int* ret_out, DriverRunCrashFn on_crash, + void* user) { + (void)env; + (void)arch; + (void)on_crash; + (void)user; + *ret_out = entry(argc, argv); + return 0; +} + /* ============================================================ * Win32 CONTEXT <-> KitUnwindFrame marshalling * ============================================================ */ diff --git a/driver/lib/backtrace.c b/driver/lib/backtrace.c @@ -0,0 +1,152 @@ +#include "backtrace.h" + +#include <stdarg.h> +#include <stdio.h> + +/* Frame-pointer register (DWARF index) and pointer width per arch. Only the + * native run/dbg targets are walkable; wasm and the 32-bit x86/arm hosts kit + * does not self-host on return "unsupported". The FP layout is uniform, so a + * single index pair drives the walk on every supported arch. */ +int driver_bt_fp_dwarf_reg(KitArchKind arch) { + switch (arch) { + case KIT_ARCH_ARM_64: + return 29; /* x29 */ + case KIT_ARCH_X86_64: + return 6; /* rbp (System V DWARF numbering) */ + case KIT_ARCH_RV32: + case KIT_ARCH_RV64: + return 8; /* s0 / fp */ + default: + return -1; + } +} + +int driver_bt_ptr_size(KitArchKind arch) { + switch (arch) { + case KIT_ARCH_ARM_64: + case KIT_ARCH_X86_64: + case KIT_ARCH_RV64: + return 8; + case KIT_ARCH_RV32: + return 4; + default: + return 0; + } +} + +int driver_bt_fp_step(KitArchKind arch, KitDwarfReadMemFn read, void* read_user, + uint64_t fp, uint64_t* ra_out, uint64_t* next_fp_out) { + int ptr = driver_bt_ptr_size(arch); + uint64_t ra = 0; /* zero-init: a `ptr`-byte read leaves the high bytes 0 */ + uint64_t nfp = 0; /* (hosts are little-endian) */ + uint64_t align; + + if (ptr <= 0 || !read || fp == 0) return 0; + align = (uint64_t)ptr - 1u; + if (fp & align) return 0; /* misaligned current frame */ + + /* fp[1] = saved return address, fp[0] = caller frame pointer. */ + if (read(read_user, fp + (uint64_t)ptr, &ra, (size_t)ptr) != KIT_OK) return 0; + if (read(read_user, fp, &nfp, (size_t)ptr) != KIT_OK) return 0; + + if (ra == 0) return 0; /* synthetic stack origin */ + if (nfp <= fp) return 0; /* stack grows down: caller frame sits above */ + if (nfp & align) return 0; /* misaligned link — chain terminator/garbage */ + + *ra_out = ra; + *next_fp_out = nfp; + return 1; +} + +/* Bounded append into a fixed line buffer; silently truncates past the end. */ +static void bt_appendf(char* buf, size_t cap, size_t* len, const char* fmt, + ...) { + va_list ap; + int n; + if (*len >= cap) return; + va_start(ap, fmt); + n = vsnprintf(buf + *len, cap - *len, fmt, ap); + va_end(ap); + if (n < 0) return; + *len += (size_t)n; + if (*len >= cap) *len = cap - 1; /* clamp to keep the NUL terminator */ +} + +/* Render one frame line: "#N 0xPC [<sym+off>] [at file:line[:col]]". */ +static void bt_render_frame(const DriverBtCtx* ctx, int level, uint64_t pc) { + char line[1024]; + size_t len = 0; + uint64_t img_pc = pc; + /* Whether symbol/DWARF lookups are meaningful for this PC. With a JIT image, + * an address that doesn't translate to an image vaddr is outside the kit + * image (libc/dyld trampolines) — print it bare rather than mis-attributing + * it to the nearest symbol with a giant offset. Without a JIT, treat the PCs + * as already image-relative. */ + int in_image = 1; + + line[0] = '\0'; + bt_appendf(line, sizeof line, &len, "#%-2d 0x%llx", level, + (unsigned long long)pc); + + if (ctx->jit) { + uint64_t v = kit_jit_runtime_to_image(ctx->jit, pc); + if (v) + img_pc = v; + else + in_image = 0; + } + + if (in_image) { + KitSlice sym = KIT_SLICE_NULL; + uint64_t off = 0; + int have_name = 0; + if (ctx->jit && kit_jit_addr_to_sym(ctx->jit, pc, &sym, &off) == KIT_OK && + sym.s) { + have_name = 1; + } else if (ctx->dwarf) { + KitSlice fn; + uint64_t lo = 0, hi = 0; + if (kit_dwarf_func_at(ctx->dwarf, img_pc, &fn, &lo, &hi) == KIT_OK && + fn.s) { + sym = fn; + off = (img_pc >= lo) ? (img_pc - lo) : 0; + have_name = 1; + } + } + if (have_name) { + if (off) + bt_appendf(line, sizeof line, &len, " <%.*s+0x%llx>", (int)sym.len, + sym.s, (unsigned long long)off); + else + bt_appendf(line, sizeof line, &len, " <%.*s>", (int)sym.len, sym.s); + } + } + + if (in_image && ctx->dwarf) { + KitSlice file = KIT_SLICE_NULL; + uint32_t srcline = 0, col = 0; + if (kit_dwarf_addr_to_line(ctx->dwarf, img_pc, &file, &srcline, &col) == + KIT_OK && + file.s) { + bt_appendf(line, sizeof line, &len, " at %.*s:%u", (int)file.len, file.s, + srcline); + if (col) bt_appendf(line, sizeof line, &len, ":%u", col); + } + } + + ctx->emit(ctx->emit_user, line); +} + +void driver_backtrace_print_pcs(const DriverBtCtx* ctx, const uint64_t* pcs, + int n) { + int i; + if (!ctx || !ctx->emit || !pcs) return; + for (i = 0; i < n; ++i) { + /* Stop at the kit-image boundary: once the chain leaves into the host + * runtime trampoline / libc startup, the frames are unsymbolizable and + * their count is host-dependent. Frame #0 (the fault PC) always prints. */ + if (i > 0 && ctx->jit && kit_jit_runtime_to_image(ctx->jit, pcs[i]) == 0) + break; + bt_render_frame(ctx, i, pcs[i]); + } +} diff --git a/driver/lib/backtrace.h b/driver/lib/backtrace.h @@ -0,0 +1,59 @@ +#ifndef KIT_DRIVER_BACKTRACE_H +#define KIT_DRIVER_BACKTRACE_H + +#include <kit/arch.h> +#include <kit/core.h> +#include <kit/dwarf.h> +#include <kit/jit.h> +#include <stddef.h> +#include <stdint.h> + +/* Frame-pointer-chain backtrace shared by `kit dbg` (fault stop + `bt`) and + * `kit run` (in-process crash guard). + * + * kit keeps a frame pointer on every backend and never omits it, so each + * prologue stores a uniform frame record — fp[0] = caller fp, fp[1] = saved + * return address (in units of void*). Walking that chain needs only a way to + * read target memory plus the frame-pointer register value; no .eh_frame, no + * CFI program (the CFI stepper, kit_dwarf_unwind_step, deliberately takes no + * memory provider and so cannot self-unwind a spilled return address). This is + * the same walk rt/lib/stack/backtrace.c performs in-process, lifted to the + * tool side with a caller-supplied memory reader. */ + +/* The frame-pointer register's DWARF index for `arch` (aarch64 x29 = 29, + * x86-64 rbp = 6, riscv s0 = 8), or -1 if the arch keeps no walkable FP. */ +int driver_bt_fp_dwarf_reg(KitArchKind arch); + +/* Pointer width in bytes for `arch` (8 for the 64-bit native targets, 4 for + * rv32), or 0 if unsupported. */ +int driver_bt_ptr_size(KitArchKind arch); + +/* Advance one frame: given the current frame pointer `fp`, read the saved + * return address (fp[1]) and caller frame pointer (fp[0]) through `read`. + * Returns 1 and fills *ra_out / *next_fp_out for a valid caller frame; returns + * 0 at the chain terminator or on garbage (null/non-increasing/misaligned fp, + * null return address, or a failed read) — mirroring __kit_backtrace's guards. + */ +int driver_bt_fp_step(KitArchKind arch, KitDwarfReadMemFn read, void* read_user, + uint64_t fp, uint64_t* ra_out, uint64_t* next_fp_out); + +/* Line sink: receives one NUL-terminated, newline-free frame line. */ +typedef void (*DriverBtEmit)(void* user, const char* line); + +typedef struct DriverBtCtx { + KitJit* jit; /* optional: symbol names + runtime->image PC xlate */ + KitDebugInfo* dwarf; /* optional: file:line + func-name fallback */ + DriverBtEmit emit; /* line sink (required) */ + void* emit_user; +} DriverBtCtx; + +/* Symbolize a captured list of return addresses (innermost first, as produced + * by the FP walk) and emit one line per frame via ctx->emit: + * #0 0x401136 <bt_leaf+0x12> at addr2line_prog.c:51:3 + * Frames with no symbol/line degrade to "#N 0xADDR". Used by `kit run`, which + * must capture the chain inside its fault handler — before the post-longjmp + * stack is reused — and symbolize it afterward in normal context. */ +void driver_backtrace_print_pcs(const DriverBtCtx* ctx, const uint64_t* pcs, + int n); + +#endif /* KIT_DRIVER_BACKTRACE_H */ diff --git a/mk/driver_srcs.mk b/mk/driver_srcs.mk @@ -72,6 +72,12 @@ DRIVER_SRCS += $(call need-any,RUN,driver/lib/wasm_run.c) DRIVER_SRCS += $(call need-any,CC CHECK BUILD_EXE BUILD_LIB BUILD_OBJ,driver/lib/compile_engine.c) DRIVER_SRCS += $(call need-any,CAS PKG,driver/lib/dist_host.c) DRIVER_SRCS += $(call need-any,ADDR2LINE SYMBOLIZE,driver/lib/dwarfsym.c) +# backtrace.c (FP-walk kernel + symbolized printer) is consumed by the dbg/run +# tools AND by posix_dbg.c's run crash guard, which is part of DRIVER_ENV_SRCS +# and so links into every POSIX build regardless of tool selection. Compile it +# unconditionally so a tools-trimmed build (DBG/RUN off) still resolves the +# driver_bt_* references without relying on linker dead-stripping. +DRIVER_SRCS += driver/lib/backtrace.c DRIVER_SRCS := $(sort $(DRIVER_SRCS)) DRIVER_OBJS = $(patsubst driver/%.c,$(BUILD_DIR)/driver/%.o,$(DRIVER_SRCS)) diff --git a/test/dbg/cases/toy-trap-backtrace/args b/test/dbg/cases/toy-trap-backtrace/args @@ -0,0 +1,2 @@ +--language +toy diff --git a/test/dbg/cases/toy-trap-backtrace/expected b/test/dbg/cases/toy-trap-backtrace/expected @@ -0,0 +1,11 @@ +kit dbg — 'h' for help, 'q' to quit +Stopped on trap signal 5 at 0xADDR <bt_leaf+0x60> at <dbg-jit-1.toy>:1:35 +#0 0xADDR <bt_leaf+0x60> in _bt_leaf () at <dbg-jit-1.toy>:1:35 +#1 0xADDR <bt_mid+0x64> in _bt_mid () at <dbg-jit-1.toy>:1:102 +#2 0xADDR <bt_root+0x64> in _bt_root () at <dbg-jit-1.toy>:1:155 +#3 0xADDR <main+0x64> in _main () at <dbg-jit-1.toy>:1:191 + 1 > fn @[.noinline] bt_leaf(): i32 { @trap(); return 0 as i32; } fn @[.noinline] bt_mid(): i32 { return bt_leaf(); } fn @[.noinline] bt_root(): i32 { return bt_mid(); } fn main(): i32 { return bt_root(); } +#0 0xADDR <bt_leaf+0x60> in _bt_leaf () at <dbg-jit-1.toy>:1:35 +#1 0xADDR <bt_mid+0x64> in _bt_mid () at <dbg-jit-1.toy>:1:102 +#2 0xADDR <bt_root+0x64> in _bt_root () at <dbg-jit-1.toy>:1:155 +#3 0xADDR <main+0x64> in _main () at <dbg-jit-1.toy>:1:191 diff --git a/test/dbg/cases/toy-trap-backtrace/stdin b/test/dbg/cases/toy-trap-backtrace/stdin @@ -0,0 +1,4 @@ +jit { fn @[.noinline] bt_leaf(): i32 { @trap(); return 0 as i32; } fn @[.noinline] bt_mid(): i32 { return bt_leaf(); } fn @[.noinline] bt_root(): i32 { return bt_mid(); } fn main(): i32 { return bt_root(); } } +run +bt +q diff --git a/test/dbg/cases/toy-trap-stop/expected b/test/dbg/cases/toy-trap-stop/expected @@ -1,3 +1,4 @@ kit dbg — 'h' for help, 'q' to quit Stopped on trap signal 5 at 0xADDR <main+0x60> at <dbg-jit-1.toy>:1:19 +#0 0xADDR <main+0x60> in _main () at <dbg-jit-1.toy>:1:19 1 > fn main(): i32 { @trap(); return 0 as i32; } diff --git a/test/driver/run.sh b/test/driver/run.sh @@ -1019,5 +1019,46 @@ else ok "install-dry-run-no-changes" fi +# ---- kit run: auto-backtrace on a fatal fault ---------------------------- +# `kit run` executes the JITed entry in-process; on a fatal fault it installs a +# guard that walks the frame-pointer chain at the crash point and prints a +# symbolized backtrace to stderr, then exits non-zero (128 + signal). Gated on +# the host being able to natively `kit run` at all (cross-arch hosts can't), so +# probe with a trivial program first. +cat > "$work/run-probe.c" <<'SRC' +int main(void) { return 0; } +SRC +if "$KIT" run "$work/run-probe.c" > "$work/run-probe.out" 2> "$work/run-probe.err"; then + cat > "$work/run-crash.c" <<'SRC' +__attribute__((noinline)) void bt_leaf(int* p) { *p = 42; } +__attribute__((noinline)) void bt_mid(void) { bt_leaf((int*)0); } +__attribute__((noinline)) void bt_root(void) { bt_mid(); } +int main(void) { bt_root(); return 0; } +SRC + "$KIT" run -g "$work/run-crash.c" \ + > "$work/run-crash.out" 2> "$work/run-crash.err" + run_crash_rc=$? + # A clean exit would mean the guard missed the fault. + if [ "$run_crash_rc" -ne 0 ]; then + ok "run-backtrace-nonzero-exit" + else + { printf 'rc=%s\n' "$run_crash_rc"; cat "$work/run-crash.err"; } \ + > "$work/run-crash.diag" + not_ok "run-backtrace-nonzero-exit" "$work/run-crash.diag" + fi + # The symbolized chain (innermost first) must reach every kit frame with a + # source location; outer host-runtime frames are truncated at the image edge. + contains "run-backtrace-leaf" "$work/run-crash.err" "bt_leaf" + contains "run-backtrace-mid" "$work/run-crash.err" "bt_mid" + contains "run-backtrace-root" "$work/run-crash.err" "bt_root" + contains "run-backtrace-source" "$work/run-crash.err" "run-crash.c" +else + skip_test "run-backtrace-nonzero-exit" "host cannot natively kit-run" + skip_test "run-backtrace-leaf" "host cannot natively kit-run" + skip_test "run-backtrace-mid" "host cannot natively kit-run" + skip_test "run-backtrace-root" "host cannot natively kit-run" + skip_test "run-backtrace-source" "host cannot natively kit-run" +fi + kit_summary driver-cc kit_exit