Skip to content

node:http: complete CONNECT and Upgrade requests at dispatch, deliver HEAD and TRACE bodies, clear parser.incoming on finish - #43461

Open
robobun wants to merge 2 commits into
mainfrom
robobun/e9172250/req-complete-handoff-head-body
Open

robobun wants to merge 2 commits into
mainfrom
robobun/e9172250/req-complete-handoff-head-body

Conversation

@robobun

@robobun robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On a node:http server, three flows report the completion of a request message differently from Node v26.3.0. Items 1, 2 and 5 of node:http server: req.complete and request completion still differ from Node (flows 4, 8 and 9 remain) #43455.
  • req.complete is false inside a 'connect' or 'upgrade' listener. The two hand-off paths of onNodeHTTPRequest (src/js/node/_http_server.ts) never set it.
  • A HEAD or TRACE request that declares a body loses it. NodeHTTPResponse__createForJS (src/runtime/server/NodeHTTPResponse.rs:2601) reads the framing headers only when method.has_request_body() || method == GET. HEAD and TRACE dispatch with hasBody === false, and the body bytes never reach req. Node delivers them.
  • With optimizeEmptyRequests: true, socket.parser.incoming keeps the last request alive while the keep-alive connection idles. Only emitEOFIncomingMessageOuter clears it, and a pre-dumped request never reaches that function.

Fix

  • The 'connect' path sets req.complete = true before the emit. The 'upgrade' path does the same for a request without a body. llhttp completes both at the end of the headers, so Node's listeners see true.
  • NodeHTTPResponse__createForJS reads Content-Length and Transfer-Encoding for every method. The uWS parser already consumes the body for every method, so the bytes now flow to req instead of being dropped.
  • emitResponseFinish clears parser.incoming when the request already ended, like Node's clearIncoming in resOnFinish. A request that ends later is still cleared by emitEOFIncomingMessageOuter.
  • Verified: five new tests in test/js/node/http/node-http.test.ts (all fail on 1.4.3, all pass under Node v26.3.0). Also node-http-connect, node-http-with-ws, node-http-server-abort-events, node-http-server-timeouts, node-http-transfer-encoding, node-http-backpressure, and the upgrade, connect, HEAD and optimizeEmptyRequests fixtures in test/js/node/test/parallel.

Background

  • hasBody is computed natively once per request. It decides whether the request holds a body_read_ref on the event loop and whether handle.ondata feeds req.
  • req.complete is Node's "message fully parsed" flag. Node sets it in parserOnMessageComplete, which llhttp calls at the end of the headers for CONNECT and for an Upgrade without a body.
  • socket.parser in Bun is a shim that mirrors Node's parser surface. Its incoming field is the last dispatched request.
  • Items 3 and 4 of node:http server: req.complete and request completion still differ from Node (flows 4, 8 and 9 remain) #43455 (body buffering while req is paused, lazy EOF push) follow the current design and are not changed here.
Notes

Results of the script from #43455 (complete.js) on this branch:

connect: {"listener":true,"nextTick":true}
upgrade: {"listener":true,"nextTick":true}
head:    {"nextTick":false,"after100ms":false}
paused:  {"complete":false,"readableLength":0}   (item 3, unchanged)
ended:   {"complete":false,"ended":false}        (item 4, unchanged)
parser:  {"parserKeepsRequest":false}

HEAD / HTTP/1.1 with Content-Length: 5 and the 5 bytes: req now delivers hello (was an empty body).

#43456 sets req.complete on the normal dispatch path for a request without a body. It relies on hasBody, so with this change it no longer reports true early for HEAD and TRACE with a declared body. The two changes touch different lines.

node-http-connect.test.ts: "should handle backpressure" and "tests should run on bun" time out at 5 s in this ASAN debug build on main too (64 MB through a tunnel).

@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for one more day.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 3 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0b8e2686-023a-47b1-a413-9c22e4769077

📥 Commits

Reviewing files that changed from the base of the PR and between 26e7a4b and 0124bb1.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • src/runtime/server/NodeHTTPResponse.rs
  • test/js/node/http/node-http.test.ts

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

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

I worked on item 1 of #43455 at the same time, on the branch robobun/a94266f9/req-complete-connect-upgrade (commit f0eb074). This PR covers that item, so I do not open a second PR. One difference from that branch matters here.

Set the flag together with the EOF push. The 'connect' and 'upgrade' lines of this PR are the same as the first version of my branch. A review of that version found a state that Node cannot produce. For a CONNECT request with Content-Length: 5, req.complete is true in the listener, but req still gets the tunnel bytes as 'data' and never emits 'end' when the listener reads it. hasBody is true for that request, so IncomingMessage._read registers handle.ondata, and the native side feeds it the tunnel.

The branch does this instead:

  • src/js/internal/http.ts: export emitEOFIncomingMessageOuter under the name completeIncomingMessage. It is the existing step that sets complete, takes the trailers and calls push(null), like Node's parserOnMessageComplete.
  • src/js/node/_http_server.ts: call completeIncomingMessage(http_req) at both hand-off sites, after releaseServerParserShim(socket, http_req). Always for CONNECT, and for Upgrade when !hasBody. After the release socket.parser is null, so the step schedules no parser.incoming tick.

Results, compared with Node v26.3.0:

  • req._readableState.ended is true inside both listeners. Node gives true. The flag alone leaves it false.
  • A CONNECT request that declares a body ends when it is read, and it gets no tunnel bytes. I ran nine rows: Content-Length, chunked and plain, each with a flowing reader, a 'readable' reader, and a pause() with a late resume(). All nine match Node. On main, and with the flag alone, the Content-Length and chunked rows put the tunnel bytes into req and never end.
  • After EOF, read() does not reach _read, so handle.ondata stays unset for that request. No row produced ERR_STREAM_PUSH_AFTER_EOF.

The commit has two tests in test/js/node/http/node-http.test.ts that this PR can take:

  • One pins complete and _readableState.ended for 'request', 'connect', CONNECT with Content-Length, 'upgrade', and an Upgrade whose body arrives later.
  • One pins that a CONNECT request with a declared body ends when it is read, and that the tunnel bytes reach only the socket.

Both tests fail on bun 1.4.3-canary and pass as a script on Node v26.3.0. With the change, node-http.test.ts passes in full on a debug build (164 pass, 1 skip), and so do Node's test-http-upgrade-* and test-http-connect* tests.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked two things this diff raises and ruled them out: a HEAD/TRACE request that declares Content-Length but never sends the bytes now leaves req waiting for 'end' instead of ending at dispatch — that matches Node, where llhttp frames the body by the headers for every method; and the parser.incoming overwrite on pipelined dispatch (_http_server.ts:734) is pre-existing and unchanged, the new clearing in emitResponseFinish only nulls it when it still points at the finishing request.

Extended reasoning...

