Skip to content

usockets: drain + back off on accept() EMFILE instead of spinning - #32705

Draft
robobun wants to merge 3 commits into
mainfrom
farm/36c27092/usockets-accept-emfile-backoff
Draft

robobun wants to merge 3 commits into
mainfrom
farm/36c27092/usockets-accept-emfile-backoff

Conversation

@robobun

@robobun robobun commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

When Bun.serve (or any usockets listener) is at the process fd limit and a connection is waiting in the listen backlog, accept() returns EMFILE. The listener poll is level-triggered, so the event loop wakes immediately, fails accept() again, and repeats: the process pegs a core for as long as the fd limit is hit, serves nothing, and surfaces no error.

strace on the spin (Bun.serve under prlimit --nofile=64, 200 held connections; ~98k EMFILE returns in ~10s, ~80% of a core):

accept4(8, ..., SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EMFILE (Too many open files)
epoll_pwait2(3, [{events=EPOLLIN, data={...}}], 1024, {tv_sec=0, tv_nsec=98981071}, [], 8) = 1
accept4(8, ..., SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EMFILE (Too many open files)
epoll_pwait2(3, [{events=EPOLLIN, data={...}}], 1024, {tv_sec=0, tv_nsec=98930609}, [], 8) = 1
accept4(8, ..., SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EMFILE (Too many open files)

The backoff was a literal todo in packages/bun-usockets/src/loop.c:

LIBUS_SOCKET_DESCRIPTOR client_fd = bsd_accept_socket(us_poll_fd(p), &addr);
if (client_fd == LIBUS_SOCKET_ERROR) {
    /* Todo: start timer here */
}

Fix

(a) Reserve-fd drain (POSIX): hold one spare fd (open("/dev/null", O_RDONLY|O_CLOEXEC)) per loop. On EMFILE, close it, accept()+close() each queued connection so the peer sees a clean refusal instead of a connection that never answers, then reopen the reserve. Draining the backlog clears the level-triggered readable so the loop returns to idle without touching the listener poll. Same trick as libuv's uv__emfile_trick.

(b) Timed backoff fallback: if the drain cannot make progress (ENFILE, reserve spent, or a concurrent thread stole the freed slot), us_poll_change(listener, loop, 0) and schedule a re-arm ~200 ms later via a new accept_rearm_next_tick_ns deadline on us_internal_loop_data_t, folded into the epoll_pwait2/kevent timeout the same way sweep_next_tick_ns is (no timerfd). On Windows/libuv there is no reserve-fd path (sockets are a separate table from file handles), so WSAEMFILE/WSAENOBUFS goes straight to backoff via the sweep timer.

A one-shot warning is written to stderr so the condition is no longer silent:

warn: accept() failed with EMFILE; out of file descriptors, refusing queued connections. Raise `ulimit -n` if this is unexpected.

Verification

test/js/bun/http/serve-listen-emfile.test.ts spawns a child under ulimit -n 128, starts Bun.serve, exhausts the remaining fds by opening /dev/null until EMFILE, then the parent puts a connection in the backlog (sending a request so TCP_DEFER_ACCEPT lets the listener poll fire). The child samples process.cpuUsage() across an 800 ms window.

USE_SYSTEM_BUN=1 bun test test/js/bun/http/serve-listen-emfile.test.ts
  -> FAIL  { cpuMs: 803.51, wallMs: 800, spin: true, stderr: "" }

bun bd test test/js/bun/http/serve-listen-emfile.test.ts
  -> PASS  { spin: false, stderr: "...accept() failed with EMFILE..." }

POSIX-only test (needs /bin/sh, ulimit, /dev/null); the Windows path goes through the same us_internal_dispatch_ready_poll branch.


[review] gate passed · iteration 5 · 8 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/http/serve-listen-emfile.test.ts
bun test v1.4.0 (7ce30daf4)

test/js/bun/http/serve-listen-emfile.test.ts:
112 | 
113 |   // A spinning loop burns roughly wallMs of CPU. The fixed loop either drains
114 |   // the backlog with its reserve fd or idles in epoll_wait waiting for the
115 |   // re-arm deadline, consuming ~0. stderr should carry the one-shot warning;
116 |   // other lanes may add their own noise so match loosely.
117 |   expect({ ...sample, spin: sample.cpuMs > sample.wallMs / 2, stderr }).toEqual({
                                                                              ^
error: expect(received).toEqual(expected)

  {
-   "cpuMs": Any<Number>,
-   "spin": false,
-   "stderr": StringContaining "accept() failed with EMFILE",
+   "cpuMs": 910.325,
+   "spin": true,
+   "stderr": "",
    "wallMs": 800,
  }

- Expected  - 3
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/bun/http/serve-listen-emfile.test.ts:117:73)
(fail) Bun.serve does not spin at 100% CPU when accept() fails with EMFILE [2332.
... (truncated)

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

test/js/bun/http/serve-listen-emfile.test.ts:
(pass) Bun.serve does not spin at 100% CPU when accept() fails with EMFILE [843.77ms]

 1 pass
 0 fail
 4 expect() calls
Ran 1 test across 1 file. [1004.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/http/serve-listen-emfile.test.ts
bun test v1.4.0 (7ce30daf4)

test/js/bun/http/serve-listen-emfile.test.ts:
(pass) Bun.serve does not spin at 100% CPU when accept() fails with EMFILE [2400.24ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 700ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/25] cc obj/packages/bun-usockets/src/crypto/openssl.c.o
[2/25] cc obj/packages/bun-usockets/src/context.c.o
[3/25] cc obj/packages/bun-usockets/src/bsd.c.o
[4/25] cc obj/packages/bun-usockets/src/eventing/libuv.c.o
[5/25] cc obj/packages/bun-usockets/src/fault_inject.c.o
[6/25] cc obj/packages/bun-usockets/src/loop.c.o
[7/25] cc obj/packages/bun-usockets/src/eventing/epoll_kqueue.c.o
[8/25] cc obj/packages/bun-usockets/src/node_quic_shim.c.o
[9/25] cc obj/packages/bun-usockets/src/socket.c.o
[10/25] cc obj/packages/bun-usockets/src/quic.c.o
[11/25] cc obj/packages/bun-usockets/src/udp.c.o
[12/25] cxx obj/unified/UnifiedSource-packages_bun_usockets_src_crypto-0.cpp.o
[13/25] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[14/25] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[15/25] cxx obj/src/jsc/bindings/bindings.cpp.o
[16/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[17/25] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[18/25] cxx obj/unified/UnifiedSource-src_j
... (truncated)
diff hotspot
packages/bun-usockets/src/context.c               |   7 ++
 packages/bun-usockets/src/eventing/epoll_kqueue.c |   2 +
 packages/bun-usockets/src/internal/internal.h     |   6 +
 packages/bun-usockets/src/internal/loop_data.h    |   7 ++
 packages/bun-usockets/src/loop.c                  | 139 ++++++++++++++++++++--
 src/runtime/server/server_body.rs                 |  27 +++++
 src/uws_sys/InternalLoopData.rs                   |   4 +
 test/js/bun/http/serve-listen-emfile.test.ts      | 124 +++++++++++++++++++
 8 files changed, 307 insertions(+), 9 deletions(-)

gate history · 2 passed · 0 rejected · iteration 5

evidence per changed file
file                                               reads  edits  tests
packages/bun-usockets/src/context.c                    1      2      9
packages/bun-usockets/src/eventing/epoll_kqueue.c      1      0      9
packages/bun-usockets/src/internal/internal.h          2      3      9
packages/bun-usockets/src/internal/loop_data.h         1      0      9
packages/bun-usockets/src/loop.c                       6      6      9
src/runtime/server/server_body.rs                      2      2      9
src/uws_sys/InternalLoopData.rs                        1      0      9
test/js/bun/http/serve-listen-emfile.test.ts           3      5      9

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 7cdaec60-285a-4c83-b65b-9f6e037b672c

📥 Commits

Reviewing files that changed from the base of the PR and between 9fd27d7 and 402e15c.

📒 Files selected for processing (8)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/loop_data.h
  • packages/bun-usockets/src/loop.c
  • src/runtime/server/server_body.rs
  • src/uws_sys/InternalLoopData.rs
  • test/js/bun/http/serve-listen-emfile.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The accept loop now handles EMFILE and ENFILE by draining queued connections when possible or pausing the affected listener. The loop re-arms paused listeners after a delay. A one-time warning and a POSIX regression test cover the behavior.

Changes

EMFILE listener throttling

Layer / File(s) Summary
Accept exhaustion handling
packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/internal/loop_data.h, packages/bun-usockets/src/loop.c
The loop tracks paused listeners and a reserve descriptor. On descriptor exhaustion, it drains queued connections when possible. Otherwise, it pauses the listener and schedules a re-arm.
Re-arm scheduling and listener lifecycle
packages/bun-usockets/src/loop.c, packages/bun-usockets/src/eventing/epoll_kqueue.c, packages/bun-usockets/src/context.c
Poll timeouts and loop execution account for the re-arm deadline. The libuv sweep callback resumes paused listeners. Listener initialization and close handling update the pause state.
Warning and regression coverage
src/runtime/server/server_body.rs, src/uws_sys/InternalLoopData.rs, test/js/bun/http/serve-listen-emfile.test.ts
The runtime logs a one-time warning for accept-side descriptor exhaustion. The POSIX test forces descriptor exhaustion, queues a connection, checks CPU use, and verifies the warning and child exit.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 402e1

The change adds descriptor-exhaustion backoff and POSIX regression coverage. No concrete merge-blocking issue was established; merge after normal checks.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed No active directly linked issue remains. Issue #3271 is closed and concerns cold bun install memory use. Its coding requirements do not apply to this pull request.
Out of Scope Changes check ✅ Passed The changes stay within the stated accept-loop scope. They add POSIX reserve-fd draining, timed listener re-arm, Windows backoff, warning handling, loop integration, and a regression test for EMFILE…
Title check ✅ Passed The title clearly summarizes the main change: draining and backing off when accept() returns EMFILE to prevent CPU spinning.
Description check ✅ Passed The description explains the problem, implementation, platform-specific behavior, warning, regression test, and verification results. It provides the information requested by the template, although it…

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

@robobun

robobun commented Jun 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:32 AM PT - Oct 1st, 2026

❌ @robobun, your commit 402e15c has 1 failures in Build #122339 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32705

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

bun-32705 --bun

@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: 2

🤖 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 `@test/js/bun/http/serve-listen-emfile.test.ts`:
- Around line 11-115: This Bun.serve regression test should not live in a new
standalone file; move the EMFILE coverage into the existing serve test module
and keep it alongside the other Bun.serve cases. Relocate the test body into
test/js/bun/http/serve.test.ts, preserving the current test name and logic, so
it extends the existing serve coverage rather than creating a sibling file.
- Around line 94-100: The test is starting the CPU sample before the HTTP
request is guaranteed to be flushed from the socket. Update the flow around the
connect/write sequence in serve-listen-emfile.test.ts so the request write is
awaited or confirmed flushed before calling proc.stdin.write("go\n"), using the
existing sock/once path to wait for the actual send condition instead of relying
on timing. Keep the fix localized to the request setup in this test and make the
failure path reject rather than race.
🪄 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: f5578271-40f9-4dc6-b126-67bb107d092b

📥 Commits

Reviewing files that changed from the base of the PR and between 5621c5d and 9fd27d7.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/loop.c
  • test/js/bun/http/serve-listen-emfile.test.ts

Comment thread test/js/bun/http/serve-listen-emfile.test.ts
Comment thread test/js/bun/http/serve-listen-emfile.test.ts
Comment thread test/js/bun/http/serve-listen-emfile.test.ts

@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.

No bugs found and the approach looks sound, but this touches the core uSockets accept dispatch and sweep-timer refcounting, so it's worth a human pass on the event-loop interactions (and the untested Windows WSAEMFILE branch).

Extended reasoning...

Overview

This PR fixes a 100%-CPU spin when accept() returns EMFILE/ENFILE on a level-triggered listener poll. It adds an emfile_paused flag to us_listen_socket_t, disarms the listener poll on EMFILE, takes a sweep-timer ref, and re-arms the poll on the next sweep tick. Touched files: packages/bun-usockets/src/loop.c (accept dispatch + sweep callback + new pause/resume helpers), context.c (init + close-time ref release), internal.h (struct field), and a new POSIX-only regression test.

Security risks

None identified. No new attack surface; the change only alters error-path behavior for accept failures and does not touch auth, parsing, or memory ownership.

Level of scrutiny

High. This is production-critical event-loop C code in the hot accept path of every Bun.serve listener. Specific areas that warrant human eyes:

  • Sweep-timer refcount balance: pause does enable_sweep_timer (+1), resume/close do disable_sweep_timer (-1). The guards (if (ls->emfile_paused) return on pause; flag cleared before disable on both resume and close) look correct, but an imbalance here would either leak the timer or prematurely disarm it and break all socket timeouts process-wide.
  • Resume runs inside the sweep-timer callback and may drop the refcount to 0, which calls us_timer_set(..., 0, 0) from inside the timer's own callback. This is likely fine but is the kind of edge a maintainer should confirm for both the epoll/kqueue and libuv backends.
  • LIBUS_ERR (errno / WSAGetLastError) read after the accept loop: relies on the failing bsd_accept_socket being the last errno-setting call before the check, which holds in the current control flow but is subtle.
  • Windows path (WSAEMFILE/WSAENOBUFS) is asserted to go through the same branch but is not exercised by the new test (POSIX-only via /bin/sh + ulimit).

Other factors

The bug-hunting system found nothing. CodeRabbit's two comments and my earlier stderr: "" nit have all been addressed and resolved. The implementation mirrors the well-known libuv/nginx pattern and the PR description is thorough. No CODEOWNERS cover packages/bun-usockets/. Still, given this is core event-loop machinery with cross-platform refcount invariants, I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. This PR is not ready to merge.

The review of 1 October on the rebased head (402e15c) found defects. I confirmed these by reading the code. I did not reproduce them yet.

  • The reserve descriptor leaks when loop initialization fails. us_internal_loop_data_init opens it (loop.c:242) before us_internal_create_async (loop.c:252). The failure branch does not close it.
  • Each POSIX event loop now holds one more descriptor, also in a process that never listens. Loop creation can fail one descriptor below the limit, where main succeeds. Bun.spawnSync and Worker create loops.
  • The drain loop has no bound. The re-arm ignores the result of us_poll_change. Windows re-arms on the 4 s sweep timer, POSIX after 200 ms.
  • serve-listen-emfile.test.ts reaches only the reserve-descriptor drain. The timed pause, the re-arm, and the Windows path have no test.

The shape of the fix is under review again. The two candidates are a timed pause of the listener poll with no reserve descriptor, and the reserve-descriptor drain that this PR has now. The next push follows that review and answers the open review threads.

Corrections to earlier text on this PR:

  • The Closes line in the PR body named pull request 3271. That pull request is unrelated (bun install memory, 2023). I removed the line. No issue is linked to this PR. The same number is still in the message of commit ad51466 and in a comment in the test file. The next push removes the comment.
  • An earlier version of this comment said that no-orphans.test.ts was also red on main. On main build 83238 it failed two times and then passed on a retry. serve.test.ts was red on that build.
  • An earlier version of this comment said that serve-listen-emfile.test.ts passed on every POSIX lane. That was an inference from the failure annotations. I did not read the pass lines, and on builds 64725 and 64761 the darwin 26 aarch64 lane ran no tests.

CI on 402e15c (build 122339): test/js/bun/spawn/spawn.test.ts is red on debian 13 x64-asan (an idle reader stopped at the highwater mark does not keep the process alive, 4 of 4 attempts). The same failure is on builds of five other branches on the same day (122341, 122338, 122336, 122316, 122314). It does not come from this PR.

@robobun
robobun force-pushed the farm/36c27092/usockets-accept-emfile-backoff branch from 599340d to 4887732 Compare July 27, 2026 19:37
Comment thread src/runtime/server/server_body.rs Outdated
@robobun robobun changed the title usockets: back off listen accept on EMFILE instead of spinning usockets: drain + back off on accept() EMFILE instead of spinning Jul 27, 2026

@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 also checked: the new InternalLoopData fields are mirrored in the same order/cfg on the Rust side; errno is not clobbered between the failing bsd_accept_socket and the us_internal_is_emfile(LIBUS_ERR) check (only a field read sits between them, and us_internal_accept_emfile captures err before calling into Rust); and the reserve fd is released in us_internal_loop_data_free. On kqueue, us_poll_change(ls, 0) adds a one-shot EVFILT_WRITE to the listen fd via kqueue_change's neither-readable-nor-writable branch — harmless since us_poll_events masks it to 0 in dispatch, but worth a glance.

Extended reasoning...

This PR touches the core usockets accept loop, adds per-loop fd state with a C/Rust struct mirror, and has divergent POSIX (reserve-fd drain + folded-timeout deadline) vs Windows/libuv (sweep-timer refcount) paths. The one finding is a nit (POSIX-only ulimit -n hint on Windows). I verified errno-capture ordering, struct-mirror layout for the new fields, reserve-fd lifecycle, and that the kqueue us_poll_change(0) quirk (adds a one-shot EVFILT_WRITE on the listener) is masked out before dispatch. Deferring because event-loop dispatch changes of this scope warrant maintainer eyes; nothing here is a blocker I can point to.

Comment thread src/runtime/server/server_body.rs

@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.

No bugs found, but deferring to a human — this touches the core usockets accept/event-loop path with three platform-specific branches (epoll, kqueue, libuv/Windows), adds a per-loop reserve fd, and changes the C/Rust us_internal_loop_data_t layout. Both of my earlier inline nits (exact-empty stderr assertion, POSIX-only ulimit hint on Windows) have been addressed.

What was reviewed:

  • LIBUS_ERR is read immediately after the failing bsd_accept_socket (no user callback runs in between), so errno is fresh.
  • us_poll_change(ls, loop, 0) on kqueue: the EVFILT_WRITE add on a listening socket fails with EINVAL under KEVENT_FLAG_ERROR_EVENTS and is silently ignored; the EVFILT_READ delete still lands, so the listener goes quiet as intended and re-arms cleanly.
  • InternalLoopData layout: the two new #[cfg(not(windows))] fields keep sweep_timer_count/wakeup_async at matching offsets on both POSIX and Windows (the Windows-only fin_deferred_count sits in what Rust sees as padding, unchanged by this PR).
  • Closing a paused listener on POSIX leaves accept_rearm_next_tick_ns set; the next re-arm walks zero listeners and clears it — no leak.
Extended reasoning...

Overview

The PR replaces a long-standing /* Todo: start timer here */ in bun-usockets' accept path: when accept() returns EMFILE/ENFILE, the level-triggered listener poll would spin the loop at 100% CPU. The fix has two parts: (a) a libuv-style reserve-fd drain on POSIX — hold one spare /dev/null fd per loop, close it under EMFILE, accept()+close() the backlog so peers see a clean refusal and the readable clears, then reopen; (b) a timed-backoff fallback that unpolls the listener and re-arms it ~200 ms later via a new accept_rearm_next_tick_ns deadline folded into the existing poll-timeout computation (no timerfd). Windows/libuv has no reserve-fd path and goes straight to backoff via the sweep timer. A one-shot stderr warning is emitted from Rust. Eight files touched: loop.c (core logic), epoll_kqueue.c (call the re-arm check each tick), context.c (init/close bookkeeping), internal.h/loop_data.h (new fields), InternalLoopData.rs (layout mirror), server_body.rs (warning), plus a new test.

Security risks

None identified. The change is defensive resource-exhaustion handling; no new user-controlled input reaches native code. The reserve fd is O_RDONLY|O_CLOEXEC on /dev/null and is opened/closed only by the loop thread. The drain loop is bounded by the listen backlog plus one (it stops on EAGAIN or any non-EMFILE error).

Level of scrutiny

High. This is the core accept dispatch in us_internal_dispatch_ready_poll — every Bun.serve/Bun.listen connection flows through it — and it has three platform arms (epoll, kqueue, libuv) with meaningfully different us_poll_change(…, 0) semantics. The us_internal_loop_data_t layout is mirrored by hand in Rust (#[repr(C)]), so field ordering/cfg-gating must match exactly. The test only exercises the POSIX path (skipIf(!isPosix)), so the Windows sweep-timer backoff and the kqueue unpoll/re-arm are reasoned about but not CI-verified.

Other factors

  • Both of my prior inline comments were applied (050f006: stderr: expect.any(String) → now expect.stringContaining(...) for the warning; 7ce30da: cfg-gated the ulimit -n hint off Windows). The comment-cop note on server_body.rs is also resolved — the doc comment on Bun__warnAcceptEMFILE is now one line.
  • I traced several edge cases and found them handled: a duplicate readable already queued in ready_polls[] re-enters us_internal_accept_emfile and hits the emfile_paused early-return; closing a paused listener on POSIX leaves the deadline set but the next accept_rearm_if_due finds no listeners and clears it; on kqueue, us_poll_change(ls, 0) adds a one-shot EVFILT_WRITE that fails EINVAL on a passive socket, which KEVENT_FLAG_ERROR_EVENTS reports back and us_poll_change ignores — net effect is the intended "read filter deleted, listener quiet".
  • The Rust InternalLoopData mirror checks out: on POSIX the two new fields (i64 + c_int) land sweep_timer_count at offset 20 and wakeup_async at 24, matching C; on Windows the new fields are cfg-gated out and the pre-existing layout (where C's fin_deferred_count occupies what Rust sees as pointer-alignment padding) is unchanged.
  • The test demonstrates fail-before/pass-after on both debug+ASAN and release, awaits the socket write flush before signalling the child (per the earlier CodeRabbit thread), and asserts on the observable warning string rather than exact stderr.

Given the scope — hot-path C in the event loop, hand-mirrored struct layouts, and platform branches without direct test coverage — this warrants a maintainer's eyes even though I found nothing wrong.

@robobun
robobun force-pushed the farm/36c27092/usockets-accept-emfile-backoff branch from 7ce30da to 496b55b Compare August 1, 2026 02:48

@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.

No bugs found, but this touches the core usockets accept path with per-platform branches (reserve-fd drain on epoll/kqueue, sweep-timer backoff on libuv), a new per-loop fd lifecycle, and an FFI struct-layout change — worth a maintainer's eyes.

What was reviewed:

  • Reserve-fd pairing: opened in us_internal_loop_data_init, closed in us_internal_loop_data_free, re-acquired on rearm; no leak on the drain path.
  • InternalLoopData Rust mirror: new POSIX-only fields inserted at the same offset/order as loop_data.h (i64/i64/c_int before sweep_timer_count).
  • us_poll_change(ls, loop, 0) on epoll registers only HUP/ERR (no EPOLLIN spin); on kqueue it deletes EVFILT_READ and adds a one-shot EVFILT_WRITE via the shared !readable && !writable branch — didn't find a concrete failure, but a listen-socket EVFILT_WRITE is unusual enough to flag.
  • libuv sweep-timer refcount: each emfile_paused enable is balanced by us_listen_socket_close or us_internal_accept_rearm_libuv; the 4 s LIBUS_TIMEOUT_GRANULARITY backoff vs. 200 ms on POSIX is a design choice to note.
Extended reasoning...

Overview

The PR replaces a long-standing /* Todo: start timer here */ in packages/bun-usockets/src/loop.c with a real EMFILE/ENFILE handler for the listener accept path. On POSIX it holds one /dev/null fd per loop (accept_reserve_fd), closes it under EMFILE to drain-and-close the backlog (libuv's uv__emfile_trick), and if that can't clear the level-triggered readable it disarms the listener poll and schedules a 200 ms re-arm folded into the epoll/kqueue timeout via a new accept_rearm_next_tick_ns deadline. On Windows/libuv it goes straight to disarm + sweep-timer re-arm. It also adds a one-shot stderr warning (Bun__warnAcceptEMFILE), an emfile_paused bit on us_listen_socket_t, mirrors the new loop-data fields in src/uws_sys/InternalLoopData.rs, and ships a POSIX-only regression test that spawns Bun.serve under ulimit -n 128, exhausts fds, and asserts CPU stays ~0 over an 800 ms window with the warning on stderr.

Security risks

None identified. The reserve fd is O_RDONLY|O_CLOEXEC on /dev/null, drained connections are immediately closed without dispatching to JS, and no untrusted input reaches new parsing. The change is defensive against a self-inflicted DoS (100% CPU spin), not attack-surface-expanding.

Level of scrutiny

High. This is the accept loop for every Bun.serve/Bun.listen socket, with three platform arms (epoll, kqueue, libuv) and an FFI struct-layout change that must stay byte-for-byte in sync with the C header. The reserve-fd lifecycle and the sweep-timer refcount balancing on the libuv path both need to be exactly right on every exit path. The kqueue interaction — us_poll_change(listener, loop, 0) routes through kqueue_change's !readable && !writable branch, which EV_ADD|EV_ONESHOTs an EVFILT_WRITE on the listening socket — is the same code path us_socket_pause uses on connected sockets, but a listen socket has no send buffer; I couldn't find a concrete failure and the darwin CI failures were reported as pre-existing on main, but it's worth a maintainer confirming that's benign.

Other factors

All prior review threads (CodeRabbit's write-flush race and file placement, my stderr: "" nit and the Windows ulimit hint) are addressed and resolved. The test is verified to fail under USE_SYSTEM_BUN=1 and pass on the PR build (ASAN + release). The Windows path's backoff granularity is LIBUS_TIMEOUT_GRANULARITY (4 s) rather than the POSIX 200 ms — reasonable given no reserve-fd trick exists there, but a design point a maintainer should sign off on. The 200 ms constant itself and whether accept_reserve_fd should be per-loop (one fd per worker) are similarly maintainer-level calls. Given the breadth of platform-specific event-loop mechanics here, deferring rather than approving.

When the process is at its fd limit and a connection is waiting in the
listen backlog, accept() returns EMFILE. The listener poll is
level-triggered, so the loop immediately wakes again, fails accept()
again, and pegs a core (~98k accept4/epoll_pwait2 alternations in 10s,
80% of a core measured).

Fix:

(a) Hold a reserve fd (open("/dev/null")) per loop. On EMFILE, close
    it, accept()+close() each queued connection so the peer sees a clean
    refusal instead of a connection that never answers, then reopen the
    reserve. Draining the backlog clears the level-triggered readable.
    Same trick as libuv's uv__emfile_trick.

(b) If the drain cannot make progress (reserve spent, ENFILE, or a
    concurrent thread stole the slot), unpoll the listener and schedule
    a re-arm 200ms later via a new accept_rearm_next_tick_ns deadline,
    folded into the epoll_pwait2/kevent timeout the same way
    sweep_next_tick_ns is. Windows (libuv) has no reserve-fd path and
    uses the sweep timer for backoff.

Emit a one-shot warning so the condition is no longer silent.

Closes #3271
The warning is called from the Windows libuv arm too (on WSAEMFILE /
WSAENOBUFS), where ulimit does not exist. Match crash_handler's
ProcessFdQuotaExceeded handling: keep the remedy on POSIX, drop it on
Windows and say 'socket handles' instead of 'file descriptors'.
@robobun
robobun force-pushed the farm/36c27092/usockets-accept-emfile-backoff branch from 496b55b to 402e15c Compare October 1, 2026 10:15
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (95690fc). The branch was 1598 commits behind. The previous head was 496b55b.

Four files had a conflict:

  • packages/bun-usockets/src/context.c and packages/bun-usockets/src/internal/internal.h: main added the accept_paused field at the position where this PR adds emfile_paused. The rebase keeps both fields.
  • packages/bun-usockets/src/loop.c: main now includes <fcntl.h> and <unistd.h>. The rebase drops the two includes that this PR added.
  • src/runtime/server/server_body.rs: main removed Server__setIdleTimeout and server_set_idle_timeout, which were adjacent to the new Bun__warnAcceptEMFILE. The rebase keeps that removal and adds only Bun__warnAcceptEMFILE.

Apart from the two includes, the diff against main has the same added and removed lines as before. The two new fields are at the same position in us_internal_loop_data_t and in the Rust mirror in src/uws_sys/InternalLoopData.rs.

Checked on a debug build of the rebased tree:

  • serve-listen-emfile.test.ts passes.
  • tcp-server.test.ts passes.
  • serve.test.ts has 327 passes and 2 failures. The 2 failures do not touch this change: one needs a user that is not root, and one needs network access that my machine blocks.

@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 findings, I also checked two things and ruled them out: LIBUS_ERR is read at loop.c:705 with no intervening syscall after the last failed bsd_accept_socket, so the errno gate is not clobbered; and us_listen_socket_close unlinks the listener from head_listen_sockets before the re-arm walk at loop.c:68 runs, so a listener closed while paused is never re-armed from freed memory.

Extended reasoning...

The change adds EMFILE drain/backoff state to the usockets loop (reserve fd, re-arm deadline, per-listener pause flag) across epoll/kqueue and libuv, a Rust-side struct mirror, a one-shot stderr warning, and a POSIX-only subprocess test. It touches no auth or crypto surface, but it is core event-loop and fd-lifecycle code with several confirmed findings (fd leak on loop-init failure, untested backoff path, stuck-listener risk on re-arm failure), and the hunt was cut off at the bug cap rather than running dry, so approval is not appropriate.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 packages/bun-usockets/src/loop.c — When loop creation fails, the process now leaks one fd per failed attempt, which the base branch does not do. us_internal_loop_data_init opens accept_reserve_fd at loop.c:242 before the fallible us_internal_create_async, and the failure branch at loop.c:253-259 frees recv_buf and send_buf but never closes the reserve fd, contradicting the comment at loop.c:233 that nothing is left allocated. Fix: close accept_reserve_fd (and reset it to -1) on the init failure path, or open it only after the wakeup async has been created, so every acquisition in loop-data init is released on the -1 return. [also at: packages/bun-usockets/src/loop.c:242 - Creating a loop now needs one more free descriptor, so a process that is one fd from its limit gets a failed Bun.spawnSync or Worker where the base branch succeeded. us_internal_loop_data_init opens accept_reserve_fd at loop.c:242 before the fallible us_internal_create_async at loop.c:252, so the reserve consumes the last slot the eventfd needed and init returns -1. SpawnSyncEventLoop.rs:151 turns that into a hard failure of the call.]

    Why this was flagged

    A process near its RLIMIT_NOFILE creates a new usockets loop (for example new Worker() on POSIX), reaching us_internal_loop_data_init via us_create_loop at epoll_kqueue.c:249. loop.c:242 opens /dev/null as the reserve fd first; it can take the last free slot. us_internal_create_async then fails (eventfd at epoll_kqueue.c:811 returns -1 with EMFILE) and the branch at loop.c:253-259 frees recv_buf and send_buf and returns -1 without closing loop->data.accept_reserve_fd. The caller at epoll_kqueue.c:249-253 closes loop->fd and frees the loop, so the reserve fd has no owner left and stays open for the life of the process. On the base branch there is no reserve fd, so a failed loop creation leaves no fd behind and the eventfd itself had one more slot available. Each retry of loop creation under fd pressure now leaks another fd, making the shortage worse. The comment at loop.c:233 claims nothing is left allocated, but the new field is not covered by that cleanup.

    Verification: Triggered when a POSIX (epoll) loop is created at RLIMIT_NOFILE so that open("/dev/null") succeeds but the subsequent eventfd() fails. packages/bun-usockets/src/loop.c:242 loop->data.accept_reserve_fd = us_internal_open_accept_reserve_fd(); runs before the fallible us_internal_create_async at loop.c:252. The failure branch at loop.c:253-259 has no close(loop->data.accept_reserve_fd); the base branch had no reserve fd.

Comment on lines +206 to +212

if (ls->emfile_paused) return;
us_poll_change((struct us_poll_t *) ls, loop, 0);
ls->emfile_paused = 1;
long long deadline = (long long) us_internal_monotonic_ns() + LIBUS_ACCEPT_REARM_NS;
if (loop->data.accept_rearm_next_tick_ns < 0 || deadline < loop->data.accept_rearm_next_tick_ns) {
loop->data.accept_rearm_next_tick_ns = deadline;

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.

🔴 The timed backoff that actually stops the spin when the reserve-fd trick cannot help, and the whole Windows/libuv path, ship with no test, so a break there merges green. The new test only reaches the drain branch at loop.c:190-203 (reserve fd still held, EMFILE); the unpoll + accept_rearm_next_tick_ns re-arm at loop.c:206-212 and us_internal_accept_emfile at loop.c:103-109 never run under test. Fix: add a case that forces the fallback, e.g. socketFaultInjection.set({ syscall: "accept", action: "errno", errno: , repeat: -1 }) from bun:internal-for-testing, assert low CPU while armed and that a client connects after fault.clear(), and run it on Windows too (no ulimit needed). [also at: packages/bun-usockets/src/loop.c:213 - nit: The timed-backoff fallback and its re-arm path are not exercised by any test, so a regression there would land silently. serve-listen-emfile.test.ts only reaches the reserve-fd drain (loop.c:190-204), which returns before loop.c:207; the pause at loop.c:208-213, us_internal_accept_rearm_if_due, and the libuv sweep-timer branch could be deleted and the suite still passes.; test/js/bun/http/serve-listen-emfile.test.ts:97 - Maintainers get no test for the pause/re-arm half of this fix, so a regression there (listener never resumed) merges green.]

Why this was flagged

test/js/bun/http/serve-listen-emfile.test.ts:14 is skipIf(!isPosix) and exhausts fds with openSync while the loop's reserve fd is still open, so in the child the first EMFILE takes the drain branch at loop.c:190-203, bsd_accept_socket succeeds once, and drain_err is EAGAIN, returning before loop.c:206. Nothing exercises us_poll_change(ls, loop, 0) at loop.c:208, the deadline at loop.c:210-213, us_internal_accept_rearm_if_due at loop.c:216-228, or the libuv arm at loop.c:103-114 and sweep_timer_cb at loop.c:550-553. Deleting the rearm call at epoll_kqueue.c:467/560 leaves every paused listener dead forever and no test fails. On base there is no such code; after merging, a regression in the fallback silently turns a spinning server into one that never accepts again (ENFILE, reserve open failure, or any Windows WSAEMFILE).

Verification: In test/js/bun/http/serve-listen-emfile.test.ts:14 (test.skipIf(!isPosix)), the reserve fd is still held when the single backlog connection triggers EMFILE, so us_internal_accept_emfile takes loop.c:190, bsd_accept_socket succeeds once and the function returns at loop.c:202, before loop.c:205-212. Deleting lines 205-212 or 215-227 leaves the suite green. The libuv path (loop.c:102-113, 551) is skipped entirely.

Comment on lines +103 to +109
void us_internal_accept_emfile(struct us_listen_socket_t *ls, struct us_loop_t *loop) {
Bun__warnAcceptEMFILE(LIBUS_ERR);
if (ls->emfile_paused) return;
us_poll_change((struct us_poll_t *) ls, loop, 0);
ls->emfile_paused = 1;
us_internal_enable_sweep_timer(loop);
}

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.

🟡 (optional) Windows servers that hit a transient WSAEMFILE/WSAENOBUFS now stop accepting for up to 4 seconds, where the base branch resumed as soon as a handle freed. The libuv arm of us_internal_accept_emfile at loop.c:103-109 unpolls the listener and relies on sweep_timer_cb to re-arm it, but that timer fires on the LIBUS_TIMEOUT_GRANULARITY cadence (4s, libusockets.h:100), so queued clients wait 0-4s even after handles are available. Fix: re-arm paused listeners on the same ~200ms backoff as POSIX on every backend, e.g. a dedicated one-shot us_create_timer with LIBUS_ACCEPT_REARM_NS, instead of piggybacking on the 4s sweep timer. [also at: packages/bun-usockets/src/loop.c:108 - On Windows a listener that hits WSAEMFILE/WSAENOBUFS stops accepting for up to 4 seconds, not the ~200 ms the PR describes.]

Why this was flagged

On Windows, bsd_accept_socket fails with WSAENOBUFS or WSAEMFILE (us_internal_is_emfile at loop.c:55-61) during a burst, reached from us_internal_dispatch_ready_poll at loop.c:701-705. The libuv us_internal_accept_emfile at loop.c:103-109 calls us_poll_change(ls, loop, 0) and us_internal_enable_sweep_timer. us_internal_enable_sweep_timer at loop.c:86-92 arms sweep_timer with period LIBUS_TIMEOUT_GRANULARITY * 1000 = 4000 ms (libusockets.h:100), and if sweep_timer_count was already >0 the existing 4s cadence is untouched. The only re-arm is us_internal_accept_rearm_libuv called from sweep_timer_cb at loop.c:551, so the listener stays unpolled until the next sweep tick, anywhere from 0 to 4 s later. The base branch kept the listener polled and accepted the queued connection on the very next loop iteration after a handle freed, so clients that arrived during a short handle shortage now see several seconds of connect latency instead of milliseconds. POSIX uses LIBUS_ACCEPT_REARM_NS (200 ms) at loop.c:169, so this is platform-parity only on paper.

Verification: Trigger: on Windows (LIBUS_USE_LIBUV), bsd_accept_socket fails with WSAEMFILE or WSAENOBUFS while the backlog is non-empty. The only path that re-arms a libuv-paused listener is sweep_timer_cb (loop.c:551-553), with period 4000 ms (libusockets.h:100). On base the listener stayed polled and accepted the instant a handle freed; after this change Windows clients wait 0-4 s after recovery.

Comment on lines +223 to +227
loop->data.accept_rearm_next_tick_ns = -1;
if (loop->data.accept_reserve_fd == -1) {
loop->data.accept_reserve_fd = us_internal_open_accept_reserve_fd();
}
us_internal_resume_emfile_paused_listeners(loop);

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.

🟡 (optional) A server whose listener was paused on EMFILE can silently stop accepting forever if the re-arm's epoll/kqueue registration fails, whereas the base branch never unregisters the listener. us_internal_accept_rearm_if_due at loop.c:223 clears accept_rearm_next_tick_ns and loop.c:70-71 clears emfile_paused before us_poll_change, whose return code is discarded. On kqueue the pause deleted the EVFILT_READ knote (kqueue_change EV_DELETE, epoll_kqueue.c:629), so the re-arm is a fresh EV_ADD that can fail with ENOMEM under exactly the resource pressure that caused the pause. Fix: check the us_poll_change rc on every re-arm site (POSIX loop.c:71 and the libuv arm loop.c:112) and, on failure, keep emfile_paused set and re-schedule the deadline so the listener is retried instead of abandoned.

Why this was flagged

Trigger: a POSIX listener enters the pause path at loop.c:211-218 (reserve fd absent or ENFILE), then 200 ms later us_internal_accept_rearm_if_due runs from us_loop_run_bun_tick (epoll_kqueue.c:560). It sets accept_rearm_next_tick_ns = -1 at loop.c:223, then us_internal_resume_emfile_paused_listeners sets ls->emfile_paused = 0 at loop.c:70 and calls us_poll_change at loop.c:71, ignoring its return. On kqueue the pause ran kqueue_change(old=READABLE,new=0,keep=0) which is EV_DELETE (epoll_kqueue.c:629), so the re-arm is a new EV_ADD that kevent can reject with ENOMEM. After the failure no state records the listener as paused and no deadline is pending, so nothing ever re-arms it: the server keeps running, the port stays bound, and every new connection sits in the backlog until it times out, with no error. The base branch never removes the listener's read interest, so it cannot reach this state. Remedy: on a non-zero rc keep emfile_paused and re-schedule the deadline.

Verification: The re-arm clears accept_rearm_next_tick_ns = -1 first (loop.c:223), then sets ls->emfile_paused = 0 (loop.c:70) and calls us_poll_change(READABLE) (loop.c:71) discarding the result. If the EV_ADD fails, nothing records it, so the listener is never re-registered and silently accepts nothing until close. Base branch never deletes the listener knote, so this is a new failure mode.

Comment on lines +195 to +197
while ((fd = bsd_accept_socket(us_poll_fd((struct us_poll_t *) ls), &addr)) != LIBUS_SOCKET_ERROR) {
bsd_close_socket(fd);
}

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.

🟡 (optional) An fd-exhausted server under a connection flood now stalls every existing connection and timer, where the base branch returned to the event loop on each failed accept(). The drain loop at loop.c:195-197 runs inside one dispatch until accept() errors; as long as new connections arrive faster than accept()+close() completes, us_internal_dispatch_ready_poll never returns, so no other poll, JS timer, or response on the fds that are actually held gets serviced. Fix: bound the drain to a fixed number of accept()+close() iterations per dispatch and fall through to the timed pause at loop.c:207-213 when the bound is hit; apply the same bound to any future libuv drain.

Why this was flagged

Trigger: a Bun.serve at RLIMIT_NOFILE (an attacker can get it there by holding connections open) receiving a sustained connect rate that exceeds the per-iteration cost of accept4()+close() on the listener; arrives through us_internal_dispatch_ready_poll at loop.c:702-706 into us_internal_accept_emfile. On the base branch the loop body was one failed accept() then return to epoll_pwait2, so other ready polls, Bun timers and the held connections' reads/writes were still dispatched every iteration (the spin cost CPU but not progress). After this change loop.c:195-197 is while (accept != ERROR) close(fd) with no iteration cap and no return to the loop; the only exit is accept() failing, which a flood prevents. us_internal_drain_ready_polls (epoll_kqueue.c:412) caps its re-poll at 48 iterations for exactly this starvation reason, but the new drain sits below that cap and is unbounded. Existing clients on the held fds observe their responses freeze; the one-shot warning gives no indication.

Verification: packages/bun-usockets/src/loop.c:195-197 is while ((fd = bsd_accept_socket(us_poll_fd((struct us_poll_t *) ls), &addr)) != LIBUS_SOCKET_ERROR) { bsd_close_socket(fd); } with no iteration cap, invoked from us_internal_dispatch_ready_poll at loop.c:704-708. On the base, the EMFILE branch was a single failed accept followed by return to epoll, so the starvation is new on this path.

// refusal and the readable clears), falling back to unpolling the listener
// with a timed re-arm if the drain cannot make progress.
// https://github.com/oven-sh/bun/issues/3271
test.skipIf(!isPosix)("Bun.serve does not spin at 100% CPU when accept() fails with EMFILE", async () => {

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.

🟡 nit (optional): this Bun.serve regression test is added as a new file instead of the existing test/js/bun/http/serve.test.ts, which CLAUDE.md:49 names explicitly for Bun.serve bugs ("Do not create a new file"). Fix: move the test into test/js/bun/http/serve.test.ts (keeping the test.skipIf(!isPosix) gate), or state in the PR why a standalone file is required.

Why this was flagged

The diff creates test/js/bun/http/serve-listen-emfile.test.ts:14 as a new file for a Bun.serve accept() regression. CLAUDE.md:49 states the default is to add to the existing test file and gives Bun.serve bugs -> test/js/bun/http/serve.test.ts as the example; test/CLAUDE.md:152 repeats the rule. Nothing in the test needs a separate file: it spawns a subprocess with its own tempDir and uses harness exports that serve.test.ts already imports. Maintainers looking for accept/EMFILE coverage next to the other Bun.serve listener tests will not find it; this is a convention violation, not a behavioral defect.

Verification: nit. The diff adds test/js/bun/http/serve-listen-emfile.test.ts as a brand-new file (124 lines). CLAUDE.md:49 states "Do not create a new file." and that "a Bun.serve bug goes in test/js/bun/http/serve.test.ts". Nothing in the test requires a standalone file, and neither the PR description nor the test states a reason for a separate file.

Comment on lines +55 to +59
static int us_internal_is_emfile(int err) {
#ifdef _WIN32
return err == WSAEMFILE || err == WSAENOBUFS;
#else
return err == EMFILE || err == ENFILE;

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.

🟣 pre-existing, not blocking: Linux servers whose accept() fails with ENOBUFS or ENOMEM still peg a core, the same spin this change claims to remove for EMFILE. us_internal_is_emfile at loop.c:59 only matches EMFILE and ENFILE, so the gate at loop.c:705 sends those errors back to the level-triggered poll with the connection still queued, and the Windows arm at loop.c:57 does include WSAENOBUFS, so the two platforms disagree on the same error. Fix: make the backoff cover every accept() errno that leaves the pending connection in the queue (ENOBUFS, ENOMEM alongside EMFILE/ENFILE), routing them to the pause/re-arm path rather than only the two names, so the spin is fixed as a class rather than for one errno.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: accept4() on Linux returns ENOBUFS or ENOMEM (socket buffer / kernel memory exhaustion, documented in accept(2)) while a connection is pending; reached from us_internal_dispatch_ready_poll loop.c:703-706 on every Bun.serve listener. The new predicate us_internal_is_emfile at loop.c:59 returns 0 for those errnos, so us_internal_accept_emfile is never called, the listener keeps EPOLLIN level-triggered readable, and epoll_pwait2 returns immediately on every tick exactly as the PR's strace shows for EMFILE. The base branch spun on every accept error too, but this PR's purpose is to end that spin and it adds a per-errno gate that leaves siblings unhandled; the Windows branch at loop.c:57 treats WSAENOBUFS as a backoff case, so the omission is a platform-parity gap in the same function. Population: memory-constrained containers under connection bursts. Remedy: extend the predicate to ENOBUFS/ENOMEM (or invert it to 'not EAGAIN/EWOULDBLOCK/ECONNABORTED/EINTR') so any persistent accept failure backs off.

Verification: On Linux accept4() fails with ENOMEM while a connection is still queued in the backlog. packages/bun-usockets/src/loop.c:56-62 us_internal_is_emfile returns true only for err == EMFILE || err == ENFILE, and the only backoff gate is loop.c:703-707. For ENOMEM/ENOBUFS no drain happens and epoll_pwait2 returns immediately every tick. The base branch already spins on these errnos by the same route.

@robobun
robobun marked this pull request as draft October 1, 2026 10:54
@robobun

robobun commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my rebase note above. The rebase left one gap, which the review found.

After this PR was written, main changed us_internal_loop_data_init in packages/bun-usockets/src/loop.c: it now returns -1 when the wakeup async cannot be created, and its cleanup branch must leave nothing allocated. The rebased code opens accept_reserve_fd before that call, and the cleanup branch does not close it. A failed loop creation leaks one descriptor.

The cleanup branch needs a close() of the reserve descriptor, or the open() must move after the async is created. I did not push that change with the rebase. It goes with the other review findings on this PR.

CI on the rebased head (build 122339): the test of this PR passed on every lane. The one red test is test/js/bun/spawn/spawn.test.ts on the x64 ASAN lane, which also fails on builds of other PRs and is not in this diff.

This branch has not been deployed

No deployments
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