import { crash_handler } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, isLinux, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; import { rmSync } from "node:fs"; import path from "path"; const { getMachOImageZeroOffset } = crash_handler; // CI sets BUN_CRASH_REPORT_URL so unexpected crashes are captured; these // deliberate crashes must not upload there or the runner pins them on the // next unrelated failing test as "crash reported" and blocks its retries. const noReportEnv = { ...bunEnv, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" }; // 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-21")); test.if(isDebug && isLinux && hasSymbolizer)( "crash trace starts at the crash site, not inside the crash handler", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "panic"], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); // The panic header goes to stderr; the symbolized frames are printed by // llvm-symbolizer, which is spawned with inherited stdio, so they land on // stdout. const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("panic(main thread): invoked crashByPanic() handler"); expect(exitCode).not.toBe(0); // The innermost frame of the trace must be the code that crashed (the // js_panic test hook)... const firstFrame = stdout.split("\n").find(line => line.trim().length > 0); expect(firstFrame ?? "").toContain("js_panic"); // ...not the capture machinery. A mismatched trim anchor used to leave // `capture_stack_trace` → `crash_handler` → `panic_impl` as the innermost // frames of every report, burying the real crash site. expect(stdout).not.toContain("capture_stack_trace"); }, 60_000, // symbolizing the debug binary takes several seconds ); // `crash()` resets fatal-signal dispositions to SIG_DFL before re-raising so // that JS-registered listeners (`process.on("SIGABRT")` etc., installed by // npm's widely-used signal-exit package) cannot swallow the termination. A // JS listener's backing sigaction enqueues to the JS thread and returns; // without the reset the process would survive the raise and fall through to // the trap fallback, which on aarch64 (brk → SIGTRAP) used to spin forever. test.if(isPosix)( "panic terminates the process even when JS registered trap-signal listeners", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `process.on("SIGTRAP", () => {}); process.on("SIGILL", () => {}); process.on("SIGABRT", () => {}); require("bun:internal-for-testing").crash_handler.panic();`, // Make debug builds take the fast trace-string path instead of // spawning llvm-symbolizer, which can take tens of seconds. "--debug-crash-handler-use-trace-string", ], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); // Without the fix the child never exits — it loops SIGTRAP delivery on the // trap instruction. Bound the wait and fail explicitly rather than hanging // the test runner and leaking a core-pinning process. const exited = await Promise.race([proc.exited, Bun.sleep(8_000).then(() => "spinning" as const)]); if (exited === "spinning") { proc.kill("SIGKILL"); } const stderr = await proc.stderr.text(); expect(exited, `process should have died from the trap, stderr:\n${stderr}`).not.toBe("spinning"); // It went through the crash handler... expect(stderr).toContain("invoked crashByPanic() handler"); // ...and died from the trap's default action, not a clean exit, and not a // JS-observed SIGTRAP (the JS listener must never swallow the crash). expect(proc.signalCode === null ? proc.exitCode : proc.signalCode).not.toBe(0); }, 20_000, ); // After printing the crash report the handler must terminate with a signal // that reflects the crash cause: panics abort (SIGABRT), a caught fault is // re-raised as the original signal. Previously the handler ended in a trap // instruction (ud2 → SIGILL on x86_64, brk → SIGTRAP on aarch64) so shells // reported "illegal hardware instruction" for every crash and parent // processes could not distinguish a panic from a CPU/codegen fault. describe.if(isPosix)("terminal signal reflects the crash cause", () => { test.each([ ["panic", "SIGABRT"], ["outOfMemory", "SIGABRT"], ["segfault", "SIGSEGV"], ["abort", "SIGABRT"], ["trap", "SIGTRAP"], ] as const)("%s terminates with %s", async (approach, expectedSignal) => { await using proc = Bun.spawn({ cmd: [ bunExe(), path.join(import.meta.dir, "fixture-crash.js"), approach, "--debug-crash-handler-use-trace-string", ], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); if (approach === "segfault") { expect(stderr).toContain("Segmentation fault at address"); } else if (approach === "panic") { expect(stderr).toContain("invoked crashByPanic() handler"); } else if (approach === "abort") { expect(stderr).toContain("abort() called"); } else if (approach === "trap") { expect(stderr).toContain("Trap instruction"); } expect(proc.signalCode).toBe(expectedSignal); expect(exitCode).not.toBe(0); void stdout; }); }); // 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 => { using dir = tempDir("cwd-unlinked", {}); const gone = String(dir); await using proc = Bun.spawn({ cmd: ["/bin/sh", "-c", `cd "${gone}" && rmdir "${gone}" && exec "${bunExe()}" '${cmd}'`], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: expect.stringContaining("The current working directory was deleted"), exitCode: 1, }); expect(stderr).not.toContain("Bun could not find a file"); }); test.concurrent("bun -e boots via the exe-dir fallback instead", async () => { using dir = tempDir("cwd-unlinked-run", {}); const gone = String(dir); await using proc = Bun.spawn({ cmd: ["/bin/sh", "-c", `cd "${gone}" && rmdir "${gone}" && exec "${bunExe()}" -e 'console.log(1)'`], env: bunEnv, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout).toBe("1\n"); expect(stderr).toBe(""); expect(exitCode).toBe(0); }); }); // Windows: the VEH handler must walk the stack from the fault CONTEXT record // (RtlVirtualUnwind), not from inside the handler. When the fault is in an // external DLL the old RtlCaptureStackBackTrace path could stop at // KiUserExceptionDispatcher on some Windows versions, leaving only the // handler's own frames in the trace and none of the bun callers. test.if(isWindows && isDebug)("Windows: segfault inside a system DLL captures the bun callers", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "segfaultInDll"], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("Segmentation fault at address 0xDEADBEEF"); expect(exitCode).not.toBe(0); // The debug build's fallback printer emits one `???:?:?: 0x` line per // captured frame. A walk seeded from the fault CONTEXT reaches through the // DLL into the bun call chain (the JS host-fn dispatch is several frames // deep), so a short trace means the unwind stopped at the exception // dispatcher and the handler's own frames are all that was captured. const frameAddrs = [...stderr.matchAll(/: (0x[0-9a-f]{6,}) in /gi)].map(m => BigInt(m[1])); expect(frameAddrs.length).toBeGreaterThanOrEqual(7); // Frame 0 is the fault PC inside ntdll.dll; frames 1+ must be the bun call // chain with no handler or ntdll-dispatch frames interleaved. Frames 1..6 all // coming from one image means their address span fits inside that image's // mapped range; the old RtlCaptureStackBackTrace path left // [handler x3][ntdll-dispatch x3] ahead of the first real caller, so frames // 4-6 landed in ntdll and the span covered the >10 GiB gap between the EXE // and system-DLL HEASLR regions. const callers = frameAddrs.slice(1, 7); const span = callers.reduce((a, b) => (a > b ? a : b)) - callers.reduce((a, b) => (a < b ? a : b)); expect(span).toBeLessThan(2n ** 31n); }); // The Windows crash handler is a Vectored Exception Handler, which sees every // first-chance exception process-wide before frame-based SEH does. Third-party // DLLs injected into the process (AV/EDR agents such as BeyondTrust's // PGHook.dll, virtualization guest tools, shell extensions) routinely raise // and then handle access violations under SEH as part of normal operation. // The VEH must let those through rather than treating them as a fatal crash. // `IsBadReadPtr` is the canonical example: it probes its argument inside a // `__try`/`__except` in kernel32, so the AV it raises is inside a system DLL // and is immediately swallowed by that DLL's own SEH. // // See https://github.com/oven-sh/bun/issues/10056 (Carbon Black), // https://github.com/oven-sh/bun/issues/11898 (Trend Micro). describe.if(isWindows)("Windows VEH handler and first-chance faults in external DLLs", () => { test("SEH-guarded probe survives", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `const { dlopen } = require("bun:ffi"); const lib = dlopen("kernel32.dll", { IsBadReadPtr: { args: ["usize", "usize"], returns: "i32" }, }); const rc = lib.symbols.IsBadReadPtr(0xE8, 8); console.log("SURVIVED rc=" + rc);`, ], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).not.toContain("Segmentation fault"); // rc=1: kernel32's SEH caught the AV and reported the pointer as bad. expect(stdout.trim()).toBe("SURVIVED rc=1"); expect(exitCode).toBe(0); }); // `RtlFillMemory` has no `__try`/`__except` around its store. With the VEH // now returning CONTINUE_SEARCH for out-of-image PCs, the catch point is // JSC's jscJITSEHHandler (registered for JIT frames), which routes to // Bun__crashHandlerFromJSCFrame, or UEF. This exercises that the crash is // still reported and the report carries the fault address. test("unguarded fault still crash-reports", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "--debug-crash-handler-use-trace-string", "-e", `const { dlopen } = require("bun:ffi"); const lib = dlopen("ntdll.dll", { RtlFillMemory: { args: ["usize", "usize", "i32"], returns: "void" }, }); lib.symbols.RtlFillMemory(0xE8, 8, 0); console.log("SHOULD NOT REACH");`, ], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("Segmentation fault at address 0xE8"); expect(stdout).not.toContain("SHOULD NOT REACH"); expect(exitCode).not.toBe(0); }); // Validate WebKit's registerJITUnwindInfo against the actual unwinder: // RtlLookupFunctionEntry must return a RUNTIME_FUNCTION for a JIT pool PC. // This is the smoke test for the hand-encoded UNWIND_INFO / .xdata bytes. // LLInt PCs are not covered here: LLInt lives in image .text and Windows // only consults static .pdata for in-module PCs; that needs build-time // .seh_* emission in offlineasm (follow-up). test("RtlLookupFunctionEntry resolves JSC JIT pool PCs", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `const { dlopen, FFIType, ptr } = require("bun:ffi"); const { symbols } = dlopen("ntdll.dll", { RtlLookupFunctionEntry: { args: [FFIType.u64, FFIType.pointer, FFIType.pointer], returns: FFIType.pointer, }, }); const { jscInternals } = require("bun:internal-for-testing"); const pool = jscInternals.startOfFixedExecutableMemoryPool(); const imageBase = new BigUint64Array(1); const jitEntry = symbols.RtlLookupFunctionEntry(pool + 0x100n, ptr(imageBase), null); console.log(JSON.stringify({ pool: pool.toString(16), jitEntry: jitEntry === null ? "null" : "ok", }));`, ], env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const out = JSON.parse(stdout.trim()); expect(out.jitEntry).toBe("ok"); expect(exitCode).toBe(0); }); // End-to-end: warm a JS function into the JIT, then fault from inside it // via FFI. The crash report must fire via jscJITSEHHandler at the JIT // boundary. Clears the UEF backstop first so the assertion isolates the JSC // handler (deleting setJITExceptionHandlerWin would break this test, not // just fall through to UEF). Disables the concurrent JIT so warm-up is // deterministic. test("unguarded fault from inside a JIT-compiled frame still crash-reports via the JSC handler", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "--debug-crash-handler-use-trace-string", "-e", `const { dlopen } = require("bun:ffi"); const ntdll = dlopen("ntdll.dll", { RtlFillMemory: { args: ["usize", "usize", "i32"], returns: "void" }, }); const k32 = dlopen("kernel32.dll", { SetUnhandledExceptionFilter: { args: ["usize"], returns: "usize" }, }); function hot(i) { if (i === 10000) ntdll.symbols.RtlFillMemory(0xE8, 8, 0); return i; } for (let i = 0; i < 10000; i++) hot(i); k32.symbols.SetUnhandledExceptionFilter(0); hot(10000); console.log("SHOULD NOT REACH");`, ], env: { ...noReportEnv, BUN_JSC_jitPolicyScale: "0", BUN_JSC_useConcurrentJIT: "0" }, stdio: ["ignore", "pipe", "pipe"], }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("Segmentation fault at address 0xE8"); expect(stdout).not.toContain("SHOULD NOT REACH"); expect(exitCode).not.toBe(0); }); }); 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. expect(getMachOImageZeroOffset()).toBe(0x100000000); }); test("raise ignoring panic handler does not trigger the panic handler", async () => { let sent = false; const resolve_handler = Promise.withResolvers(); using server = Bun.serve({ port: 0, fetch(request, server) { sent = true; resolve_handler.resolve(); return new Response("OK"); }, }); const proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "raiseIgnoringPanicHandler"], env: mergeWindowEnvs([ bunEnv, { BUN_CRASH_REPORT_URL: server.url.toString(), BUN_ENABLE_CRASH_REPORTING: "1", }, ]), }); await proc.exited; /// Wait two seconds for a slow http request, or continue immediately once the request is heard. await Promise.race([resolve_handler.promise, Bun.sleep(2000)]); expect(proc.exited).resolves.not.toBe(0); expect(sent).toBe(false); }); // 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. describe.if(isPosix)("SIGABRT/SIGTRAP are caught by the crash handler", () => { test.concurrent.each([ ["abort", "SIGABRT", "abort() called"], ["trap", "SIGTRAP", "Trap instruction"], ] as const)("%s produces a crash report", async (approach, expectedSignal, expectedMsg) => { let sent = false; const resolve_handler = Promise.withResolvers(); using server = Bun.serve({ port: 0, fetch(request) { expect(request.url).toEndWith("/ack"); sent = true; resolve_handler.resolve(); return new Response("OK"); }, }); await using proc = Bun.spawn({ cmd: [ bunExe(), path.join(import.meta.dir, "fixture-crash.js"), approach, "--debug-crash-handler-use-trace-string", ], env: mergeWindowEnvs([ bunEnv, { BUN_CRASH_REPORT_URL: server.url.toString(), BUN_ENABLE_CRASH_REPORTING: "1", GITHUB_ACTIONS: undefined, CI: undefined, }, ]), stdio: ["ignore", "pipe", "pipe"], }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); expect(stderr).toContain(expectedMsg); expect(stderr).toContain("oh no"); expect(stderr).toContain(server.url.toString()); expect(proc.signalCode).toBe(expectedSignal); expect(exitCode).not.toBe(0); await resolve_handler.promise; expect(sent).toBe(true); }); // These two tests terminate via SIG_DFL (not via a test hook that calls // suppress_core_dumps_if_necessary()), so on the --coredump-upload CI lane // the runner would flag leaked core files as a hard failure. ulimit -c 0 in // a shell wrapper is inherited by the bun child; the whole describe is // isPosix-gated so /bin/sh is available. const noCoreCmd = (argv: string[]) => ["/bin/sh", "-c", `ulimit -c 0 && exec "$@"`, "--", ...argv]; // The above goes via the internal test hook, which under ASAN calls the // handler directly because ASAN owns the fault signals. This case raises the // signal for real to prove the sigaction registration itself; ASAN builds // never install those handlers so skip there. test.skipIf(isASAN).concurrent.each(["SIGABRT", "SIGTRAP"] as const)( "raised %s produces a crash report", async signal => { await using proc = Bun.spawn({ cmd: noCoreCmd([ bunExe(), "-e", `process.kill(process.pid, "${signal}")`, "--debug-crash-handler-use-trace-string", ]), env: noReportEnv, stdio: ["ignore", "pipe", "pipe"], }); const [stderr] = await Promise.all([proc.stderr.text(), proc.exited]); expect(stderr).toContain("oh no"); expect(proc.signalCode).toBe(signal); }, ); // process.abort() is a deliberate user action, not a Bun crash. It must still // terminate with SIGABRT but must not print a crash report or upload one. test.concurrent("process.abort() does not report a crash", async () => { let sent = false; using server = Bun.serve({ port: 0, fetch() { sent = true; return new Response("OK"); }, }); await using proc = Bun.spawn({ cmd: noCoreCmd([bunExe(), "-e", "process.abort()"]), env: mergeWindowEnvs([ bunEnv, { BUN_CRASH_REPORT_URL: server.url.toString(), BUN_ENABLE_CRASH_REPORTING: "1", GITHUB_ACTIONS: undefined, CI: undefined, }, ]), stdio: ["ignore", "pipe", "pipe"], }); const [stderr] = await Promise.all([proc.stderr.text(), proc.exited]); expect(stderr).not.toContain("Bun has crashed"); expect(stderr).not.toContain(server.url.toString()); expect(proc.signalCode).toBe("SIGABRT"); expect(sent).toBe(false); }); }); describe("automatic crash reporter", () => { for (const approach of ["panic", "segfault", "outOfMemory"]) { test(`${approach} should report`, async () => { let sent = false; const resolve_handler = Promise.withResolvers(); // Self host the crash report backend. using server = Bun.serve({ port: 0, fetch(request, server) { expect(request.url).toEndWith("/ack"); sent = true; resolve_handler.resolve(); return new Response("OK"); }, }); const proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), approach], env: mergeWindowEnvs([ bunEnv, { BUN_CRASH_REPORT_URL: server.url.toString(), BUN_ENABLE_CRASH_REPORTING: "1", GITHUB_ACTIONS: undefined, CI: undefined, }, ]), stdio: ["ignore", "pipe", "pipe"], }); const exitCode = await proc.exited; const stderr = await proc.stderr.text(); console.log(stderr); await resolve_handler.promise; expect(exitCode).not.toBe(0); expect(stderr).toContain(server.url.toString()); if (approach !== "outOfMemory") { expect(stderr).toContain("oh no: Bun has crashed. This indicates a bug in Bun, not your code"); } else { expect(stderr.toLowerCase()).toContain("out of memory"); expect(stderr.toLowerCase()).not.toContain("panic"); } expect(sent).toBe(true); }); } }); test.if(isWindows)( "Windows: crash report upload runs the system PowerShell, not a powershell.exe in the working directory", async () => { let sent = false; const acked = Promise.withResolvers(); using server = Bun.serve({ port: 0, fetch(request) { expect(request.url).toEndWith("/ack"); sent = true; acked.resolve(); return new Response("OK"); }, }); // Not `using`: the crash reporter's PowerShell child inherits this cwd // and can outlive the crashed process, so a scoped delete races it. const dir = tempDir("crash-report-system-powershell", { "placeholder.js": "" }); try { await Bun.write(path.join(String(dir), "powershell.exe"), Bun.file(bunExe())); await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "panic"], cwd: String(dir), env: mergeWindowEnvs([ bunEnv, { BUN_CRASH_REPORT_URL: server.url.toString(), BUN_ENABLE_CRASH_REPORTING: "1", GITHUB_ACTIONS: undefined, CI: undefined, }, ]), stdio: ["ignore", "pipe", "pipe"], }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); /// Wait two seconds for a slow http request, or continue immediately once the request is heard. await Promise.race([acked.promise, Bun.sleep(2000)]); expect(stderr).toContain(server.url.toString()); expect(sent).toBe(true); expect(exitCode).not.toBe(0); } finally { try { rmSync(String(dir), { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); } catch {} } }, );