The Rust change removes the method gate so has_body is derived from Content-Length/Transfer-Encoding alone, which is what llhttp does; the consequence that a HEAD with a declared-but-unsent body stalls the request stream is Node-consistent rather than a regression introduced here. The parser.incoming clearing is guarded by identity (parser.incoming === req), so it cannot drop a later pipelined request that has already replaced it; the existing line-734 overwrite behavior is unchanged by this PR. The confirmed inline finding about req.complete remaining false for optimizeEmptyRequests requests is the substantive open item, so a human should still weigh in.

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

  • 🟣 src/js/node/_http_server.ts — With optimizeEmptyRequests, users still read req.complete === false after the response finishes, while Node reports true; this PR now also drops parser.incoming for that same request at _http_server.ts:2387-2389, so the object looks finished to the parser but unfinished to the user. _dumpAndCloseReadable (_http_incoming.ts:745-752) marks the readable ended and closed but never sets complete, and the pre-dumped request never reaches emitEOFIncomingMessageOuter where complete is set (src/js/internal/http.ts:122). Fix: set req.complete = true wherever the message is known fully parsed at dispatch (the pre-dump at :945 and the no-body dispatch path), not only the CONNECT/upgrade branches this PR touched.

    Extended reasoning...

    Server created with optimizeEmptyRequests: true. A GET without Content-Length or Transfer-Encoding dispatches; _http_server.ts:940-946 calls http_req._dumpAndCloseReadable(). readableEnded becomes true (endEmitted at _http_incoming.ts:748), complete stays false from the constructor (:82). Handler ends the response; emitResponseFinish at :2387 sees readableEnded true and nulls parser.incoming. Any code reading req.complete afterwards (or the PR's own complete.js script, whose item for the normal body-less path stays false) sees false. Node's parserOnMessageComplete sets complete before the response ever finishes. The PR sets complete on the CONNECT (:760) and upgrade (:980) hand-offs using the same 'llhttp completes at end of headers' rule but not on this sibling site, the exact 'fix the whole class' gap. The dismissing finders deferred to #43456, an unmerged claim; if it lands first it gates on hasBody and does not cover the pre-dumped request either. Population: every request on an optimizeEmptyRequests server, at request rate. Remedy: set http_req.complete = true next to…

    Verification: pre-existing. Trigger: a server created with optimizeEmptyRequests: true receives a request with neither Content-Length nor Transfer-Encoding. Mechanism verified: /home/claude/bun/src/js/node/_http_server.ts:940-946 calls http_req._dumpAndCloseReadable(), which at /home/claude/bun/src/js/node/_http_incoming.ts:745-752 sets _readableState.ended/endEmitted/destroyed/closed/closeEmitted…

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

On the optimizeEmptyRequests finding: req.complete for a request without a body on the normal dispatch path is what #43456 sets. It does so after the listener returns, because Node's parserOnMessageComplete runs after parserOnIncoming emitted 'request', so inside the listener the flag is still false even with optimizeEmptyRequests. Setting it at the pre-dump site would turn it true inside the listener, which Node does not do. This PR stays with the hand-off paths, the HEAD and TRACE body, and parser.incoming. The two changes touch different lines.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:21 AM PT - Sep 19th, 2026

✅ @robobun, your commit 0124bb161ace008f568381463041429753c50d6d passed in Build #118342! 🎉


🧪   To try this PR locally:

bunx bun-pr 43461

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

bun-43461 --bun

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

I worked on the HEAD and TRACE body (item 2 of #43455) at the same time, on the branch robobun/52594c22/node-http-head-trace-request-body (commit 6ff01a4). The change to NodeHTTPResponse__createForJS is the same as in this PR, so I do not open a second PR.

That branch removes the method gate in NodeHTTPResponse__createForJS. It adds four test cases: HEAD and TRACE, each with Content-Length: 5 and with Transfer-Encoding: chunked.

Two differences from this PR:

  • The tests here cover Content-Length only. The gate also skipped request_ref.has_transfer_encoding(), so the branch has a chunked case for each method (5\r\nhello\r\n0\r\n\r\n). Take it from the branch if you want that arm covered.
  • The doc comment of Method::which (src/http_types/Method.rs:214) still says that the lookup is inlined into NodeHTTPResponse.createForJS. After this change that function does not call it. The branch rewords the comment.

I also compared the native change with Node v26.3.0 for HEAD and TRACE in these flows. Each result is the same as Node's:

  • The listener ignores the declared body and calls res.end(). A second request on the same connection is dispatched.
  • The body arrives after the response went out. The next request is still dispatched.
  • The client closes after 2 of 5 body bytes. req emits 'aborted', then 'error' with ECONNRESET, then 'close' with complete === false. Before the change it emitted 'end', then 'close' with complete === true.
  • Two pipelined requests with bodies arrive in one write (Content-Length, then chunked).
  • A 1 MB body arrives while the listener calls req.pause() and req.resume() on each chunk.

An Upgrade request with a body now gives the same result for HEAD and TRACE as for GET and POST. The bytes after the body reach the socket as data, not as the head argument. That differs from Node for every method, and #35983 covers it.

@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

This PR and #43555 edit the same condition in NodeHTTPResponse__createForJS, and the two edits must be combined at merge time.

  • This PR removes the method check, so the framing headers decide has_body for every method. That is right for HEAD and TRACE.
  • For a CONNECT it computes the same value as main, because Method::has_request_body() is already true for CONNECT. A CONNECT with Content-Length above 0 or Transfer-Encoding stays has_body = true.
  • node:http: give the request of a CONNECT no body #43555 makes a CONNECT body-less. The parser tunnels every byte after a CONNECT head, so the body state stays Pending for the whole tunnel. After one socket.pause(), each tunnel chunk is also copied into buffered_request_body_data_during_pause, and nothing drains it. Pause, resume, then a 256 MB upload on one debug build: 842 MB RSS with Content-Length: 10, 383 MB without. A reader on req also gets every tunnel byte.

The combined condition is: read the framing headers for every method except CONNECT.

let method = HttpMethod::which(request_ref.method()).unwrap_or(HttpMethod::OPTIONS);
if method != HttpMethod::CONNECT {
    *has_body = req_len > 0 || request_ref.has_transfer_encoding();
}

The PR that lands second must resolve the conflict this way. This PR also deletes the HttpMethod import, which that condition needs. The 8 tests that #43555 adds to test/js/node/http/node-http-connect.test.ts fail if the CONNECT check is lost.

One more point, from the two diffs and not from a run of this branch: the 'connect' path here sets req.complete = true for every CONNECT. For a CONNECT with framing headers the native body state is still Pending, so req is complete and can still emit 'data' with tunnel bytes. With the CONNECT check above, that case is gone.

Jarred-Sumner pushed a commit that referenced this pull request Sep 20, 2026
…g received (#43561)

### Problem
- A request whose body stalls behind a pending pipelined response never
gets 'timeout'. Its `req.setTimeout(ms, cb)` callback never runs and the
server destroys the socket. Node v26.3.0 emits 'timeout' on it (#43455,
item 6).
- `onNodeHTTPServerSocketTimeout` (`src/js/node/_http_server.ts:221`)
reads `socket[kRequest]`. That slot held the request whose response owns
the socket. With pipelining that request is already complete.

### Fix
- The dispatcher sets `kRequest` for every request, and
`advanceResponsePipeline` no longer overwrites it. `detachSocket` still
clears it when that request's response detaches.
- The handler does not read Node's `parser.incoming`. That slot must
outlive the response, and a request that `stream.pipeline()` destroyed
never ends. In the first version of this PR it kept getting 'timeout'
and held the idle socket open.
- Self-reviewed: 5 concerns, 2 closed by probes, 3 left to separate work
(Notes). Two edges are new. A pipelined request now gets 'timeout' while
paused with its whole body received (#43557), or after `pipeline()`
destroyed it. A request that is not pipelined already does.
- Verified: three new tests in
`test/js/node/http/node-http-server-timeouts.test.ts`. Two fail on
1.4.3-canary, the third on the first version. Also the timeout and
pipelining fixtures, and `node-http.test.ts`.

### Background
- `req.setTimeout` and `server.timeout` arm one inactivity timer per
socket. The server forwards its 'timeout' to the request, the response
and the server. With no listener, it destroys the socket.
- With pipelining, the next request arrives before the previous response
ends. Later responses wait for the socket.
- `stream.pipeline()` destroys a failed server request with `req.socket
= null`, so the connection survives for the error response.

<details><summary>Notes</summary>

#### Repro

From #43455, item 6: `GET /a` is never answered, `POST /b` with
`Content-Length: 100` sends 10 bytes, and only the POST calls
`req.setTimeout(200, cb)`.

```
node v26.3.0:  ["POST /b 'timeout' (complete=false)"]
1.4.3-canary:  ["client socket closed by the server"]
this branch:   ["POST /b 'timeout' (complete=false)"]
```

#### The first version, and why `kRequest` stays

The first version (cfc4149) made the handler read
`this.parser?.incoming`, like Node's `socketOnTimeout`, and removed
`kRequest`. The review found a regression against the base. An upload
handler calls `req.setTimeout(ms, cb)` and `stream.pipeline(req, dest,
cb)`. The destination fails, so `pipeline()` destroys `req` with
`req.socket = null`. `_destroy` releases the native handle, so
`req.complete` never becomes `true`, and 'end' never fires. The handler
answers 500, the client sends the rest of the body and idles. The
destroyed request stayed in `parser.incoming`, got 'timeout' at the
keep-alive timeout, and its listener kept the idle socket open. Node and
the base close that socket.

`parser.incoming` cannot be cleared when the response detaches.
`test-http-server-keepalive-end` reads it inside an 'end' listener after
a synchronous `res.end()`, and expects the request there. So the timeout
target needs its own slot with its own end of life, and that is what
`kRequest` is. 0e3a237 restores it and fixes its lifecycle for
pipelining. The result for a connection without pipelining is the same
as on main by construction: set at dispatch, cleared at the detach of
that response.

#### Probe, 15 scenarios

Who sees 'timeout', and does the server close the socket. Each build is
compared with Node v26.3.0. The first version and the current version
give the same results here.

| Scenario | 1.4.3-canary | This branch |
| --- | --- | --- |
| Stalled POST pipelined behind a pending GET, only the POST listens |
differs | same as Node |
| Same wire, listeners on both requests, both responses and the server |
differs (no `req POST /b`) | same as Node |
| Two pending GETs, then a stalled POST | differs | same as Node |
| Early `res.end()` for a POST whose body never arrives, then the
keep-alive timeout | differs | differs |
| Single complete POST that the listener paused | differs | differs |
| Pipelined complete POST that the listener paused | same as Node |
differs |
| Nine more (see below) | same as Node | same as Node |

The nine: single stalled POST, single complete GET, single complete POST
(read, and unread), pipelined complete GET, pipelined complete POST
(read, and unread), first response answered then stalled POST,
`optimizeEmptyRequests`.

More scenarios that match Node on this branch and differ on the canary:
an HTTPS server, `server.setTimeout(ms)` with a request listener that
keeps the socket, a chunked POST that stalls inside a chunk, a pipelined
request that `maxRequestsPerSocket` drops ('dropRequest'), a pipelined
`Expect: 100-continue` request in 'checkContinue', a pipelined POST
larger than the high water mark that nobody reads, and a pipelined POST
that gets 'timeout', keeps the socket, then completes (the client
receives both responses, the canary never answers).

#### A request that `stream.pipeline()` destroyed

| Flow | Node v26.3.0 | 1.4.3-canary | This branch |
| --- | --- | --- | --- |
| Response finished, the rest of the body arrives | closes the idle
socket | closes | closes |
| Response finished, the body stalls | 'timeout' on the request | closes
| closes |
| Response pending, pipelined, the rest arrives | socket destroyed |
socket destroyed | 'timeout' on the request |
| Response pending, pipelined, the body stalls | 'timeout' on the
request | socket destroyed | 'timeout' on the request |
| Response pending, not pipelined, the rest arrives | socket destroyed |
'timeout' on the request | 'timeout' on the request |
| Response pending, not pipelined, the body stalls | 'timeout' on the
request | 'timeout' on the request | 'timeout' on the request |

With a pending response, a pipelined request now behaves like a request
that is not pipelined. A `!req.destroyed` check in the handler fixes the
"rest arrives" rows and breaks the "stalls" rows, so it is not in this
PR. The cause is that `req.complete` never becomes `true` after
`_destroy` releases the handle.

#### The differences that remain all come from `req.complete`

- Early `res.end()`: Bun ends a request when its response ends, whether
the listener reads `req` or not. `req.complete` is `true` and 'end'
fires while the declared body is still missing. Node keeps `complete ===
false` until the parser finishes the message. This is tracked
separately.
- Paused POST: the native handle keeps the body and its end to itself
until `req` resumes, so `req.complete` stays `false` (item 3 of #43455,
fixed in #43557). A check of the native body state in the handler would
hide this for 'timeout' only. The other readers of `req.complete` would
keep the difference.
- Destroyed request: see the table above.

#### Self-review, the 5 concerns

1. Does the slot keep the last request alive on an idle keep-alive
connection? No. A probe with `FinalizationRegistry` and forced GC gives
the same result on the canary and on this branch for six request shapes,
and no symbol slot of the socket holds the request. Only the
`optimizeEmptyRequests` request stays reachable through
`parser.incoming`, on both (item 5 of #43455, fixed in #43461).
2. Do the new tests depend on how the wire data is split across reads?
No. One write, three writes, one byte per write, and a split inside the
POST head all give the same events, under Node and on this branch. The
new tests also passed every stress run on the debug build at a host load
above 100.
3. A pipelined request that is paused after its whole body arrived now
gets 'timeout'. Left to item 3 of #43455.
4. An early `res.end()` ends the request in Bun, so that request still
gets no 'timeout'. Left to separate work.
5. The 'connect' and 'upgrade' hand-off removes the 'timeout' listener
even while the body of an Upgrade request still arrives. Node keeps its
listener until that body ends. This is the same before and after this
change. Left to separate work.

#### Review findings

- Regression with a request that `pipeline()` destroyed: fixed, see
above.
- Two code comments were longer than one line: one is now one line, the
other is back to the text on main.
- The third test passed on the base and could pass on an early close: it
now uses the pipelined shape and asserts `Connection: keep-alive` on
both responses. It still passes on the base. It fails on the first
version, and it guards the reason `kRequest` stays.
- Two optional findings (paused request, destroyed request with a
pending response) are the `req.complete` edges above. No change here.

Seen on the way, not related to this change: when the socket is
destroyed, a queued pipelined response emits 'close' before the socket's
'close'. Node emits it after. The canary and this branch agree with each
other.

Merge check: `git merge-tree` of this branch with #43456, #43461 and
#43557 reports no conflicts.

#### Suites run with the debug build

- `test/js/node/http/node-http-server-timeouts.test.ts`,
`node-http.test.ts`, `node-http-server-abort-events`,
`node-http-req-socket-pause`, `node-http-transfer-encoding`,
`node-http-uaf`
- `test/js/node/test/parallel`: `test-http-set-timeout-server`,
`test-http-set-timeout`, `test-http-timeout`,
`test-http-timeout-overflow`, `test-http-outgoing-settimeout`,
`test-http-server-consumed-timeout`, `test-http-server-keepalive-end`,
`test-http-server-keep-alive-timeout`, the four
`test-http-keep-alive-timeout*`,
`test-http(s)-server-close-destroy-timeout`, the seven
`test-http-server-request-timeout-*`, the four
`test-http-server-headers-timeout-*`,
`test-https-server-headers-timeout`, the nine pipelining fixtures
(`test-http-pipeline-*`, `test-http-get-pipeline-problem`,
`test-http-incoming-pipelined-socket-destroy`,
`test-http-keep-alive-pipeline-max-requests`,
`test-http-many-ended-pipelines`), and the fallback fixtures
(`test-http2-allow-http1`, `test-http2-https-fallback*`,
`test-http-generic-streams`, `test-http-insecure-parser-per-stream`,
`test-http-max-header-size-per-stream`,
`test-http-server-unconsume-consume`)
-
`test/js/node/test/sequential/test-http-server-request-timeouts-mixed.js`
- `test/js/bun/test/parallel/test-http-should-emit-timeout-event.ts`,
`test-http-should-emit-timeout-event-when-using-server-setTimeout.ts`,
`test-http-timeout-destruction-should-be-visible-using-kConnectionsCheckingInterval.ts`

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 1 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-server-timeouts.test.ts
bun test v1.4.3 (367d939)

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 [776.10ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [436.27ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [327.54ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1383.04ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [1065.35ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [350.55ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [612.56ms]
312 |     const cl
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (b3bf769)

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 [265.18ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [354.35ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [204.31ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1204.71ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [1006.91ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [254.45ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [406.27ms]
(pass) node:http server timeout enforcement > a pipelined request that is still being received gets 'timeout' and can keep the socket [204.20ms]
(pass) node:http server timeout enforcement > wi
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-server-timeouts.test.ts
bun test v1.4.3 (367d939)

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 [836.05ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [415.19ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [349.50ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1430.25ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [1124.03ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [347.78ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [594.68ms]
(pass) node:http s
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1054ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[2/126] gen JS modules (bundle-modules)
Preprocess modules (8567ms)
Bundle modules (102ms)
Postprocesss modules (307ms)
Bundle Functions (607ms)
Generate Code (64ms)

[9.66s] Bundled "src/js" for production
  2606 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/9] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/node/_http_server.ts                        |   6 +-
 .../js/node/http/node-http-server-timeouts.test.ts | 136 +++++++++++++++++++++
 2 files changed, 138 insertions(+), 4 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                                 reads  edits  tests
src/js/node/_http_server.ts                             11     12     22
test/js/node/http/node-http-server-timeouts.test.ts      3      3     22
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 26, 2026

Copy link
Copy Markdown
Collaborator Author

Update to my note above: it no longer applies. #43557 is merged (5d5f03f), and #43555 is closed. On main, NodeHTTPResponse__createForJS has the combined condition: the framing headers decide for every method except CONNECT.

I built main and checked two of the changes of this PR there:

  • A HEAD and a TRACE request with Content-Length: 5 deliver hello to req.
  • req.complete is true inside the 'connect' listener.

I did not check the other changes of this PR against main.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant