Repository navigation
Conversation
…troy
Server.prototype.setTimeout was arming the native uWS per-connection idle
timeout (setServerIdleTimeout), which closes the socket unconditionally in
HttpContext.h's onTimeout handler after running the JS callback. The
socketOnTimeout-style veto logic (emit on req/res/server; destroy only if
no listener handled it) ran, but the native close was already committed,
so a 'timeout' listener could observe the event but not keep the
connection alive. Idle keep-alive connections were reaped with no
'timeout' event at all, since their NodeHTTPResponse was gone by then.
Drive server.setTimeout from an unref'd JS timer per connection instead,
like Node.js: Server.prototype.setTimeout stores server.timeout and each
request arms/refreshes socket.setTimeout(server.timeout). The timer
fires socket._onTimeout -> emit('timeout') -> onNodeHTTPServerSocketTimeout,
which now makes the actual destroy decision. Activity (new request,
body data, tunnel data/drain, response finish) refreshes the timer.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR removes the server idle-timeout API and shifts HTTP timeout handling to per-socket timers. It also updates incoming, response, runtime, and test paths to match the new timeout listener behavior. ChangesIdle-timeout removal and socket timeout handling
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 6:13 AM PT - Jul 8th, 2026
❌ @robobun, your commit b7f981c has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33735That installs a local version of the PR into your bun-33735 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Re the duplicate-PR bot: #33732 and #33733 touch the same lines but address different bugs (the This PR drives the timeout from an unref'd JS timer per connection instead, which is what makes the existing veto logic in |
…etServerIdleTimeout binding Response body writes (ServerResponse.write/_send and the native drain callback) now refresh the socket's inactivity timer so server.setTimeout(N) without a listener no longer destroys an actively-streaming response. Fix _onTimeout to check the property that actually exists on the native NodeHTTPResponse (bufferedAmount, not writableLength). Don't clear a user-set per-socket timeout on the next keep-alive request when server.timeout is 0: re-arm only when server.timeout is set, otherwise just refresh any existing timer. Remove the now-unused jsHTTPSetServerIdleTimeout binding and its Rust-side Server__setIdleTimeout / set_idle_timeout implementation.
…e CONNECT; refresh on raw socket _write IncomingMessage.prototype.setTimeout now delegates to socket.setTimeout so all four entry points (server/socket/req/res.setTimeout) share the one JS inactivity timer and req.setTimeout(0) clears what server.setTimeout armed. Move the per-request arm above the CONNECT early-return so server.timeout applies to CONNECT tunnels too, and refresh the timer in NodeHTTPServerSocket._write so raw socket writes (CONNECT/upgrade handlers) count as activity like Node.js's net.Socket._writeGeneric.
server.setTimeout and req.setTimeout listeners now veto the default socket destroy (Node.js semantics), so these tests' callbacks must destroy the socket themselves for the server to close cleanly. The previous versions relied on the native idle timeout force-closing the connection regardless.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/js/node/_http_server.ts:1187-1190— This guard suppresses'timeout'wheneverbufferedAmount > 0and re-arms via_unrefTimer(), so a client that stops reading after triggering a large buffered write causes the timer to loop_onTimeout → bufferedAmount>0 → refreshforever and the connection is never reaped. Node'snet.Socket._onTimeoutcompares the currentwriteQueueSizeagainstthis[kLastWriteQueueSize]and only suppresses when they differ (progress); a stalled write falls through and emits'timeout'. Track the last-observedbufferedAmountand only suppress when it has changed since the previous check.Extended reasoning...
What the bug is
The 36e1b22 fix changed this guard from
response.writableLength(alwaysundefined, so dead) toresponse.bufferedAmountand added thethis._unrefTimer()re-arm. That made the presence-only check live: whenever there is any buffered outbound data on the nativeNodeHTTPResponse,_onTimeout()refreshes#timeoutTimerand returns without emitting. It never checks whether the buffered amount has changed since the last fire.Node.js's
net.Socket.prototype._onTimeout(lib/net.js) implements a progress check, not a presence check: it comparesthis._handle.writeQueueSizeagainstthis[kLastWriteQueueSize](cached at the last write and at the last timeout) and suppresses the timeout only when the two differ — i.e. bytes have drained since the last observation. When they're equal (a genuinely stalled write), it falls through, updateskLastWriteQueueSize, and emits'timeout'. Bun has no equivalent last-value tracking onNodeHTTPServerSocket.The specific code path that triggers it
NodeHTTPResponse.get_buffered_amount()(src/runtime/server/NodeHTTPResponse.rs:654) returns the uWS backpressure buffer size unlessREQUEST_HAS_COMPLETEDorSOCKET_CLOSEDis set — neither is set for a live request whose response write is stalled (client stopped reading, TCP receive window closed). SobufferedAmountstays> 0across every timer fire.The
_unrefTimer()calls added elsewhere in 36e1b22 don't help:allowWritesToContinueonly fires on native drain (which never happens when nothing is draining), and theServerResponse.write/_sendcalls only fire when the handler callswrite()again (it isn't). The socket's#onDrainis only wired for CONNECT/upgrade tunnels. Nothing else refreshes or clears the timer.Step-by-step proof
const srv = http.createServer((req, res) => { res.writeHead(200); res.write(Buffer.alloc(4 * 1024 * 1024)); // exceeds socket send buffer }); srv.setTimeout(5000); // no 'timeout' listener → default destroy path
- Client sends the request, receives the headers, then stops calling
read()(TCP receive window closes). The 4 MB write partially flushes; the remainder sits in the uWS backpressure buffer, soresponse.bufferedAmount > 0. - At t≈5 s
#timeoutTimerfires →_onTimeout()→bufferedAmount > 0→_unrefTimer()→#timeoutTimer.refresh()→return.'timeout'is not emitted. - At t≈10 s, 15 s, …: same. Loop forever.
onNodeHTTPServerSocketTimeoutnever runs,this.destroy()never runs, the socket and its multi-MB buffer are held indefinitely.
In Node.js the same scenario emits
'timeout'and destroys the socket at t≈5 s: on the first_onTimeout,writeQueueSize === kLastWriteQueueSize(nothing drained since the write cached it) → the guard falls through.Why this is a regression
Before this PR,
server.setTimeout(N)setconfig.idle_timeoutand each request calledresp.timeout(idle_timeout)on the native uWS response. Under a fully-stalled write, uWS'son_writablenever fires, soresetTimeout()is never called, and the nativeonTimeouteventually closes the socket. After this PR, node:http passesidleTimeout: 0toBun.serveand relies solely on the JS timer — which now loops forever under the same conditions. So a slow-reader client that lets its TCP window fill after triggering a large response now defeatsserver.setTimeoutentirely (a slowloris-adjacent resource-hold).How to fix
Track the last-observed buffered amount on the socket and only suppress when it has changed since the previous check, mirroring Node's
kLastWriteQueueSize:#lastBufferedAmount = 0; _onTimeout() { const response = this[kHandle]?.response; const buffered = response ? response.bufferedAmount : 0; if (buffered > 0 && buffered !== this.#lastBufferedAmount) { this.#lastBufferedAmount = buffered; this._unrefTimer(); return; } this.#lastBufferedAmount = buffered; this.emit('timeout'); }
and update
#lastBufferedAmountfrom the write paths (ServerResponse.write/_send/allowWritesToContinue) so an actively-writing response also counts as progress. Alternatively, drop the guard entirely and rely on the write-time_unrefTimer()calls — Node's guard exists because kernel-level write completion doesn't call back into JS; ifallowWritesToContinuereliably fires on every partial drain, the guard may be unnecessary. - Client sends the request, receives the headers, then stops calling
-
🟡
src/js/node/_http_server.ts:713-715— Thesocket.setTimeout(server.timeout)arm at line 714 runs afterserver.emit('connection', socket)at line 710, so a user'connection'listener that callssocket.setTimeout(N)to override the server default is immediately clobbered whenserver.timeout > 0. In Node.js,connectionListenerInternal(registered as the first'connection'listener) armssocket.setTimeout(server.timeout)before any user listener runs, so user listeners can override it. Combined with the CONNECT-placement fix already suggested at this line: move the arm to right after the socket is created (above theif (method === "CONNECT")block and beforeemit('connection')), gated onisSocketNew, and only_unrefTimer()on subsequent requests.Extended reasoning...
What the bug is
In
onNodeHTTPRequest, the server-timeout armconst serverTimeoutMs = server.timeout; if (serverTimeoutMs) socket.setTimeout(serverTimeoutMs); else socket._unrefTimer();
runs at lines 713-715 — after
server.emit('connection', socket)at line 710. Whenserver.timeout > 0, a user'connection'listener that callssocket.setTimeout(N)to override the server default (a documented Node.js pattern) has its value immediately overwritten bysocket.setTimeout(server.timeout)on the very next line.Additionally, because this arm is not gated on
isSocketNew, every subsequent keep-alive request re-clobbers a user's per-socket timeout back toserver.timeout— so the user cannot overrideserver.timeoutper-connection at all whenserver.timeout > 0.Node.js behaviour
In Node.js, the
http.Serverconstructor registersconnectionListeneras the first'connection'listener (vianet.Server.call(this, opts, connectionListener)).connectionListenerInternaldoesif (server.timeout) socket.setTimeout(server.timeout)there, before any user-added'connection'listener runs. So user listeners run afterwards and can override the socket timeout. Subsequent requests on the same connection only refresh viasocket._unrefTimer()inparserOnIncoming; they never re-applyserver.timeout.Step-by-step proof
const srv = http.createServer((req, res) => res.end('ok')); srv.setTimeout(5000); srv.on('connection', s => s.setTimeout(30000)); // user wants 30s for this connection
- Client connects and sends a request.
onNodeHTTPRequestruns withisSocketNew = true. - Line 710:
server.emit('connection', socket)— user listener runs, callssocket.setTimeout(30000). Per the newNodeHTTPServerSocket.setTimeout(lines 1330-1342),#timeoutTimeris armed to 30s andsocket.timeout = 30000. - Line 713-714:
serverTimeoutMs = 5000(truthy) →socket.setTimeout(5000). This clears the 30s timer and arms a fresh 5s timer. - The user's override is lost; the socket times out at 5s instead of 30s.
- On the next keep-alive request, step 3 runs again (
isSocketNew = falsedoesn't gate it), so even ares.setTimeout(30000)set inside request N is clobbered back to 5s at the start of request N+1.
In Node.js, step 2's user listener runs after Node's own arm, so
socket.timeoutstays at 30000; and step 5 never happens because Node only_unrefTimer()s on subsequent requests.Why this is distinct from earlier comments
The (now-resolved) comment #3 covered the
server.timeout === 0case — the fix in 36e1b22 chose theelse socket._unrefTimer()branch of #3's suggestion rather than gating onisSocketNew, so theserverTimeoutMs > 0branch still unconditionally re-arms and does so afteremit('connection'). Comment #6 is about the arm being unreachable for CONNECT. Neither noted the ordering relative toemit('connection')for the first request, which is the primary observation here.Impact
Node-compat gap in the very feature this PR adds — not a regression vs pre-PR Bun (
NodeHTTPServerSocket.setTimeoutwas previously a no-op stub, so a user's per-connection override never worked anyway). Narrow trigger:server.timeout > 0and the user overrides per-connection via a'connection'listener orres.setTimeout.Fix
Move the arm to right after the socket is created/reused — above the
if (method === "CONNECT")block and beforeserver.emit('connection', socket)— and gate it onisSocketNew, matching Node'sconnectionListenerInternal:if (!socket) { socket = new NodeHTTPServerSocket(server, socketHandle, !!tls); } if (isSocketNew) { const serverTimeoutMs = server.timeout; if (serverTimeoutMs) socket.setTimeout(serverTimeoutMs); } else { socket._unrefTimer(); }
This also folds in the CONNECT-placement fix suggested in the other open comment on this line.
- Client connects and sends a request.
-
🟡
src/js/node/_http_incoming.ts:381— Read-side sibling of the write-refresh gaps flagged at lines 1083/934/714: this_unrefTimer()only fires oncehandle.ondatais wired inIncomingMessage._read(i.e. after the user starts consuming).onNodeHTTPRequestcallshandle.pause()before emitting'request', and while paused, incoming body bytes are buffered natively inon_buffer_request_body_while_paused(NodeHTTPResponse.rs) with no JS callback — so an actively-uploading client does not refresh#timeoutTimeruntil the handler starts reading. On non-Windowsdo_pause()also OS-pauses the socket so little data arrives anyway; on Windows it does not, making this a genuine regression there vs the pre-PR native idle timeout (which reset on socket-layer reads viaHttpContext.h:384). Consider also refreshing inonIncomingMessageResumeNodeHTTPResponse/ thedrainRequestBodypath in_read, which push the initial buffered chunk without calling_unrefTimer.Extended reasoning...
What the bug is
This is the read-side sibling of the write-side "activity must refresh the JS idle timer" gaps already flagged on this PR (open comments at
_http_server.ts:1083,:934,:714). The PR addsthis.socket?._unrefTimer?.()inonDataIncomingMessage(_http_incoming.ts:381), but that callback is only reachable oncehandle.ondata = onDataIncomingMessage.bind(this)is assigned — which happens inIncomingMessage.prototype._read(_http_incoming.ts:364-367), i.e. only after the user starts consuming the body via.on('data')/.pipe()/.resume().In
onNodeHTTPRequest, whenhasBodyis true,handle.pause()is called unconditionally before the'request'event is emitted. While paused, incoming body chunks are delivered to the native shimon_buffer_paused_shim→on_buffer_request_body_while_paused(NodeHTTPResponse.rs:1220-1234), which just appends tobuffered_request_body_data_during_pauseand sets a flag — no JS callback is invoked. So body bytes arriving during the paused window never call_unrefTimer(), and#timeoutTimercounts down as if the connection were idle.Even after the user starts consuming, the initial buffered chunk is delivered via
handle.resume()(inonIncomingMessageResumeNodeHTTPResponse) orhandle.drainRequestBody()(in_read), both of whichthis.push(...)directly without calling_unrefTimer(). Only subsequent chunks routed throughonDataIncomingMessagerefresh the timer.Step-by-step proof
const srv = http.createServer(async (req, res) => { await someAsyncAuth(req.headers); // takes 2s; body not yet consumed req.pipe(fs.createWriteStream('/tmp/upload')); }); srv.setTimeout(1000); // no 'timeout' listener → default destroy
- Client sends
POST /with a large body, streaming continuously. onNodeHTTPRequestruns:handle.pause()is called (hasBody true). Thenserver.timeout === 1000→socket.setTimeout(1000)arms#timeoutTimer. Thenserver.emit('request', ...).- The handler awaits async auth. Meanwhile the client keeps sending body bytes. Each chunk reaches
on_buffer_request_body_while_paused(NodeHTTPResponse.rs), which appends to the native buffer and returns — no JS callback → no_unrefTimer(). - At t≈1000ms
#timeoutTimerfires_onTimeout().response.bufferedAmountis 0 (no outgoing writes yet), so the guard is skipped.this.emit('timeout')→onNodeHTTPServerSocketTimeout:req.completeis false butreqhas no'timeout'listener →reqTimeout = false; nores/serverlistener either →this.destroy(). The actively-uploading connection is torn down.
Why existing safeguards don't help
onDataIncomingMessage's new_unrefTimer()is unreachable while paused:handle.ondatais only assigned in_read, which requires the user to start consuming.- The socket's own
#onData(which also refreshes) is only wired viakEnableStreaming(true)for CONNECT/upgrade tunnels; normal requests callkEnableStreaming(false)which setshandle.ondata = undefinedon the socket handle. _onTimeout'sbufferedAmount > 0guard checks the outgoing response buffer, not the inbound paused-body buffer.
Regression scope
This is primarily a Windows regression.
do_pause()atNodeHTTPResponse.rs:1019-1022is gated#[cfg(not(windows))]for thepause_socket()call:- Non-Windows: the OS socket is paused, so after the kernel/TCP buffers fill no further data arrives to buffer natively. Both pre-PR (native uWS timer) and post-PR (JS timer) see no read activity and would time out similarly — no behavior change.
- Windows: the OS socket is not paused, so uWS keeps delivering data into
on_buffer_paused_shim. Pre-PR, the native uWS timer atHttpContext.h:382-386applied a throughput-basedresetTimeout()on incoming body data (sinceinStreamis set), keeping a fast uploader alive. Post-PR, the JS timer sees none of it — a genuine regression.
The Node.js baseline also refreshes on socket reads:
net'sonStreamReadcallsself._unrefTimer()on every incoming chunk before the HTTP parser sees it. Node does eventually pause the socket onceIncomingMessage's highWaterMark (~16KB) fills viareadStopinparserOnBody, after which its timer also stops refreshing — so the gap is that Node reads (and refreshes on) up to ~16KB before pausing, while Bun pauses immediately with zero refreshes.How to fix
Same class as the other open comments — refresh the JS timer on every activity path. Options, cheapest first:
- Add
this.socket?._unrefTimer?.()inonIncomingMessageResumeNodeHTTPResponse(afterhandle.resume()returns data) and afterhandle.drainRequestBody()in_read, so the initial buffered chunk counts as activity. - In
_onTimeout, also check the paused-body buffer size (e.g. exposebuffered_request_body_data_during_pause.len()alongsidebufferedAmount) and treat a non-empty inbound buffer as "write in progress". - Cleanest: mirror Node and refresh from the native data-received path regardless of pause state — have the paused-buffer shim bump a JS-visible activity signal.
Filed as a nit: same bug class the author is already addressing per the other three open comments, narrow trigger (handler delays body consumption + short
server.setTimeoutwith no listener), and the regression is Windows-only in practice. - Client sends
…m, refresh on buffered-body drain _onTimeout now compares bufferedAmount against the last-observed value and only suppresses 'timeout' when they differ (progress), so a fully-stalled write still emits 'timeout' instead of looping the refresh forever. Arm the socket timer only on the first request on a connection (isSocketNew), matching Node.js's connectionListenerInternal, so a user 'connection' listener or earlier-request res.setTimeout is not re-clobbered on subsequent keep-alive requests; later requests only refresh. Refresh the socket timer when the initial paused-body buffer is drained in onIncomingMessageResumeNodeHTTPResponse and _read's drainRequestBody, so body bytes that arrived while the native handle was paused still count as activity once consumption starts.
|
Addressed the three additional findings in d066a09:
|
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/_http_server.ts:1189-1195— This guard now suppresses'timeout'wheneverbufferedAmount > 0, but unlike Node'snet.Socket._onTimeoutit doesn't check whether the buffer is draining — Node comparesthis[kLastWriteQueueSize]against the currenthandle.writeQueueSizeand only suppresses when they differ. A client that sends a request head then stops reading (slow-read / half-open TCP) leavesbufferedAmountstuck >0, so_onTimeoutself-refreshes via_unrefTimer()every N ms and never emits —server.setTimeout(N)is defeated for exactly the stalled-connection case it exists to reap. Track the previousbufferedAmounton the socket and only suppress when it has changed, or drop this guard entirely (the write-path_unrefTimer()calls added in 36e1b22 already keep an actively-writing response alive).Extended reasoning...
What the bug is
Commit 36e1b22 changed this guard from
response.writableLength > 0(alwaysundefined > 0→ false, dead code) toresponse.bufferedAmount > 0(a real getter onNodeHTTPResponse, now live) and simultaneously wired_onTimeoutup to a real timer via#timeoutTimer. The guard suppresses the timeout and re-arms the timer whenever any bytes are buffered on the socket, with no check for whether the write queue is actually making progress.Node.js's equivalent,
net.Socket.prototype._onTimeout(lib/net.js), does this instead:const lastWriteQueueSize = this[kLastWriteQueueSize]; if (lastWriteQueueSize > 0 && handle) { const { writeQueueSize } = handle; if (lastWriteQueueSize !== writeQueueSize) { this[kLastWriteQueueSize] = writeQueueSize; this._unrefTimer(); return; } } this.emit('timeout');
It only suppresses when the queue size has changed since the last check — i.e., the write is draining. If the queue is nonzero but unchanged (client stopped ACKing, TCP zero-window, network partition without RST, slow-read attacker), Node falls through to
emit('timeout')so the default destroy can reap the socket. There is no equivalent last-buffered-amount tracking anywhere insrc/js/.Step-by-step proof
const srv = http.createServer((req, res) => res.end(Buffer.alloc(1 << 20))); srv.setTimeout(1000); // no 'timeout' listener → default destroy
- Client connects, sends
GET / HTTP/1.1\r\nHost: a\r\n\r\n, then never reads the response (or its route dies without a FIN/RST). The 1 MB write backpressures;response.bufferedAmountsits at some nonzero value that never decreases. onNodeHTTPRequestarmedsocket.setTimeout(1000)at line 616, so#timeoutTimerfires_onTimeout()at t≈1000ms.handle.responseis the nativeNodeHTTPResponse(still attached —get_buffered_amount()returns 0 only whenREQUEST_HAS_COMPLETED || SOCKET_CLOSED, andREQUEST_HAS_COMPLETEDis set by uWS when the response is fully flushed, not whenhandle.end()is called).bufferedAmount > 0→this._unrefTimer()→#timeoutTimer.refresh()→ return.- At t≈2000, 3000, … the same thing happens.
'timeout'never fires,onNodeHTTPServerSocketTimeoutnever runs, the socket stays open until the OS TCP retransmit timer gives up (~15 min on Linux with defaulttcp_retries2).
In Node.js the same program emits
'timeout'at t≈2000ms (the second_onTimeoutseeswriteQueueSizeunchanged from the first) and destroys the socket.Why nothing else prevents it
Every other
_unrefTimer()call site this PR added —ServerResponse.write/_send(lines 2214/2379),allowWritesToContinue(line 2529),_write(line 1361),#onData/#onDrain(lines 1089/1100),onDataIncomingMessage,endSocketOnFinishIfNeeded— requires activity to fire. A truly stalled backpressured write generates none of those events, so_onTimeoutis the only thing running, and it self-perpetuates. The native uWS idle timeout is not a fallback: this PR removedsetServerIdleTimeoutand the server passesidleTimeout: 0toBun.serve.Why this is technically a regression
Before this PR,
server.setTimeout(N)armed the native uWS per-response idle timer (resp.timeout(config.idle_timeout)at mod.rs:1160), which resets on socket-level I/O. A stalled write produces no socket-level I/O, so uWS'sHttpContext::onTimeoutfired and unconditionallyclose()d the socket after ~N seconds. After this PR that path is gone and only the JS guard runs, which loops. (The pre-PR behavior was itself broken in the way this PR fixes — no listener veto, no idle-keepalive event — so this is a narrow regression inside a large net improvement.)Impact
server.setTimeout(N)is defeated for the slow-read / half-open-connection case it exists to reap. It's a resource-exhaustion vector but requires a backpressure-sized response and a non-reading client; nothing crashes or corrupts. Filed as a nit given the narrow trigger and trivial fix.Fix
Match Node — track the previous buffered amount on the socket and only suppress when it changed:
_onTimeout() { const response = this[kHandle]?.response; const buffered = response ? response.bufferedAmount : 0; if (buffered > 0 && buffered !== this.#lastBufferedAmount) { this.#lastBufferedAmount = buffered; this._unrefTimer(); return; } this.#lastBufferedAmount = 0; this.emit('timeout'); }
Alternatively, just drop the guard: the write-path
_unrefTimer()calls added in 36e1b22 (ServerResponse.write/_send/allowWritesToContinue) already keep an actively-writing response from timing out, which is the case this guard was meant to cover. - Client connects, sends
IncomingMessage.setTimeout delegates to socket.setTimeout since 4398e28, so setRequestTimeout has no JS consumer. Remove it from internal/http.ts and the jsHTTPSetTimeout host function / putDirect / extern decls from NodeHTTP.cpp, along with the Rust-side Request__setTimeout and NodeHTTPResponse__setTimeout exports it called.
|
CI on 3d84d87 is red on two lanes unrelated to this diff:
|
NodeHTTPServerSocket.setTimeout now validates msecs with getTimerDuration (like net.Socket.setTimeout), so invalid input throws ERR_INVALID_ARG_TYPE or ERR_OUT_OF_RANGE synchronously instead of arming a ~1ms timer and silently destroying the socket on the next tick. Also early-return on a destroyed socket, matching the sibling in net.ts. Request::set_timeout and its c_uint import became dead once ffi_set_timeout was removed in 2f5af30; drop them.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/js/node/_http_server.ts (1)
1345-1358: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winValidate timeout callbacks by presence, not truthiness.
setTimeout()should treatnull,false, and0as invalid callbacks here; gate oncallback !== undefined, validate once with$isCallable/$ERR_INVALID_ARG_TYPE, and use the same presence check for bothonce()andremoveListener().🤖 Prompt for 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. In `@src/js/node/_http_server.ts` around lines 1345 - 1358, The setTimeout method in _http_server.ts is checking callback truthiness instead of presence, which can let invalid values like null, false, or 0 slip through. Update the callback handling in setTimeout() to gate on callback !== undefined, validate the provided callback once with $isCallable and $ERR_INVALID_ARG_TYPE before using it, and apply the same presence-based check consistently for both once("timeout", callback) and removeListener("timeout", callback).Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@src/js/node/_http_server.ts`:
- Around line 1345-1358: The setTimeout method in _http_server.ts is checking
callback truthiness instead of presence, which can let invalid values like null,
false, or 0 slip through. Update the callback handling in setTimeout() to gate
on callback !== undefined, validate the provided callback once with $isCallable
and $ERR_INVALID_ARG_TYPE before using it, and apply the same presence-based
check consistently for both once("timeout", callback) and
removeListener("timeout", callback).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: d84621a7-4978-4fca-8d2d-3f4f8cad61ea
📒 Files selected for processing (2)
src/js/node/_http_server.tssrc/runtime/webcore/Request.rs
💤 Files with no reviewable changes (1)
- src/runtime/webcore/Request.rs
|
CI on b7f981c is red on two lanes unrelated to this diff:
|
|
Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-08, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main. |
Node's contract for
server.setTimeout(msecs)is that once a'timeout'listener is installed on the server, req, or res, the runtime does not destroy the socket when the timeout fires; the listener decides. Bun fired the event and tore the connection down anyway, and an idle keep-alive connection reaped by the server timeout never emitted'timeout'at all.Repro
Node keeps the connection open (the listener vetoes the destroy). Bun closed it ~4s after connect regardless.
Cause
Server.prototype.setTimeoutcalledsetServerIdleTimeout(server, ceil(ms/1000)), which sets the uWS per-connection idle timeout. When that fires,HttpContext::onTimeoutruns the JS callback and then unconditionallyclose()s the socket:So
onNodeHTTPServerSocketTimeout'sif (!reqTimeout && !resTimeout && !serverTimeout) this.destroy()was dead code: by the time it ran, the native close was already committed. For an idle keep-alive socket there is noNodeHTTPResponseattached, so no JS callback fired at all; the socket was closed silently.Fix
Drive the server timeout from an unref'd JS timer per connection, like Node.js.
Server.prototype.setTimeoutnow only storesthis.timeoutand registers the callback. Each request on a connection armssocket.setTimeout(server.timeout); the timer firessocket._onTimeout()->emit('timeout')-> the existingonNodeHTTPServerSocketTimeoutveto logic, which now actually decides. Activity (new request, body data, tunnel data/drain, response finish) refreshes the timer via_unrefTimer(), and the timer is cleared on close.The native idle-timeout path is no longer used for
node:http(the server already passesidleTimeout: 0toBun.serve).Verification
The one passing case on the old build is "with no listener, the socket is destroyed on timeout", which both implementations satisfy.