diff --git a/src/bun_core/debug.rs b/src/bun_core/debug.rs index eeeb02d04ded..4dbca4941bd8 100644 --- a/src/bun_core/debug.rs +++ b/src/bun_core/debug.rs @@ -58,6 +58,19 @@ pub fn frame_address() -> usize { } } +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn mach_vm_read_overwrite( + target_task: libc::mach_port_t, + address: u64, + size: u64, + data: u64, + out_size: *mut u64, + ) -> libc::kern_return_t; + // `mach_task_self()` in C is `#define mach_task_self() mach_task_self_`. + safe static mach_task_self_: libc::mach_port_t; +} + /// Reads memory from any address of the current process, tolerating unmapped /// or corrupt pages so a damaged stack can't fault the walker itself. struct MemoryAccessor { @@ -76,6 +89,28 @@ impl MemoryAccessor { }; fn read(&mut self, address: usize, buf: &mut [u8]) -> bool { + #[cfg(target_os = "macos")] + { + // `msync` only checks *mapped*, not *readable*, so a PROT_NONE + // page (guard, mimalloc/JSC reservation) would pass and the raw + // copy below would fault — inside the SIGSEGV handler, with + // SA_RESETHAND set, that loses the whole report. + // `mach_vm_read_overwrite` asks the kernel to do the copy and + // returns KERN_INVALID_ADDRESS / KERN_PROTECTION_FAILURE instead. + let mut out: u64 = 0; + // SAFETY: `buf` is a valid writable slice; the kernel validates + // `address` and writes at most `buf.len()` bytes into `buf`. + let kr = unsafe { + mach_vm_read_overwrite( + mach_task_self_, + address as u64, + buf.len() as u64, + buf.as_mut_ptr() as u64, + &raw mut out, + ) + }; + return kr == 0 && out == buf.len() as u64; + } #[cfg(any(target_os = "linux", target_os = "android"))] loop { match self.mem { @@ -137,14 +172,17 @@ impl MemoryAccessor { } } } - if !is_valid_memory(address) { - return false; - } - // SAFETY: is_valid_memory just confirmed the page at `address` is mapped. - unsafe { - core::ptr::copy_nonoverlapping(address as *const u8, buf.as_mut_ptr(), buf.len()); + #[cfg(not(target_os = "macos"))] + { + if !is_valid_memory(address) { + return false; + } + // SAFETY: is_valid_memory just confirmed the page at `address` is mapped. + unsafe { + core::ptr::copy_nonoverlapping(address as *const u8, buf.as_mut_ptr(), buf.len()); + } + true } - true } fn load_usize(&mut self, address: usize) -> Option { @@ -155,6 +193,24 @@ impl MemoryAccessor { None } } + + /// Quick plausibility filter for a candidate return address recovered + /// from `lr` / `[rsp]`: mapped page and (on aarch64) 4-byte instruction + /// alignment. Not a precise text-segment test; the goal is only to drop + /// obvious garbage (small integers, unmapped) so a clobbered `lr` in a + /// framed function doesn't inject a nonsense frame. + #[cfg(not(windows))] + fn looks_like_code(&mut self, address: usize) -> bool { + if address == 0 { + return false; + } + #[cfg(target_arch = "aarch64")] + if !address.is_multiple_of(4) { + return false; + } + let mut probe = [0u8; 1]; + self.read(address, &mut probe) + } } impl Drop for MemoryAccessor { @@ -167,6 +223,7 @@ impl Drop for MemoryAccessor { } } +#[cfg(not(target_os = "macos"))] fn is_valid_memory(address: usize) -> bool { let page_size = bun_alloc::page_size(); let aligned_address = address & !(page_size - 1); @@ -322,14 +379,29 @@ pub(crate) fn capture_current(first_address: Option, out: &mut [usize]) - /// POSIX: walk frame pointers from `fp` (the saved frame pointer register). /// No trimming is needed — the walk starts on the faulting stack, so the /// signal handler's own frames (on the altstack) are never in the chain. +/// `lr` (aarch64 x30) or `[sp]` (x86_64, the word `call` pushed) is inserted +/// after `pc` when it names a caller the fp-walk would skip — i.e. a fault +/// inside a frameless leaf, where `fp` still belongs to the caller and the +/// walk's first hop is the caller's caller. When the faulting function has +/// its own frame record, the walk's first hop is already the caller and the +/// recovered value would either duplicate it (not yet clobbered) or be stale +/// (clobbered); both are suppressed. The `[sp]` read is deferred to here (not +/// done in the signal handler) so a stack overflow with `rsp` in a guard page +/// cannot recursively fault before the crash header is printed. /// /// Windows: `rbp` is not a reliable frame pointer across all linked code (the /// prebuilt JavaScriptCore and LLInt assembly do not maintain it), so an /// fp-walk derails at the C++ boundary. Use the native `.pdata`-based /// `RtlCaptureStackBackTrace` instead — it works with or without unwind tables /// since `.pdata` is always emitted — and trim the handler's own frames by -/// scanning for `pc`. `fp` is unused on Windows. -pub fn capture_from_context(pc: usize, fp: usize, out: &mut [usize]) -> usize { +/// scanning for `pc`. `fp` / `lr` / `sp` are unused on Windows. +pub fn capture_from_context( + pc: usize, + fp: usize, + lr: usize, + sp: usize, + out: &mut [usize], +) -> usize { if out.is_empty() { return 0; } @@ -337,7 +409,7 @@ pub fn capture_from_context(pc: usize, fp: usize, out: &mut [usize]) -> usize { let mut n = 1usize; #[cfg(windows)] { - let _ = fp; + let _ = (fp, lr, sp); let cap = (out.len() - 1).min(u16::MAX as usize) as u32; // SAFETY: out[1..] is valid for `cap` writes; hash ptr may be null. let got = unsafe { @@ -369,6 +441,45 @@ pub fn capture_from_context(pc: usize, fp: usize, out: &mut [usize]) -> usize { #[cfg(not(windows))] { let mut it = StackIterator::init(fp); + let first = it.next(); + // x86_64 has no link register; derive one from the word `call` + // pushed. A stack overflow can leave `rsp` in a PROT_NONE guard page + // — `it.ma` tolerates that (process_vm_readv / mach_vm_read_overwrite + // return an error rather than faulting). + let lr = if lr == 0 && sp != 0 && sp.is_multiple_of(core::mem::align_of::()) { + it.ma.load_usize(sp).unwrap_or(0) + } else { + lr + }; + // Frameless-leaf recovery: emit `lr` between `pc` and the fp-walk when + // it is a distinct, plausible return address. `first` (the saved LR at + // `[fp+8]`) is the caller when the faulting function pushed a frame + // record, but the caller's caller when it didn't; only in the latter + // case does `lr` add information. `lr == first` means the faulting + // function pushed a frame and hasn't clobbered x30/[rsp] yet, so skip + // the duplicate. `lr == pc` covers a fault on the leaf's very first + // instruction. The stack-proximity check rejects the x86_64 case + // where a framed function has adjusted `rsp` and `[rsp]` is a local + // rather than the pushed return address. A stale clobbered `lr` that + // still lands in the image is tolerated — one noisy frame is cheaper + // than a missing one. + const STACK_RADIUS: usize = 64 * 1024 * 1024; + if lr != 0 + && lr != pc + && Some(lr) != first + && lr.abs_diff(fp) > STACK_RADIUS + && n < out.len() + && it.ma.looks_like_code(lr) + { + out[n] = lr; + n += 1; + } + if let Some(addr) = first { + if n < out.len() { + out[n] = addr; + n += 1; + } + } while n < out.len() { match it.next() { Some(addr) => { diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 71fd75a8dfd3..3b63ee2fbfa1 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -79,6 +79,13 @@ pub mod debug { bun_core::return_address() } + /// Re-export for the `segfaultInFramelessLeaf` test hook, which + /// synthesises a `TraceSeed::Fault` under ASAN. + #[inline(always)] + pub fn frame_address() -> usize { + bun_core::debug::frame_address() + } + /// Thin re-export of the canonical safe /// wrapper in bun_core so this crate's internal callers don't churn. #[inline] @@ -852,8 +859,15 @@ mod draft { pub enum TraceSeed<'a> { /// Signal/exception handler saved the fault register context: walk frame /// pointers from `fp` (POSIX) / RtlCapture and trim by `pc` (Windows). `pc` - /// becomes frame 0. - Fault { pc: usize, fp: usize }, + /// becomes frame 0. `lr` (aarch64 x30) / `sp` (x86_64 rsp, whose top word + /// `call` pushed) recover a frameless leaf's immediate caller that the + /// fp-walk would skip; pass 0 for whichever the platform doesn't have. + Fault { + pc: usize, + fp: usize, + lr: usize, + sp: usize, + }, /// A trace was already captured upstream. ErrorReturn(&'a StackTrace<'a>), /// Walk the current stack and trim the capture machinery above this PC. @@ -1119,8 +1133,8 @@ mod draft { // `SA_ONSTACK` altstack, so its own frame chain is // disjoint from the faulting thread's, and release builds // strip the unwind tables a CFI-based capture would need. - TraceSeed::Fault { pc, fp } => { - bun_core::debug::capture_from_context(pc, fp, &mut addr_buf) + TraceSeed::Fault { pc, fp, lr, sp } => { + bun_core::debug::capture_from_context(pc, fp, lr, sp, &mut addr_buf) } TraceSeed::BeginAddr(addr) => { debug::capture_stack_trace(addr, &mut addr_buf) @@ -1604,27 +1618,46 @@ mod draft { }, ); - /// Extract `(pc, fp)` from the `ucontext_t` the kernel hands the signal - /// handler. Seeds the frame-pointer walk from the faulting frame. Returns + /// Extract `(pc, fp, lr, sp)` from the `ucontext_t` the kernel hands the + /// signal handler. Seeds the frame-pointer walk from the faulting frame. + /// Returns /// `None` on arch/OS combos we don't have register offsets for (the caller /// then falls back to a current-stack capture). + /// + /// `lr` is the return-into-caller address at the fault: on aarch64 this is + /// x30 straight from the register file. x86_64 has no link register; `sp` + /// is returned instead and `capture_from_context` reads `[sp]` (the word + /// `call` pushed) once the handler body has started, so a stack overflow + /// with `rsp` in a guard page cannot recursively fault before the header + /// is printed. When the fault is inside a frameless leaf, `fp` still + /// belongs to the caller's frame and the fp-walk's first hop yields the + /// *caller's* return address; `lr` / `[sp]` is the only place the + /// immediate caller survives. #[cfg(unix)] - fn fault_context_from_ucontext(ctx: *mut c_void) -> Option<(usize, usize)> { + fn fault_context_from_ucontext(ctx: *mut c_void) -> Option<(usize, usize, usize, usize)> { debug_assert!(!ctx.is_null()); let uc = ctx.cast::().cast_const(); #[cfg(all(target_os = "linux", target_arch = "x86_64"))] // SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg. unsafe { let mc = &(*uc).uc_mcontext; - let pc = mc.gregs[libc::REG_RIP as usize] as usize; - let fp = mc.gregs[libc::REG_RBP as usize] as usize; - Some((pc, fp)) + Some(( + mc.gregs[libc::REG_RIP as usize] as usize, + mc.gregs[libc::REG_RBP as usize] as usize, + 0, + mc.gregs[libc::REG_RSP as usize] as usize, + )) } #[cfg(all(target_os = "linux", target_arch = "aarch64"))] // SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg. unsafe { let mc = &(*uc).uc_mcontext; - Some((mc.pc as usize, mc.regs[29] as usize)) + Some(( + mc.pc as usize, + mc.regs[29] as usize, + mc.regs[30] as usize, + 0, + )) } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] // SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg. @@ -1633,7 +1666,12 @@ mod draft { if mc.is_null() { return None; } - Some(((*mc).__ss.__rip as usize, (*mc).__ss.__rbp as usize)) + Some(( + (*mc).__ss.__rip as usize, + (*mc).__ss.__rbp as usize, + 0, + (*mc).__ss.__rsp as usize, + )) } #[cfg(all(target_os = "macos", target_arch = "aarch64"))] // SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg. @@ -1642,7 +1680,12 @@ mod draft { if mc.is_null() { return None; } - Some(((*mc).__ss.__pc as usize, (*mc).__ss.__fp as usize)) + Some(( + (*mc).__ss.__pc as usize, + (*mc).__ss.__fp as usize, + (*mc).__ss.__lr as usize, + 0, + )) } #[cfg(not(any( all(target_os = "linux", target_arch = "x86_64"), @@ -1672,7 +1715,7 @@ mod draft { _ => unreachable!(), }, match fault_context_from_ucontext(ctx) { - Some((pc, fp)) => TraceSeed::Fault { pc, fp }, + Some((pc, fp, lr, sp)) => TraceSeed::Fault { pc, fp, lr, sp }, None => TraceSeed::None, }, ); @@ -2062,7 +2105,15 @@ mod draft { let pc = unsafe { (*info.ExceptionRecord).ExceptionAddress } as usize; // Windows: capture_from_context uses RtlCaptureStackBackTrace and trims // by `pc`; the frame-pointer slot is unused. - crash_handler(reason, TraceSeed::Fault { pc, fp: 0 }); + crash_handler( + reason, + TraceSeed::Fault { + pc, + fp: 0, + lr: 0, + sp: 0, + }, + ); } #[cfg(all(target_os = "linux", target_env = "gnu"))] diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index d04d7b9a7325..c9b71055c988 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -99,6 +99,7 @@ export const cssInternals = { export const crash_handler = $rust("crash_handler.rs", "js_bindings.generate") as { getMachOImageZeroOffset: () => number; segfault: () => void; + segfaultInFramelessLeaf: () => void; panic: () => void; rootError: () => void; outOfMemory: () => void; diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index ebf4942c5ead..1db5590ab4f1 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -12,7 +12,7 @@ pub mod js_bindings { use super::*; pub fn generate(global: &JSGlobalObject) -> JSValue { - let obj = JSValue::create_empty_object(global, 8); + let obj = JSValue::create_empty_object(global, 9); // `#[bun_jsc::host_fn]` emits an `extern "C"` shim named `__jsc_host_`; that // shim is the `JSHostFn` value passed to `JSFunction::create`. const ENTRIES: &[(&str, bun_jsc::JSHostFn)] = &[ @@ -23,6 +23,10 @@ pub mod js_bindings { ("getFeaturesAsVLQ", __jsc_host_js_get_features_as_vlq), ("getFeatureData", __jsc_host_js_get_feature_data), ("segfault", __jsc_host_js_segfault), + ( + "segfaultInFramelessLeaf", + __jsc_host_js_segfault_in_frameless_leaf, + ), ("panic", __jsc_host_js_panic), ("rootError", __jsc_host_js_root_error), ("outOfMemory", __jsc_host_js_out_of_memory), @@ -67,6 +71,74 @@ pub mod js_bindings { } } + // A one-instruction leaf with no frame prologue, so at the fault `fp` + // still belongs to the caller. The fp-walk's first hop is therefore the + // caller's caller, and the immediate caller survives only in `lr` + // (aarch64) / `[rsp]` (x86_64) — which the crash handler must capture. + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] + unsafe extern "C" { + fn bun_test_frameless_segv(); + } + #[cfg(target_arch = "x86_64")] + core::arch::global_asm!( + ".globl {s}", + ".p2align 4", + "{s}:", + " movq 0, %rax", + " retq", + s = sym bun_test_frameless_segv, + options(att_syntax), + ); + #[cfg(target_arch = "aarch64")] + core::arch::global_asm!( + ".globl {s}", + ".p2align 2", + "{s}:", + " mov x8, #0", + " ldr x0, [x8]", + " ret", + s = sym bun_test_frameless_segv, + ); + + #[inline(never)] + fn frameless_segv_caller() -> ! { + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] + { + // ASAN owns SIGSEGV in sanitizer builds, so drive + // `capture_from_context` directly with the (pc, fp, lr) the + // real fault would have produced: `fp` is ours (the stub pushes + // no frame), `lr` is the return-into-us the `call`/`bl` would + // set — approximated as our entry + one instruction so it + // symbolicates to this function and is distinct from `[fp+8]`. + if Environment::ENABLE_ASAN { + let pc = bun_test_frameless_segv as *const () as usize; + let fp = crash_handler::debug::frame_address(); + let step: usize = if cfg!(target_arch = "aarch64") { 4 } else { 1 }; + let lr = frameless_segv_caller as *const () as usize + step; + crash_handler::crash_handler( + crash_handler::CrashReason::SegmentationFault(0), + crash_handler::TraceSeed::Fault { pc, fp, lr, sp: 0 }, + ); + } + // SAFETY: test-only, intentionally faults. + unsafe { bun_test_frameless_segv() }; + } + // Other arches: an ordinary null deref so the helper still + // terminates; the frame-shape assertion is gated on x64/arm64. + // SAFETY: test-only, intentionally faults. + unsafe { core::ptr::write_volatile(core::ptr::null_mut::(), 0) }; + unreachable!() + } + + #[bun_jsc::host_fn] + pub(crate) fn js_segfault_in_frameless_leaf( + _global: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { + crash_handler::suppress_core_dumps_if_necessary(); + frameless_segv_caller(); + } + #[bun_jsc::host_fn] pub(crate) fn js_segfault(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { crash_handler::suppress_core_dumps_if_necessary(); diff --git a/test/cli/run/fixture-crash.js b/test/cli/run/fixture-crash.js index 7e9082c78d6c..be6f3de90648 100644 --- a/test/cli/run/fixture-crash.js +++ b/test/cli/run/fixture-crash.js @@ -11,5 +11,7 @@ const approach = process.argv[2]; if (approach in crash_handler) { crash_handler[approach](); } else { - console.error("usage: bun fixture-crash.js "); + console.error( + "usage: bun fixture-crash.js ", + ); } diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 882cd7bdde68..2a247b8e7391 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -1,6 +1,6 @@ import { crash_handler } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isLinux, isPosix, mergeWindowEnvs } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isPosix, mergeWindowEnvs } from "harness"; import path from "path"; const { getMachOImageZeroOffset } = crash_handler; @@ -117,6 +117,38 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { }); }); +// A fault inside a function with no frame prologue (machine-outlined +// sequence, ICF-folded thunk, hand-written asm) leaves `fp` pointing at the +// caller's frame record, so an fp-walk seeded from the fault context alone +// skips straight to the caller's caller and the immediate caller vanishes +// from the report. The crash handler has to recover that frame from `lr` +// (aarch64 x30) / `[rsp]` (x86_64). Under ASAN the SIGSEGV handler isn't +// installed so the helper synthesises an equivalent `TraceSeed::Fault` +// instead of taking a real signal. +test.if(isDebug && isLinux && hasSymbolizer && ["x64", "arm64"].includes(process.arch))( + "crash in a frameless leaf still reports its immediate caller", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "segfaultInFramelessLeaf"], + env: bunEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("Segmentation fault at address"); + if (!isASAN) expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + + // The symbolized trace must name the Rust wrapper that called the + // frameless asm stub. Without the lr/[rsp] seed the first non-pc frame + // is the wrapper's caller and `frameless_segv_caller` never appears. + expect(stdout.includes("frameless_segv_caller") ? "frameless_segv_caller" : stdout || stderr).toBe( + "frameless_segv_caller", + ); + }, + 60_000, +); + test.if(process.platform === "darwin")("macOS has the assumed image offset", () => { // If this fails, then https://bun.report will be incorrect and the stack // trace remappings will stop working.