Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 51 additions & 10 deletions src/crash_handler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// builds only. Declaring the feature where neither is compiled (Windows
// release) trips `unused_features`.
#![cfg_attr(any(not(windows), debug_assertions), feature(core_intrinsics))]
#![feature(alloc_error_hook)]
#![allow(internal_features)]
#![allow(nonstandard_style, static_mut_refs, unexpected_cfgs)]
#![warn(unused_must_use)]
Expand All @@ -41,7 +42,9 @@ pub use error::{Error, Result};
#[inline(never)]
fn out_of_memory() -> ! {
draft::crash_handler(
draft::CrashReason::OutOfMemory,
draft::CrashReason::OutOfMemory {
requested_bytes: None,
},
draft::TraceSeed::BeginAddr(bun_core::return_address()),
)
}
Expand Down Expand Up @@ -640,7 +643,10 @@ mod draft {
/// Either `main` returned an error, or somewhere else in the code a trace string is printed.
ZigError(&'static [u8]),

OutOfMemory,
OutOfMemory {
/// `None` when the caller only has an `AllocError`.
requested_bytes: Option<usize>,
},
}

impl CrashReason {
Expand All @@ -663,7 +669,7 @@ mod draft {
| CrashReason::DatatypeMisalignment
| CrashReason::StackOverflow
| CrashReason::ZigError(_)
| CrashReason::OutOfMemory => libc::SIGABRT,
| CrashReason::OutOfMemory { .. } => libc::SIGABRT,
}
}
}
Expand Down Expand Up @@ -692,7 +698,23 @@ mod draft {
CrashReason::ZigError(err_name) => {
write!(writer, "error.{}", bstr::BStr::new(err_name))
}
CrashReason::OutOfMemory => writer.write_str("Bun ran out of memory"),
CrashReason::OutOfMemory { requested_bytes } => write!(
writer,
"Bun ran out of memory{}",
RequestedBytes(*requested_bytes)
),
}
}
}

/// ` (failed to allocate N bytes)`, or nothing when the size is unknown.
struct RequestedBytes(Option<usize>);

impl fmt::Display for RequestedBytes {
fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
Some(bytes) => write!(writer, " (failed to allocate {bytes} bytes)"),
None => Ok(()),
}
}
}
Expand Down Expand Up @@ -922,7 +944,7 @@ mod draft {
}
}

if !matches!(reason, CrashReason::OutOfMemory) || debug_trace {
if !matches!(reason, CrashReason::OutOfMemory { .. }) || debug_trace {
if enable_ansi_colors_stderr() {
if writer
.write_all(&Output::pretty_fmt::<true>("<red>"))
Expand Down Expand Up @@ -1116,10 +1138,16 @@ mod draft {
{
abort();
}
} else if matches!(reason, CrashReason::OutOfMemory) {
if writer.write_all(
b"Bun has run out of memory.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n",
).is_err() { abort(); }
} else if let CrashReason::OutOfMemory { requested_bytes } = reason {
if write!(
writer,
"Bun has run out of memory{}.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n",
RequestedBytes(requested_bytes)
)
.is_err()
{
abort();
}
} else {
if writer.write_all(
b"Bun has crashed. This indicates a bug in Bun, not your code.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n",
Expand Down Expand Up @@ -1707,6 +1735,19 @@ mod draft {
// this hook, a bare `panic!` would print the std
// default hook + unwind with no trace string and no upload.
std::panic::set_hook(Box::new(rust_panic_hook));
std::alloc::set_alloc_error_hook(rust_alloc_error_hook);
}

/// Not a null check in `GlobalAlloc`: `try_reserve` reports failure with null too. Must not allocate.
#[cold]
#[inline(never)]
fn rust_alloc_error_hook(layout: core::alloc::Layout) {
crash_handler(
CrashReason::OutOfMemory {
requested_bytes: Some(layout.size()),
},
TraceSeed::BeginAddr(debug::return_address()),
)
}

/// `std::panic` hook: emit the same trace-string + auto-report as the fatal
Expand Down Expand Up @@ -2677,7 +2718,7 @@ mod draft {
writer.write_all(err_name)?;
}

CrashReason::OutOfMemory => writer.write_byte(b'9')?,
CrashReason::OutOfMemory { .. } => writer.write_byte(b'9')?,

CrashReason::Abort => writer.write_byte(b'a')?,
CrashReason::Trap(addr) => {
Expand Down
1 change: 1 addition & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const crash_handler = $rust("crash_handler.rs", "js_bindings.generate") a
panic: () => void;
rootError: () => void;
outOfMemory: () => void;
allocError: () => void;
abort: () => void;
fastfail: () => void;
trap: () => void;
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/api/crash_handler_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub(crate) mod js_bindings {
("panic", __jsc_host_js_panic),
("rootError", __jsc_host_js_root_error),
("outOfMemory", __jsc_host_js_out_of_memory),
("allocError", __jsc_host_js_alloc_error),
("abort", __jsc_host_js_abort),
("fastfail", __jsc_host_js_fastfail),
("trap", __jsc_host_js_trap),
Expand Down Expand Up @@ -208,6 +209,21 @@ pub(crate) mod js_bindings {
bun_core::out_of_memory();
}

/// Fails a real infallible allocation; `outOfMemory` covers the explicit `AllocError` path.
#[bun_jsc::host_fn]
fn js_alloc_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult<JSValue> {
crash_handler::suppress_core_dumps_if_necessary();
// Above any 64-bit address space (null even with overcommit), below `isize::MAX` (no capacity panic).
const SIZE: usize = 1 << 62;
// ASAN's allocator aborts on an oversized request instead of returning null.
if Environment::ENABLE_ASAN {
std::alloc::handle_alloc_error(core::alloc::Layout::from_size_align(SIZE, 1).unwrap());
}
let buf: Vec<u8> = Vec::with_capacity(SIZE);
core::hint::black_box(buf);
Ok(JSValue::UNDEFINED)
}

#[bun_jsc::host_fn]
fn js_raise_ignoring_panic_handler(
_global: &JSGlobalObject,
Expand Down
2 changes: 1 addition & 1 deletion test/cli/run/fixture-crash.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ if (approach in crash_handler) {
crash_handler[approach]();
} else {
console.error(
"usage: bun fixture-crash.js <segfault|segfaultInDll|panic|rootError|outOfMemory|abort|trap|raiseIgnoringPanicHandler>",
"usage: bun fixture-crash.js <segfault|segfaultInDll|panic|rootError|outOfMemory|allocError|abort|trap|raiseIgnoringPanicHandler>",
);
}
131 changes: 88 additions & 43 deletions test/cli/run/run-crash-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const noReportEnv = { ...bunEnv, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPO
// without it the fallback printer has no Rust symbol names to assert on.
const hasSymbolizer = !!(Bun.which("llvm-symbolizer") || Bun.which("llvm-symbolizer-21"));

// The allocation the `allocError` test hook requests (`js_alloc_error` in
// src/runtime/api/crash_handler_jsc.rs). The crash report prints it.
const allocErrorSize = 2n ** 62n;

test.if(isDebug && isLinux && hasSymbolizer)(
"crash trace starts at the crash site, not inside the crash handler",
async () => {
Expand Down Expand Up @@ -44,6 +48,38 @@ test.if(isDebug && isLinux && hasSymbolizer)(
60_000, // symbolizing the debug binary takes several seconds
);

// A failed infallible allocation (`Vec` growth, `Box::new`, ...) reaches std's
// alloc error handler. Without the alloc error hook std prints "memory
// allocation of N bytes failed" and calls abort(), so the report said
// "abort() called" and its trace was seeded inside libc, where the frame
// pointer walk stops after one frame. The hook reports it as out of memory,
// with the requested size, and captures the trace while the allocating
// frame is still on the stack.
test.if(isDebug && isLinux && hasSymbolizer)(
"failed Rust allocation is reported as out of memory with the allocating frame in the trace",
async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "allocError"],
env: noReportEnv,
stdio: ["ignore", "pipe", "pipe"],
});
// Header on stderr, symbolized frames on stdout (see the test above).
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toContain(`panic(main thread): Bun ran out of memory (failed to allocate ${allocErrorSize} bytes)`);
expect(stderr).not.toContain("abort() called");
expect(stderr).not.toContain("memory allocation of");
expect(exitCode).not.toBe(0);

// The trace walks from the alloc error hook through std's handler into
// the test hook that made the allocation...
expect(stdout).toContain("js_alloc_error");
// ...and does not include the capture machinery.
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
Expand Down Expand Up @@ -99,6 +135,7 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => {
test.each([
["panic", "SIGABRT"],
["outOfMemory", "SIGABRT"],
["allocError", "SIGABRT"],
["segfault", "SIGSEGV"],
["abort", "SIGABRT"],
["trap", "SIGTRAP"],
Expand All @@ -119,6 +156,11 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => {
expect(stderr).toContain("Segmentation fault at address");
} else if (approach === "panic") {
expect(stderr).toContain("invoked crashByPanic() handler");
} else if (approach === "outOfMemory") {
expect(stderr).toContain("Bun has run out of memory.");
} else if (approach === "allocError") {
expect(stderr).toContain(`Bun has run out of memory (failed to allocate ${allocErrorSize} bytes).`);
expect(stderr).not.toContain("abort() called");
} else if (approach === "abort") {
expect(stderr).toContain("abort() called");
} else if (approach === "trap") {
Expand Down Expand Up @@ -562,52 +604,55 @@ describe.if(isPosix)("process.kill() aimed at the process itself is not reported
});

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 crashed = "oh no: Bun has crashed. This indicates a bug in Bun, not your code";
// The uploaded URL is the trace string followed by "/ack". The trace string
// ends with the reason; both out-of-memory paths encode it as "9" (see
// encode_trace_string in src/crash_handler/lib.rs), which is what tells
// bun.report to classify the report as out of memory.
test.each([
["panic", crashed, "/ack"],
["segfault", crashed, "/ack"],
["outOfMemory", "oh no: Bun has run out of memory.", "9/ack"],
["allocError", `oh no: Bun has run out of memory (failed to allocate ${allocErrorSize} bytes).`, "9/ack"],
])("%s should report", async (approach, expectedMessage, expectedUrlSuffix) => {
const reported = Promise.withResolvers<string>();

// Self host the crash report backend.
using server = Bun.serve({
port: 0,
fetch(request) {
reported.resolve(request.url);
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);
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;
const reportedUrl = await reported.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);
});
}
expect(exitCode).not.toBe(0);
expect(stderr).toContain(server.url.toString());
expect(stderr).toContain(expectedMessage);
if (approach === "outOfMemory" || approach === "allocError") {
expect(stderr.toLowerCase()).not.toContain("panic");
}
expect(reportedUrl).toEndWith(expectedUrlSuffix);
});
});

test.if(isWindows)(
Expand Down
Loading