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:
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