diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 5431a0b9959b..b9a0247fb023 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -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) { @@ -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); @@ -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); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index d8289364c3ed..9aa30d2b2860 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -75,6 +75,10 @@ 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. Exits with the + * file descriptor limit error for EMFILE/ENFILE, crashes 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 diff --git a/src/bun_bin/c_abi_exports.rs b/src/bun_bin/c_abi_exports.rs index b7383e0c9b5b..a16a5d3e3e9e 100644 --- a/src/bun_bin/c_abi_exports.rs +++ b/src/bun_bin/c_abi_exports.rs @@ -1,5 +1,5 @@ //! 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 and the fatal-exit hooks. //! //! Everything else that used to live here has a real home in `bun_jsc` / //! `bun_runtime` and is exported via `generate-host-exports.ts`. @@ -27,3 +27,30 @@ extern "C" fn Bun__panic(msg: *const u8, len: usize) -> ! { extern "C" fn Bun__outOfMemory() -> ! { bun_core::out_of_memory() } + +/// Exit for bun-usockets when `us_create_loop` cannot get a descriptor. +/// `syscall_name` must be NUL-terminated. +#[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}" + )), + } +} diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index a5cb8c8966ea..b431073f29b7 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -1286,7 +1286,7 @@ mod draft { 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") { #[cfg(unix)] { let limit = getrlimit_nofile().map(|l| l.rlim_cur); @@ -1329,7 +1329,7 @@ mod draft { "error: Your computer ran out of file descriptors (SystemFdQuotaExceeded)", ); } - } else if name == b"ProcessFdQuotaExceeded" { + } else if matches!(name, b"ProcessFdQuotaExceeded" | b"EMFILE") { #[cfg(unix)] { let limit = getrlimit_nofile().map(|l| l.rlim_cur); diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 4d436bbedc7b..fe7999acca7d 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -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; @@ -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 +#include +#include +#include +#include + +/* 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) { + 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 () => {