Files

603 lines
24 KiB
TypeScript
Raw Permalink Normal View History

2026-08-27 21:09:14 +00:00
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 ?? "<no frames printed>").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<addr>` 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<void>();
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<void>();
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 {}
}
},
);