Repository navigation
node:http: a request gets its body behind the FIN of socket.end() - #44419
Conversation
…r buffered bytes The native end() of the server socket defers its shutdown while uWS holds unsent bytes. For destroySoon() the drain gate of HttpContext::onWritable closed the connection. For a plain end() on a response that never ends, no arm of the gate matched, so the FIN was never sent. The deferral now sets HTTP_NODE_SHUTDOWN_AFTER_DRAIN. When the bytes are out and a response is still in flight, the gate calls JSNodeHTTPServerSocket::halfClose(): the FIN goes out and the socket stays until the peer ends. If the peer ended first, the socket closes behind the FIN. The connection reads behind that FIN as it did before it: - HttpContext::onData keeps parsing, so the rest of a request body reaches the request. - The loop reports the FIN of the peer to HttpContext::onEnd before it closes the socket (us_socket_t::end_after_shutdown). The socket emits 'end', and a request that the FIN cuts gets its 'clientError'. - A pause of flood prevention ends at the FIN. Unsent bytes and the queue of responses can no longer end it. A request body that paused the reads keeps its pause. A response gets no 'drain' once its socket has ended, as in Node. A writer that continues on 'drain' can then no longer hold the FIN back.
… the one that waits The connection reads on behind the FIN of every socket.end(). A request gets the body that arrives there, and an Upgrade request with a body becomes a tunnel when that body ends. A body that nothing reads is dropped when the buffer of the request is full, so that the peer's FIN closes the connection.
…top the reads for ever
StatusThis pull request is a draft while it is reworked. Its current head ( The rework is narrower. It keeps one rule: on a Out of this pull request: reads behind the FIN and the Upgrade request with a body (#45058 covers that), and the request timeout window behind the FIN. I will replace this comment with the reproduction and the measured outputs when the rework is pushed. |
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp— A handler that paused a request body and then calls socket.end() receives up to one extra 512 KB read pushed past the 16 KB highWaterMark, where the base delivered nothing after the FIN. halfClose at src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp:391-401 calls us_socket_pause then us_socket_resume unconditionally for the immediate path (keepReadPause false at JSNodeHTTPServerSocketPrototype.cpp:314), lifting the body pause set at src/js/internal/http.ts:200-203, and the next read is parsed because readsBehindOwnFin is now true. Fix: pass keepReadPause true (or skip the resume when socket->flags.is_paused was set by a body pause) on the nothing-queued end() path, as halfCloseAfterDrain already does.Why this was flagged
Trigger: a request body larger than the IncomingMessage highWaterMark; push() fails at src/js/internal/http.ts:190 and the socket is paused at :200-203. The handler then calls req.socket.end() with nothing queued. jsFunctionNodeHTTPServerSocketEnd at JSNodeHTTPServerSocketPrototype.cpp:314 calls halfClose(globalObject, false); cycleReads at JSNodeHTTPServerSocket.cpp:391 is true, so :401 calls us_socket_resume and clears flags.is_paused. On the base the same resume happened but HttpContext.h:342 dropped every read behind the FIN. On this branch deferShutdownUntilResponseDrains at :356 set HTTP_NODE_SHUTDOWN_AFTER_DRAIN first, so the next read (up to the 512 KB read buffer) is parsed and pushed into a stream whose consumer asked for backpressure; only then does :195-203 re-pause or stopParsing. The deferred-FIN path avoids this with keepReadPause true at :1116. Remedy: pass keepReadPause true from the nothing-queued end() path as well.
Verification: The request body already filled the IncomingMessage buffer and the handler calls req.socket.end() with nothing queued. jsFunctionNodeHTTPServerSocketEnd (JSNodeHTTPServerSocketPrototype.cpp:314) calls
halfClose(globalObject, false); cycleReads at JSNodeHTTPServerSocket.cpp:391 is true, so :400us_socket_resume(socket)clears is_paused and one more read (≤ 524288) is parsed into the already-full IncomingMessage. -
🟡
packages/bun-uws/src/HttpContext.h— On Windows a connection that stopped parsing mid-read keeps the event loop alive until the peer closes; the socket also stays in a cork slot. stopParsing() is now called from JavaScript inside a read (src/js/internal/http.ts:197 and src/js/node/_http_server.ts:969); when the same read holds a later request head, the request lambda returns nullptr at packages/bun-uws/src/HttpContext.h:474, so returnedData is null and the us_socket_unref and uncork at HttpContext.h:810/844 are skipped. The ref taken at the top of onData is uv_ref on libuv. Fix: when HTTP_NODE_PARSING_STOPPED ends a parse mid-read, run the same unref and uncork the closed-socket branch at HttpContext.h:459-460 runs before returning nullptr.Why this was flagged
Trigger: a client sends a request whose body is larger than req's high-water mark together with a pipelined request head in one read, the handler calls req.socket.end() with nothing queued, and nothing reads the body. onDataIncomingMessage at src/js/internal/http.ts:195-197 sees finSent and calls socket[kHandle].stopParsing(), setting HTTP_NODE_PARSING_STOPPED. The parser then reaches the next head in the same read and the request lambda returns nullptr at packages/bun-uws/src/HttpContext.h:474 without the unref/uncork the other early return does at HttpContext.h:459-461. onData's returnedData is therefore null (HttpContext.h:796) and the us_socket_unref at :810 is skipped. On libuv (Windows) us_socket_ref is uv_ref, so this socket keeps the loop alive until it closes, which happens only at the peer's FIN (loop.c:927). On the base this nullptr return was reached only through onSocketCloseStopParsing, a path the user triggers explicitly; the diff makes it a normal consequence of socket.end() with an unread body.
Verification: packages/bun-uws/src/HttpContext.h:328 takes
us_socket_ref(s)at the top of onData; theHTTP_NODE_PARSING_STOPPEDcheck at lines 473-474 does a barereturn nullptr;with no unref, so theus_socket_unrefat line 810 is skipped. On Windowsus_socket_ref/unrefareuv_ref/uv_unref(packages/bun-usockets/src/socket.c:813-818, 858-863). The cork claim is wrong: line 902 uncorks on the nullptr path.
…er server.close() A full buffer drops the rest of a body behind the FIN only when nothing reads it. A reader gets backpressure, also after server.close().
…n flight and no other request A request that arrives behind the FIN gets no response, so it is not dispatched. A request body that nothing reads pauses the reads while the FIN waits and is dropped behind it. requestTimeout ends a connection that its peer does not end.
There was a problem hiding this comment.
The two points from the earlier round (a slower-than-peer reader losing its body at server.close(), and the new cases running serially) are addressed by the later commits. Beyond the inline findings, I also checked the new us_socket_unref in the HTTP_NODE_PARSING_STOPPED early return of the request lambda in packages/bun-uws/src/HttpContext.h: it is the only nullptr return from that lambda that leaves the socket open without an unref, parsing stops there so it runs at most once per onData, and it mirrors the isDrainingBeforeClose path, so the onData ref stays balanced. I also checked that the requestTimeout re-arm in stopReadsBehindOwnFin is reachable only behind this side's FIN (both call sites gate on us_socket_is_shut_down plus HTTP_NODE_SHUTDOWN_AFTER_DRAIN), so ordinary keep-alive connections do not pick up the new ERR_HTTP_REQUEST_TIMEOUT.
Extended reasoning...
Findings-present ruled-out note: the onData ref/unref balance on the new early return and the reachability of the timeout re-arm were traced through HttpContext.h, HttpResponseData.h and JSNodeHTTPServerSocket.cpp and found consistent; the three confirmed inline findings stand on their own.
Findings marked 🟡 are optional suggestions and need no follow-up push.
| // No `impl Default` on purpose: only `is_single_line`/`close_brace_loc` have | ||
| // sensible defaults; `properties` does not, so every constructor must supply it. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// The tape rows that hold [`Self::properties`]. Code that keeps the tape alive longer than | ||
| /// this node can store the range and read it back with [`JsonTape::properties`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Text on the line, avoiding the need to refetch the source code. It may be prefixed by the | ||
| /// preceding lines, which are printed too. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Returns a source line as it is shown in a diagnostic: control characters other than tabs are | ||
| /// written as escape sequences such as `\x1b`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Width in terminal columns of the first `units` UTF-16 code units of `line`, as `printable` | ||
| /// renders them. A tab counts as one column. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// RFC 5929 §4.1 `tls-server-end-point`: hashed with the digest of the signature algorithm, | ||
| /// SHA-256 in place of MD5 and SHA-1. `None` when the algorithm names no digest (Ed25519). |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| //! `ArenaBox<'a, T>` — an owning pointer to one block of a [`MimallocArena`], | ||
| //! as wide as `Box<T>`: 8 B for a sized `T`, 16 B for a slice. | ||
| //! | ||
| //! `Box<T, &'a MimallocArena>` stores the allocator handle next to the pointer | ||
| //! (16 B / 24 B). The handle is only needed to allocate: `mi_free` finds the | ||
| //! heap of a block from its address, which `<&MimallocArena as | ||
| //! Allocator>::deallocate` and [`crate::transfer_arena`] already rely on. So an | ||
| //! `ArenaBox` is allocated through an arena that is passed in, and frees its | ||
| //! block without one. The lifetime keeps it from outliving the arena. | ||
| //! | ||
| //! Dropping it drops the value and returns the block to the heap at once, from | ||
| //! any thread. What is still allocated when the arena is dropped is bulk-freed | ||
| //! without `Drop`, like everything else in an arena. | ||
| //! | ||
| //! Not `Clone`, because a clone has to be allocated: see | ||
| //! [`ArenaBox::copy_from_slice_in`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Frees a block of any [`MimallocArena`], and allocates nothing. Zero-sized. | ||
| /// | ||
| /// Private, and not `Clone`: `Box<T, A>: Clone` would allocate through it. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// If the iterator reports its exact length, one block of exactly that | ||
| /// size. Otherwise the block is resized at the end. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// The elements in a block of their exact size. They are copied only if the allocator moves | ||
| /// the block to resize it. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Kills the process with `sig`'s default action. Exits with 128 + `sig` if the kernel discards | ||
| /// the signal (PID 1 in a pid namespace). Async-signal-safe. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// True if TLS should also trust the system's certificate authorities. `root_certs.cpp` reads this | ||
| /// when it builds the default certificate store. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// True if there's enough stack left to write one more nested expression, statement or type. If not, this sets | ||
| /// `ran_out_of_stack`, and the document must be discarded. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Sets whether the content being written is inside a call to a function that doesn't take classes. Returns the | ||
| /// previous value, if there is a context. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| //! ESTree node fields, as methods on the handles. | ||
| //! | ||
| //! Use `match e.kind()` to destructure a node. These methods are for checking one field of a node whose kind is known, | ||
| //! usually a parent: `parent.object() == Some(e)`. Each returns `None`, or an empty list, for a node without that | ||
| //! field. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `.argument` of a `UnaryExpression`, an `UpdateExpression`, an `AwaitExpression`, a | ||
| /// `YieldExpression` or a `SpreadElement`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `.expression` of a `TSAsExpression`, a `TSSatisfiesExpression`, a `TSTypeAssertion`, a | ||
| /// `TSNonNullExpression` or a `TSInstantiationExpression`, and the index of a | ||
| /// `ComputedMemberExpression`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `.typeAnnotation` of a `TSAsExpression`, a `TSSatisfiesExpression` or a `TSTypeAssertion`. | ||
| /// `None` for `as const`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| //! [`Format`] impls for the `bun_lint::ast` handles. | ||
| //! | ||
| //! `write!(f, expr)` runs the steps that are common to every node, and calls the `print/` function for this kind of | ||
| //! node: | ||
| //! 1. If the node is ignored (`// prettier-ignore`), its source text is copied instead of steps 4 to 6. | ||
| //! 2. `/** @type {T} */ (e)` keeps its parentheses. | ||
| //! 3. The leading comments of the node. | ||
| //! 4. `(`, if the node needs parentheses in this position: `parentheses/`. | ||
| //! 5. The node. | ||
| //! 6. `)` | ||
| //! 7. The trailing comments of the node. | ||
| //! | ||
| //! Most nodes have no comment anywhere near them. While [`Formatter::is_quiet`] is true, only steps 4 to 6 run. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// True while writing a node that has no comment inside its span, and no node inside it that owns a comment from | ||
| /// elsewhere. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // The nodes that contain the cursor region, and the nodes right before and after it, aren't quiet, so this | ||
| // still gets called for them. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Returns the span from Prettier's `locStart` to its `locEnd`. It includes the decorators before an `export` and | ||
| /// excludes the `;` of a JavaScript statement. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // These end where their last body ends. If that body is an empty statement, its `;` is the statement itself and | ||
| // stays in the span. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Writes `node` with the comments it owns: Prettier's `printComments`. `write` writes the node itself. If the node is | ||
| /// ignored, its source text is copied instead. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Writes an ignored node with the comments it owns. `write_source_text` copies the node, and marks the comments in the | ||
| /// copied text as printed. | ||
| /// | ||
| /// A comment that the node owns can be inside the node. It's copied with the node, so it isn't printed here: | ||
| /// | ||
| /// ```js | ||
| /// a | ||
| /// // prettier-ignore | ||
| /// .b; | ||
| /// ``` | ||
| /// | ||
| /// A dangling comment can be outside the node: Prettier moves this one into the empty body, copies the body, and then | ||
| /// throws because the comment wasn't printed. Here it's printed before the body. | ||
| /// | ||
| /// ```js | ||
| /// class A // prettier-ignore | ||
| /// {} | ||
| /// ``` |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Writes a node whose own printer prints its leading and trailing comments (Prettier's `willPrintOwnComments`). This | ||
| /// only handles `prettier-ignore`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// [`format_name`] for a name that Prettier prints like any other node, so a `prettier-ignore` comment applies to it: | ||
| /// `import /* prettier-ignore */ * as a from /* prettier-ignore */ 'b'`, `a./* prettier-ignore */ \u{62}`. Don't use | ||
| /// it for a property key. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Prettier's `printIgnored`: copies the source text of `node`, adding the parentheses and the `;` it needs in its | ||
| /// position. This is the only place that writes an ignored node. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// A name that's a node in ESTree but not here, with the comments around it: the `b` of `a.b`, the name of a function, | ||
| /// a label. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Options for writing a function or arrow function, which depend on its position. See `print/function.rs` and | ||
| /// `print/arrow_function_expression.rs`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `ChainExpression.expression`: `expr` is a whole optional chain, and this formats it as the member access or call | ||
| /// inside the `ChainExpression`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // typescript-estree has a `ChainExpression` around an optional chain, which can't own comments. The comments | ||
| // belong to the member access or the call in it, so they're printed inside its parentheses. Babel has no such | ||
| // node, so there they're printed outside. oxfmt prints them outside too, because its `ChainExpression` prints | ||
| // them: `(/* comment */ a?.b).c` is `/* comment */ (a?.b).c`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // A JSX element prints its comments together with its parentheses. Prettier copies an ignored element without | ||
| // them: `((<a />) /* prettier-ignore */, b)` |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `(/** @type {T} */ () => {})();`: for oxfmt a comment that looks like a type cast stays directly before its `(`, | ||
| /// which here is the `(` of the parameters. Prettier prints it before the parentheses around the function. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 14 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
I re-reviewed the reworked version of this PR (the HTTP_NODE_HALF_CLOSED / readsBodyBehindOwnFin design) and found no bugs; since it changes which shut-down early-outs in uWS's onData let bytes through, a maintainer's look is still worthwhile.
What was reviewed:
- Traced
HTTP_NODE_HALF_CLOSEDfrom its only setter (the no-wait branch ofdeferShutdownUntilResponseDrains) through the three bypassed checks inHttpContext.h::onData; thefinclause on the per-chunk check still stops the parse at the last chunk, so a pipelined request behind the body is never dispatched, andHTTP_NODE_PARSING_STOPPED/isDrainingBeforeCloseearly-outs still drop later reads. - Checked the
us_socket_refbalance: the top-level early-out still unrefs, and the new bypass only reroutes to the existingreturnedDatapath; Bun.serve (IsNodeHttp=false) never sets the flag, so its behavior is unchanged. - Checked
hasIncompleteRequestBodyagainstChunkedEncoding.h:STATE_IS_TRAILERS_DONEis only reached after the complete trailer section, and Content-Length bodies already hit 0 before the handler runs, so a genuinely cut-short body still reportsHPE_INVALID_EOF_STATE. - Checked
destroySoon(): thefinreturn at the per-chunk check skips the in-callbackcloseDestroyedNodeHttpSocket, but the post-parseHTTP_NODE_CLOSE_AFTER_MESSAGEgate still runs it.
Extended reasoning...
The diff adds a connection-scoped state bit in packages/bun-uws/src/HttpResponseData.h, a predicate and three bypasses in HttpContext.h::onData, a one-clause change to HttpParser::hasIncompleteRequestBody, a read-only finSent getter on JSNodeHTTPServerSocket, and a stopParsing/pause decision in src/js/internal/http.ts, plus a describe.each test suite over http and https. It touches the node:http request-parsing path (peer-controlled bytes behind a server-sent FIN) but no auth, crypto or injection surface. It was not approved because the change alters shutdown gating in native HTTP parsing code, the PR conversation could not be read in full this run, and a maintainer has open inline comments posted after the latest commit whose text is not visible here.
|
BuildKite has already queued a retry of both jobs. Nothing in the branch can change this, so there is no fix to push. Generated by Claude Code |
What does this PR do?
Fixes a
node:httpregression: a handler that rejects an upload early and then half-closes the socket never sees its request end.A raw client sends the POST head with
Content-Length: 1000and sends the body once it has seen the server's FIN.reqeventsend,closeend,closeend,closeCause.
HttpContext::onDatadrops every read of a socket that is shut down, unless it is a tunnel. Anhttp.Serversocket hasallowHalfOpen, sosocket.end()only ends the write side. Since #43557 a request ends at its real last chunk, and that chunk is never read. The response has finished, so the close of the socket does not abort the request either.Fix.
socket.end()marks the connection (HTTP_NODE_HALF_CLOSED, set indeferShutdownUntilResponseDrains). The three shut-down checks ofHttpContext::onDatalet the body of the request in flight through (readsBodyBehindOwnFin). The last chunk stops the parse as before, so no other request is read behind the FIN.HttpParser::hasIncompleteRequestBody()no longer counts a chunked body whose last chunk was delivered. A data handler that stops the parse there leavesSTATE_IS_TRAILERS_DONE. Without this, https reportsHPE_INVALID_EOF_STATEfor a complete body and the connection never counts as idle.onDataIncomingMessage: behind the FIN, a reader that is slower than the client pauses the reads like on any other connection. A body that nothing reads is dropped from the next read on, so the client's FIN still closes the connection (the existingserver.close()test in this file needs that). The newfinSentgetter tells "the FIN has left" from "end()was called and the FIN waits for queued bytes", where nothing changes.socket.destroySoon()goes through the same code. The request now gets the body that came in the read of its head, as in Node. The socket still closes behind that read.Source change against main: +35/-8 lines.
Unchanged, and still different from Node:
socket.end()while response bytes are still queued (a response larger than the send buffer, or a client that does not read). With a finished response the connection closes at the flush and the body is lost. node:http: half-close from the drain gate when socket.end() waited for buffered bytes #44244 covers the case of an unfinished response. This PR no longer builds on it.'end'and noHPE_INVALID_EOF_STATEfor a body that was cut short. https reports the cut as on main.reqfilled behind the FIN gets only the buffered part. Node pauses the reads and gives it all.Changed as a consequence: a handler that ends the socket and never ends
res(req.socket.end("HTTP/1.1 400 ...")). On mainrequestTimeoutcloses that connection, because the body is never read. Now the request completes, so only the client's FIN closes it, as in Node. Withres.end()the keep-alive timeout applies as usual.How did you verify your code works?
test/js/node/http/node-http-server-socket-end-drain.test.ts: 36 new tests, over http and https.HTTP_CONNECTION_SCOPEDentry. That one only matters when a request is dispatched betweenend()and a FIN that a pending TLS write delays.requestTimeouton a stalled body,server.close(), pipelined requests, CONNECT, Upgrade,Connection: close. Whatreqemits and reads matches Node in every rejection scenario. The scenarios listed as unchanged give the same output as main.Generated by Claude Code