From 82e1128a320129f0980391ba12bb018555ee2925 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:48:28 +0000 Subject: [PATCH 1/3] crash handler: report failed Rust allocations as out of memory A failed infallible allocation (Vec growth, Box::new, ...) reaches std's alloc error handler, which prints "memory allocation of N bytes failed" and calls abort(). The SIGABRT handler reported that as "abort() called" with the trace seeded inside libc, where the frame pointer walk stops after one frame. On Windows std aborts with __fastfail and nothing was reported. Register a std alloc error hook next to the panic hook. It reports CrashReason::OutOfMemory and captures the trace while the allocating frames are still on the stack. The report now also prints the size of the request that failed. The trace string encoding is unchanged. The hook is the right layer: a null return from the global allocator is also how try_reserve and the other fallible allocation paths report failure, so GlobalAlloc itself cannot treat null as fatal. Adds a crash_handler.allocError test hook that fails a real Vec::with_capacity (or calls handle_alloc_error directly under ASAN, whose allocator aborts on oversized requests instead of returning null). --- src/crash_handler/lib.rs | 81 +++++++++++++-- src/js/internal-for-testing.ts | 1 + src/runtime/api/crash_handler_jsc.rs | 24 +++++ test/cli/run/fixture-crash.js | 2 +- test/cli/run/run-crash-handler.test.ts | 131 +++++++++++++++++-------- 5 files changed, 185 insertions(+), 54 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index a7ab69fcab8b..5b06641ad698 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -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)] @@ -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()), ) } @@ -640,7 +643,13 @@ mod draft { /// Either `main` returned an error, or somewhere else in the code a trace string is printed. ZigError(&'static [u8]), - OutOfMemory, + OutOfMemory { + /// Size of the allocation that failed. Known in `rust_alloc_error_hook`, + /// unknown to `bun_alloc::out_of_memory()` callers, which only have an + /// `AllocError`. Printed to stderr only: the trace string encodes every + /// OOM the same way. + requested_bytes: Option, + }, } impl CrashReason { @@ -663,7 +672,7 @@ mod draft { | CrashReason::DatatypeMisalignment | CrashReason::StackOverflow | CrashReason::ZigError(_) - | CrashReason::OutOfMemory => libc::SIGABRT, + | CrashReason::OutOfMemory { .. } => libc::SIGABRT, } } } @@ -692,7 +701,25 @@ 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. + /// Shared by the `CrashReason` `Display` impl and the release-mode + /// "oh no" message in `crash_handler`. + struct RequestedBytes(Option); + + 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(()), } } } @@ -922,7 +949,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::("")) @@ -1116,10 +1143,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", @@ -1707,6 +1740,34 @@ 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); + } + + /// `std::alloc` error hook: an infallible allocation (`Vec` growth, + /// `Box::new`, ...) got null from the global allocator. + /// + /// Without it std prints `memory allocation of N bytes failed` and calls + /// `abort()`. On POSIX the SIGABRT handler then reports `abort() called` + /// with the trace seeded inside libc's `abort`/`raise`, which have no + /// frame pointers, so the report has one unresolvable frame and neither + /// the reason nor the size. On Windows std aborts with `__fastfail`, which + /// no exception handler sees. Here the allocating frames are still on the + /// stack. + /// + /// A hook rather than a null check in `GlobalAlloc` because a null return + /// is also how `try_reserve` and the other fallible paths report failure. + /// + /// Must not allocate: a failure inside the report re-enters here and is + /// cut short by the `PANIC_STAGE` guard in `crash_handler`. + #[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 @@ -2677,7 +2738,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) => { diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 9ca3ed722253..95bea049a478 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -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; diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index 79a6f931c22f..e721caef2612 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -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), @@ -208,6 +209,29 @@ pub(crate) mod js_bindings { bun_core::out_of_memory(); } + /// Fails an infallible std allocation (`Vec::with_capacity`), the path a + /// `Vec`/`Box`/`String` takes when the global allocator returns null. + /// `outOfMemory` above covers the explicit `AllocError` path instead. + #[bun_jsc::host_fn] + fn js_alloc_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + crash_handler::suppress_core_dumps_if_necessary(); + // Larger than any 64-bit address space, so mimalloc returns null no + // matter how much memory or overcommit the machine has, and within + // `isize::MAX`, so `Vec` reaches the allocator instead of panicking + // with a capacity overflow. + const SIZE: usize = 1 << 62; + // Under ASAN the global allocator is libc's, and ASAN treats a request + // this large as an error of its own (allocation-size-too-big) instead + // of returning null, so enter std's failure path directly. This is the + // same call `Vec` makes below once the allocator has returned null. + if Environment::ENABLE_ASAN { + std::alloc::handle_alloc_error(core::alloc::Layout::from_size_align(SIZE, 1).unwrap()); + } + let buf: Vec = Vec::with_capacity(SIZE); + core::hint::black_box(buf); + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] fn js_raise_ignoring_panic_handler( _global: &JSGlobalObject, diff --git a/test/cli/run/fixture-crash.js b/test/cli/run/fixture-crash.js index d44ba5bbc61d..332663c8c092 100644 --- a/test/cli/run/fixture-crash.js +++ b/test/cli/run/fixture-crash.js @@ -12,6 +12,6 @@ if (approach in crash_handler) { crash_handler[approach](); } else { console.error( - "usage: bun fixture-crash.js ", + "usage: bun fixture-crash.js ", ); } diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 4a02bcde975f..d12dce9184d2 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -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 () => { @@ -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 @@ -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"], @@ -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") { @@ -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(); + + // 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)( From e3c6bcfdfcee27893491d3899af7b1e11975f4ef Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:05:12 +0000 Subject: [PATCH 2/3] crash handler: shorten the alloc error hook comments --- src/crash_handler/lib.rs | 27 +++++---------------------- src/runtime/api/crash_handler_jsc.rs | 17 ++++++----------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 5b06641ad698..780e0f40ed88 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -644,10 +644,7 @@ mod draft { ZigError(&'static [u8]), OutOfMemory { - /// Size of the allocation that failed. Known in `rust_alloc_error_hook`, - /// unknown to `bun_alloc::out_of_memory()` callers, which only have an - /// `AllocError`. Printed to stderr only: the trace string encodes every - /// OOM the same way. + /// `None` when the caller only has an `AllocError`. requested_bytes: Option, }, } @@ -711,8 +708,6 @@ mod draft { } /// ` (failed to allocate N bytes)`, or nothing when the size is unknown. - /// Shared by the `CrashReason` `Display` impl and the release-mode - /// "oh no" message in `crash_handler`. struct RequestedBytes(Option); impl fmt::Display for RequestedBytes { @@ -1743,22 +1738,10 @@ mod draft { std::alloc::set_alloc_error_hook(rust_alloc_error_hook); } - /// `std::alloc` error hook: an infallible allocation (`Vec` growth, - /// `Box::new`, ...) got null from the global allocator. - /// - /// Without it std prints `memory allocation of N bytes failed` and calls - /// `abort()`. On POSIX the SIGABRT handler then reports `abort() called` - /// with the trace seeded inside libc's `abort`/`raise`, which have no - /// frame pointers, so the report has one unresolvable frame and neither - /// the reason nor the size. On Windows std aborts with `__fastfail`, which - /// no exception handler sees. Here the allocating frames are still on the - /// stack. - /// - /// A hook rather than a null check in `GlobalAlloc` because a null return - /// is also how `try_reserve` and the other fallible paths report failure. - /// - /// Must not allocate: a failure inside the report re-enters here and is - /// cut short by the `PANIC_STAGE` guard in `crash_handler`. + /// Reports a failed infallible allocation (`Vec` growth, `Box::new`) as + /// out of memory before std aborts, with the allocating frames still on + /// the stack. A hook, not a null check in `GlobalAlloc`: a null return is + /// also how `try_reserve` reports failure. Must not allocate. #[cold] #[inline(never)] fn rust_alloc_error_hook(layout: core::alloc::Layout) { diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index e721caef2612..ea40d1d7f3cb 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -209,21 +209,16 @@ pub(crate) mod js_bindings { bun_core::out_of_memory(); } - /// Fails an infallible std allocation (`Vec::with_capacity`), the path a - /// `Vec`/`Box`/`String` takes when the global allocator returns null. - /// `outOfMemory` above covers the explicit `AllocError` path instead. + /// Fails a real infallible allocation. `outOfMemory` covers the explicit + /// `AllocError` path. #[bun_jsc::host_fn] fn js_alloc_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { crash_handler::suppress_core_dumps_if_necessary(); - // Larger than any 64-bit address space, so mimalloc returns null no - // matter how much memory or overcommit the machine has, and within - // `isize::MAX`, so `Vec` reaches the allocator instead of panicking - // with a capacity overflow. + // Above any 64-bit address space (mimalloc returns null even with + // overcommit), below `isize::MAX` (no capacity overflow panic). const SIZE: usize = 1 << 62; - // Under ASAN the global allocator is libc's, and ASAN treats a request - // this large as an error of its own (allocation-size-too-big) instead - // of returning null, so enter std's failure path directly. This is the - // same call `Vec` makes below once the allocator has returned null. + // 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()); } From b805e575c15a1d9d8d45fb3a31a3bbe10e40634c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:07:55 +0000 Subject: [PATCH 3/3] crash handler: one-line comments on the alloc error hook --- src/crash_handler/lib.rs | 5 +---- src/runtime/api/crash_handler_jsc.rs | 9 +++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 780e0f40ed88..db45f7e1c080 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -1738,10 +1738,7 @@ mod draft { std::alloc::set_alloc_error_hook(rust_alloc_error_hook); } - /// Reports a failed infallible allocation (`Vec` growth, `Box::new`) as - /// out of memory before std aborts, with the allocating frames still on - /// the stack. A hook, not a null check in `GlobalAlloc`: a null return is - /// also how `try_reserve` reports failure. Must not allocate. + /// 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) { diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index ea40d1d7f3cb..b55e98a91dee 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -209,16 +209,13 @@ pub(crate) mod js_bindings { bun_core::out_of_memory(); } - /// Fails a real infallible allocation. `outOfMemory` covers the explicit - /// `AllocError` path. + /// Fails a real infallible allocation; `outOfMemory` covers the explicit `AllocError` path. #[bun_jsc::host_fn] fn js_alloc_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { crash_handler::suppress_core_dumps_if_necessary(); - // Above any 64-bit address space (mimalloc returns null even with - // overcommit), below `isize::MAX` (no capacity overflow panic). + // 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. + // 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()); }