Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
23 changes: 18 additions & 5 deletions packages/bun-usockets/src/eventing/epoll_kqueue.c
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ 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 (UNLIKELY(loop->fd == -1)) {
us_free(loop);
return NULL;
}

if (has_epoll_pwait2 == -1) {
if (Bun__isEpollPwait2SupportedOnLinuxKernel() == 0) {
Expand All @@ -174,9 +178,17 @@ struct us_loop_t *us_create_loop(void *hint, void (*wakeup_cb)(struct us_loop_t

#else
loop->fd = kqueue();
if (UNLIKELY(loop->fd == -1)) {
us_free(loop);
return NULL;
}
#endif

us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb);
if (UNLIKELY(us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb) != 0)) {
close(loop->fd);
us_free(loop);
return NULL;
}
return loop;
}

Expand Down Expand Up @@ -611,10 +623,11 @@ 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?)");
if (!fallthrough) {
loop->num_polls--;
}
us_free(p);
return NULL;
}
us_poll_init(p, efd, POLL_TYPE_CALLBACK);

Expand Down
2 changes: 1 addition & 1 deletion packages/bun-usockets/src/eventing/libuv.c
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ 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);
(void) us_internal_loop_data_init(loop, wakeup_cb, pre_cb, post_cb);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// if we do not own this loop, we need to integrate and set up timer
if (hint) {
Expand Down
8 changes: 4 additions & 4 deletions packages/bun-usockets/src/internal/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,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);
Expand Down
13 changes: 12 additions & 1 deletion packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ 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 on fd exhaustion (EMFILE/ENFILE from eventfd on
* Linux); 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
Expand All @@ -134,12 +136,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) {
free(loop->data.recv_buf);
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) {
Expand Down
15 changes: 12 additions & 3 deletions packages/bun-uws/src/Loop.h
Original file line number Diff line number Diff line change
Expand Up @@ -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? */
Expand Down Expand Up @@ -119,7 +122,13 @@ 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) {
/* fd exhaustion (EMFILE). Leave lazyLoop null so a later
* get() retries once descriptors are available. */
return nullptr;
}
getLazyLoop().loop = loop;
getLazyLoop().cleanMe = true;
}
}
Expand Down
31 changes: 31 additions & 0 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,37 @@ impl<'a> AsyncHTTP<'a> {
}
}

impl AsyncHTTP<'static> {
/// Fail a queued request that was never handed to `start_queued_task`.
/// Runs the caller's `result_callback` with `fail = err` so the owning
/// `fetch()` promise (or install NetworkTask) rejects. Used when the HTTP
/// thread cannot create its event loop under fd exhaustion.
///
/// # Safety
/// `http` must be a live JS-thread-owned `AsyncHTTP` popped from
/// `HttpThread::queued_tasks`.
pub unsafe fn fail_before_start(http: NonNull<AsyncHTTP<'static>>, err: crate::Error) {
// Callbacks (e.g. `FetchTasklet::callback`) borrow both the JS-side
// `AsyncHTTP` and the `async_http` arg. Mirror `start_queued_task`: a
// bitwise stack copy supplies the arg; it is forgotten afterwards as
// every owned field still belongs to the original.
let http = http.as_ptr();
// SAFETY: caller guarantees `http` is live and exclusively owned by
// this thread (popped from the MPSC queue).
let mut copy = core::mem::ManuallyDrop::new(unsafe { core::ptr::read(http) });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
copy.real = NonNull::new(http);
copy.err = Some(err);
copy.state.store(State::Fail, Ordering::Relaxed);
let callback = copy.result_callback;
let result = HTTPClientResult {
fail: Some(err),
has_more: false,
..Default::default()
};
callback.run(core::ptr::from_mut(&mut *copy), result);
}
}

// ──────────────────────────────────────────────────────────────────────────
// send_sync
// ──────────────────────────────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions src/http/HTTPThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1271,6 +1271,24 @@ mod _event_loop_draft {
core::sync::atomic::Ordering::Relaxed,
);

