Skip to content
131 changes: 121 additions & 10 deletions src/bun_core/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<usize> {
Expand All @@ -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)
}
Comment thread
robobun marked this conversation as resolved.
}

impl Drop for MemoryAccessor {
Expand All @@ -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);
Expand Down Expand Up @@ -322,22 +379,37 @@ pub(crate) fn capture_current(first_address: Option<usize>, 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;
}
out[0] = pc;
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 {
Expand Down Expand Up @@ -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::<usize>()) {
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) => {
Expand Down
81 changes: 66 additions & 15 deletions src/crash_handler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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::<libc::ucontext_t>().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.
Expand All @@ -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,
))
Comment thread
robobun marked this conversation as resolved.
}
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
// SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg.
Expand All @@ -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"),
Expand Down Expand Up @@ -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,
},
);
Expand Down Expand Up @@ -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"))]
Expand Down
1 change: 1 addition & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading