From 095e502cfb58f550090f90453d98b0e2190154b6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:44:54 +0000 Subject: [PATCH 1/7] crash_handler: register a sigaltstack on every thread and keep SA_ONSTACK after JSC init sigaltstack(2) is per-thread state, but the crash handler only registered the static 512 KiB alternate stack on whichever thread ran init() (the main thread). Workers, the HTTP thread and every thread-pool thread inherited the process-wide SIGSEGV disposition with SA_ONSTACK set but had no alternate stack of their own, so on a guard-page fault the kernel had nowhere to push the signal frame and the process died with no bun.report output. Additionally, WTF::SignalHandlers::finalize() (run on the first JSC::VM creation) installs its own SIGSEGV/SIGBUS action with sa_flags = SA_SIGINFO, dropping SA_ONSTACK from the disposition, so after VM init even the main thread could no longer use its alternate stack. Fix by: - adding bun_crash_handler::init_thread(), which heap-allocates and registers a per-thread alternate stack; called from Source::configure_thread() and configure_thread_no_js(), the common entry point for every Bun-spawned thread. A smaller pre-existing stack (e.g. ASAN's ~56 KiB) is replaced and restored on thread exit. - adding bun_crash_handler::ensure_sa_onstack(), which re-applies SA_ONSTACK to the SIGSEGV/SIGBUS/SIGILL/SIGFPE dispositions without otherwise disturbing whatever handler WTF installed (it chains to ours); called right after Zig__GlobalObject__create. - always setting SA_ONSTACK in update_posix_segfault_handler() rather than only on the first call. Add a stackOverflow() hook to the internal-for-testing crash_handler object that recurses in native code until the guard page is hit, and tests that both the main thread and a worker thread produce a crash diagnostic (Bun's 'Segmentation fault at address' or, under ASAN, its stack-overflow report) instead of dying silently. --- src/bun_core/Global.rs | 16 +++ src/bun_core/lib.rs | 3 + src/bun_core/output.rs | 2 + src/crash_handler/lib.rs | 153 +++++++++++++++++++++++-- src/jsc/JSGlobalObject.rs | 5 + src/jsc/VirtualMachine.rs | 5 + src/runtime/api/crash_handler_jsc.rs | 23 +++- test/cli/run/run-crash-handler.test.ts | 76 +++++++++++- 8 files changed, 272 insertions(+), 11 deletions(-) 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..bbb2998ac1a5 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 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..b96617499de8 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,26 @@ 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 + /// `SIGSEGV`-on-guard-page 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)] + #[allow(unconditional_recursion)] + fn recurse(depth: usize) -> usize { + let frame = [0u8; 4096]; + core::hint::black_box(&frame); + core::hint::black_box(recurse(core::hint::black_box(depth) + 1)) + frame[0] 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..19796b4d1995 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,80 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { }); }); +// `sigaltstack(2)` is per-thread. Previously only the main thread registered +// one, and WTF's SIGSEGV handler (installed on the first JSC::VM creation) +// dropped `SA_ONSTACK` from the disposition, so a native stack overflow on any +// thread became an unrecoverable guard-page fault with no crash output. With a +// per-thread altstack plus `SA_ONSTACK` re-applied after VM init, the kernel +// delivers the fault on the alternate stack and the handler chain runs. +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) => { + const patterns = isASAN + ? [/AddressSanitizer:.*stack-overflow/] + : [/Segmentation fault at address/, /bun\.report/]; + for (const p of patterns) expect(stderr).toMatch(p); + }; + // 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", + }; + + test( + "on the main thread", + async () => { + await using proc = Bun.spawn({ + cmd: [ + 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: [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. From 9931f380f28803de55beed6773d679f01cae68c0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:47:28 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- test/cli/run/run-crash-handler.test.ts | 74 +++++++++++--------------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 19796b4d1995..f1c9518a8cd5 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -133,9 +133,7 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { // 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) => { - const patterns = isASAN - ? [/AddressSanitizer:.*stack-overflow/] - : [/Segmentation fault at address/, /bun\.report/]; + const patterns = isASAN ? [/AddressSanitizer:.*stack-overflow/] : [/Segmentation fault at address/, /bun\.report/]; for (const p of patterns) expect(stderr).toMatch(p); }; // Skip llvm-symbolizer in the child; the unwinder walks hundreds of @@ -145,32 +143,26 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=1:symbolize=0:fast_unwind_on_fatal=1", }; - test( - "on the main thread", - async () => { - await using proc = Bun.spawn({ - cmd: [ - 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 the main thread", async () => { + await using proc = Bun.spawn({ + cmd: [ + 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": ` + 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)); @@ -180,20 +172,18 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { process.stderr.write("unreachable\\n"); } `, - }); - await using proc = Bun.spawn({ - cmd: [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, - ); + }); + await using proc = Bun.spawn({ + cmd: [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", () => { From 48f8077439623b36879f20efa7e1456720aed47f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:56:26 +0000 Subject: [PATCH 3/7] test: trim rationale comment to 3 lines --- test/cli/run/run-crash-handler.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index f1c9518a8cd5..4a113297b7c1 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -122,12 +122,9 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { }); }); -// `sigaltstack(2)` is per-thread. Previously only the main thread registered -// one, and WTF's SIGSEGV handler (installed on the first JSC::VM creation) -// dropped `SA_ONSTACK` from the disposition, so a native stack overflow on any -// thread became an unrecoverable guard-page fault with no crash output. With a -// per-thread altstack plus `SA_ONSTACK` re-applied after VM init, the kernel -// delivers the fault on the alternate stack and the handler chain runs. +// `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 From ee718b6c316ea9736f062a34a8a41f284235709d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:17:02 +0000 Subject: [PATCH 4/7] test: drop bun.report assertion (noReportEnv sets empty URL so it never appears) --- test/cli/run/run-crash-handler.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 4a113297b7c1..cacd3d352ac4 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -130,8 +130,7 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { // 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) => { - const patterns = isASAN ? [/AddressSanitizer:.*stack-overflow/] : [/Segmentation fault at address/, /bun\.report/]; - for (const p of patterns) expect(stderr).toMatch(p); + expect(stderr).toMatch(isASAN ? /AddressSanitizer:.*stack-overflow/ : /Segmentation fault at address/); }; // Skip llvm-symbolizer in the child; the unwinder walks hundreds of // identical frames and symbolising them all takes several seconds. From f74b252c37324adbb2813390f1e1d359dcee413e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:48:31 +0000 Subject: [PATCH 5/7] crash_handler: harden stackOverflow() hook and main-thread altstack for release/ASAN CI - stackOverflow(): recurse through a black-boxed fn pointer with volatile frame I/O so release LLVM can't prove the call is self-recursive and turn it into a loop (hung forever on linux-x64-asan). - init(): call init_thread() on the main thread too so it gets the 512 KiB altstack even under ASAN, where reset_on_posix() early-returns and only ASAN's ~56 KiB stack was registered. - test: accept 'Bus error at address' as well as 'Segmentation fault at address'; macOS delivers a guard-page fault as SIGBUS. --- src/crash_handler/lib.rs | 4 ++++ src/runtime/api/crash_handler_jsc.rs | 16 +++++++++++----- test/cli/run/run-crash-handler.test.ts | 5 ++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index bbb2998ac1a5..6c5ccc954526 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -1912,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/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index b96617499de8..fc3481643402 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -102,7 +102,7 @@ pub mod js_bindings { /// Recurse in native code until the guard page is hit. Unlike JS recursion /// (caught by JSC's soft stack limit), this exercises the real - /// `SIGSEGV`-on-guard-page path and so the sigaltstack/`SA_ONSTACK` setup. + /// guard-page-fault path and so the sigaltstack/`SA_ONSTACK` setup. #[bun_jsc::host_fn] pub(crate) fn js_stack_overflow( _global: &JSGlobalObject, @@ -110,11 +110,17 @@ pub mod js_bindings { ) -> JsResult { crash_handler::suppress_core_dumps_if_necessary(); #[inline(never)] - #[allow(unconditional_recursion)] fn recurse(depth: usize) -> usize { - let frame = [0u8; 4096]; - core::hint::black_box(&frame); - core::hint::black_box(recurse(core::hint::black_box(depth) + 1)) + frame[0] as 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) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index cacd3d352ac4..da218b93bd37 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -130,7 +130,10 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { // 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) => { - expect(stderr).toMatch(isASAN ? /AddressSanitizer:.*stack-overflow/ : /Segmentation fault at address/); + // 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. From 45b53c8fb2f637a59223aba5c8f860f32e047f72 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:50:40 +0000 Subject: [PATCH 6/7] [autofix.ci] apply automated fixes --- test/cli/run/run-crash-handler.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index da218b93bd37..8b764089af6b 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -131,9 +131,7 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { // 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/, - ); + 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. From 31493a59d0f8a0d89992efc4782c567e2b802784 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:28:03 +0000 Subject: [PATCH 7/7] test: bound child stack via ulimit -s so main-thread overflow hits a guard page on CI CI Linux runners set `ulimit -s unlimited` (scripts/bootstrap.sh:499), so the main thread has no guard page and the native recursion in stackOverflow() never faults within the 20 s timeout. Worker threads already have a fixed 4 MiB stack. Wrap both spawns in `sh -c 'ulimit -s 8192 && exec ...'` so the child's initial stack is bounded at exec time. --- test/cli/run/run-crash-handler.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 8b764089af6b..1be8da586054 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -139,15 +139,19 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { ...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: [ + cmd: boundedStack([ bunExe(), "--debug-crash-handler-use-trace-string", "-e", `require("bun:internal-for-testing").crash_handler.stackOverflow();`, - ], + ]), env: overflowEnv, stdio: ["ignore", "pipe", "pipe"], }); @@ -171,7 +175,7 @@ describe.if(isPosix)("native stack overflow produces a crash report", () => { `, }); await using proc = Bun.spawn({ - cmd: [bunExe(), "--debug-crash-handler-use-trace-string", "entry.ts"], + cmd: boundedStack([bunExe(), "--debug-crash-handler-use-trace-string", "entry.ts"]), env: overflowEnv, cwd: String(dir), stdio: ["ignore", "pipe", "pipe"],