diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index dd59d7770bab..f8830028471e 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -185,6 +185,22 @@ pub fn dump_stack_trace(trace: &StackTrace<'_>, limits: DumpStackTraceOptions) { } } +/// Register a per-thread alternate signal stack. Dispatches to +/// `bun_crash_handler::init_thread` via a link-time `extern "Rust"` symbol so +/// `bun_core` does not depend on the crash-handler crate. Under `cfg(test)` +/// (this crate's standalone test binary does not link `bun_crash_handler`) it +/// is a no-op. +#[inline] +pub fn crash_handler_init_thread() { + #[cfg(not(test))] + { + unsafe extern "Rust" { + safe fn __bun_crash_handler_init_thread(); + } + __bun_crash_handler_init_thread() + } +} + /// Capture and dump the current call stack. Dispatches to /// `bun_crash_handler::dump_current_stack_trace`. /// The upward call is routed through a link-time `extern "Rust"` diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 85aed1f307ce..c0ae4c2a1747 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -2600,6 +2600,9 @@ pub mod ffi { // `usize` sighandler_t on every libc target). #[cfg(unix)] unsafe impl Zeroable for libc::sigaction {} + // SAFETY: C POD (raw-pointer/size_t/int fields only); all-zero is valid. + #[cfg(unix)] + unsafe impl Zeroable for libc::stack_t {} // `sigset_t` is a `u32` typedef on Darwin (covered by the primitive // blanket → E0119 if re-impl'd) but a real struct on Linux/Android // (`__val: [c_ulong; 16]`) and FreeBSD (`__bits: [u32; 4]`). Gate the diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 17433f88d5d6..09f6d8570e0b 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -441,6 +441,7 @@ impl Source { SOURCE.with_borrow_mut(|s| unsafe { Source::init(s, STDOUT_STREAM.read(), STDERR_STREAM.read()) }); + crate::crash_handler_init_thread(); crate::StackCheck::configure_thread(); } @@ -469,6 +470,7 @@ impl Source { SOURCE.with_borrow_mut(|s| unsafe { Source::init(s, STDOUT_STREAM.read(), STDERR_STREAM.read()) }); + crate::crash_handler_init_thread(); // Intentionally NOT calling `crate::StackCheck::configure_thread()`. } diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index f350f3134562..6c5ccc954526 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -65,6 +65,14 @@ pub(crate) extern "Rust" fn __bun_crash_handler_dump_stack_trace( draft::dump_current_stack_trace_from_core(first_address, limits) } +/// `extern "Rust"` symbol resolved by `bun_core`'s per-thread setup at link +/// time. Registers an alternate signal stack for the calling thread. +#[doc(hidden)] +#[unsafe(no_mangle)] +pub(crate) extern "Rust" fn __bun_crash_handler_init_thread() { + draft::init_thread() +} + pub use draft::*; // ────────────────────────────────────────────────────────────────────────── @@ -1679,34 +1687,42 @@ mod draft { ); } + /// Size of the alternate signal stack. The crash handler formats output, + /// captures a backtrace and may spawn a child (curl / llvm-symbolizer), so + /// this is much larger than `SIGSTKSZ`. + #[cfg(unix)] + pub(crate) const SIGALTSTACK_SIZE: usize = 512 * 1024; + #[cfg(unix)] static DID_REGISTER_SIGALTSTACK: AtomicBool = AtomicBool::new(false); - /// 512K alternate signal stack. The kernel writes here during signal delivery; - /// Rust never reads/writes the bytes, so `RacyCell` only needs to provide a - /// stable `*mut u8` for `sigaltstack(2)`. + /// 512K alternate signal stack for the main thread. The kernel writes here + /// during signal delivery; Rust never reads/writes the bytes, so `RacyCell` + /// only needs to provide a stable `*mut u8` for `sigaltstack(2)`. Other + /// threads get a heap-allocated altstack via [`init_thread`]. #[cfg(unix)] - static SIGALTSTACK: bun_core::RacyCell<[u8; 512 * 1024]> = - bun_core::RacyCell::new([0; 512 * 1024]); + static SIGALTSTACK: bun_core::RacyCell<[u8; SIGALTSTACK_SIZE]> = + bun_core::RacyCell::new([0; SIGALTSTACK_SIZE]); #[cfg(unix)] fn update_posix_segfault_handler(mut act: Option<&mut libc::sigaction>) -> crate::Result<()> { if let Some(act_) = act.as_deref_mut() { - // SAFETY: single global; only mutated during signal-handler setup if !DID_REGISTER_SIGALTSTACK.load(Ordering::Relaxed) { let stack = libc::stack_t { ss_flags: 0, - ss_size: 512 * 1024, + ss_size: SIGALTSTACK_SIZE, // SAFETY: SIGALTSTACK is a process-lifetime static byte buffer; the kernel only writes to it during signal delivery (no Rust aliasing) ss_sp: SIGALTSTACK.get().cast(), }; // SAFETY: stack points to a valid static buffer if unsafe { libc::sigaltstack(&raw const stack, core::ptr::null_mut()) } == 0 { - act_.sa_flags |= libc::SA_ONSTACK; - // SAFETY: single global; only mutated during signal-handler setup DID_REGISTER_SIGALTSTACK.store(true, Ordering::Relaxed); } } + // Always request altstack delivery. `SA_ONSTACK` on a thread without + // an altstack falls back to the normal stack, so this is never worse + // than omitting it. + act_.sa_flags |= libc::SA_ONSTACK; } let act_ptr: *const libc::sigaction = act @@ -1722,6 +1738,125 @@ mod draft { Ok(()) } + /// Heap-backed per-thread alternate signal stack. Installed by + /// [`init_thread`]; on drop the previous altstack is restored so this does + /// not fight ASAN's per-thread stack (which ASAN tears down itself). + #[cfg(unix)] + struct ThreadAltStack { + buf: Box<[u8]>, + prev: libc::stack_t, + } + + #[cfg(unix)] + impl Drop for ThreadAltStack { + fn drop(&mut self) { + // Restore the altstack that was in place before we installed ours + // (none, or ASAN's) before the backing buffer is freed. `SS_ONSTACK` + // in the queried `ss_flags` is a status bit, not a request bit; pass + // either SS_DISABLE or 0. + let prev = libc::stack_t { + ss_flags: if self.prev.ss_flags & libc::SS_DISABLE != 0 || self.prev.ss_sp.is_null() + { + libc::SS_DISABLE + } else { + 0 + }, + ..self.prev + }; + // SAFETY: `prev` is either the valid altstack queried at install + // time or an SS_DISABLE request; null oldss is permitted. + unsafe { + libc::sigaltstack(&raw const prev, core::ptr::null_mut()); + } + let _ = &self.buf; + } + } + + #[cfg(unix)] + thread_local! { + static THREAD_ALTSTACK: core::cell::RefCell> = + const { core::cell::RefCell::new(None) }; + } + + /// Register an alternate signal stack for the current thread so that a + /// guard-page fault (stack overflow) can still deliver `SIGSEGV` to the + /// crash handler. `sigaltstack(2)` is per-thread state; `init()` only sets + /// it on the thread it runs on. + /// + /// Called from every Bun-spawned thread's entry point via + /// `bun_core::output::Source::configure_thread` / + /// `configure_thread_no_js`. + /// + /// If the thread already has an altstack of at least `SIGALTSTACK_SIZE` + /// (e.g. the main thread's static one) it is left alone; a smaller one + /// (e.g. ASAN's ~56 KiB) is replaced so the full crash-report path has + /// room to run, and restored on thread exit. + pub fn init_thread() { + #[cfg(unix)] + THREAD_ALTSTACK.with(|cell| { + if cell.borrow().is_some() { + return; + } + let mut prev: libc::stack_t = bun_core::ffi::zeroed(); + // SAFETY: null ss = query-only; `prev` is a valid out-pointer. + if unsafe { libc::sigaltstack(core::ptr::null(), &raw mut prev) } != 0 { + return; + } + if prev.ss_flags & libc::SS_DISABLE == 0 + && !prev.ss_sp.is_null() + && prev.ss_size >= SIGALTSTACK_SIZE + { + return; + } + let mut buf = vec![0u8; SIGALTSTACK_SIZE].into_boxed_slice(); + let ss = libc::stack_t { + ss_sp: buf.as_mut_ptr().cast(), + ss_flags: 0, + ss_size: SIGALTSTACK_SIZE, + }; + // SAFETY: `ss.ss_sp` points into a live heap allocation of + // `ss.ss_size` bytes that is kept alive for the thread's lifetime + // by the thread-local below. + if unsafe { libc::sigaltstack(&raw const ss, core::ptr::null_mut()) } != 0 { + return; + } + *cell.borrow_mut() = Some(ThreadAltStack { buf, prev }); + }); + } + + /// Ensure the currently installed fatal-signal dispositions carry + /// `SA_ONSTACK`. WTF's `SignalHandlers::finalize()` (run on the first + /// `JSC::VM` creation) installs its own `SIGSEGV`/`SIGBUS` action with + /// `sa_flags = SA_SIGINFO` only, dropping our `SA_ONSTACK` bit so even a + /// thread that has an altstack can no longer use it. Reapply the bit here + /// without otherwise disturbing whatever handler is in place (WTF's chains + /// to ours). + pub fn ensure_sa_onstack() { + #[cfg(unix)] + for &sig in &[libc::SIGSEGV, libc::SIGBUS, libc::SIGILL, libc::SIGFPE] { + let mut act: libc::sigaction = bun_core::ffi::zeroed(); + // SAFETY: null act = query-only; `act` is a valid out-pointer. + if unsafe { libc::sigaction(sig, core::ptr::null(), &raw mut act) } != 0 { + continue; + } + if act.sa_flags & libc::SA_ONSTACK != 0 { + continue; + } + // Don't touch SIG_DFL / SIG_IGN. + if act.sa_flags & libc::SA_SIGINFO == 0 + && (act.sa_sigaction == libc::SIG_DFL || act.sa_sigaction == libc::SIG_IGN) + { + continue; + } + act.sa_flags |= libc::SA_ONSTACK; + // SAFETY: `act` was just read from the kernel for `sig` and only + // `sa_flags` was modified; null oldact is permitted. + unsafe { + libc::sigaction(sig, &raw const act, core::ptr::null_mut()); + } + } + } + // Windows VEH handle storage lives at T0 (`bun_core::WINDOWS_SEGFAULT_HANDLE`, // `AtomicPtr`) so `bun_core::raise_ignoring_panic_handler` can remove // it before re-raising without an upward dep. Single source of truth — this @@ -1777,6 +1912,10 @@ mod draft { ))] { reset_on_posix(); + // Under ASAN `reset_on_posix` early-returns without registering the + // static altstack; give the main thread the full-size one here so + // the ASAN-chained handler has room to run. + init_thread(); } install_hooks(); diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 982eabb65c46..12311fc1801b 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1396,6 +1396,11 @@ impl JSGlobalObject { // JSC might mess with the stack size. StackCheck::configure_thread(); + // The first `JSC::VM::tryCreate` runs `WTF::Config::finalize()` → + // `SignalHandlers::finalize()`, which installs SIGSEGV/SIGBUS actions + // with `sa_flags = SA_SIGINFO` only. Reapply `SA_ONSTACK` so the crash + // handler can still run after a guard-page fault. + bun_crash_handler::ensure_sa_onstack(); global } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d715e84b68f..aceb956a5d1b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2206,6 +2206,11 @@ impl VirtualMachine { ); // JSC may mess with the stack size. bun_core::StackCheck::configure_thread(); + // The first `JSC::VM::tryCreate` runs `WTF::Config::finalize()` → + // `SignalHandlers::finalize()`, which installs SIGSEGV/SIGBUS actions + // with `sa_flags = SA_SIGINFO` only. Reapply `SA_ONSTACK` so the crash + // handler can still run after a guard-page fault. + bun_crash_handler::ensure_sa_onstack(); // SAFETY: write through the raw `vm` ptr (not `vm_ref`) so no // `&mut VirtualMachine` is held live across the FFI call above; same // pattern as the `init_runtime_state` hook above. `global` is freshly diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index a58067e6b4f5..fc3481643402 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,7 @@ pub mod js_bindings { ("getFeaturesAsVLQ", __jsc_host_js_get_features_as_vlq), ("getFeatureData", __jsc_host_js_get_feature_data), ("segfault", __jsc_host_js_segfault), + ("stackOverflow", __jsc_host_js_stack_overflow), ("panic", __jsc_host_js_panic), ("rootError", __jsc_host_js_root_error), ("outOfMemory", __jsc_host_js_out_of_memory), @@ -99,6 +100,32 @@ pub mod js_bindings { crash_handler::panic_impl(b"invoked crashByPanic() handler", None, None); } + /// Recurse in native code until the guard page is hit. Unlike JS recursion + /// (caught by JSC's soft stack limit), this exercises the real + /// guard-page-fault path and so the sigaltstack/`SA_ONSTACK` setup. + #[bun_jsc::host_fn] + pub(crate) fn js_stack_overflow( + _global: &JSGlobalObject, + _frame: &CallFrame, + ) -> JsResult { + crash_handler::suppress_core_dumps_if_necessary(); + #[inline(never)] + fn recurse(depth: usize) -> usize { + let mut frame = [0u8; 4096]; + // Volatile I/O keeps the stack array from being elided; calling + // through a black-boxed fn pointer keeps release LLVM from proving + // the call is self-recursive and turning it into a loop. + // SAFETY: `frame` is a live stack array; the pointer is in-bounds. + unsafe { core::ptr::write_volatile(frame.as_mut_ptr(), depth as u8) }; + let f: fn(usize) -> usize = core::hint::black_box(recurse); + let next = f(depth.wrapping_add(1)); + // SAFETY: `frame` is a live stack array; the pointer is in-bounds. + next.wrapping_add(unsafe { core::ptr::read_volatile(frame.as_ptr()) } as usize) + } + core::hint::black_box(recurse(0)); + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] pub(crate) fn js_root_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { crash_handler::handle_root_error("Unexpected", None); diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index dcff2e6ce680..1be8da586054 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, tempDir } from "harness"; import path from "path"; const { getMachOImageZeroOffset } = crash_handler; @@ -122,6 +122,71 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { }); }); +// `sigaltstack(2)` is per-thread and WTF's SIGSEGV handler drops `SA_ONSTACK` +// on VM init; without a per-thread altstack + reapplied `SA_ONSTACK`, a native +// stack overflow becomes an unrecoverable guard-page fault with no output. +describe.if(isPosix)("native stack overflow produces a crash report", () => { + // ASAN builds leave Bun's SIGSEGV handler uninstalled so ASAN's DEADLYSIGNAL + // diagnostic stays in charge; the handler chain is WTF -> ASAN there. Either + // way the process must emit a diagnostic rather than dying silently. + const expectCrashDiagnostic = (stderr: string) => { + // macOS delivers a guard-page fault as SIGBUS, Linux as SIGSEGV. + expect(stderr).toMatch(isASAN ? /AddressSanitizer:.*stack-overflow/ : /(Segmentation fault|Bus error) at address/); + }; + // Skip llvm-symbolizer in the child; the unwinder walks hundreds of + // identical frames and symbolising them all takes several seconds. + const overflowEnv = { + ...noReportEnv, + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0:fast_unwind_on_fatal=1", + }; + // CI Linux runners set `ulimit -s unlimited`, so the main thread has no + // guard page and native recursion never faults. Bound the stack at exec + // time so both tests overflow deterministically. + const boundedStack = (argv: string[]) => ["/bin/sh", "-c", `ulimit -s 8192 && exec "$@"`, "--", ...argv]; + + test("on the main thread", async () => { + await using proc = Bun.spawn({ + cmd: boundedStack([ + bunExe(), + "--debug-crash-handler-use-trace-string", + "-e", + `require("bun:internal-for-testing").crash_handler.stackOverflow();`, + ]), + env: overflowEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expectCrashDiagnostic(stderr); + expect(stderr).not.toContain("unreachable"); + expect(exitCode).not.toBe(0); + }, 20_000); + + test("on a worker thread", async () => { + using dir = tempDir("crash-handler-worker-stackoverflow", { + "entry.ts": ` + import { Worker, isMainThread } from "worker_threads"; + if (isMainThread) { + const w = new Worker(new URL(import.meta.url)); + await new Promise(r => w.on("exit", r)); + } else { + require("bun:internal-for-testing").crash_handler.stackOverflow(); + process.stderr.write("unreachable\\n"); + } + `, + }); + await using proc = Bun.spawn({ + cmd: boundedStack([bunExe(), "--debug-crash-handler-use-trace-string", "entry.ts"]), + env: overflowEnv, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expectCrashDiagnostic(stderr); + expect(stderr).not.toContain("unreachable"); + expect(exitCode).not.toBe(0); + }, 20_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.