Skip to content

usockets: stop pause() from arming writable interest it never had - #37099

Open
robobun wants to merge 9 commits into
mainfrom
farm/7c2d84da/pause-shutdown-kqueue
Open

usockets: stop pause() from arming writable interest it never had#37099
robobun wants to merge 9 commits into
mainfrom
farm/7c2d84da/pause-shutdown-kqueue

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

What

Two related us_socket_pause bugs, both from pause() forcing the poll to WRITABLE instead of keeping whatever writable interest already existed:

  1. Every pause() with nothing buffered fired a bogus JS drain event (the always-writable socket dispatches the freshly armed writable immediately). Node never emits drain without a preceding failed write.
  2. macOS: shutdown() then pause() closed the socket within ~2ms even though the peer was alive and silent. The fresh kqueue EVFILT_WRITE one-shot reports our own SS_CANTSENDMORE as EV_EOF instantly, and the eof dispatch treats a shut-down socket's eof as the connection being over. Verified on macOS hardware (closed after 2ms; Linux correctly stayed open), so the platforms diverged.

Fix

  • us_socket_pause now drops readable interest and only keeps pre-existing writable interest, matching libuv's uv_read_stop which never manufactures write interest. A backpressured write keeps its re-arm.
  • The one consumer that depended on the pause-armed writable is node:http's pipelined flood prevention: its park/replay machinery (HTTP_NODE_READS_PAUSED, replay from the onWritable tail) was woken by that spurious event, and the queued pipelined responses live in the JS pipeline queue or the AsyncSocket buffer without any kernel send having been attempted, so nothing else ever armed the poll. CI caught this as a deadlock in the pipelined-responses test (parked requests never replayed). New us_socket_mark_writable_pending() arms writable interest for bytes held outside the socket's own write path, and the three park sites (onNodeHttpReadsPaused plus both HttpContext backpressure branches) now request their flush/replay wakeup explicitly.
  • On kqueue, a paused shut-down socket would otherwise be left with zero filters, so the peer's FIN/RST was never delivered (review catch; epoll keeps the implicit EPOLLHUP|EPOLLERR). kqueue_change now arms a read-side teardown watch (EV_ADD|EV_CLEAR: the read filter's EV_EOF is the peer's FIN/RST, never our own SS_CANTSENDMORE echo) for 0-event polls on shut-down sockets, deletes any leftover write one-shot, and us_internal_socket_raw_shutdown arms the watch directly when the poll was already at 0 events (the interest diff would no-op).

Verification

Three tests in test/js/bun/net/socket.test.ts:

  • "pause() with nothing buffered must not fire a drain event": fails on current bun (1 spurious drain), passes with this change.
  • "shutdown() then pause() keeps a half-closed socket open while the peer is silent": on current bun fails on macOS (closes at pause) and passes on Linux; passes everywhere with this change. Repro confirmed against released bun on a darwin arm64 host: closed after 2ms (PREMATURE).
  • "shutdown() then pause() still closes when the peer terminates": pins the teardown watch (the flip side of staying open for a silent peer).

node-http.test.ts including the pipelined flood-prevention test, socket.test.ts, and node-net.test.ts show the same failure set as clean main in this environment.

Related: #37077 iterates the adjacent three-state rule for write-side EV_EOF on shut-down sockets; this PR removes the pause-path arming that made those echoes fire in the first place, and the two compose. #33974 owns the paused-eof deferral for allow_half_open sockets; this PR is about interest arming, not eof dispatch.


no test proof · iteration 8 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts

us_socket_pause forced the poll to WRITABLE. Two consequences:

- The always-writable socket immediately dispatched a writable event, so
  every pause() with nothing buffered fired a bogus JS drain.
- On a socket whose write side we already shut down, the fresh kqueue
  EVFILT_WRITE one-shot reported our own SS_CANTSENDMORE as EV_EOF
  instantly, and the eof dispatch closed the half-closed socket within
  milliseconds even though the peer was alive and silent (verified on
  macOS; Linux kept it open, so the platforms diverged). libuv's
  uv_read_stop only ever removes read interest.

pause() now only keeps pre-existing writable interest (a backpressured
write stays armed), and kqueue_change's 0-event fallback (the one-shot
write filter armed to catch peer teardown) skips sockets we shut down
ourselves: for them any write filter completes instantly with our own
EV_EOF and can never distinguish anything.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5edc43f0-2040-4003-ba48-dae5ae64d2d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6206cde and 8d7a63e.

📒 Files selected for processing (3)
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/net/socket.test.ts

Walkthrough

The change adds explicit writable-event scheduling for queued socket and HTTP work. It updates kqueue shutdown and pause/resume filter handling to preserve peer teardown detection and adds regression tests for idle, paused, and half-closed sockets.

Changes

Socket polling behavior

