Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 7 additions & 4 deletions packages/bun-usockets/src/eventing/epoll_kqueue.c
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ struct us_loop_t *us_create_loop(void *hint, void (*wakeup_cb)(struct us_loop_t

#ifdef LIBUS_USE_EPOLL
loop->fd = epoll_create1(EPOLL_CLOEXEC);
if (loop->fd == -1) {
Bun__loopInitFailed("epoll_create1", errno);
}

if (has_epoll_pwait2 == -1) {
if (Bun__isEpollPwait2SupportedOnLinuxKernel() == 0) {
Expand All @@ -242,6 +245,9 @@ struct us_loop_t *us_create_loop(void *hint, void (*wakeup_cb)(struct us_loop_t

#else
loop->fd = kqueue();
if (loop->fd == -1) {
Bun__loopInitFailed("kqueue", errno);
}
#endif

us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb);
Expand Down Expand Up @@ -782,10 +788,7 @@ struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int f

int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
if (efd == -1) {
// eventfd only fails on EMFILE/ENFILE — the loop is unusable without
// wakeup_async, and the sole caller doesn't NULL-check. Crash loudly
// rather than NULL-deref or store -1 as a poll fd.
BUN_PANIC("eventfd() failed during loop init (out of file descriptors?)");
Bun__loopInitFailed("eventfd", errno);
}
us_poll_init(p, efd, POLL_TYPE_CALLBACK);

Expand Down
6 changes: 6 additions & 0 deletions packages/bun-usockets/src/internal/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ extern void __attribute__((__noreturn__)) Bun__panic(const char *message, size_t
* allocations this library has no way to fail gracefully from. */
extern void __attribute__((__noreturn__)) Bun__outOfMemory(void);

/* us_create_loop could not get a descriptor the loop needs (the epoll/kqueue
* instance or the wakeup eventfd). Every caller dereferences the new loop, so
* this exits: with the file descriptor limit error for EMFILE/ENFILE, with a
* crash report for anything else. */
extern void __attribute__((__noreturn__)) Bun__loopInitFailed(const char *syscall_name, int err);

/* The error code a loop-driven close carries (recv()'s error, SO_ERROR, or
* the fallback where those report nothing) is in LIBUS_ERR's numbering: errno
* on POSIX, a WSA code on Windows, which on_close maps (socket_body.rs). The
Expand Down
36 changes: 35 additions & 1 deletion src/bun_bin/c_abi_exports.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! C-ABI entry points that belong to the final binary rather than any
//! library crate: the process-level panic hook and the OOM crash handler.
//! library crate: the process-level panic hook, the OOM crash handler, and
//! the fatal exit for an event loop that cannot be created.
Comment thread
robobun marked this conversation as resolved.
Outdated
//!
//! Everything else that used to live here has a real home in `bun_jsc` /
//! `bun_runtime` and is exported via `generate-host-exports.ts`.
Expand Down Expand Up @@ -27,3 +28,36 @@ extern "C" fn Bun__panic(msg: *const u8, len: usize) -> ! {
extern "C" fn Bun__outOfMemory() -> ! {
bun_core::out_of_memory()
}

/// Entry point for bun-usockets when `us_create_loop` cannot get a descriptor
/// the loop needs (`epoll_create1`, `kqueue`, or the wakeup `eventfd`). Every
/// caller dereferences the new loop, so the failure is fatal either way. An
/// exhausted descriptor limit is the environment's limit, not a bug, so it
/// gets the same report and exit code as `main` returning that errno instead
/// of a crash report. Any other errno is still a crash.
///
/// `syscall_name` must be a NUL-terminated string.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[cfg(unix)]
#[unsafe(no_mangle)]
unsafe extern "C" fn Bun__loopInitFailed(
syscall_name: *const core::ffi::c_char,
err: core::ffi::c_int,
) -> ! {
use bun_sys::{E, SystemErrno};

let errno = SystemErrno::init(i64::from(err));
if let Some(errno @ (E::EMFILE | E::ENFILE)) = errno {
bun_crash_handler::handle_root_error(errno, None);
}
// SAFETY: the caller passes a NUL-terminated string literal.
let syscall_name =
bstr::BStr::new(unsafe { core::ffi::CStr::from_ptr(syscall_name) }.to_bytes());
match errno {
Some(errno) => bun_core::output::panic(format_args!(
"{syscall_name}() failed while creating the event loop: {errno}"
)),
None => bun_core::output::panic(format_args!(
"{syscall_name}() failed while creating the event loop: errno {err}"
)),
}
}
6 changes: 4 additions & 2 deletions src/crash_handler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1286,7 +1286,9 @@
err_generic!(
"The current working directory was deleted, so that command didn't work. Please cd into a different directory and try again.",
);
} else if name == b"SystemFdQuotaExceeded" {
} else if matches!(name, b"SystemFdQuotaExceeded" | b"ENFILE") {
// Errno errors (`SystemErrno`) are named after the errno; the
// `*FdQuotaExceeded` spellings are the variants in `bun_runtime::Error`.
Comment thread
robobun marked this conversation as resolved.
Outdated
#[cfg(unix)]
{
let limit = getrlimit_nofile().map(|l| l.rlim_cur);
Expand Down Expand Up @@ -1329,7 +1331,7 @@
"<r><red>error<r>: Your computer ran out of file descriptors <d>(<red>SystemFdQuotaExceeded<r><d>)<r>",
);
}
} else if name == b"ProcessFdQuotaExceeded" {
} else if matches!(name, b"ProcessFdQuotaExceeded" | b"EMFILE") {

Check failure on line 1334 in src/crash_handler/lib.rs

View check run for this annotation

Claude / Claude Code Review

PR description claims >> is now escaped in fd-limit messages, but the escaping change is missing

The PR description says the Linux fd-limit messages "now escape" `>>`, but that hunk is missing from the diff — lines 1312 and 1355 still pass unescaped `>> /etc/sysctl.conf` to `pretty_error!`, whose tag rewriter silently drops bare `>`. Since this PR makes both branches reachable for the first time, users will now see the broken advice `sudo echo -e "..." /etc/sysctl.conf` (no redirection). Escape as `\>\>` at both sites.
Comment thread
robobun marked this conversation as resolved.
#[cfg(unix)]
{
let limit = getrlimit_nofile().map(|l| l.rlim_cur);
Expand Down
152 changes: 151 additions & 1 deletion test/cli/run/run-crash-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
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 {
bunEnv,
bunExe,
isASAN,
isDebug,
isGlibc,
isLinux,
isPosix,
isWindows,
mergeWindowEnvs,
tempDir,
} from "harness";
import { rmSync } from "node:fs";
import path from "path";
const { getMachOImageZeroOffset } = crash_handler;
Expand Down Expand Up @@ -500,6 +511,145 @@ describe.if(isPosix)("SIGABRT/SIGTRAP are caught by the crash handler", () => {
});
});

// The event loop is created before the entry point is read. On Linux that
// takes two descriptors, an epoll instance and the wakeup eventfd, so a process
// that starts out of descriptors (`ulimit -n 4`) fails right there. That is the
// environment's limit, not a bug in Bun: it must print the file descriptor
// error and exit 1 instead of aborting with a crash report.
//
// An LD_PRELOAD shim fails the syscall instead of a real `ulimit -n` because
// the limit at which the loop's syscall is the first one to fail depends on
// how many descriptors the build holds by then (debug builds hold one more).
// bun-musl is statically linked, so LD_PRELOAD cannot interpose there.
const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang");
describe.skipIf(!isGlibc || !cc)("out of file descriptors while creating the event loop", () => {
// FAIL_LOOP_SYSCALL names the function that fails, FAIL_LOOP_ERRNO the errno
// it fails with. The other function passes through to libc.
const SHIM_C = /* c */ `
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>

/* A build without the fix aborts. Keep its core file off the CI runner, which
* flags leaked core files. RLIMIT_CORE survives exec. */
__attribute__((constructor)) static void no_core(void) {
struct rlimit rl = {0, 0};
setrlimit(RLIMIT_CORE, &rl);
}

static int should_fail(const char *name) {
const char *target = getenv("FAIL_LOOP_SYSCALL");
if (!target || strcmp(target, name) != 0) return 0;
const char *err = getenv("FAIL_LOOP_ERRNO");
errno = err && strcmp(err, "ENFILE") == 0 ? ENFILE : err && strcmp(err, "ENOMEM") == 0 ? ENOMEM : EMFILE;
return 1;
}

int epoll_create1(int flags) {
if (should_fail("epoll_create1")) return -1;
return ((int (*)(int)) dlsym(RTLD_NEXT, "epoll_create1"))(flags);
}

int eventfd(unsigned int initval, int flags) {
if (should_fail("eventfd")) return -1;
return ((int (*)(unsigned int, int)) dlsym(RTLD_NEXT, "eventfd"))(initval, flags);
}
`;

async function runWithFailingSyscall(syscall: string, errno: string, env: Record<string, string | undefined>) {
using dir = tempDir("loop-init-fd-limit", {
"shim.c": SHIM_C,
"app.js": "console.log('entry point ran');\n",
});
const shimPath = path.join(String(dir), "shim.so");
await using ccProc = Bun.spawn({
cmd: [cc!, "-shared", "-fPIC", "-o", shimPath, path.join(String(dir), "shim.c"), "-ldl"],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [ccOut, ccErr, ccExit] = await Promise.all([ccProc.stdout.text(), ccProc.stderr.text(), ccProc.exited]);
if (ccExit !== 0) throw new Error(`shim compile failed: ${ccErr || ccOut}`);

await using proc = Bun.spawn({
// The flag only matters when the child crashes: it makes debug builds
// print the trace string instead of spawning llvm-symbolizer.
cmd: [bunExe(), "app.js", "--debug-crash-handler-use-trace-string"],
cwd: String(dir),
env: {
...env,
LD_PRELOAD: env.LD_PRELOAD ? `${shimPath}:${env.LD_PRELOAD}` : shimPath,
FAIL_LOOP_SYSCALL: syscall,
FAIL_LOOP_ERRNO: errno,
},
stdio: ["ignore", "pipe", "pipe"],
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode, signalCode: proc.signalCode };
}

test.concurrent.each([
["epoll_create1", "EMFILE", "bun ran out of file descriptors (ProcessFdQuotaExceeded)"],
["eventfd", "EMFILE", "bun ran out of file descriptors (ProcessFdQuotaExceeded)"],
["eventfd", "ENFILE", "Your computer ran out of file descriptors (SystemFdQuotaExceeded)"],
])(
"%s failing with %s exits 1 with the file descriptor error and no crash report",
async (syscall, errno, message) => {
let sent = false;
using server = Bun.serve({
port: 0,
fetch() {
sent = true;
return new Response("OK");
},
});

const result = await runWithFailingSyscall(
syscall,
errno,
mergeWindowEnvs([
bunEnv,
{
BUN_CRASH_REPORT_URL: server.url.toString(),
BUN_ENABLE_CRASH_REPORTING: "1",
GITHUB_ACTIONS: undefined,
CI: undefined,
},
]),
);

expect(result).toEqual({
stdout: "",
stderr: expect.stringContaining(message),
exitCode: 1,
signalCode: null,
});
expect(result.stderr).toContain("ulimit -n");
expect(result.stderr).not.toContain("Bun has crashed");
expect(result.stderr).not.toContain(server.url.toString());
expect(sent).toBe(false);
},
);

// Only the descriptor limit is the environment's problem. Any other failure
// of the same syscalls is still a bug and still gets a crash report that
// names the syscall and the errno.
test.concurrent("eventfd failing with another errno is still reported as a crash", async () => {
const result = await runWithFailingSyscall("eventfd", "ENOMEM", noReportEnv);

expect(result).toEqual({
stdout: "",
stderr: expect.stringContaining("panic: eventfd() failed while creating the event loop: ENOMEM"),
exitCode: 134,
signalCode: "SIGABRT",
});
expect(result.stderr).toContain("Bun has crashed");
});
});

describe("automatic crash reporter", () => {
for (const approach of ["panic", "segfault", "outOfMemory"]) {
test(`${approach} should report`, async () => {
Expand Down
Loading