// Ensure this thread's uSockets loop exists before `init_global`
// derefs it. `uws::Loop::get()` returns null if epoll_create1 /
// timerfd_create / eventfd fail (EMFILE). Rather than aborting the
// process, reject every queued fetch with `FailedToOpenSocket` and
// retry once fds free up — the next `get()` re-attempts creation.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
while uws::Loop::get().is_null() {
let thread = crate::http_thread_mut();
while let Some(http) = NonNull::new(thread.queued_tasks.pop()) {
// SAFETY: `http` was pushed by `HttpThread::schedule` and is
// live until its `result_callback` runs (the owner holds a
// ref for the in-flight callback).
unsafe {
AsyncHttp::fail_before_start(http, crate::Error::FailedToOpenSocket);
}
}
std::thread::sleep(core::time::Duration::from_millis(50));
}

// Critical side effect: `init_global` calls
// `internal_loop_data.set_parent_raw(2 /* mini */, mini_ptr)` on this
// thread's uSockets loop. Without it, the macOS DNS cache-miss path
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,14 @@ impl VirtualMachine {
pub fn init(mut opts: InitOptions) -> crate::CrateResult<*mut VirtualMachine> {
jsc::mark_binding();

// `uws::Loop::get()` lazily creates the per-thread uSockets loop; on
// fd exhaustion (epoll_create1/timerfd/eventfd → EMFILE) it returns
// null. Check before allocating anything so the worker's null-vm
// `shutdown()` path has nothing to reclaim.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if uws::Loop::get().is_null() {
return Err(bun_errno::SystemErrno::EMFILE.into());
}

let log: *mut bun_ast::Log = match opts.log {
Some(l) => l.as_ptr(),
None => bun_core::heap::into_raw(Box::new(bun_ast::Log::default())),
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/webcore/Worker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,11 @@ extern "C" void WebWorker__dispatchExit(Worker* worker, int32_t exitCode)
worker->dispatchExit(exitCode);
}

extern "C" void WebWorker__dispatchErrorMessage(Worker* worker, BunString* message)
{
worker->dispatchErrorWithMessage(message->transferToWTFString());
}

// The entry module just finished (or failed) its top-level evaluation. Flush
// the worker_threads hub's deferred cross-thread deliveries: node's bootstrap
// runs the synchronous CJS main before any port delivery, so a routed message
Expand Down
24 changes: 19 additions & 5 deletions src/jsc/web_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ pub struct WebWorker {
/// observed concurrently by `terminate_all_and_wait` / parent-thread FFI;
/// producing `&mut WebWorker` while another thread holds `&WebWorker` is UB).
exit_called: AtomicBool,
/// Exit code `shutdown()` posts when the VM never came up. Set to 1 by
/// `thread_main` on `start_vm()` failure so the parent's close/exit event
/// reflects abnormal exit (Node's ERR_WORKER_INIT_FAILED gives code 1).
init_fail_exit_code: Cell<i32>,
}

#[repr(u8)]
Expand Down Expand Up @@ -210,6 +214,7 @@ unsafe extern "C" {
// `ctx`. `&JSGlobalObject` is the non-null handle proof; remaining args are
// by-value scalars/`#[repr(C)]` PODs.
safe fn WebWorker__dispatchExit(cpp_worker: *mut c_void, exit_code: i32);
safe fn WebWorker__dispatchErrorMessage(cpp_worker: *mut c_void, message: &mut BunString);
// Re-declared here (also private in VM.rs) so `thread_main` can take the
// API lock as a raw FFI call with NO RAII guard — see the note there.
safe fn JSC__VM__getAPILock(vm: &jsc::VM);
Expand Down Expand Up @@ -566,6 +571,7 @@ impl WebWorker {
worker_env_map: Cell::new(core::ptr::null_mut()),
worker_env_loader: Cell::new(core::ptr::null_mut()),
exit_called: AtomicBool::new(false),
init_fail_exit_code: Cell::new(0),
}));
// `worker` is non-null (just heap-allocated). Wrap once for the safe
// shared reborrows below; the raw `worker` is still used for
Expand Down Expand Up @@ -780,10 +786,18 @@ impl WebWorker {
let vm_ptr = match self.start_vm() {
Ok(vm) => vm,
Err(err) => {
bun_core::output::panic(format_args!(
"An unhandled error occurred while starting a worker: {}\n",
err.name()
));
// VM init failed before a JSGlobalObject exists (e.g. EMFILE
// from the per-thread uSockets loop, or getcwd ENOENT from
// the transpiler hook). Surface it as a Worker `error` event
// on the parent and tear down this thread cleanly, matching
// Node's ERR_WORKER_INIT_FAILED behaviour.
let mut msg = BunString::clone_utf8(
format!("Worker initialization failed: {}", err.name()).as_bytes(),
);
WebWorker__dispatchErrorMessage(self.cpp_worker, &mut msg);
self.init_fail_exit_code.set(1);
self.shutdown();
return;
Comment thread
robobun marked this conversation as resolved.
}
};

Expand Down Expand Up @@ -1239,7 +1253,7 @@ impl WebWorker {
}

// ---- 2. User exit handlers -----------------------------------------
let mut exit_code: i32 = 0;
let mut exit_code: i32 = self.init_fail_exit_code.get();
let mut global_object: Option<*const JSGlobalObject> = None;
if !vm_ptr.is_null() {
// SAFETY: vm_ptr valid; unpublished above under vm_lock, so no
Expand Down
86 changes: 86 additions & 0 deletions test/js/bun/util/emfile-loop-init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// The first fetch() and the first new Worker() lazily create a per-thread
// uSockets event loop (epoll_create1 + timerfd_create + eventfd on Linux,
// kqueue on macOS). Under fd exhaustion those syscalls fail with EMFILE. The
// process must not abort: the one operation should fail and, once fds are
// freed, a retry should succeed.
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isPosix } from "harness";

const fixture = /* js */ `
import * as fs from "node:fs";
const held = [];
for (;;) { try { held.push(fs.openSync("/dev/null", "r")); } catch { break; } }
// First use of the lazily-created HTTP-client event loop: must reject, not abort.
await fetch("http://127.0.0.1:1/").then(
() => { console.error("UNEXPECTED: fetch resolved"); process.exit(1); },
e => console.error("rejected:", e?.code ?? String(e)),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
for (const fd of held) fs.closeSync(fd);
// With fds freed, the HTTP thread's loop should come up and a retry should
// reach a normal connect failure (ECONNREFUSED to 127.0.0.1:1), not abort.
await fetch("http://127.0.0.1:1/").then(
() => console.error("retry resolved"),
e => console.error("retry rejected:", e?.code ?? String(e)),
);
console.error("SURVIVED");
`;

const workerFixture = /* js */ `
import * as fs from "node:fs";
import * as os from "node:os";
const W = os.tmpdir() + "/w-" + process.pid + ".mjs";
fs.writeFileSync(W, "postMessage(42)\\n");
Comment thread
robobun marked this conversation as resolved.
// Warm lazy builtin loads (debug builds read internal:fixed_queue from disk
// on the first nextTick, which new Worker()'s close-event dispatch triggers).
process.nextTick(() => {});
const held = [];
try {
for (;;) { try { held.push(fs.openSync("/dev/null", "r")); } catch { break; } }
await new Promise(resolve => {
const w = new Worker(W);
w.onerror = ev => { console.error("worker error:", ev?.message ?? "error"); resolve(); };
w.onmessage = () => { console.error("UNEXPECTED: worker message"); resolve(); };
});
} finally {
for (const fd of held) fs.closeSync(fd);
fs.unlinkSync(W);
}
console.error("SURVIVED");
`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe.skipIf(!isPosix)("lazy event-loop creation under EMFILE", () => {
test.concurrent("first fetch() rejects 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("rejected: FailedToOpenSocket");
// The retry must reach the normal connect path (loop recovered), not
// another FailedToOpenSocket.
expect(stderr).toContain("retry rejected: ConnectionRefused");
});

test.concurrent("first new Worker() fires an error event 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(), workerFixture],
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("worker error: Worker initialization failed: EMFILE");
});
});
Loading