Layer / File(s) Summary
Pending writable work
packages/bun-usockets/src/libusockets.h, packages/bun-usockets/src/socket.c, packages/bun-uws/src/HttpContext.h, src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp, src/uws_sys/libuwsockets.cpp
Adds us_socket_mark_writable_pending and uses it for buffered HTTP responses, parked request replay, and sendfile output.
Shutdown and interest transitions
packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/eventing/epoll_kqueue.c, packages/bun-usockets/src/socket.c, src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Updates kqueue shutdown handling and preserves writable interest during pause and resume without generating stale writable or EOF events.
Pause and half-close regression coverage
test/js/bun/net/socket.test.ts
Adds tests for idle pause/resume, shutdown ordering, silent half-open peers, and peer termination.

Possibly related PRs

Suggested reviewers: cirospaciari, 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 clearly summarizes the main change: pause() no longer arms writable interest that was not already active.
Description check ✅ Passed The description explains the bugs, fix, kqueue behavior, affected node:http paths, and verification results, despite using different section headings than the template.

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread packages/bun-usockets/src/eventing/epoll_kqueue.c Outdated
Comment thread test/js/bun/net/socket.test.ts Outdated
Comment thread test/js/bun/net/socket.test.ts Outdated
… teardown watch for shut-down sockets

CI caught node:http pipelined flood prevention deadlocking: the park/
replay machinery (HTTP_NODE_READS_PAUSED -> onWritable tail ->
Bun__NodeHTTP__onReadsResumable) relied on the writable event pause()
used to force-arm. The queued pipelined responses live in the JS
pipeline queue (or the AsyncSocket buffer) without any kernel send
having been attempted, so no write failure ever arms the poll and the
replay never ran: requests parked forever.

New us_socket_mark_writable_pending() arms writable interest for bytes
held outside the socket's own write path; the three sites that park
requests behind queued responses (onReadsPaused and both HttpContext
backpressure branches) now ask for their flush/replay wakeup
explicitly instead of riding a side effect of pause().

Review fix: the pause change left a paused shut-down kqueue socket
with zero filters, so the peer's FIN/RST was never delivered (epoll
keeps the implicit EPOLLHUP|EPOLLERR). kqueue_change now arms a
read-side teardown watch (EV_ADD|EV_CLEAR: the read filter's EV_EOF is
the peer's FIN/RST, never our own SS_CANTSENDMORE echo) for 0-event
polls on shut-down sockets, deleting any leftover write one-shot, and
us_internal_socket_raw_shutdown arms it directly when the poll was
already at 0 events (the diff would no-op). New test pins the flip
side: shutdown+pause still closes when the peer terminates.

Also from review: the silent-peer window is 250ms with rationale, and
both tests release their sockets before asserting.
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the node:http pipelined flood-prevention deadlock from the previous run is fixed (that suite is green on 199dbf3). The remaining red lane is a filesystem_router.test.ts segfault on debian 13 aarch64 with no socket involvement; it passes repeatedly with this diff locally and has been reported for main-break triage. The two Windows failures passed on retry.

Comment thread packages/bun-usockets/src/socket.c
Comment thread packages/bun-usockets/src/socket.c
robobun added 2 commits August 7, 2026 06:09
…h; resume() keeps rather than manufactures writable

Review catches, both the same class as the PR:

- pause() before shutdown() armed the 0-event fallback write one-shot
  while own_shutdown was still false, and the teardown transition's
  conditional delete read the caller's old_events (a literal 0 from the
  raw_shutdown direct call), so the phantom one-shot survived and echoed
  our own SS_CANTSENDMORE as EV_EOF: the sibling ordering still closed
  a paused half-closed socket prematurely on kqueue. The teardown watch
  now deletes EVFILT_WRITE unconditionally (ENOENT receipt is harmless
  when none exists). New test pins the pause-then-shutdown ordering.

- us_socket_resume manufactured WRITABLE the same way pause() used to,
  firing one bogus drain per pause/resume round trip; it now re-adds
  readable and only keeps pre-existing writable interest (backpressure
  during the pause already re-armed it). The drain test now covers the
  full round trip.
The libuv backend has no event for a reset against a paused 0-event
poll (AFD only reports subscribed events and the DISCONNECT was
consumed), so the test strands on Windows. That gap predates this
change and is tracked separately; the test pins the POSIX contract
this PR fixes.
Comment thread packages/bun-usockets/src/socket.c
The comments described the pre-change pause/resume interest steps
(W -> R|W -> R and the undeleted write one-shot); the cycle macOS 26
needs is preserved but now runs through the shut-down teardown
transition.
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp Outdated
Comment thread packages/bun-usockets/src/socket.c
Comment thread packages/bun-usockets/src/socket.c
… every shutdown; share the writable-pending helper

macOS 26 only delivers a peer's close on a read filter registered after
SHUT_WR: the teardown watch armed before the shutdown syscall made
node-http-halfclose-midupload time out at connection-closed on the
darwin 26 lanes (the End path's previous delete-then-re-add cycle
re-added after). The watch now arms after shutdown(2); EV_EOF is level
state, so a FIN landing in the gap is still reported by the fresh
registration.

Review catches, same class:
- pause() then resume() then shutdown() left the 0-event fallback's
  phantom write one-shot armed across SHUT_WR (resume no longer records
  writable, so neither diff saw it). The still-reading shutdown path now
  scrubs EVFILT_WRITE explicitly; new silent-peer test pins the third
  ordering.
