Skip to content

node:http: skip TCP_DEFER_ACCEPT so server.setTimeout fires on time for silent clients - #36074

Open
robobun wants to merge 1 commit into
mainfrom
farm/13f7164b/http-server-settimeout-precision
Open

node:http: skip TCP_DEFER_ACCEPT so server.setTimeout fires on time for silent clients#36074
robobun wants to merge 1 commit into
mainfrom
farm/13f7164b/http-server-settimeout-precision

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

What

server.setTimeout(N) fires roughly a second late for a client that connects and never sends. The 'connection' event is late by the same amount.

Repro

import http from 'node:http'; import net from 'node:net';
const out = {};
for (const v of [500, 1500, 2500, 3500]) {
  const srv = http.createServer(() => {}); srv.setTimeout(v, s => s.destroy());
  await new Promise(r => srv.listen(0, '127.0.0.1', r));
  const t0 = Date.now(); const s = net.connect(srv.address().port, '127.0.0.1'); s.on('error', () => {});
  out['t' + v] = await new Promise(r => { s.on('close', () => r(Date.now() - t0)); setTimeout(() => r(null), 8000); });
  s.destroy(); srv.close();
}
console.log(JSON.stringify(out));

Node v26: {"t500":504,"t1500":1502,"t2500":2503,"t3500":3500}
Bun (before): {"t500":1811,"t1500":2539,"t2500":3540,"t3500":4564}
Bun (after): {"t500":586,"t1500":1530,"t2500":2529,"t3500":3530} (debug+ASAN overhead)

Cause

HttpContext::listen unconditionally set LIBUS_LISTEN_DEFER_ACCEPT, which applies TCP_DEFER_ACCEPT with a 1 second timeout on Linux. When a client connects and never writes, the kernel holds the connection back from accept() for that full second. uWS's onOpen (and the filter callback that drives node:http's onServerConnection) only runs after the defer window expires, so the NodeHTTPServerSocket is constructed, 'connection' is emitted, and socket.setTimeout(server.timeout) is armed roughly a second late.

headersTimeout/requestTimeout look precise in the same scenario because those tests send bytes, and any data short-circuits the defer.

Fix

Skip LIBUS_LISTEN_DEFER_ACCEPT when the context is in node:http compat mode (isNodeHttp() is already set before listen()). Node.js does not use deferred accept; the 'connection' event and the per-socket inactivity timer are measured from TCP accept. Bun.serve keeps the optimisation.

Verification

New test in test/js/node/http/node-http-server-timeouts.test.ts connects without writing and asserts 'connection' is seen in under 900 ms and the 500 ms timeout fires in under 1250 ms. Fails on main (connection seen at ~1000 ms, timeout at ~1500 ms), passes with the fix.

First commit is the same resume_()resume() build fix as #36072 so this branch compiles; drop it once that lands.


[review] gate passed · iteration 2 · 2 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/node/http/node-http-server-timeouts.test.ts
bun test v1.4.0 (7529c3f08)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [656.25ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [420.02ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [313.00ms]
154 |       // being tight enough to flake under ASAN.
155 |       expect({
156 |         connectionSeenPromptly: connectionAt !== undefined && connectionAt < 900,
157 |         timeoutNearConfigured: timeoutAt !== undefined && timeoutAt < 1250,
158 |         closedNearConfigured: closedAt < 1250,
159 |       }).toEqual({
               ^
error: expect(received).toEqual(expected)

  {
-   "closedNearConfigured": true,
-   "connectionSeenPromptly": true,
-   "timeoutNearConfigured": true,
+   "closedNearConfigured": false,
+   "connecti
... (truncated)

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

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [262.52ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [304.79ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [203.53ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires close to the configured time for a client that never sends [504.60ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1206.07ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [254.05ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [405.95ms]

 7 pass
 0 fail
 10 expect() calls
Ran 7 tests across 1 file. [3.38s]
__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/node/http/node-http-server-timeouts.test.ts
bun test v1.4.0 (7529c3f08)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [637.86ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [431.00ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [300.75ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires close to the configured time for a client that never sends [571.75ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1332.80ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [319.73ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [582.44ms]

 7 pass
 0 fail
 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 747ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/12] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bun_valkey v
... (truncated)
diff hotspot
packages/bun-uws/src/HttpContext.h                 | 11 +++++-
 .../js/node/http/node-http-server-timeouts.test.ts | 44 ++++++++++++++++++++++
 2 files changed, 53 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                                 reads  edits  tests
packages/bun-uws/src/HttpContext.h                       3      1      0
test/js/node/http/node-http-server-timeouts.test.ts      3      1      0

root cause · written by the author bot

The listen socket was unconditionally setting TCP_DEFER_ACCEPT, so on Linux a client that connects without writing is not handed to userspace for roughly one second, delaying the 'connection' event and the start of the idle timer by that amount. The fix stops passing the defer-accept option when the HttpContext is running in node:http compat mode, so connections are accepted at TCP establishment and server.setTimeout measures from the correct origin. Bun.serve retains the defer-accept optimisation since it does not expose these Node semantics.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 1 minute

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: 8d03f634-4e79-411b-ae99-b10e83f32ce8

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 7529c3f.

📒 Files selected for processing (2)
  • packages/bun-uws/src/HttpContext.h
  • test/js/node/http/node-http-server-timeouts.test.ts

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

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

LGTM — targeted fix that skips TCP_DEFER_ACCEPT only for node:http compat contexts so 'connection' and server.setTimeout are measured from TCP accept, matching Node.

What was reviewed:

  • Confirmed enableNodeHttpCompat() runs before listen() (via assignOnNodeJSCompat in NodeHTTP.cpp), so isNodeHttp() is accurate at the gate.
  • Bun.serve keeps the defer-accept optimisation; listen_unix never set the flag so no sibling change needed.
  • The resume_()resume() renames are the mechanical build fix already on main (5008c2c).
  • Test bounds (< 900 ms / < 1250 ms) are categorical vs the 1 s kernel defer window, not a race, and every failure path is wired to the awaited close.
Extended reasoning...

Overview

The functional change is a 3-line gate in packages/bun-uws/src/HttpContext.h::listen(): LIBUS_LISTEN_DEFER_ACCEPT is now OR'd into options only when !isNodeHttp(). The remaining Rust hunks (RequestContext.rs, streams.rs) are the resume_()resume() rename that already landed on main as 5008c2c and is carried here so the branch compiles. A new regression test in test/js/node/http/node-http-server-timeouts.test.ts connects without writing and asserts 'connection' fires promptly and the 500 ms inactivity timeout lands under 1250 ms.

Security risks

None. This only decides whether TCP_DEFER_ACCEPT is set on a listen socket; no new input parsing, allocation, or trust boundary. If anything it slightly narrows a slow-loris variant for node:http (silent-connect clients now start their inactivity timer immediately instead of after the kernel's 1 s defer).

Level of scrutiny

Low-to-medium. The mechanism is well explained and verifiable: TCP_DEFER_ACCEPT on Linux holds back a data-less connection for the configured timeout, delaying uWS onOpen and the filter callback that emits node:http's 'connection'. The gate uses the existing isNodeHttp() predicate (vtable identity), which multiple in-tree comments already document as being set before listen(); I traced the call chain through assignOnNodeJSCompatapp->enableNodeHttpCompat() to confirm. Bun.serve is unaffected, so this respects the "don't change a Bun-native default to fix Node compat" rule. listen_unix never set the flag, so no sibling site was missed.

Other factors

The test uses timing thresholds, but they are structurally sound: the defer window is a hard 1 s floor, so connectionAt < 900 vs the ~1000 ms observed on main is a categorical distinction rather than a scheduler race, and the comment says as much. The test awaits the socket 'close' event (with 'error' wired to a no-op) rather than sleeping, and cleans up in finally. The resume_resume hunks will drop out on rebase since 5008c2c is already on main.

…or silent clients

The HttpContext listener unconditionally set LIBUS_LISTEN_DEFER_ACCEPT
(TCP_DEFER_ACCEPT with a 1 second timeout on Linux). For node:http
compat that made the server see a silent connection roughly a second
late: the 'connection' event fired after the kernel's defer window
expired, and the socket.setTimeout armed in the NodeHTTPServerSocket
constructor only started counting from there. A server configured with
server.setTimeout(500) would close an idle, never-writing client after
~1.5s instead of ~0.5s (Node.js fires within a few ms of the configured
value).

Skip the defer-accept flag when the context is in node:http compat mode
so accept() (and therefore 'connection' and the per-socket inactivity
timer) runs at TCP accept time like Node.js. Bun.serve keeps the
optimisation.
@robobun
robobun force-pushed the farm/13f7164b/http-server-settimeout-precision branch from fb5edda to 7529c3f Compare July 27, 2026 10:57

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

LGTM — gates TCP_DEFER_ACCEPT on !isNodeHttp() so node:http's 'connection' event and server.setTimeout are measured from TCP accept, matching Node; Bun.serve keeps the optimisation.

What was reviewed:

  • Confirmed enableNodeHttpCompat() swaps the vtable before listen() (asserted via head_listen_sockets == nullptr), so isNodeHttp() is accurate at this call site — same predicate already used for socketExtSize() right below.
  • bsd_set_defer_accept sets a 1 s TCP_DEFER_ACCEPT on Linux, so the test's <900 ms / <1250 ms bounds distinguish immediate accept from deferred accept with margin for ASAN.
  • listen_unix() never OR'd the flag in, so no sibling fix needed there.
Extended reasoning...

Overview

Two-file change: packages/bun-uws/src/HttpContext.h makes HttpContext::listen() skip LIBUS_LISTEN_DEFER_ACCEPT when the context is in node:http compat mode, and test/js/node/http/node-http-server-timeouts.test.ts gains a test that connects without writing and asserts the server's 'connection' event fires promptly (<900 ms) and a 500 ms setTimeout fires within 1250 ms.

Security risks

None. This drops a Linux-only accept-time optimisation for the node:http path; it does not touch auth, TLS, parsing, or any untrusted-input handling. Removing deferred accept cannot introduce a new attack surface — Node.js itself never used it.

Level of scrutiny

Medium. The listen path is production-critical, but the edit is a 3-line conditional on an existing predicate (isNodeHttp()) that is already load-bearing for socketExtSize() in the same function group. The vtable swap in enableNodeHttpCompat() is asserted to happen before any listen socket exists, so the predicate is guaranteed correct at this point. Bun.serve is unaffected (still gets LIBUS_LISTEN_DEFER_ACCEPT), so the perf tradeoff is scoped to node:http compat, where matching Node's accept semantics is required.

Other factors

  • Verified bsd_set_defer_accept in bsd.c uses a 1 s TCP_DEFER_ACCEPT timeout, which matches the PR's root-cause analysis and justifies the test's timing bounds (900 ms is comfortably below the 1 s defer window; 1250 ms gives ~750 ms slack over the 500 ms setTimeout for debug+ASAN).
  • Checked for sibling sites: listen_unix() never set the flag, and the only LIBUS_LISTEN_DEFER_ACCEPT producer in the tree is this line, so no other call site needs the same guard.
  • The test's t0 is a let initialised before net.connect and reassigned on client 'connect'; the server-side 'connection' closure reads it after both are on the same event-loop turn, so no TDZ and negligible skew on localhost.
  • On macOS/Windows the test would pass with or without the fix (no TCP_DEFER_ACCEPT), but it still asserts the correct Node-compatible contract there.
  • PR evidence shows the new test fails on main (connection at ~1000 ms, timeout at ~1500 ms) and passes on both debug+ASAN and release with the fix.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green. The added test (node-http-server-timeouts.test.ts) passes on every lane, and the evidence gate confirms fail-before/pass-after on both ASAN and release.

The remaining red is unrelated to this change:

  • test/js/bun/http/serve.test.ts ("request body backpressure > releases a paused request body") on darwin x64: also failing on main (build 83238, commit 4eb6f99) on both darwin 14 x64 and darwin 26 aarch64. macOS has no TCP_DEFER_ACCEPT, so this change is a no-op there.
  • darwin-14-aarch64 lanes are waiting on agents (job expiry/reschedule).
  • The rest are retried flakes.

Ready for review.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:53 AM PT - Jul 27th, 2026

@robobun, your commit 7529c3f has 1 failures in Build #83303 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36074

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

bun-36074 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Independent confirmation of the 'connection' half of this, found from a different direction (a node:http test that awaits once(server, 'connection') for a client that sends nothing and pays about a second per connection for it).

On current main (8a1cd8d), with a client that connects to a 127.0.0.1 listener and writes nothing, measured with a plain synchronous 'connection' listener:

server bytes sent by client 'connection' emitted after
net.createServer none ~5 ms
http.createServer none ~1045 ms (1020 to 1070 ms across runs)
http.createServer a request line ~1 ms

Node v26.3.0 emits at ~1 to 2 ms in all three cases.

net.createServer does not go through HttpContext::listen, so the only difference between the first two rows is the LIBUS_LISTEN_DEFER_ACCEPT that listen() still ORs in unconditionally (bsd_set_defer_accept sets TCP_DEFER_ACCEPT to 1 second), and any data short-circuits the defer, which is the third row. So the delay is entirely kernel side, before accept(); the uws filter and onServerConnection dispatch are immediate once the socket is accepted, and this PR's gate on isNodeHttp() covers it (same predicate socketExtSize() already relies on at listen time, and it applies to the TLS instantiation as well).

The diff still applies cleanly on main. Beyond server.setTimeout, this also trims about a second per silent connection from tests such as test/js/node/http/node-http-server-close-connections.test.ts ("skips connections with an incomplete request head" opens two of them).

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