Conversation
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.
|
Warning Review limit reached
Next review available in: 33 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThe 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. ChangesSocket polling behavior
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
… 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.
|
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. |
…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.
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.
… 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).
There was a problem hiding this comment.
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
📒 Files selected for processing (9)
packages/bun-usockets/src/eventing/epoll_kqueue.cpackages/bun-usockets/src/internal/internal.hpackages/bun-usockets/src/libusockets.hpackages/bun-usockets/src/socket.cpackages/bun-uws/src/HttpContext.hsrc/jsc/bindings/node/JSNodeHTTPServerSocket.cppsrc/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cppsrc/uws_sys/libuwsockets.cpptest/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.
…; 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.
There was a problem hiding this comment.
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_WRITEone-shot scrub — all now covered by the still-reading branch's unconditional delete. - Remaining unconditional-R|W siblings in libuwsockets.cpp — both
sendfile_needs_moreandmark_needs_more_not_sslnow delegate to the pause-respecting helper. kqueue_changeteardown_watch:change_list[2]is not overrun (the two teardown entries are mutually exclusive with the readable-diff and 0-event-fallback branches).- The
loopCyclescheckpoint 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.
### Problem - `test/js/node/tls/node-tls-server.test.ts` is red on every darwin lane since #39600: "reports the reset that arrives while the socket is paused as ECONNRESET, not 'end'" hangs to the timeout. Linux passes. - Cause: on kqueue a paused socket has no filter. `us_socket_pause` (`socket.c:864`) deletes the read filter, and the one-shot write filter it adds is consumed at once. The peer's RST is reported only after `resume()`. - Also found: `EV_ADD` on an existing knote keeps its flags (macOS 14 and 26). The read sentinel from #37077 stayed edge-triggered after the re-add on resume. ### Fix - `kqueue_change` (`epoll_kqueue.c`) keeps the read knote of a socket poll in both modes: level-triggered while the socket reads, `EV_CLEAR` while it does not. A mode switch deletes the knote and adds a new one. `us_poll_stop` deletes it in either mode. - The three hand-armed sentinel sites and the write filter added at zero events are removed. A delete of an unregistered filter is not a failure. - Correct because the dispatcher already handles these events: it masks the readable bit, defers a FIN while paused, and closes on a reset. After resume the knote is level-triggered again, so a partial drain cannot stall. - Verified: a six-scenario harness of the real `kqueue_change` on both macs (notes), the related suites on Linux, and new `Bun.listen` tcp/tls tests in `test/js/bun/net/socket.test.ts`. Linux behavior does not change, so this PR's darwin lanes prove the red test. ### Background - epoll reports HUP and ERR for a socket that polls nothing. libuv probes for a reset. kqueue reports nothing without a knote, and its write filter is one-shot since #25475. - An `EV_CLEAR` knote fires once per activation (data, FIN, RST). The dispatcher sees a FIN as eof and a reset as error (`fflags == ECONNRESET`). - `us_poll_resize` re-adds both filters only to move the udata. `EV_ADD` keeps the mode of an existing knote, so that still works. <details><summary>Notes</summary> Culprit: #39600 added the tests. The kqueue gap predates it: the write filter became one-shot in #25475, and the sentinel from #37077 covered shutdown and half-open but not pause. The rare darwin pass in CI was the RST landing before the one-shot write filter was consumed. kqueue probes, run as small C programs on darwin-arm64 (macOS 26.6, xnu 12377) and darwin-x64 (macOS 14.8, xnu 10063), identical results: - read filter deleted + one-shot write consumed, then RST: no event (the bug). RST while the one-shot is still armed: `EVFILT_WRITE` with `EV_EOF fflags=54` (the flaky pass). - `EV_ADD|EV_CLEAR` knote, then plain `EV_ADD`: still edge-triggered, udata updated. `EV_ADD|EV_CLEAR` over a level knote: still level. `EV_DELETE` + `EV_ADD` in one changelist: level again. - `EV_CLEAR` knote, RST behind 10 unread bytes: one event, `EV_EOF fflags=ECONNRESET`, `SO_ERROR=ECONNRESET`, no re-fire. FIN behind data: one event, `fflags=0`, no re-fire. More data while not reading: one wakeup per arrival. - `EV_CLEAR` knote registered before our own `SHUT_WR` still reports the peer's later FIN and RST, fresh or already cleared once. So dropping the post-shutdown re-arm in `raw_shutdown` is safe. - `KEVENT_FLAG_ERROR_EVENTS` with a failing delete first: the error entry keeps `EV_DELETE` in flags and `ENOENT` in data, and the following add still applies. The real `kqueue_change` body, compiled into a harness on both machines: pause then RST (reported, no spin), pause then FIN then resume (FIN deferred once, level-triggered after resume), stop on a paused socket (nothing left), resize touch (udata moved, mode kept), plain changes on a reading socket, EBADF still reported. The changed files also compile with `-fsyntax-only` in the kqueue configuration. Linux, debug build: `node-tls-server.test.ts` (73 pass, the SNICallback failure is pre-existing in this container), `node-net.test.ts`, `node-net-allowHalfOpen.test.js`, `fetch-backpressure.test.ts`, `node-http-backpressure.test.ts` (the same 15 pre-existing failures as unmodified main: localhost resolution and h3), `socket.test.ts` (the same 9 pre-existing failures as main, the 2 new tests pass), the nine `test-net-half-open-peer-reset-*.mjs` fixtures, `node-http-server-socket-end-drain`, `node-http-connect`, `tls-syscall-fault`, `net-syscall-fault`. The new `socket.test.ts` variants also fail on a release binary from before #39600 (the close carried no error), so they pin that contract for the Bun socket API on Linux as well. On macOS without this diff they hang like the node ones. Node itself does not report a reset while a socket is paused. libuv removes the fd from the poll set, and node v26.3.0 reports `ECONNRESET` after the resume (checked with the same server logic). Bun's epoll and libuv backends have reported it at once for a long time, and #39600 made that the tested contract. This PR only brings kqueue to the same contract. Related open PRs: #37098 rewrites the kqueue branch of `us_poll_resize` and would delete the read knote of a non-reading socket, which this rule keeps. #37099 touches the same pause code against an older base. `JSNodeHTTPServerSocketPrototype.cpp:226` pauses and resumes around a shutdown to work around the same gap. It still works and is left alone here. kqueue on FreeBSD (shimmed, not a CI target) stops a changelist at the first failing entry. The delete in a mode switch always finds a knote, because every socket poll starts with a level read filter, so the add after it is not affected. Windows lanes on the first CI run: the new tests reached the close with `syscall: "read"` but no `code`. The libuv backend reports the reset on a paused socket as intended, but on Windows the close carries the raw WSA code, which `on_close` stores unmapped (node:net accepts `code === undefined` as a reset, which is why the node tests pass there). That is a pre-existing bug in a different layer and is handed off separately. The tests check the code on POSIX only until it is fixed. </details>
) ### Problem - A `TLSSocket` or `net.Socket` that `unpipe()` paused never emits `'end'` or `'close'` when the peer closes, so `server.close(cb)` never calls back. Node emits both. Bun 1.4.0 hangs. - `Socket.prototype.pause` (`src/js/node/net.ts`) stopped the native handle, so the peer's FIN was never read. Node stops the handle only for an onread socket ([net.js#L817](https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L817-L827)) or when `push()` returns false ([stream_base_commons.js#L191](https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L191-L198)). Since #33974 a stopped handle holds the FIN back. - A usockets socket comes up reading, a Node handle comes up stopped. So `pauseOnConnect` was a pause issued from JS after the accept. ### Fix - `pause()` uses Node's condition: a connected onread socket. The sites where `push()` returned false still stop the handle (`readStop`). - `pauseOnConnect` sockets are paused natively: `on_open` pauses a plain socket, `on_handshake` a TLS socket (the handshake needs the reads), each once (`PAUSE_ON_CONNECT`). Accepted and adopted sockets (`LIBUS_SOCKET_OPEN_PAUSED`: cluster primary and worker) are registered paused to begin with, so that pause is free: 2 epoll_ctl per connection instead of 4 plus a wakeup. `net.ts` reads `server.pauseOnConnect` per connection like Node, and its `pauseOnCreate` is Node's two lines, `readStop()` and `readableFlowing = false`: the stop is free for a handle that opened paused, and real for a `pauseOnConnect` set after `listen()`. - `afterConnect` decides where Node's `read(0)` does, after the `'connect'` listeners ran: a plain socket whose stream is paused by then (paused while it connected, or by one of the listeners) has its handle stopped, unless a listener asked for a read (`_readableState.reading`), because Node's handle reads then. A TLS socket keeps reading to handshake, as Node's `initRead` does. - Verified: sixteen new tests (`node-net`, `node-tls-server`, `node-tls-connect`, `cluster`, `socket-reconnect-live`, `renegotiation`). Also the net, tls, http, cluster, fetch and serve suites and the vendored net, tls and cluster files on Linux, the net, tls and cluster suites on Windows (notes). ### Background - `kPausedUnref` (#33974) records that a stopped handle gave up its hold on the event loop. `read()`, `_read()` and `resume()` start the handle and take the hold back. A plain `pause()` now touches neither, as in Node. - A paused socket on kqueue keeps an `EV_CLEAR` read knote, so the peer's FIN or RST still reaches the dispatcher. `us_poll_start_rc` now gives a socket that starts paused the same knote. - `unpipe()` pauses the source when its last destination goes away. Only the peer's FIN can finish such a socket. <details><summary>Notes</summary> Reproduction, from the report: a `tls.Server` pipes each accepted socket to an upstream `net` connection and back. The upstream replies and closes. `inner.pipe(sock)` calls `sock.end()`, the cleanup of `sock.pipe(inner)` unpipes and pauses `sock`. The client then closes. ``` node v26.3.0 : inner close; sock.isPaused=true | client data reply | client end | client close | server sock end | server sock close || server.close(): ok bun 1.4.0 : client data reply | client end | inner close; sock.isPaused=true | client close || server.close(): HUNG this branch : client data reply | client end | inner close; sock.isPaused=true | client close | server sock end | server sock close || server.close(): ok ``` The same with plain `net` hangs on 1.4.0 as well, and so does a socket that is only `pause()`d on a later tick (no `end()`) whose peer then ends, for both transports. Data that arrives while paused stays in the buffer and holds `'end'` back until it is read, in Node and here alike. Loop hold, against Node v26.3.0: a socket paused after it started to read keeps the process alive (1.4.0 exited). `tls.connect(...).pause()` completes the handshake and keeps the process alive until then, with and without `onread` (1.4.0 exited after 18 ms without a handshake, the case of #35151). A plain socket paused while connecting lets the process exit (vendored `test-net-connect-paused-connection.js`, which fails without the `afterConnect` stop), and one paused by a `'connect'` listener reads nothing until `resume()` (`pause() in a 'connect' listener`, `bytesRead` 0 in node too). An onread client paused after `'connect'` but before the handshake completes it in both: the `_read()` queued at connect time starts the handle again. `pauseOnConnect` natively. Before, `pauseOnCreate` (and on main, `self.pause()`) issued `us_socket_pause` on an accepted socket that had just been registered readable, and `us_socket_pause` arms writable interest, which fires once (#37099 is the open fix for that). Measured with an `epoll_ctl` interposer on a cluster primary, per accepted connection: before `ADD IN`, `MOD OUT`, a wakeup, `MOD HUP|ERR`, `DEL`; now `ADD HUP|ERR`, `DEL`. The accept loop and `us_socket_from_fd` register the socket that way (epoll: HUP/ERR only, the state a paused socket reaches anyway. kqueue: the `EV_CLEAR` read knote a paused socket keeps, added in `us_poll_start_rc` for a socket poll that starts without read interest; listen and connect polls are semi-sockets there, so nothing else changes. libuv: `UV_DISCONNECT` only, the same call a pause makes). Both ignore the bit for a TLS socket. A dialed socket does not use the bit: `on_open` pauses it through the ordinary pause path, which is one `us_socket_pause` on a rare path and keeps the connect code untouched. For a Windows named pipe the `on_open` pause is the real `read_stop`. The option is a `SocketConfig` dictionary field (`SocketConfig.bindv2.ts`, coerced like `Boolean()`), so `Bun.listen` and `Bun.connect` accept it; it is not added to `bun.d.ts` in this PR. Node's TLS `pauseOnConnect` reads ahead into TLSWrap (`bytesRead` is 5 in the tls test under Node), ours stops reading once the handshake completed, so bytes sent after it stay in the kernel (application data in the same segment as the client's Finished is still decrypted and buffered); both deliver nothing before `resume()`. Two states that span connections, found in review. `IS_PAUSED` lived on the wrapper, so a socket that was left paused and then reconnected (`socket.connect()` again) started its new connection, which usockets opened reading, with the flag still set: `pause()` was a no-op and for TLS the post handshake pause was skipped. `on_open` now clears it, which is the one entry point all paths (TCP, TLS, named pipe, upgrades) go through for a new connection. `PAUSE_ON_CONNECT` is consumed the first time it acts, because a TLS 1.2 client gets a second `on_handshake` after a renegotiation (which Bun dispatches together with the next application data) and paused itself again there. `socket-reconnect-live.test.ts` and `renegotiation.test.ts` pin the two. Each fails with its line removed (the reconnect test resumes only after the server's `end()` callback ran, so the data is queued, and after one more poll of the loop: 12 of 12 runs fail without the reset, 12 of 12 pass with it). Rebased onto main at 8fce1d2 (after #39856, #39949, #39860 landed). #39856 added the same `IS_PAUSED` reset to `on_open` and now applies a pre-connect `ref()`/`unref()` there; the conflict resolution keeps main's lines and adds the `pause_on_connect_once()` call after them. #39856 also made `pause_stream` a no-op on a socket that is not established yet, which the `on_open` pause satisfies (the socket is installed first). Its new `unref()/pause() around connect()` tests and `s.pause() survives an autoSelectFamily retry` pass on this branch. #39949 gave the paused read knote on kqueue a `NOTE_LOWAT` sentinel and uses the same transition trick in `us_poll_resize` that `us_poll_start_rc` uses here, so an open-paused socket gets the sentinel knote too. The first version of the post-listener stop fired whenever `isPaused()` was true after the listeners and broke https over an http proxy on every Linux lane (build 104296): `establishTunnel` calls `socket.read()` and `once('readable')` in the connect listener, a `'readable'` listener sets `flowing` to false as well, and the handle was stopped with the CONNECT response unread. c49c87e skips the stop while `_readableState.reading` is set, pinned by `read() and once('readable') in a 'connect' listener still receive the peer's bytes`; the 48 vendored `test-http*-proxy*` files pass again. Rebased again at 0dc653c: the only conflict was `LIBUS_LISTEN_DISALLOW_REUSE_PORT_FAILURE`, which #37181 removed from `uws_sys` next to the new constant; this branch keeps only `LIBUS_SOCKET_OPEN_PAUSED`. Review pass over the whole diff (asked for by the reporter), what it checked and what changed. Every in-tree caller of `socket.pause()` was compared with its Node counterpart: `_http_incoming` readStart/readStop and `internal/http.ts` are Node's lines (stream level flow control, kernel backpressure once the socket buffer fills), `_http2_upgrade.ts` feeds TLS from `'data'` events so its pause loses nothing, the in-place tls upgrade swaps native handlers synchronously and never pauses, `Ipc.ts` adopts a received socket reading like Node's `got()`, tty and stdin build on `fs.ReadStream`. The remaining behavior change is therefore the Node one: after `pause()` bytes may land in the JS buffer, which is why Node documents `pauseOnConnect` for handoffs. Event order for a paused socket whose peer sends FIN or RST was traced for node, main and this branch (plain, tls, unix, shut down first, with a pending write): identical to main apart from the fixed hang; an RST while paused is reported at once by main and by this branch (Node only notices it on resume). Changes from the pass: (1) on kqueue the open-paused registration had no read knote, so an RST before `resume()` went unnoticed until then on macOS only; fixed in `us_poll_start_rc` and the dial path dropped from the bit, see above, pinned by `still reports a peer reset before resume()` (passes on epoll either way, the darwin lanes exercise the fix). (2) `onconnection` read the raw options while the tls site read the live property and the cluster worker path read the live property too; both now read `server.pauseOnConnect` like Node's `onconnection`, pinned by `reads server.pauseOnConnect per connection` (fails before: `bytesRead` 5, `paused` false; node prints `paused` true, 0). Setting the property after `listen()` works as well: the native listener keeps the value it was created with and `pauseOnCreate` stops the handle from JS (the same test sets it after `listen()`). (3) `onconnection` wrote a `pauseOnConnect` expando onto every accepted handle object that nothing reads, removed. (4) `pauseOnCreate` is Node's `readStop()` plus `readableFlowing = false` instead of `pause()`, which also emitted `'pause'`. Left alone on purpose: an in-place tls upgrade of a socket that already has bytes in its JS buffer ignores them (Node does the same, pre-existing), `us_socket_pause` arming writable interest on every pause (#37099), and the typed option question above. What pins what: the four end/close tests and the two paused-while-connecting tests fail on main (`pause()` and its `connecting` check). `test-net-server-pause-on-connect.js` (`bytesRead === 0`) pins the accepted socket being paused natively now that JS no longer pauses it, the cluster early-bytes test pins the primary's accept, the strengthened cluster pauseOnConnect test (write barrier, `bytesRead`) pins the worker's `from_fd` adoption, `applies to a dialed socket` pins the dial path, the tls test pins the post-handshake pause, `node-net-paused-unix-hangup-fixture.js` pins the AF_UNIX accept with a peer hangup while paused. The cluster test's barrier is the client's write callback (on loopback the bytes are in the peer's receive queue when `send(2)` returns, the IPC message follows it) plus one poll of the worker's loop before it samples `bytesRead`, since the IPC message can be dispatched in the same poll as, and ahead of, the socket's readable event; with a reading socket the same fixture reports `bytesRead` 5. Existing tests are left as they were, with one exception: `socket-syscall-fault.test.ts`'s parked-socket test reached the parked state through a plain `pause()` after `end()`, which this PR removes (the reply is read instead of staying in the kernel, so the original now prints an extra `data` line); it uses onread mode to reach the same stopped handle, and its expected output is unchanged. The stricter variants of the node-net paused-RST test and of the cluster RR pauseOnConnect test are separate new tests next to the originals. Suites. Linux, debug build, every remaining failure is unrelated and was matched against the released binary or a debug build of main: `test/js/node/net/` (10 `localhost` resolves to `::1` here, `unref survives an autoSelectFamily retry` also fails on main's debug build), `test/js/node/tls/` (1 `localhost`), `test/js/node/http/` (1 `localhost`, 3 subprocess tests that pass with a longer timeout), `test/js/node/http2/`, `cluster.test.ts`, `bun-serve-file.test.ts`, `websocket-server-backpressure-buffer`, `sql-mariadb-json`, the valkey tests that pause a peer: clean. `test/js/node/child_process/` (a GC test that passes with a longer timeout, a shell test that fails on the released binary too). `test/js/bun/net/` (13 `localhost` failures, same on the released binary). `serve.test.ts` and `bun-server.test.ts` (4 and 3, same on the released binary). `fetch.test.ts` and `websocket-server.test.ts`: the extra failures against the released binary are all timeouts at load average 40 on this host and pass alone. Vendored: 139 `test-net-*`, 80 `test-cluster-*`, 16 sequential net/cluster, 186 `test-tls-*` (one needs `bun test`) pass; earlier rounds also ran 445 `test-http*`/`test-https-*` and 179 `test-child-process-*` with only the failures noted in the review thread. After the last round (80a1cea): `node-net`, `socket-reconnect-live`, `renegotiation`, `node-tls-server`, `node-tls-connect` and `cluster.test.ts` (238 pass, the 12 failures above), `bun/net/socket.test.ts` (the same 9 failures as the released binary), 40 vendored cluster handoff tests and the 4 vendored pause tests. Windows x64 (debug build of the first native head): the two vendored pause tests, the cluster, tls and net selections above (27 + 7 + 2 + 2 tests), `node-net.test.ts` + `allowHalfOpen` + `bun/net/socket.test.ts` (168 pass, 0 fail), and a pauseOnConnect server on a named pipe (`bytesRead` 0 until `resume()`, like node). The Windows code did not change after that. Socket instances had a `pauseOnConnect` field that only `SocketHandlers2.open` read. Both are gone. Node's `net.Socket` has no such field. </details> <!-- robobun:evidence:begin --> --- **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/node/tls/node-tls-server.test.ts, test/js/node/net/node-net.test.ts, test/js/node/cluster.test.ts, test/js/bun/net/socket-syscall-fault.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: Dylan Conway <dylan.conway567@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
What
Two related
us_socket_pausebugs, both from pause() forcing the poll to WRITABLE instead of keeping whatever writable interest already existed:pause()with nothing buffered fired a bogus JSdrainevent (the always-writable socket dispatches the freshly armed writable immediately). Node never emitsdrainwithout a preceding failed write.shutdown()thenpause()closed the socket within ~2ms even though the peer was alive and silent. The fresh kqueueEVFILT_WRITEone-shot reports our ownSS_CANTSENDMOREasEV_EOFinstantly, 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_pausenow drops readable interest and only keeps pre-existing writable interest, matching libuv'suv_read_stopwhich never manufactures write interest. A backpressured write keeps its re-arm.HTTP_NODE_READS_PAUSED, replay from theonWritabletail) 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). Newus_socket_mark_writable_pending()arms writable interest for bytes held outside the socket's own write path, and the three park sites (onNodeHttpReadsPausedplus bothHttpContextbackpressure branches) now request their flush/replay wakeup explicitly.EPOLLHUP|EPOLLERR).kqueue_changenow arms a read-side teardown watch (EV_ADD|EV_CLEAR: the read filter'sEV_EOFis the peer's FIN/RST, never our ownSS_CANTSENDMOREecho) for 0-event polls on shut-down sockets, deletes any leftover write one-shot, andus_internal_socket_raw_shutdownarms 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:closed after 2ms (PREMATURE).node-http.test.tsincluding the pipelined flood-prevention test,socket.test.ts, andnode-net.test.tsshow the same failure set as clean main in this environment.Related: #37077 iterates the adjacent three-state rule for write-side
EV_EOFon 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 forallow_half_opensockets; 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