- us_socket_sendfile_needs_more was the pre-existing sibling of the new
  helper with the old force-READABLE semantics; it now routes through
  us_socket_mark_writable_pending (respects pause and shut-down).
Comment thread src/uws_sys/libuwsockets.cpp Outdated

@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 `@test/js/bun/net/socket.test.ts`:
- Around line 365-398: Replace duration-based sleeps in the socket tests with
observable checkpoints and bounded polling. In test/js/bun/net/socket.test.ts
ranges 365-398, await deterministic connection/event-loop checkpoints before
asserting no drain; in ranges 401-439, 441-477, and 479-516, replace each 250 ms
wait with the peer protocol checkpoint proving the socket remains live, using
the same observable half-closed lifecycle checkpoint for the latter two ranges.
🪄 Autofix

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: d3c2db7d-bfac-4341-83f6-97dbfdc6431d

📥 Commits

Reviewing files that changed from the base of the PR and between 45eda51 and 6206cde.

📒 Files selected for processing (9)
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/socket.c
  • packages/bun-uws/src/HttpContext.h
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/net/socket.test.ts

Comment thread test/js/bun/net/socket.test.ts
…ints

An echo round trip through an independent pair on the same loop cannot
complete before already-ready events for other sockets have dispatched,
so N round trips prove N full poll cycles ran: the buggy drain/close
would have fired inside the first. Deterministic and faster than the
fixed windows. Also trims the sendfile wrapper comment.
Comment thread src/uws_sys/libuwsockets.cpp
Comment thread src/uws_sys/libuwsockets.cpp
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp Outdated
…; fix a stale ordering comment

us_socket_mark_needs_more_not_ssl was the remaining open-coded copy of
the pre-fix pattern (forces READABLE, no shut-down guard) with a live
caller in the file-response stream; it now delegates to
us_socket_mark_writable_pending like its sendfile twin. The End-path
comment also still said the teardown watch arms before SHUT_WR after
6206cde moved it after; the ordering claim now defers to socket.c.
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp

@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 prior inline findings have been addressed (8d7a63e routes us_socket_mark_needs_more_not_ssl through the shared helper and fixes the stale ordering comment). I didn't find anything further this round, but this is core usockets eventing-layer work — pause/resume interest semantics, kqueue teardown-watch arming, and a macOS 26 ordering workaround — that a human familiar with the loop should sign off on.

What was reviewed:

  • The three pause/shutdown orderings and the phantom EVFILT_WRITE one-shot scrub — all now covered by the still-reading branch's unconditional delete.
  • Remaining unconditional-R|W siblings in libuwsockets.cpp — both sendfile_needs_more and mark_needs_more_not_ssl now delegate to the pause-respecting helper.
  • kqueue_change teardown_watch: change_list[2] is not overrun (the two teardown entries are mutually exclusive with the readable-diff and 0-event-fallback branches).
  • The loopCycles checkpoint helper for the negative assertions — same-loop echo round trips replace the earlier sleeps.
Extended reasoning...

Overview

This PR changes the semantics of us_socket_pause/us_socket_resume in usockets so they no longer manufacture writable interest, adds a kqueue read-side teardown watch for shut-down 0-event polls (so peer FIN/RST still delivers), scrubs the unrecorded 0-event-fallback EVFILT_WRITE one-shot at every shutdown transition, and introduces us_socket_mark_writable_pending() for callers (node:http flood-prevention park sites, the two Bun.serve "needs more" helpers) that hold bytes outside the socket's write path and previously relied on pause() arming writable for them. Nine files across usockets, uWS, the node:http C++ bindings, and tests.

Security risks

None identified. This is I/O eventing / poll-interest management; no auth, crypto, or untrusted-input parsing is touched.

Level of scrutiny

High. epoll_kqueue.c and socket.c are the lowest layer of Bun's networking — every TCP socket, HTTP server, and WebSocket runs through this dispatch. The change alters kqueue filter arming with platform-divergent behavior (epoll's implicit EPOLLHUP vs kqueue needing an explicit filter), threads a macOS 26 kernel-behavior workaround (filter must be registered after SHUT_WR) through us_internal_socket_raw_shutdown, and rewires the wakeup for node:http's pipelined-request replay. A subtle mistake here manifests as hangs, spurious closes, or busy-loops that only reproduce on one platform under load.

Other factors

The PR has been through several review rounds; each round surfaced same-class siblings (sendfile_needs_more, mark_needs_more_not_ssl, the pause→resume→shutdown ordering) that were all taken. The four new tests use a same-loop echo checkpoint instead of sleeps and cover both directions of the shutdown/pause interaction plus the peer-terminates flip side. CI covers the macOS lanes this is primarily aimed at (the Linux environment cannot exercise the kqueue paths). Given the depth, platform sensitivity, and the number of iterations it took to converge, a maintainer who owns this layer should confirm the final shape — particularly the kqueue_change teardown_watch branch and the AFTER-shutdown(2) ordering claim for macOS 26.

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