diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 47dedb61e337..2c28730842f5 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "f20ce7744553c910bcf16a33faf976af208de091"; +export const WEBKIT_VERSION = "autobuild-preview-pr-741-242085de"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index 06b54e67431e..053ad1e67914 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -613,6 +613,42 @@ pub fn set_thread_name(name: &ZStr) { } } +/// The calling thread's name, whoever set it (bun, WTF, `std::thread`), or empty. +/// Called from the crash handler: a syscall on Linux, elsewhere a libpthread +/// read of the calling thread's own record. +#[cfg(unix)] +pub fn current_thread_name(buf: &mut [u8; 64]) -> &[u8] { + buf.fill(0); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + // SAFETY: PR_GET_NAME writes at most 16 bytes (TASK_COMM_LEN), NUL + // included, into `buf`. + if unsafe { libc::prctl(libc::PR_GET_NAME, buf.as_mut_ptr() as usize) } != 0 { + return &[]; + } + } + #[cfg(target_os = "macos")] + { + // SAFETY: `buf` is writable for `buf.len()` bytes; the call writes a + // NUL-terminated name that fits. + if unsafe { + libc::pthread_getname_np(libc::pthread_self(), buf.as_mut_ptr().cast(), buf.len()) + } != 0 + { + return &[]; + } + } + #[cfg(target_os = "freebsd")] + { + // SAFETY: `buf` is writable for `buf.len()` bytes; the call writes a + // NUL-terminated name that fits. + unsafe { + libc::pthread_get_name_np(libc::pthread_self(), buf.as_mut_ptr().cast(), buf.len()); + } + } + crate::slice_to_nul(buf) +} + // ────────────────────────────────────────────────────────────────────────── // Exit callbacks // ────────────────────────────────────────────────────────────────────────── diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index c13f39ee42f2..2044e5d74725 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -20,6 +20,8 @@ pub mod comptime_string_map; pub mod error; pub mod hint; pub mod result; +#[cfg(unix)] +pub mod signal_stack; pub mod thread_id; pub mod tty; pub mod util; @@ -2604,6 +2606,9 @@ pub mod ffi { // SAFETY: integer-array struct on the gated targets; all-zero is valid. #[cfg(all(unix, not(target_vendor = "apple")))] unsafe impl Zeroable for libc::sigset_t {} + // SAFETY: C POD (raw pointer + integer fields); all-zero is valid. + #[cfg(unix)] + unsafe impl Zeroable for libc::stack_t {} // SAFETY: C POD (integer/array/raw-pointer fields only); all-zero is valid. #[cfg(unix)] unsafe impl Zeroable for libc::utsname {} diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 0df174211d37..93cc368cebab 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -352,6 +352,8 @@ impl Source { SOURCE.with_borrow_mut(|s| unsafe { Source::init(s, STDOUT_STREAM.read(), STDERR_STREAM.read()) }); + #[cfg(unix)] + crate::signal_stack::install_for_current_thread(); crate::StackCheck::configure_thread(); } @@ -380,6 +382,8 @@ impl Source { SOURCE.with_borrow_mut(|s| unsafe { Source::init(s, STDOUT_STREAM.read(), STDERR_STREAM.read()) }); + #[cfg(unix)] + crate::signal_stack::install_for_current_thread(); // Intentionally NOT calling `crate::StackCheck::configure_thread()`. } diff --git a/src/bun_core/signal_stack.rs b/src/bun_core/signal_stack.rs new file mode 100644 index 000000000000..84632a888b72 --- /dev/null +++ b/src/bun_core/signal_stack.rs @@ -0,0 +1,87 @@ +//! Per-thread alternate signal stack for the crash handler. A stack overflow +//! faults on the guard page, so the kernel can deliver the signal only onto an +//! alternate stack (`SA_ONSTACK` handler + `sigaltstack(2)` on the thread). +//! `Output::Source::configure_thread` installs one on every bun thread; the +//! main thread uses the crash handler's static buffer. + +use core::cell::Cell; + +/// Excludes the guard page below it. The crash handler runs its report on it. +pub const ALT_STACK_SIZE: usize = 512 * 1024; + +/// This thread's alternate stack mapping; dropped by the thread-local destructor. +struct Mapping { + base: *mut libc::c_void, + len: usize, +} + +impl Drop for Mapping { + fn drop(&mut self) { + let mut disable: libc::stack_t = crate::ffi::zeroed(); + disable.ss_flags = libc::SS_DISABLE; + // Darwin's libc rejects a size below MINSIGSTKSZ, also for SS_DISABLE. + disable.ss_size = ALT_STACK_SIZE; + // SAFETY: `disable` is a valid `stack_t`; a null `old_ss` is permitted. + if unsafe { libc::sigaltstack(&raw const disable, core::ptr::null_mut()) } != 0 { + // Still registered: the kernel can write a signal frame to it. + return; + } + // SAFETY: `base`/`len` describe the mapping `install_for_current_thread` + // created, and the kernel no longer uses it as a signal stack. + unsafe { libc::munmap(self.base, self.len) }; + } +} + +thread_local! { + static MAPPING: Cell> = const { Cell::new(None) }; +} + +/// Register an alternate signal stack for the calling thread. No-op when one is +/// already active (main thread, a repeat call, or ASAN's). Failure is silent. +pub fn install_for_current_thread() { + let mut current: libc::stack_t = crate::ffi::zeroed(); + // SAFETY: a null `ss` only queries; `current` is a valid out-pointer. + if unsafe { libc::sigaltstack(core::ptr::null(), &raw mut current) } != 0 + || current.ss_flags & libc::SS_DISABLE == 0 + { + return; + } + + // SAFETY: `sysconf` has no preconditions. + let page = match usize::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }) { + Ok(page) if page > 0 => page, + _ => 4096, + }; + let len = ALT_STACK_SIZE + page; + // SAFETY: anonymous private mapping; no file, no fixed address. + let base = unsafe { + libc::mmap( + core::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANONYMOUS, + -1, + 0, + ) + }; + if base == libc::MAP_FAILED { + return; + } + // Guard page: an overflow of the handler itself faults instead of writing + // into the mapping below. + // SAFETY: `base` is page-aligned and the first page belongs to the mapping. + unsafe { libc::mprotect(base, page, libc::PROT_NONE) }; + + let mut stack: libc::stack_t = crate::ffi::zeroed(); + // SAFETY: `page` is within the mapping of `len` bytes. + stack.ss_sp = unsafe { base.byte_add(page) }; + stack.ss_size = ALT_STACK_SIZE; + // SAFETY: `stack` describes a live, writable mapping; a null `old_ss` is + // permitted. + if unsafe { libc::sigaltstack(&raw const stack, core::ptr::null_mut()) } != 0 { + // SAFETY: the mapping was never registered; nothing else references it. + unsafe { libc::munmap(base, len) }; + return; + } + MAPPING.with(|slot| slot.set(Some(Mapping { base, len }))); +} diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index 3733ae7d87ad..05a992969799 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -150,6 +150,7 @@ impl BundleThread { let ptr = SendPtr(instance); let thread = std::thread::Builder::new() .name("Bundler".into()) + .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) .spawn(move || { let ptr = ptr; // SAFETY: caller guarantees `instance` is valid for 'static; `thread_main` diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 91e2ee8025fc..988260c9110c 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -634,7 +634,7 @@ mod draft { Trap(usize), /// Windows-only DatatypeMisalignment, - /// Windows-only + /// Windows: `EXCEPTION_STACK_OVERFLOW`. POSIX: a guard-page SIGSEGV/SIGBUS. StackOverflow, /// Either `main` returned an error, or somewhere else in the code a trace string is printed. @@ -648,11 +648,12 @@ mod draft { /// been printed. Signal-originated crashes re-raise the original /// fault so the parent process (and core-dump analyzers) see the /// real cause instead of a misleading SIGILL/SIGTRAP from a trap - /// instruction; everything else (panics, OOM) uses SIGABRT. + /// instruction; everything else (panics, OOM) uses SIGABRT. A POSIX + /// `StackOverflow` is a classified SIGSEGV. #[cfg(unix)] fn terminal_signal(&self) -> c_int { match self { - CrashReason::SegmentationFault(_) => libc::SIGSEGV, + CrashReason::SegmentationFault(_) | CrashReason::StackOverflow => libc::SIGSEGV, CrashReason::IllegalInstruction(_) => libc::SIGILL, CrashReason::BusError(_) => libc::SIGBUS, CrashReason::FloatingPointError(_) => libc::SIGFPE, @@ -661,7 +662,6 @@ mod draft { | CrashReason::Panic(_) | CrashReason::Unreachable | CrashReason::DatatypeMisalignment - | CrashReason::StackOverflow | CrashReason::ZigError(_) | CrashReason::OutOfMemory => libc::SIGABRT, } @@ -989,13 +989,23 @@ mod draft { } } } - #[cfg(any( - target_os = "macos", - target_os = "linux", - target_os = "android", - target_os = "freebsd" - ))] - { /* no-op */ } + #[cfg(unix)] + { + let mut name_buf = [0u8; 64]; + let name = bun_core::current_thread_name(&mut name_buf); + let written = if name.is_empty() { + write!( + writer, + "(thread {})", + bun_threading::current_thread_id() + ) + } else { + write!(writer, "({})", bstr::BStr::new(name)) + }; + if written.is_err() { + abort(); + } + } } if writer.write_all(b": ").is_err() { @@ -1491,27 +1501,57 @@ mod draft { ARCH_DISPLAY_STRING, ); - /// Extract `(pc, fp)` 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). + /// Registers of the faulting frame, from the signal handler's `ucontext_t`. #[cfg(unix)] - fn fault_context_from_ucontext(ctx: *mut c_void) -> Option<(usize, usize)> { + #[derive(Clone, Copy)] + struct FaultRegisters { + pc: usize, + fp: usize, + sp: usize, + } + + #[cfg(unix)] + impl FaultRegisters { + /// An overflow is a data access in the frame being entered: just below + /// `sp` (`push`/`call`, red zone) or above it in a frame allocated in one + /// step. Never an instruction fetch, never above the frame pointer. + fn is_stack_overflow(self, fault_addr: usize) -> bool { + const BELOW: usize = 4096; + const ABOVE: usize = 256 * 1024; + let mut end = self.sp.saturating_add(ABOVE); + if self.fp >= self.sp { + end = end.min(self.fp); + } + fault_addr != self.pc && fault_addr >= self.sp.saturating_sub(BELOW) && fault_addr < end + } + } + + /// `pc`/`fp` seed the frame-pointer walk from the faulting frame. `None` on + /// arch/OS combos without register offsets (the caller then captures the + /// current stack). + #[cfg(unix)] + fn fault_context_from_ucontext(ctx: *mut c_void) -> Option { 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(FaultRegisters { + pc: mc.gregs[libc::REG_RIP as usize] as usize, + fp: mc.gregs[libc::REG_RBP as usize] as usize, + sp: 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(FaultRegisters { + pc: mc.pc as usize, + fp: mc.regs[29] as usize, + sp: mc.sp as usize, + }) } #[cfg(all(target_os = "macos", target_arch = "x86_64"))] // SAFETY: the kernel passes a valid ucontext_t as the handler's 3rd arg. @@ -1520,7 +1560,11 @@ mod draft { if mc.is_null() { return None; } - Some(((*mc).__ss.__rip as usize, (*mc).__ss.__rbp as usize)) + Some(FaultRegisters { + pc: (*mc).__ss.__rip as usize, + fp: (*mc).__ss.__rbp as usize, + sp: (*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. @@ -1529,7 +1573,11 @@ mod draft { if mc.is_null() { return None; } - Some(((*mc).__ss.__pc as usize, (*mc).__ss.__fp as usize)) + Some(FaultRegisters { + pc: (*mc).__ss.__pc as usize, + fp: (*mc).__ss.__fp as usize, + sp: (*mc).__ss.__sp as usize, + }) } #[cfg(not(any( all(target_os = "linux", target_arch = "x86_64"), @@ -1548,9 +1596,15 @@ mod draft { // SAFETY: kernel provides a valid siginfo_t; `si_addr` reads the per-platform // sigfault address field. let addr: usize = unsafe { (*info).si_addr() as usize }; + let registers = fault_context_from_ucontext(ctx); crash_handler( match sig { + libc::SIGSEGV | libc::SIGBUS + if registers.is_some_and(|r| r.is_stack_overflow(addr)) => + { + CrashReason::StackOverflow + } libc::SIGSEGV => CrashReason::SegmentationFault(addr), libc::SIGILL => CrashReason::IllegalInstruction(addr), libc::SIGBUS => CrashReason::BusError(addr), @@ -1560,8 +1614,8 @@ mod draft { // we do not register this handler for other signals _ => unreachable!(), }, - match fault_context_from_ucontext(ctx) { - Some((pc, fp)) => TraceSeed::Fault { pc, fp }, + match registers { + Some(FaultRegisters { pc, fp, .. }) => TraceSeed::Fault { pc, fp }, None => TraceSeed::None, }, ); @@ -1597,11 +1651,14 @@ mod draft { // 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); } } + // Keep the flag on every re-install, not only the first. + if DID_REGISTER_SIGALTSTACK.load(Ordering::Relaxed) { + act_.sa_flags |= libc::SA_ONSTACK; + } } let act_ptr: *const libc::sigaction = act diff --git a/src/jsc/NodeCompileCache.rs b/src/jsc/NodeCompileCache.rs index e3b3c71f400c..526e9de87948 100644 --- a/src/jsc/NodeCompileCache.rs +++ b/src/jsc/NodeCompileCache.rs @@ -909,6 +909,7 @@ fn generate_bytecode(format: Format, code: &[u8], url: &[u8]) -> Option JsResult { + crash_handler::suppress_core_dumps_if_necessary(); + + #[inline(never)] + #[allow(unconditional_recursion)] + fn recurse(depth: usize) -> usize { + let mut frame = [0u8; 1024]; + frame[depth % frame.len()] = depth as u8; + core::hint::black_box(&mut frame); + // Reading `frame` after the call keeps this from being a tail call. + recurse(depth + 1) + usize::from(frame[0]) + } + + core::hint::black_box(recurse(0)); + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] fn js_panic(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { crash_handler::suppress_core_dumps_if_necessary(); diff --git a/src/runtime/node/fs_events.rs b/src/runtime/node/fs_events.rs index f0377b033a74..173326382dc6 100644 --- a/src/runtime/node/fs_events.rs +++ b/src/runtime/node/fs_events.rs @@ -456,6 +456,7 @@ impl FSEventsLoop { let handle = match std::thread::Builder::new() .name("CFThreadLoop".into()) + .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) .spawn(move || this.cf_thread_loop()) { Ok(handle) => handle, diff --git a/src/runtime/node/path_watcher.rs b/src/runtime/node/path_watcher.rs index 2b1f30bd81df..0453dac6122c 100644 --- a/src/runtime/node/path_watcher.rs +++ b/src/runtime/node/path_watcher.rs @@ -745,7 +745,10 @@ impl Linux { manager.platform_fd.set(Fd::from_native(rc)); // The manager is process-global and never torn down, so the reader thread is // a daemon — detach it instead of stashing a handle we'd never join. - match std::thread::Builder::new().spawn(move || Linux::thread_main(manager)) { + match std::thread::Builder::new() + .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) + .spawn(move || Linux::thread_main(manager)) + { Ok(handle) => drop(handle), // detach Err(_) => { manager.platform_fd.get().close(); @@ -1407,7 +1410,10 @@ impl Kqueue { let manager: &'static PathWatcherManager = unsafe { &*manager_ptr }; manager.platform_fd.set(kq); // Daemon reader — the manager is process-global and never torn down. - match std::thread::Builder::new().spawn(move || Kqueue::thread_main(manager)) { + match std::thread::Builder::new() + .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) + .spawn(move || Kqueue::thread_main(manager)) + { Ok(handle) => drop(handle), // detach Err(_) => { manager.platform_fd.get().close(); diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index 59cdd4020637..77998f6ab536 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -241,6 +241,7 @@ impl Watcher { let spawn = || { std::thread::Builder::new() .name("FileWatcher".into()) + .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) .spawn(move || { // SAFETY: Watcher outlives the thread; shutdown() // coordinates teardown via `running`/`close_descriptors` diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 8d4c67c50388..43d53ce3d9ec 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -1,7 +1,7 @@ import { crash_handler } from "bun:internal-for-testing"; -import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isLinux, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; -import { rmSync } from "node:fs"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; +import { existsSync, rmSync } from "node:fs"; import { constants as osConstants } from "node:os"; import path from "path"; const { getMachOImageZeroOffset } = crash_handler; @@ -11,10 +11,20 @@ const { getMachOImageZeroOffset } = crash_handler; // next unrelated failing test as "crash reported" and blocks its retries. const noReportEnv = { ...bunEnv, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" }; +// For children that die via SIG_DFL (rather than via a test hook that calls +// suppress_core_dumps_if_necessary()): on the --coredump-upload CI lane the +// runner flags leaked core files as a hard failure. ulimit -c 0 in a shell +// wrapper is inherited by the bun child (and by anything it spawns); every +// user is isPosix-gated so /bin/sh is available. +const noCoreCmd = (argv: string[]) => ["/bin/sh", "-c", `ulimit -c 0 && exec "$@"`, "--", ...argv]; + // On Linux, debug builds symbolize crash traces by spawning llvm-symbolizer; // without it the fallback printer has no Rust symbol names to assert on. const hasSymbolizer = !!(Bun.which("llvm-symbolizer") || Bun.which("llvm-symbolizer-23")); +// Compiles the LD_PRELOAD shim of the native stack overflow tests. +const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); + test.if(isDebug && isLinux && hasSymbolizer)( "crash trace starts at the crash site, not inside the crash handler", async () => { @@ -151,6 +161,298 @@ test("the crash report lists the CPU features", async () => { expect(exitCode).not.toBe(0); }); +// A native stack overflow faults on the guard page, so the kernel can only run +// a signal handler on an alternate signal stack. Two things used to break that: +// JSC's VM initialization re-registers SIGSEGV/SIGBUS for the JIT without +// SA_ONSTACK, and only the main thread had a sigaltstack. Every native +// recursion that lost its stack, on any thread, died with the default action: +// exit 139 and nothing on stderr. +// +// Not in an ASAN build: JSC's handler needs more than the alternate stack that +// ASAN gives a thread, so it stays without SA_ONSTACK there. +describe.if(isPosix && !isASAN)("native stack overflow is reported", () => { + const env = noReportEnv; + + // The CI agents run with `ulimit -s unlimited`, where the main thread's stack + // grows until it exhausts memory instead of hitting a guard page. Give the + // child the usual 8 MiB so the overflow is a fault, not an OOM kill. + test.concurrent("on the main thread", async () => { + await using proc = Bun.spawn({ + cmd: [ + "/bin/sh", + "-c", + 'ulimit -s 8192; exec "$0" "$@"', + bunExe(), + path.join(import.meta.dir, "fixture-crash.js"), + "stackOverflow", + "--debug-crash-handler-use-trace-string", + ], + env, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("panic(main thread): Stack overflow"); + expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + }); + + test.concurrent("on a worker thread", async () => { + using dir = tempDir("stack-overflow-worker", { + "main.js": ` + const worker = new Worker(new URL("./worker.js", import.meta.url).href, { name: "deep" }); + worker.onerror = e => console.error("worker error: " + e.message); + `, + "worker.js": ` + require("bun:internal-for-testing").crash_handler.stackOverflow(); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--debug-crash-handler-use-trace-string", "main.js"], + env, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("panic(deep): Stack overflow"); + expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + }); + + // No input overflows the native stack in these two places, so a preloaded + // library does it: in exit() and quick_exit(), or when the named thread asks + // for its stack bounds, which JSC does on every thread that runs it. + describe.if(isLinux && !!cc)("with a preloaded library that overflows the stack", () => { + let shimDir: ReturnType | undefined; + let preload: typeof env; + + beforeAll(async () => { + shimDir = tempDir("stack-overflow-shim", { + "overflow.c": /* c */ ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +static unsigned long recurse(unsigned long depth) { + volatile char frame[1024]; + frame[depth % sizeof(frame)] = (char)depth; + return recurse(depth + 1) + frame[0]; +} + +static void overflow(void) { + struct rlimit core = {0, 0}; + setrlimit(RLIMIT_CORE, &core); + /* An unlimited main thread stack (the CI agents) grows until memory runs out. */ + struct rlimit stack; + if (getrlimit(RLIMIT_STACK, &stack) == 0 && stack.rlim_cur > (8 << 20)) { + stack.rlim_cur = 8 << 20; + setrlimit(RLIMIT_STACK, &stack); + } + recurse(0); +} + +void exit(int code) { + if (getenv("OVERFLOW_AT_EXIT")) overflow(); + ((void (*)(int))dlsym(RTLD_NEXT, "exit"))(code); + abort(); +} + +void quick_exit(int code) { + if (getenv("OVERFLOW_AT_EXIT")) overflow(); + ((void (*)(int))dlsym(RTLD_NEXT, "quick_exit"))(code); + abort(); +} + +int pthread_getattr_np(pthread_t thread, pthread_attr_t *attr) { + const char *target = getenv("OVERFLOW_ON_THREAD"); + char name[16] = {0}; + if (target && prctl(PR_GET_NAME, name) == 0 && strcmp(name, target) == 0) overflow(); + return ((int (*)(pthread_t, pthread_attr_t *))dlsym(RTLD_NEXT, "pthread_getattr_np"))(thread, attr); +} +`, + }); + const shim = path.join(String(shimDir), "overflow.so"); + await using compile = Bun.spawn({ + cmd: [cc!, "-shared", "-fPIC", "-o", shim, path.join(String(shimDir), "overflow.c"), "-ldl"], + env: bunEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + const [errors, exitCode] = await Promise.all([compile.stderr.text(), compile.exited]); + if (exitCode !== 0) throw new Error(`shim compile failed: ${errors}`); + preload = { ...env, LD_PRELOAD: [shim, env.LD_PRELOAD].filter(Boolean).join(":") }; + }); + + afterAll(() => { + shimDir?.[Symbol.dispose](); + }); + + // `bun build` creates no global object. With `--bytecode` its first JSC VM + // is the one that generates the bytecode. + test.concurrent("after `bun build --bytecode` created the first VM", async () => { + using dir = tempDir("stack-overflow-bytecode", { + "entry.js": `console.log("hello");`, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--debug-crash-handler-use-trace-string", + "--bytecode", + "--target=bun", + "--outdir=out", + "entry.js", + ], + env: { ...preload, OVERFLOW_AT_EXIT: "1" }, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("panic(main thread): Stack overflow"); + // The bytecode is on disk, so its VM existed when the process exited. + expect(existsSync(path.join(String(dir), "out", "entry.js.jsc"))).toBe(true); + expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + }); + + // The compile cache generates its bytecode on a thread of its own. + test.concurrent("on the compile cache thread", async () => { + using dir = tempDir("stack-overflow-compile-cache", { + "main.cjs": `console.log("hello");`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--debug-crash-handler-use-trace-string", "main.cjs"], + env: { + ...preload, + NODE_COMPILE_CACHE: path.join(String(dir), "cache"), + OVERFLOW_ON_THREAD: "BunCompileCache", + }, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("panic(BunCompileCache): Stack overflow"); + expect(stdout).toBe("hello\n"); + expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + }); + }); + + // A fault close to the stack pointer is not always an overflow. An overflow + // is a data access in the frame being entered, so an instruction fetch and an + // access above the frame pointer keep the segmentation fault report and its + // address. Linux: the addresses come from /proc/self/maps. + describe.if(isLinux)("a fault near the stack pointer that is not an overflow keeps its address", () => { + const prelude = ` + const { CFunction, read } = require("bun:ffi"); + const maps = require("fs").readFileSync("/proc/self/maps", "utf8").split("\\n").filter(Boolean) + .map(line => ({ start: Number("0x" + line.split("-")[0]), end: Number("0x" + line.split(/[- ]/)[1]), name: line })); + const stack = maps.find(m => m.name.endsWith("[stack]")); + let past = stack.end; + for (let next; (next = maps.find(m => m.start === past)); ) past = next.end; + const crashAt = (address, crash) => { + require("fs").writeSync(1, address.toString(16).toUpperCase()); + crash(address); + }; + `; + + test.concurrent.each([ + [ + "a call through a pointer into the stack", + `crashAt(stack.end - 4096, ptr => new CFunction({ ptr, args: [], returns: "void" })());`, + ], + ["a read past the top of the stack", `crashAt(past, ptr => read.u8(ptr, 0));`], + ])("%s", async (_, crash) => { + await using proc = Bun.spawn({ + cmd: noCoreCmd([bunExe(), "--debug-crash-handler-use-trace-string", "-e", prelude + crash]), + env, + stdio: ["ignore", "pipe", "pipe"], + }); + const [address, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(address).toMatch(/^[0-9A-F]+$/); + expect(stderr).toContain(`panic(main thread): Segmentation fault at address 0x${address}\n`); + expect(proc.signalCode).toBe("SIGSEGV"); + expect(exitCode).not.toBe(0); + }); + }); +}); + +// JSC turns an out-of-bounds WebAssembly access into a RuntimeError in its +// SIGSEGV/SIGBUS handler. On a thread that has an alternate signal stack, that +// handler runs on it, except in an ASAN build. +describe("an out-of-bounds WebAssembly access throws", () => { + const fixture = ` + // (module (memory 1) (func (export "load") (param i32) (result i32) local.get 0 i32.load)) + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, + 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x08, 0x01, 0x04, 0x6c, 0x6f, 0x61, + 0x64, 0x00, 0x00, 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x28, 0x02, 0x00, 0x0b, + ]); + function run() { + const { load } = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; + let thrown = 0; + for (let i = 0; i < ${isDebug || isASAN ? 300 : 20000}; i++) { + try { + load(0x7ffffff0); + } catch (e) { + if (e instanceof WebAssembly.RuntimeError) thrown++; + } + } + return thrown; + } + if (!Bun.isMainThread) { + postMessage(run()); + } else if (process.argv[2] === "worker") { + const worker = new Worker(import.meta.url); + worker.onmessage = e => { + console.log(JSON.stringify([run(), e.data])); + worker.terminate(); + }; + } else { + console.log(JSON.stringify([run()])); + } + `; + const all = isDebug || isASAN ? 300 : 20000; + + test.concurrent("on the main thread and in a Worker", async () => { + using dir = tempDir("wasm-out-of-bounds", { "fault.js": fixture }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fault.js", "worker"], + env: bunEnv, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([all, all]); + expect(exitCode).toBe(0); + }); + + // The profiler holds a lock that the handler waits for, while it suspends the thread. + test.concurrent("while the sampling profiler suspends the thread", async () => { + using dir = tempDir("wasm-out-of-bounds", { "fault.js": fixture }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-interval=250", "fault.js"], + env: bunEnv, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual([all]); + expect(exitCode).toBe(0); + }); +}); + // POSIX-only: Windows refuses to remove a directory that is any process's cwd. describe.if(isPosix)("cwd deleted before startup", () => { test.concurrent.each(["install", "test"])("bun %s prints the cwd-deleted hint", async cmd => { @@ -407,13 +709,6 @@ test("raise ignoring panic handler does not trigger the panic handler", async () expect(sent).toBe(false); }); -// For children that die via SIG_DFL (rather than via a test hook that calls -// suppress_core_dumps_if_necessary()): on the --coredump-upload CI lane the -// runner flags leaked core files as a hard failure. ulimit -c 0 in a shell -// wrapper is inherited by the bun child (and by anything it spawns); every -// user is isPosix-gated so /bin/sh is available. -const noCoreCmd = (argv: string[]) => ["/bin/sh", "-c", `ulimit -c 0 && exec "$@"`, "--", ...argv]; - // SIGABRT (libc abort(), mimalloc/glibc heap-corruption, std::terminate) and // SIGTRAP (WTF CRASH()/RELEASE_ASSERT, __builtin_trap() -> `brk` on aarch64) // must route through the crash handler so they are not silently lost. Outside