Skip to content

fix(spawnSync): throw instead of segfaulting when event-loop creation fails - #35033

Open
robobun wants to merge 4 commits into
mainfrom
farm/3482e701/spawnsync-loop-init-null
Open

fix(spawnSync): throw instead of segfaulting when event-loop creation fails#35033
robobun wants to merge 4 commits into
mainfrom
farm/3482e701/spawnsync-loop-init-null

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Crash

Windows only. Bun.spawnSync segfaults the whole process when uv_loop_new() fails. Crash chain:

us_create_loop -> uv_prepare_init -> uv__queue_insert_tail (fault at +0x18)
  from subprocess_spawn_sync

Cause

packages/bun-usockets/src/eventing/libuv.c::us_create_loop:

loop->uv_loop = hint ? hint : uv_loop_new();     // NULL if uv_loop_init fails
loop->uv_pre = us_malloc(sizeof(uv_prepare_t));
uv_prepare_init(loop->uv_loop, loop->uv_pre);    // NULL deref

uv_loop_new() returns NULL when uv_loop_init fails, which happens when CreateIoCompletionPort returns ERROR_INSUFFICIENT_RESOURCES under handle or non-paged-pool exhaustion. The segfault happens inside us_create_loop so the null-assert in WindowsLoop::create never fires. Bun.spawnSync (SpawnSyncEventLoop::init -> WindowsLoop::create) is the only hint == NULL caller on Windows and constructs a fresh loop per VM, so a spawnSync under resource pressure takes down the process instead of failing that one call.

The POSIX backend has the same shape: epoll_create1/kqueue returning -1 was never checked, and on Linux the wakeup eventfd() failure aborted with BUN_PANIC.

Fix

us_create_loop now returns NULL when the backing event provider cannot be created, cleaning up whatever it allocated:

  • libuv: null-check uv_loop_new()
  • epoll/kqueue: check epoll_create1/kqueue, and propagate the wakeup-async failure (eventfd/mach_port) through a now-fallible us_internal_loop_data_init

uWS::Loop::create/get return nullptr instead of dereferencing NULL.

Rust side:

  • PosixLoop::create / WindowsLoop::create return Option<NonNull<Loop>> instead of asserting
  • SpawnSyncEventLoop::init returns false on failure (storage left uninitialized)
  • RareData::spawn_sync_event_loop returns Option<&mut SpawnSyncEventLoop>; not cached on failure so a later call retries
  • spawn_maybe_sync throws a SystemError (code: "EMFILE", syscall: "uv_loop_init") at the one spawnSync call

The uWS::Loop::get() callers (HTTP client thread, Worker VM init) are not changed here; #33850 handles those. The shared usockets changes overlap with that PR, which is currently conflicting with main.

Verification

test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts (POSIX-only) runs a child under ulimit -n 512, fills the fd table, and calls Bun.spawnSync. On main the child aborts with panic: eventfd() failed during loop init (exit 134); with this change the spawnSync throws a catchable EMFILE error, and a retry after freeing descriptors succeeds.

The Windows path (uv_loop_new() NULL) routes through the same WindowsLoop::create -> SpawnSyncEventLoop::init -> spawn_maybe_sync propagation. Built and ran the child_process spawnSync suite on Windows x64.


[review] gate passed · iteration 1 · 10 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts
bun test v1.4.0 (27677661a)

test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts:
killed 1 dangling process
(fail) Bun.spawnSync event-loop creation under EMFILE > throws instead of aborting the process [5006.01ms]
  ^ this test timed out after 5000ms.

 0 pass
 1 fail
Ran 1 test across 1 file. [7.10s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (1d0f5c27f)

test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts:
(pass) Bun.spawnSync event-loop creation under EMFILE > throws instead of aborting the process [29.41ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [216.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts
bun test v1.4.0 (27677661a)

test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts:
(pass) Bun.spawnSync event-loop creation under EMFILE > throws instead of aborting the process [1360.17ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [3.42s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 786ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] cc obj/packages/bun-usockets/src/bsd.c.o
[2/24] cc obj/packages/bun-usockets/src/eventing/libuv.c.o
[3/24] cc obj/packages/bun-usockets/src/context.c.o
[4/24] cc obj/packages/bun-usockets/src/fault_inject.c.o
[5/24] cc obj/packages/bun-usockets/src/loop.c.o
[6/24] cc obj/packages/bun-usockets/src/quic.c.o
[7/24] cc obj/packages/bun-usockets/src/crypto/openssl.c.o
[8/24] cc obj/packages/bun-usockets/src/udp.c.o
[9/24] cc obj/packages/bun-usockets/src/socket.c.o
[10/24] cxx obj/unified/UnifiedSource-packages_bun_usockets_src_crypto-0.cpp.o
[11/24] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[12/24] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[13/24] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[14/24] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[15/24] cc obj/packages/bun-usockets/src/eventing/epoll_kqueue.c.o
[16/24] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[17/24] cxx obj/src/jsc/bindings/bindings.cpp.o
[18/24] cxx obj/unified/UnifiedSource-sr
... (truncated)
diff hotspot
packages/bun-usockets/src/eventing/epoll_kqueue.c  | 67 +++++++++++++++-------
 packages/bun-usockets/src/eventing/libuv.c         | 13 ++++-
 packages/bun-usockets/src/internal/internal.h      |  8 +--
 packages/bun-usockets/src/loop.c                   | 14 ++++-
 packages/bun-uws/src/Loop.h                        | 16 +++++-
 src/event_loop/SpawnSyncEventLoop.rs               | 19 +++---
 src/jsc/rare_data.rs                               | 21 +++++--
 src/runtime/api/bun/js_bun_spawn_bindings.rs       | 43 +++++++++++++-
 src/uws_sys/Loop.rs                                | 15 +++--
 .../js/bun/spawn/spawn-sync-loop-init-fail.test.ts | 65 +++++++++++++++++++++
 10 files changed, 229 insertions(+), 52 deletions(-)

gate history · 4 passed · 0 rejected · iteration 1

evidence per changed file
file                                                 reads  edits  tests
packages/bun-usockets/src/eventing/epoll_kqueue.c        4      6      0
packages/bun-usockets/src/eventing/libuv.c               2      4      0
packages/bun-usockets/src/internal/internal.h            1      1      0
packages/bun-usockets/src/loop.c                         1      1      0
packages/bun-uws/src/Loop.h                              1      1      0
src/event_loop/SpawnSyncEventLoop.rs                     2      2      0
src/jsc/rare_data.rs                                     2      2      0
src/runtime/api/bun/js_bun_spawn_bindings.rs             4      6      0
src/uws_sys/Loop.rs                                      1      1      0
test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts      0      3      0

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

SpawnSync loop failure handling

Layer / File(s) Summary
Native loop cleanup
packages/bun-usockets/src/eventing/*, packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/loop.c
Native loop and async initialization now returns failures while releasing partially allocated resources.
Fallible loop creation APIs
src/uws_sys/Loop.rs, packages/bun-uws/src/Loop.h
Loop creation uses nullable results represented as Option<NonNull<_>>, with retryable lazy initialization.
Fallible SpawnSync event-loop setup
src/event_loop/SpawnSyncEventLoop.rs, src/jsc/rare_data.rs
SpawnSync loop initialization reports failure and caches only successfully initialized loops.
Catchable spawnSync failure
src/runtime/api/bun/js_bun_spawn_bindings.rs, test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts
Bun.spawnSync throws a structured EMFILE error when loop creation fails, and the test verifies recovery after descriptors are released.

Possibly related PRs

  • oven-sh/bun#33832: Updates the same spawn_maybe_sync area for stdio draining and pipe EOF cleanup.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main change: spawnSync now throws instead of crashing when event-loop creation fails.
Description check ✅ Passed The description clearly explains the crash, cause, fix, and verification, covering the required substance despite using different headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:01 PM PT - Jul 21st, 2026

@robobun, your commit 27677661a16d810154ebe0bf15f8414fc6ede754 passed in Build #77377! 🎉


🧪   To try this PR locally:

bunx bun-pr 35033

That installs a local version of the PR into your bun-35033 executable, so you can run:

bun-35033 --bun

Comment thread src/event_loop/SpawnSyncEventLoop.rs
Comment thread packages/bun-usockets/src/eventing/epoll_kqueue.c
… use absolute argv0 in test

- macOS us_internal_create_async: free cb + machport_buf and drop the
  receive right on mach_port_* failure, now that the NULL return is
  survivable
- SpawnSyncEventLoop::uws_loop doc: the non-null guarantee is via
  init()'s early-false return, not an assert
- test: absolute /bin/sh skips which_for_spawn, which fails with ENOENT
  under EMFILE on musl before the event loop is created

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-usockets/src/eventing/libuv.c`:
- Around line 294-295: Check the return value of us_internal_loop_data_init in
the loop initialization path and propagate its -1 failure result immediately.
Preserve the function’s existing cleanup behavior and prevent returning a loop
with loop->data.wakeup_async unset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8688de43-f1f6-4613-87fe-b4684a7d9648

📥 Commits

Reviewing files that changed from the base of the PR and between 85ddc95 and 106b2a9.

📒 Files selected for processing (10)
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/eventing/libuv.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/loop.c
  • packages/bun-uws/src/Loop.h
  • src/event_loop/SpawnSyncEventLoop.rs
  • src/jsc/rare_data.rs
  • src/runtime/api/bun/js_bun_spawn_bindings.rs
  • src/uws_sys/Loop.rs
  • test/js/bun/spawn/spawn-sync-loop-init-fail.test.ts

Comment thread packages/bun-usockets/src/eventing/libuv.c

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I checked the scopeguard ordering around the new early-return in spawn_maybe_sync: the cleanup defer! (with its .expect("cached by the IS_SYNC prepare above")) is armed after the fallible spawn_sync_event_loop() call, so the expect is sound; and the earlier defer_guard (abort_signal unref) and memfd_guard are already armed at that point, so they still fire on the new error path. spawn_options is constructed after this block, so nothing to deinit() there.

Extended reasoning...

The new early return at the loop-init failure sits between two sets of scopeguards. I traced the ordering: defer_guard (abort_signal/terminal_info cleanup) and memfd_guard are armed before it and correctly run; the sync-loop cleanup defer and spawn_options are both after it and correctly do not. The two .expect("cached by the IS_SYNC prepare above") sites are only reachable after prepare() succeeded and cached the loop in rare_data.

Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs
…nit error

The real errno/syscall is not propagated through us_create_loop's NULL
return; name the platform's event-provider syscall rather than a
Windows-only one on POSIX. Also document why us_internal_loop_data_init
cannot fail on the libuv backend.
Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs
Comment thread packages/bun-usockets/src/eventing/epoll_kqueue.c Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five earlier findings have been addressed and I don't see anything further wrong on this revision, but this touches core event-loop initialization across three C backends (epoll/kqueue/libuv), changes the us_internal_loop_data_init signature, and makes uWS::Loop::get() newly nullable with callers deferred to #33850 — worth a human look before landing.

What was reviewed:

  • Cleanup on each new failure path (epoll fd, kqueue fd, eventfd poll, mach_port receive+send rights, libuv uv_loop, recv_buf/send_buf, num_polls) — all paired.
  • rare_data::spawn_sync_event_loop not caching on failure, so the two later .expect("cached by the IS_SYNC prepare above") sites are sound.
  • The Windows extra_fds.deinit() on the new early return, and that stdio[0..2] are still user-facing Stdio at that point.
  • The libuv (void)us_internal_loop_data_init cast is dead-path on that backend (its us_internal_create_async acquires no OS resource).
Extended reasoning...

Overview

The PR converts a segfault/panic in Bun.spawnSync's isolated-event-loop creation into a catchable JS error. It threads a NULL/-1 failure return through us_create_loop (all three backends: epoll, kqueue, libuv), us_internal_loop_data_init, uWS::Loop::create/get, PosixLoop/WindowsLoop::create, SpawnSyncEventLoop::init, and RareData::spawn_sync_event_loop, terminating in a thrown SystemError in spawn_maybe_sync. Ten files, ~220 lines net.

Security risks

None identified. The change is failure-path plumbing under resource exhaustion; no new user-controlled input reaches native code, and the error path only throws.

Level of scrutiny

High. us_create_loop and us_internal_loop_data_init are the core event-loop bootstrap for every uSockets consumer in the process (HTTP client thread, workers, spawnSync). The macOS us_internal_create_async mach_port block was substantially restructured (three linear early-returns → nested success ladder + shared cleanup tail), and mach_port failure paths are essentially untestable. uWS::Loop::get() now returns nullptr under exhaustion; the PR explicitly leaves those callers to a separate PR (#33850, noted as conflicting with main). The Windows failure case (uv_loop_new() NULL) is not exercised by the new test — only the POSIX eventfd/epoll path is.

Other factors

Five prior findings from earlier passes (macOS cb/machport_buf leak, stale SAFETY comment, misleading syscall string, Windows extra_fds pipe leak, mach send-right dead-name leak) were each addressed in follow-up commits and verified against the final diff. The test is hermetic (ulimit + local /bin/sh), asserts a positive retry after freeing fds, and the evidence block shows it timing out on the pre-fix ASAN build. Given the cross-backend C changes, the acknowledged overlap with an in-flight conflicting PR, and the untested Windows/macOS failure branches, a human reviewer should sign off.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #39641 touches the same three sites in epoll_kqueue.c (epoll_create1() / kqueue() unchecked, eventfd() BUN_PANIC) with the opposite strategy. It keeps the failure fatal and reports EMFILE / ENFILE as the file descriptor limit error with exit 1, which follows the review on #33850. If #39641 lands, the three usockets hunks here are superseded. The Windows uv_loop_new() NULL check in libuv.c is separate and still stands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants