diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 0512fc96d325..6ff08250663c 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -242,8 +242,20 @@ struct us_loop_t *us_create_loop(void *hint, void (*wakeup_cb)(struct us_loop_t #else loop->fd = kqueue(); #endif + /* EMFILE/ENFILE on epoll_create1/kqueue, or on the wakeup eventfd inside + * us_internal_loop_data_init: return NULL so per-call loop creation + * (Bun.spawnSync's SpawnSyncEventLoop) can surface a catchable error + * instead of aborting the process. */ + if (loop->fd == -1) { + us_free(loop); + return NULL; + } - us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb); + if (us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb) != 0) { + close(loop->fd); + us_free(loop); + return NULL; + } return loop; } @@ -697,10 +709,15 @@ 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?)"); + /* EMFILE/ENFILE: the loop is unusable without its wakeup async. + * Return NULL so us_internal_loop_data_init (and in turn + * us_create_loop) can unwind and let the caller surface a catchable + * error instead of taking the process down. */ + if (!fallthrough) { + loop->num_polls--; + } + us_free(p); + return NULL; } us_poll_init(p, efd, POLL_TYPE_CALLBACK); @@ -774,26 +791,32 @@ struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int f mach_port_t self = mach_task_self(); kern_return_t kr = mach_port_allocate(self, MACH_PORT_RIGHT_RECEIVE, &cb->port); - if (UNLIKELY(kr != KERN_SUCCESS)) { - return NULL; - } - - // Insert a send right into the port since we also use this to send - kr = mach_port_insert_right(self, cb->port, cb->port, MACH_MSG_TYPE_MAKE_SEND); - if (UNLIKELY(kr != KERN_SUCCESS)) { - return NULL; + if (kr == KERN_SUCCESS) { + // Insert a send right into the port since we also use this to send + kr = mach_port_insert_right(self, cb->port, cb->port, MACH_MSG_TYPE_MAKE_SEND); + if (kr == KERN_SUCCESS) { + // Modify the port queue size to be 1 because we are only + // using it for notifications and not for any other purpose. + mach_port_limits_t limits = { .mpl_qlimit = 1 }; + kr = mach_port_set_attributes(self, cb->port, MACH_PORT_LIMITS_INFO, (mach_port_info_t)&limits, MACH_PORT_LIMITS_INFO_COUNT); + if (kr == KERN_SUCCESS) { + return (struct us_internal_async *) cb; + } + } + /* Dropping the receive right destroys the port; release the send + * right (now a dead name) so the port-name-table entry is freed too. + * mach_port_deallocate is a harmless KERN_INVALID_RIGHT no-op when + * insert_right failed and no send right exists. */ + mach_port_mod_refs(self, cb->port, MACH_PORT_RIGHT_RECEIVE, -1); + mach_port_deallocate(self, cb->port); } - // Modify the port queue size to be 1 because we are only - // using it for notifications and not for any other purpose. - mach_port_limits_t limits = { .mpl_qlimit = 1 }; - kr = mach_port_set_attributes(self, cb->port, MACH_PORT_LIMITS_INFO, (mach_port_info_t)&limits, MACH_PORT_LIMITS_INFO_COUNT); - - if (UNLIKELY(kr != KERN_SUCCESS)) { - return NULL; + if (!fallthrough) { + loop->num_polls--; } - - return (struct us_internal_async *) cb; + us_free(cb->machport_buf); + us_free(cb); + return NULL; } // identical code as for timer, make it shared for "callback types" diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 01c3a1932372..ba036a4e4ac6 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -268,6 +268,15 @@ struct us_loop_t *us_create_loop(void *hint, (struct us_loop_t *)us_calloc(1, sizeof(struct us_loop_t) + ext_size); loop->uv_loop = hint ? hint : uv_loop_new(); + /* uv_loop_new() returns NULL when uv_loop_init fails (CreateIoCompletionPort + * under handle/non-paged-pool exhaustion on Windows). Without this check the + * uv_prepare_init below dereferences NULL and segfaults the process; the + * only hint==NULL caller is Bun.spawnSync's per-call isolated loop, so the + * failure must surface as a thrown error instead. */ + if (!loop->uv_loop) { + us_free(loop); + return NULL; + } loop->is_default = hint != 0; loop->uv_pre = us_malloc(sizeof(uv_prepare_t)); @@ -283,7 +292,9 @@ struct us_loop_t *us_create_loop(void *hint, loop->uv_check->data = loop; // here we create two unreffed handles - timer and async - us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb); + // Cannot fail on this backend: the libuv us_internal_create_async is a bare + // us_calloc (no OS resource), so wakeup_async is never NULL here. + (void)us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb); // if we do not own this loop, we need to integrate and set up timer if (hint) { diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 27148b318f55..ea78f571d309 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -167,10 +167,10 @@ void us_internal_group_maybe_unlink(struct us_socket_group_t *group); * SSL path calls _raw once it's actually time to drop the fd. */ struct us_socket_t *us_internal_socket_close_raw(us_socket_r s, int code, void *reason); struct us_socket_t *us_internal_ssl_close(us_socket_r s, int code, void *reason); -void us_internal_loop_data_init(struct us_loop_t *loop, - void (*wakeup_cb)(us_loop_r loop), - void (*pre_cb)(us_loop_r loop), - void (*post_cb)(us_loop_r loop)); +int us_internal_loop_data_init(struct us_loop_t *loop, + void (*wakeup_cb)(us_loop_r loop), + void (*pre_cb)(us_loop_r loop), + void (*post_cb)(us_loop_r loop)); void us_internal_loop_data_free(us_loop_r loop); void us_internal_loop_pre(us_loop_r loop); void us_internal_loop_post(us_loop_r loop); diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 987120f6bda6..975976857616 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -119,7 +119,10 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { #endif -void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), +/* Returns 0 on success, -1 when the wakeup async cannot be created + * (eventfd/mach_port under resource exhaustion). On failure nothing is left + * allocated in loop->data. */ +int us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. #ifdef LIBUS_USE_LIBUV @@ -136,12 +139,21 @@ void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct loop->data.pre_cb = pre_cb; loop->data.post_cb = post_cb; loop->data.wakeup_async = us_internal_create_async(loop, 1, 0); + if (!loop->data.wakeup_async) { + us_free(loop->data.recv_buf); + us_free(loop->data.send_buf); +#ifdef LIBUS_USE_LIBUV + us_timer_close(loop->data.sweep_timer, 0); +#endif + return -1; + } us_internal_async_set(loop->data.wakeup_async, (void (*)(struct us_internal_async *)) wakeup_cb); #if ASSERT_ENABLED if (Bun__lock__size != sizeof(loop->data.mutex)) { BUN_PANIC("The size of the mutex must match the size of the lock"); } #endif + return 0; } void us_internal_loop_data_free(struct us_loop_t *loop) { diff --git a/packages/bun-uws/src/Loop.h b/packages/bun-uws/src/Loop.h index c33f271f626b..aee730450863 100644 --- a/packages/bun-uws/src/Loop.h +++ b/packages/bun-uws/src/Loop.h @@ -84,8 +84,11 @@ struct Loop { } static Loop *create(void *hint) { - Loop *loop = ((Loop *) us_create_loop(hint, wakeupCb, preCb, postCb, sizeof(LoopData)))->init(); - return loop; + Loop *loop = (Loop *) us_create_loop(hint, wakeupCb, preCb, postCb, sizeof(LoopData)); + if (!loop) { + return nullptr; + } + return loop->init(); } /* What to do with loops created with existingNativeLoop? */ @@ -119,7 +122,14 @@ struct Loop { getLazyLoop().loop = create(existingNativeLoop); /* We cannot register automatic free here, must be manually done */ } else { - getLazyLoop().loop = create(nullptr); + Loop *loop = create(nullptr); + if (!loop) { + /* Resource exhaustion (epoll/kqueue/eventfd EMFILE, or + * uv_loop_init). Leave lazyLoop null so a later get() + * retries once resources free up. */ + return nullptr; + } + getLazyLoop().loop = loop; getLazyLoop().cleanMe = true; } } diff --git a/src/event_loop/SpawnSyncEventLoop.rs b/src/event_loop/SpawnSyncEventLoop.rs index 4e5aaf086dd0..b19192eb8217 100644 --- a/src/event_loop/SpawnSyncEventLoop.rs +++ b/src/event_loop/SpawnSyncEventLoop.rs @@ -146,15 +146,18 @@ impl SpawnSyncEventLoop { // below, so `Self` MUST NOT move after `init` returns (no-move invariant // upheld by the caller). The caller provides uninitialized storage, hence // `MaybeUninit` (out-param ctor exception). + // + // Returns `false` when the isolated event loop cannot be created + // (uv_loop_init / epoll_create1 / kqueue failure under resource + // exhaustion). `this` is left uninitialized on failure. pub fn init( this: &mut core::mem::MaybeUninit, vm: *mut (), /* SAFETY: erased *mut VirtualMachine */ - ) { + ) -> bool { // `uws::Loop::create` takes a `LoopHandler` impl with associated-const fn ptrs. - let loop_ = uws::Loop::create::(); - - let loop_ = - NonNull::new(loop_).expect("uws::Loop::create never returns null (asserts on OOM)"); + let Some(loop_) = uws::Loop::create::() else { + return false; + }; // Initialize the JSC EventLoop with empty state. // CRITICAL: On Windows, the impl stores our isolated loop pointer in `uws_loop`. @@ -183,6 +186,7 @@ impl SpawnSyncEventLoop { let loop_data = &mut this.uws_loop_mut().internal_loop_data; loop_data.set_parent_raw(tag, ptr); loop_data.jsc_vm = core::ptr::null(); + true } /// Erased `*mut bun_jsc::event_loop::EventLoop` (heap-owned via @@ -201,8 +205,9 @@ impl SpawnSyncEventLoop { /// Shared borrow of the isolated `uws::Loop`. /// /// # Safety (invariant) - /// `uws_loop` is created in `init` via `uws::Loop::create` (asserts - /// non-null) and freed only in `Drop`, so it is valid for all of `self`'s + /// `uws_loop` is created in `init` via `uws::Loop::create`; `init` returns + /// `false` on `None`, so `Self` is never constructed with a null loop. It + /// is freed only in `Drop`, so it is valid for all of `self`'s /// lifetime. The loop is only mutated through `&mut self` paths /// (`uws_loop_mut`), so a shared borrow tied to `&self` cannot overlap a /// unique borrow. diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 081e582008e9..78e1896b518c 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -705,20 +705,31 @@ impl RareData { .push(CleanupHook::from(global_this, ctx, func)); } - pub fn spawn_sync_event_loop(&mut self, vm: &mut VirtualMachine) -> &mut SpawnSyncEventLoop { + /// Returns `None` when the isolated event loop cannot be created + /// (uv_loop_init / epoll_create1 / kqueue failure under resource + /// exhaustion); the caller surfaces that as a thrown JS error so a + /// `Bun.spawnSync` under resource pressure fails the one call instead of + /// taking the process down. Once created the loop is cached, so only the + /// first call per VM can return `None`. + pub fn spawn_sync_event_loop( + &mut self, + vm: &mut VirtualMachine, + ) -> Option<&mut SpawnSyncEventLoop> { if self.spawn_sync_event_loop_.is_none() { // In-place out-param init: `event_loop` inside captures the // `self` address, so the value must not move after init; allocate // the Box first, then init into it. let mut boxed = Box::::new_uninit(); - SpawnSyncEventLoop::init( + if !SpawnSyncEventLoop::init( &mut *boxed, core::ptr::from_mut::(vm).cast::<()>(), - ); - // SAFETY: `init` fully initialised the slot. + ) { + return None; + } + // SAFETY: `init` fully initialised the slot when it returned `true`. self.spawn_sync_event_loop_ = Some(unsafe { boxed.assume_init() }); } - self.spawn_sync_event_loop_.as_mut().unwrap() + self.spawn_sync_event_loop_.as_deref_mut() } pub fn mime_type_from_string(&mut self, str_: &[u8]) -> Option { diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index e1d82a67e657..1458f0cd579f 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -1075,9 +1075,20 @@ pub(crate) fn spawn_maybe_sync( // SAFETY: see note above; `spawn_sync_event_loop` re-borrows the // same VM via the raw pointer for its `vm` arg. unsafe { - let sync_loop = (*jsc_vm_ptr) + let Some(sync_loop) = (*jsc_vm_ptr) .rare_data() - .spawn_sync_event_loop(&mut *jsc_vm_ptr); + .spawn_sync_event_loop(&mut *jsc_vm_ptr) + else { + // The per-spawnSync isolated event loop could not be created + // (uv_loop_init on Windows, epoll_create1/kqueue on POSIX). + // Throw instead of letting the unchecked NULL dereference in + // us_create_loop segfault the whole process. + #[cfg(windows)] + for e in &mut extra_fds { + e.deinit(); + } + return Err(throw_spawn_sync_loop_init_failed(global_this)); + }; sync_loop.prepare(jsc_vm_ptr.cast()); // `SpawnSyncEventLoop.event_loop` is type-erased to `*mut ()` // (bun_event_loop is below bun_jsc); the accessor returns the @@ -1104,6 +1115,7 @@ pub(crate) fn spawn_maybe_sync( (*jsc_vm_ptr_cleanup) .rare_data() .spawn_sync_event_loop(&mut *jsc_vm_ptr_cleanup) + .expect("cached by the IS_SYNC prepare above") .cleanup(jsc_vm_ptr_cleanup.cast(), main_loop.cast()); } } @@ -1908,7 +1920,8 @@ pub(crate) fn spawn_maybe_sync( // SAFETY: jsc_vm_ptr is the live thread VM; re-borrowed for the nested arg. let sync_loop = unsafe { &mut *jsc_vm_ptr } .rare_data() - .spawn_sync_event_loop(unsafe { &mut *jsc_vm_ptr }); + .spawn_sync_event_loop(unsafe { &mut *jsc_vm_ptr }) + .expect("cached by the IS_SYNC prepare above"); while subprocess.compute_has_pending_activity() { // Re-evaluate this at each iteration of the loop since it may change between iterations. @@ -2081,6 +2094,30 @@ pub(crate) fn spawn_maybe_sync( Ok(sync_value) } +fn throw_spawn_sync_loop_init_failed(global_this: &JSGlobalObject) -> JsError { + // us_create_loop discards the real errno by returning NULL, so the exact + // cause (EMFILE vs ENFILE, epoll_create1 vs eventfd) is not recoverable + // here. Report the common case with a platform-appropriate syscall name. + let err = SystemError { + message: BunString::static_( + b"spawnSync failed to initialize its event loop (system resource exhaustion)", + ), + code: BunString::static_("EMFILE"), + errno: -UV_E::MFILE, + path: BunString::EMPTY, + #[cfg(windows)] + syscall: BunString::static_("uv_loop_init"), + #[cfg(any(target_os = "linux", target_os = "android"))] + syscall: BunString::static_("epoll_create1"), + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + syscall: BunString::static_("kqueue"), + hostname: BunString::EMPTY, + fd: -1, + dest: BunString::EMPTY, + }; + global_this.throw_value(err.to_error_instance(global_this)) +} + fn throw_command_not_found(global_this: &JSGlobalObject, command: &[u8]) -> JsError { let err = SystemError { message: BunString::create_format(format_args!( diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 28f88fa0667e..a14a378e7101 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -1,4 +1,5 @@ use core::ffi::{c_int, c_uint, c_void}; +use core::ptr::NonNull; use crate::InternalLoopData; use crate::Timespec; @@ -229,13 +230,14 @@ impl PosixLoop { unsafe { c::us_quic_loop_flush_if_pending(self) }; } - pub fn create() -> *mut Loop { + /// Returns `None` when the kernel cannot create the backing event + /// provider (epoll/kqueue EMFILE); callers surface that as an error. + pub fn create() -> Option> { // SAFETY: us_create_loop allocates and returns a new loop; null hint is valid let p = unsafe { c::us_create_loop(core::ptr::null_mut(), Some(H::WAKEUP), H::PRE, H::POST, 0) }; - assert!(!p.is_null(), "us_create_loop returned null"); - p + NonNull::new(p) } pub fn wakeup(&mut self) { @@ -470,13 +472,14 @@ impl WindowsLoop { unsafe { c::us_quic_loop_flush_if_pending(self) }; } - pub fn create() -> *mut WindowsLoop { + /// Returns `None` when `uv_loop_init` fails (CreateIoCompletionPort under + /// handle/non-paged-pool exhaustion); callers surface that as an error. + pub fn create() -> Option> { // SAFETY: us_create_loop allocates and returns a new loop; null hint is valid let p = unsafe { c::us_create_loop(core::ptr::null_mut(), Some(H::WAKEUP), H::PRE, H::POST, 0) }; - assert!(!p.is_null(), "us_create_loop returned null"); - p + NonNull::new(p) } pub fn run(&mut self) { diff --git a/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts b/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts new file mode 100644 index 000000000000..bb2248ed48aa --- /dev/null +++ b/test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts @@ -0,0 +1,65 @@ +// Bun.spawnSync lazily creates an isolated uSockets event loop per VM +// (epoll_create1/kqueue on POSIX, uv_loop_new on Windows). When that syscall +// fails under resource exhaustion, us_create_loop used to dereference the +// NULL/invalid result and crash the whole process. The one spawnSync call must +// throw a catchable error instead, and once resources are freed a retry must +// work. +// +// The Windows variant (uv_loop_new -> CreateIoCompletionPort failing under +// handle/non-paged-pool exhaustion) routes through the same NULL propagation +// in us_create_loop / WindowsLoop::create / SpawnSyncEventLoop::init; this +// test exercises the POSIX half where the failure is reproducible with a file +// descriptor limit. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isPosix } from "harness"; + +// Absolute argv[0] so PATH lookup (which_for_spawn) is skipped; on musl that +// lookup fails under EMFILE before the event loop is created and turns the +// failure into ENOENT instead of exercising us_create_loop. +const fixture = /* js */ ` + import * as fs from "node:fs"; + // Warm anything lazily opened on first use so the fd fill below leaves zero + // descriptors for us_create_loop itself (not for a module loader read). + process.nextTick(() => {}); + + const held = []; + for (;;) { try { held.push(fs.openSync("/dev/null", "r")); } catch { break; } } + + let first; + try { + Bun.spawnSync({ cmd: ["/bin/sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); + first = { ok: false, msg: "UNEXPECTED: spawnSync succeeded" }; + } catch (e) { + first = { ok: true, code: e?.code, msg: String(e?.message ?? e) }; + } + + for (const fd of held) fs.closeSync(fd); + + if (!first.ok) { console.error(first.msg); process.exit(1); } + console.error("spawnSync threw:", first.code, first.msg); + + // Descriptors are free again: the isolated loop was not cached on failure, + // so this call creates it successfully and runs the child. + const retry = Bun.spawnSync({ cmd: ["/bin/sh", "-c", ":"], stdio: ["ignore", "ignore", "ignore"] }); + console.error("retry exit:", retry.exitCode); + console.error("SURVIVED"); +`; + +describe.skipIf(!isPosix)("Bun.spawnSync event-loop creation under EMFILE", () => { + test("throws instead of aborting the process", async () => { + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", `ulimit -n 512 && exec "$1" --no-install -e "$2"`, "sh", bunExe(), fixture], + 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("SURVIVED"), + exitCode: 0, + }); + expect(stderr).toContain("spawnSync threw: EMFILE"); + expect(stderr).toContain("retry exit: 0"); + }); +});