Skip to content

node:http: close an upgraded socket when the client FINs before the request body completes - #35063

Open
robobun wants to merge 11 commits into
mainfrom
farm/849169e4/http-upgrade-body-fin-hang
Open

robobun wants to merge 11 commits into
mainfrom
farm/849169e4/http-upgrade-body-fin-hang

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

const server = http.createServer();
server.on('upgrade', (req, socket, head) => {
  req.on('data', () => {});
  socket.on('close', () => {});
});
server.listen(0, () => {
  const c = net.connect(server.address().port);
  c.write('GET / HTTP/1.1\r\nHost: x\r\nUpgrade: foo\r\nConnection: Upgrade\r\nContent-Length: 100\r\n\r\npartial');
  c.end();          // body never completes
});
// server.close() never completes; process hangs. Node exits cleanly.

When an Upgrade request carries a body and the client half-closes before that body completes, HttpContext::onEnd treated HTTP_NODE_TUNNEL_AFTER_BODY the same as a live CONNECT tunnel and kept the connection half-open. Nothing on that path ever released the NodeHTTPResponse body-read ref or the server's pending-request count, so the event loop never drained and server.close() never completed.

Fix

Node's UpgradeStream (used for an Upgrade request whose body has not finished) wraps a raw socket whose socketOnEnd listener is still attached. A mid-body FIN there calls socket.end(), the raw socket closes, and the UpgradeStream destroys with it. Only once the body has actually finished does the connection become a tunnel that can stay half-open.

HttpContext::onEnd now handles the two states separately: isConnectRequest (CONNECT, body-less Upgrade, or Upgrade-with-body after the body completed) still stays half-open, while HTTP_NODE_TUNNEL_AFTER_BODY (body never completed) closes the socket. HttpContext::onClose already runs the tunnel-after-body cleanup (socketData and inStream with last=true), which releases the body-read ref and the pending-request count.

With the socket now closed by the time the upgraded socket emits 'end', a socket.write() inside that listener must fail like Node's does. _write now calls its callback with ERR_STREAM_WRITE_AFTER_END when the native handle reports closed instead of stashing the callback for a drain that will never arrive.

Verification

New test in test/js/node/http/node-http-upgrade-body-fin.test.ts spawns a child that sends a partial upgrade body, half-closes, writes inside the upgraded socket's 'end' listener, and waits for the socket to emit 'close' and server.close() to complete. The event list ['upgrade','end','write-cb:ERR_STREAM_WRITE_AFTER_END','error:ERR_STREAM_WRITE_AFTER_END','close'] matches Node v26.3.0. Without the fix the child hangs at the close wait and the spawn timeout kills it.

The body-completed case is unchanged: after the body finishes the connection switches to isConnectRequest and stays half-open on FIN, so the server can still write back.


[review] gate passed · iteration 4 · 3 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ 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-upgrade-body-fin.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/165] gen JS modules (bundle-modules)
Preprocess modules (7671ms)
Bundle modules (33ms)
Postprocesss modules (21ms)
Bundle Functions (722ms)
Generate Code (15ms)

[8.48s] Bundled "src/js" for development
  2210 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[1/165] 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)

[91/165] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-15.cpp.o
FAILED: obj/unified/UnifiedSource-src_jsc_bindings_webcore-15.cpp.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang++ -march=nehalem -O0 -g3 -gz=zstd -glldb -fsanitize=address -fno-exceptions -fno-c++-static-destructors -fno-rtti -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -fvisibility=hidden -fvisibility-inlines-hidden -fno-unwind-tables -fno-asynchronous-unwind-tables -Wno-c23-exte
... (truncated)

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

test/js/node/http/node-http-upgrade-body-fin.test.ts:
(pass) upgrade with a body: a mid-body client FIN closes the upgraded socket and lets server.close() complete [61.74ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [210.00ms]
__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-upgrade-body-fin.test.ts
bun test v1.4.0 (bb9d44c58)

test/js/node/http/node-http-upgrade-body-fin.test.ts:
(pass) upgrade with a body: a mid-body client FIN closes the upgraded socket and lets server.close() complete [2525.94ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [4.59s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 698ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen cpp.rs (cppbind)
[2/126] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/126] gen JS modules (bundle-modules)
Preprocess modules (7662ms)
Bundle modules (43ms)
Postprocesss modules (172ms)
Bundle Functions (737ms)
Generate Code (113ms)

[8.74s] Bundled "src/js" for production
  2040 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/126] 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)
�[1
... (truncated)
diff hotspot
packages/bun-uws/src/HttpContext.h                 | 17 ++++--
 src/js/node/_http_server.ts                        |  7 +++
 .../node/http/node-http-upgrade-body-fin.test.ts   | 69 ++++++++++++++++++++++
 3 files changed, 89 insertions(+), 4 deletions(-)

gate history · 6 passed · 1 rejected · iteration 4

evidence per changed file
file                                                  reads  edits  tests
packages/bun-uws/src/HttpContext.h                        3      2      0
src/js/node/_http_server.ts                              10      6      0
test/js/node/http/node-http-upgrade-body-fin.test.ts      3      7      0

…equest body completes

An HTTP Upgrade request that carries a body (Content-Length or chunked)
wedged the process when the client half-closed before the body completed:
HttpContext::onEnd kept the connection half-open for
HTTP_NODE_TUNNEL_AFTER_BODY the same way it does for a live CONNECT
tunnel, so the NodeHTTPResponse body-read ref and the server's
pending-request count were never released. server.close() never
completed and the event loop never drained.

Node's UpgradeStream wraps a raw socket whose socketOnEnd listener is
still attached while the body is being parsed, so a mid-body FIN ends
and closes the raw socket and the UpgradeStream destroys with it. Once
the body finishes the connection becomes a proper tunnel
(isConnectRequest) and stays half-open on FIN as before.
@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:09 AM PT - Jul 22nd, 2026

❌ @robobun, your commit bb9d44c has 2 failures in Build #77774 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35063

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

bun-35063 --bun

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Node HTTP EOF lifecycle

Layer / File(s) Summary
CONNECT and Upgrade EOF handling
packages/bun-uws/src/HttpContext.h
Separates CONNECT EOF callbacks from incomplete Upgrade tunnel uncorking and socket closure.
Deferred write closure handling
src/js/node/_http_server.ts
Completes pending write callbacks on close and immediately fails writes when the native handle is already closed.
Upgrade FIN regression coverage
test/js/node/http/node-http-upgrade-body-fin.test.ts
Adds integration coverage for mid-body FIN handling, write errors, event ordering, socket closure, and server.close().

Possibly related PRs

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main behavior change: closing upgraded sockets on mid-body FINs.
Description check ✅ Passed It clearly explains the problem, fix, and verification, which covers the template's required content despite different headings.

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

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/http/node-http.test.ts`:
- Around line 3471-3474: Shorten the comment near the UpgradeStream test to a
concise lifecycle invariant: a FIN received during body transmission must close
the accepted Upgrade socket. Remove the explanatory PR context about
socketOnEnd, pending-request accounting, and process hangs, keeping the comment
within three lines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4a7587bc-fdc8-4cf9-bde2-9222724b095e

📥 Commits

Reviewing files that changed from the base of the PR and between 03eb4be and 5ed5d9c.

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

Comment thread test/js/node/http/node-http.test.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Windows: uv_run's alive-guard wedges teardown states with zero ref'd handles (server.close() waits forever class) #34158 - This issue identifies that upgrade-detached connections never decrement pending_requests, causing server.close() to hang. The PR fixes this by calling asyncSocket->close() on upgrade sockets with incomplete bodies when FIN arrives, which triggers onClose cleanup and releases the stranded ref counts.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #34158

🤖 Generated with Claude Code

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

Beyond the inline nit, I traced the state machine: once the body completes the data handler clears HTTP_NODE_TUNNEL_AFTER_BODY and sets isConnectRequest, so the body-completed half-open path is unchanged; and onClose<IsNodeHttp> already runs both socketData last=true and inStream last=true for this flag, so the close here does release the body-read ref and pending-request count as the description claims.

Extended reasoning...

Verified the PR's mechanism claims against the surrounding code rather than taking them on faith. The two states are mutually exclusive at the point onEnd runs (the flag is cleared exactly when isConnectRequest is set in the data handler's fin path), so splitting the condition does not change behavior for the completed-body case. The new branch's uncorkWithoutSending() + close() matches the existing fallthrough shape at the bottom of onEnd. Deferring rather than approving because this is FIN-handling control flow in vendored uWS with node:http compat semantics — worth a human look even though I found no correctness issue.

Comment thread test/js/node/http/node-http.test.ts Outdated
robobun and others added 4 commits July 22, 2026 05:43
socket.write() inside the upgraded socket's 'end' listener ran with
the JS Duplex still writable but the native handle already closed;
handle.write() returned 0, the callback was stashed in #pendingCallback,
and nothing ever flushed it. Node fails the write with
ERR_STREAM_WRITE_AFTER_END.

Also flush #pendingCallback from #onClose so a backpressured write whose
drain never arrives still reports failure. Test comment trimmed and the
assertion switched to a combined object per review.
node-http.test.ts has an unrelated pre-existing failure
('request via http proxy, issue#4295' ECONNREFUSED) in the
gate environment.
Comment thread src/js/node/_http_server.ts Outdated
#pendingCallback is only ever stashed against a live handle; the socket
closing before drain is ERR_STREAM_DESTROYED('write'), not write-after-end.

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

No bugs found; both earlier nits (stderr assertion, $ERR_STREAM_DESTROYED for the backpressure-then-close flush) are addressed. Deferring to a human because this changes uWS connection-lifecycle behavior for node:http Upgrade — the ref-release path now depends on onEnd → close() → onClose running the tunnel-after-body cleanup, which is the kind of lifetime/ref-count change that warrants a maintainer look.

What was reviewed:

  • onEnd split: verified onClose (HttpContext.h:241-248) already dispatches socketData/inStream with last=true for HTTP_NODE_TUNNEL_AFTER_BODY, so the new close() path releases the body-read ref as claimed.
  • _write guard: handle.closed is an existing property read elsewhere in this file; the callback is failed instead of stashed, so it can't leak.
  • #onClose flush: only reachable for a write stashed while handle.closed === false, so ERR_STREAM_DESTROYED('write') is the right code; $ERR_STREAM_DESTROYED is a declared builtin.
  • Test: combined-object assertion, drains all pipes concurrently, spawn timeout guards the pre-fix hang; separate-file placement and the 15s per-test ceiling were considered and are fine given the 4.5s ASAN wall time.
Extended reasoning...

Overview

Three files: packages/bun-uws/src/HttpContext.h splits onEnd's half-open decision so isConnectRequest (CONNECT / body-complete Upgrade) stays half-open while HTTP_NODE_TUNNEL_AFTER_BODY (Upgrade whose body never completed) uncorks and closes; src/js/node/_http_server.ts fails a tunnel _write callback with ERR_STREAM_WRITE_AFTER_END when handle.closed (instead of stashing it for a drain that never comes) and flushes any stashed #pendingCallback with ERR_STREAM_DESTROYED('write') in #onClose; a new spawned-child test asserts the exact event order matches Node v26.3.0 and that the process exits.

Security risks

None identified. This is server-side connection teardown for a peer-initiated FIN — no new parsing of untrusted bytes, no auth/crypto surface. Closing sooner rather than staying half-open is strictly less exposure.

Level of scrutiny

High. The uWS HttpContext::onEnd change alters when a socket is closed vs kept half-open, and correctness depends on onClose<IsNodeHttp> running the socketData last=true + inStream last=true cleanup for HTTP_NODE_TUNNEL_AFTER_BODY — I verified it does (HttpContext.h:240-248), but this is exactly the ref-count/lifetime category REVIEW.md flags as most-blocked. The JS-side changes are small defensive fixes to stop callback leaks and follow existing patterns in the file.

Other factors

I reviewed this PR twice previously; both nits were addressed (combined-object stderr assertion; ERR_STREAM_DESTROYED instead of ERR_STREAM_WRITE_AFTER_END for the backpressure-then-close flush). This run found no bugs. Verifier agents ruled out concerns about test-file placement and the per-test timeout override. The mechgate evidence block's "release without fix: all passed" is noise — its baseline commit (b9825e4) already contains the HttpContext.h fix, so it wasn't actually testing without the fix. Given the change is a behavioral shift in native connection lifecycle for node:http compat rather than a mechanical fix, a maintainer should confirm the Node-semantics claim (mid-body FIN on an UpgradeStream closes rather than half-opens) and that no other consumer of the old combined isConnectRequest || TUNNEL_AFTER_BODY half-open path relied on it.

Comment thread packages/bun-uws/src/HttpContext.h Outdated

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

No new issues found; all three earlier nits (stderr assertion, ERR_STREAM_DESTROYED for the backpressured-then-closed callback, past-tense comment) are addressed. Deferring to a human because the HttpContext::onEnd change alters native socket lifecycle for the Upgrade-with-body path — small and well-reasoned, but not mechanical.

What was reviewed:

  • onEnd split: isConnectRequest still stays half-open; HTTP_NODE_TUNNEL_AFTER_BODY now closes — confirmed onClose<IsNodeHttp> already runs the tunnel-after-body cleanup (socketData + inStream last=true), so refs release.
  • _write handle.closed guard and #onClose pending-callback flush — handle.closed is an existing property (used at :1475, :1800); $ERR_STREAM_DESTROYED is defined in ErrorCode.ts.
  • Test asserts the exact Node event sequence via a spawned subprocess with combined-object assertion; timeout: 10_000 bounds the pre-fix hang.
Extended reasoning...

Overview

Three files: packages/bun-uws/src/HttpContext.h splits the onEnd handling of isConnectRequest vs HTTP_NODE_TUNNEL_AFTER_BODY (previously OR'd together) so a mid-body FIN on an accepted Upgrade closes the socket instead of staying half-open; src/js/node/_http_server.ts adds a handle.closed check in _write (fail the callback with ERR_STREAM_WRITE_AFTER_END instead of stashing it for a drain that never comes) and flushes any stashed #pendingCallback in #onClose with ERR_STREAM_DESTROYED("write"); a new subprocess test asserts the exact Node v26 event sequence.

Security risks

None identified. This narrows a half-open path to close sooner on peer FIN; no new user-controlled input is parsed, no auth/crypto surface touched.

Level of scrutiny

Medium-high. The C++ change is a two-branch split inside a templated uWS socket handler (onEnd<IsNodeHttp>) and changes lifecycle behavior for a real (if narrow) node:http path. I verified onClose<IsNodeHttp> already handles HTTP_NODE_TUNNEL_AFTER_BODY cleanup, so asyncSocket->close() correctly releases the body-read ref and pending-request count via that existing path. The uncorkWithoutSending() before close() matches the fall-through pattern just below it. The JS changes are small and use existing primitives (handle.closed, $ERR_STREAM_DESTROYED). Still, native socket-lifecycle changes in the HTTP server are exactly the kind of change a maintainer should sign off on — a subtle regression here (e.g. an Upgrade-with-body client that legitimately expects half-open before body completion) would be hard to spot from the diff alone.

Other factors

All three of my earlier inline comments were addressed (combined-object stderr assertion in c5b6e98, ERR_STREAM_DESTROYED for the backpressured-close case in adb4ed3, present-conditional comment wording in ffd3d4b). The bug hunting system found nothing this run. The test was moved to its own file (node-http-upgrade-body-fin.test.ts) in 35a1f16 rather than staying in node-http.test.ts; that's a deliberate author choice I'm not blocking on. The auto-generated evidence block's "release without fix: all passed" line appears to have run against a branch commit (3fe03ad) that already contained the fix, so it doesn't contradict the PR's claim that the test hangs pre-fix.

The client net.Socket is readOnly (writable ended, readable waiting for
the server FIN) when the server-side assertions have already completed;
under ASAN load the event loop can stay alive past the spawn timeout
waiting for that FIN. The server-side refs are proven released by
server.close() having fired.

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

No further issues from me — the earlier nits (stderr assertion, ERR_STREAM_DESTROYED vs ERR_STREAM_WRITE_AFTER_END, comment tense) are all addressed. Deferring the approval itself since this changes native uWS socket-lifecycle behavior in HttpContext::onEnd and the node:http tunnel state machine, which is worth a maintainer's eyes.

What was reviewed:

  • onEnd split: confirmed onClose<IsNodeHttp> already handles HTTP_NODE_TUNNEL_AFTER_BODY cleanup (socketData last=true + inStream last=true), so asyncSocket->close() releases the body-read ref and pending-request count as the comment claims.
  • uncorkWithoutSending() before close() matches the existing pattern at the tail of onEnd and in onClose.
  • _write's new handle.closed guard and #onClose's #pendingCallback flush — verified the only assignment to #pendingCallback is on the !flushed && handle.ondrain && !handle.closed path, so ERR_STREAM_DESTROYED there and ERR_STREAM_WRITE_AFTER_END at write-time are the right split.
  • Test: subprocess pipes drained concurrently, combined-object assertion, port: 0, spawn timeout guards the pre-fix hang.
Extended reasoning...

Overview

Three files: packages/bun-uws/src/HttpContext.h splits the onEnd FIN handler so an accepted-Upgrade-with-incomplete-body closes instead of staying half-open (previously it shared the CONNECT-tunnel branch); src/js/node/_http_server.ts adds a handle.closed guard in _write so a write during the tunnel's 'end' listener fails with ERR_STREAM_WRITE_AFTER_END instead of stashing a callback that will never drain, and flushes any already-stashed #pendingCallback with ERR_STREAM_DESTROYED('write') in #onClose; a new test file spawns a fixture that half-closes mid-body and asserts the exact event/error-code sequence matches Node v26.3.0.

Security risks

None identified. This is server-side connection-lifecycle handling on an already-accepted socket; the change closes a connection that previously stayed half-open, which is strictly less resource retention. No parsing of untrusted input was added.

Level of scrutiny

High. HttpContext::onEnd is the native uWS FIN handler for every node:http server socket, and the distinction between isConnectRequest (established tunnel, half-open allowed) and HTTP_NODE_TUNNEL_AFTER_BODY (upgrade accepted but body incomplete, must close) is a subtle protocol-state decision that the pre-PR code deliberately merged in the other direction. The reasoning in the PR body (Node's UpgradeStream still has socketOnEnd attached until the body completes) is sound and I verified onClose already runs the tunnel-after-body cleanup, but a maintainer familiar with the node:http/uWS compat layer should confirm the state-machine change and that no other path (SSL variant, backpressured writes on the tunnel-after-body socket) is affected.

Other factors

I've been through three prior rounds on this PR (stderr-empty assertion, ERR_STREAM_DESTROYED vs WRITE_AFTER_END for the backpressured-then-closed callback, comment tense) and all were addressed in subsequent commits. The bug hunting system found nothing this run. The test is a hang-guard fixture (10s spawn timeout) that asserts the child exits on its own with the exact event list, and the PR evidence block shows it passes on ASAN+debug and release with the fix. The gate evidence shows the release build passed without the fix too (the ASAN-without-fix build failed to compile, so the "fails on main" leg didn't actually run the test on a working unfixed binary) — worth noting but the mechanism is well-explained enough that this isn't blocking on its own.

…letes

server.close() resolving proves pending_requests reached zero (the ref
the pre-fix half-open path stranded); the fixture then exits explicitly
so ASAN CI lanes are not at the mercy of natural-exit timing. Without
the fix the fixture blocks at await gotSocketClose and the spawn
timeout kills it.
Comment thread test/js/node/http/node-http-upgrade-body-fin.test.ts Outdated

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/http/node-http-upgrade-body-fin.test.ts`:
- Line 66: Update the stderr assertion in the fixture test to require exact
emptiness rather than merely rejecting lowercase “error” substrings. Replace the
broad string-content matcher with the exact empty-string invariant while
preserving the existing bunEnv-based test setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7e288135-373f-4e08-b33a-cb00712945c1

📥 Commits

Reviewing files that changed from the base of the PR and between 5ed5d9c and 723445a.

📒 Files selected for processing (3)
  • packages/bun-uws/src/HttpContext.h
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-upgrade-body-fin.test.ts

Comment thread test/js/node/http/node-http-upgrade-body-fin.test.ts

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

All four of my earlier inline nits are addressed and the bug-hunting pass found nothing new. I'm not approving on my own because the split in HttpContext::onEnd changes EOF handling for every node:http server connection in the HTTP_NODE_TUNNEL_AFTER_BODY state, and the uWS + node:http-compat state machine is subtle enough to warrant a look from someone who owns it.

What was reviewed:

  • onEnd split: confirmed onClose<IsNodeHttp> already runs the tunnel-after-body cleanup (socketData + inStream last=true), so the refs the half-open path stranded are released on close.
  • _write handle.closed guard and #onClose flushing #pendingCallback — checked against the only assignment site (line 2019) and #onDrain; no double-invoke path.
  • Candidate concerns ruled out: separate test file vs. node-http.test.ts (spawned hang-guard fixture; several sibling files in this dir follow the same pattern), and the 30s per-test timeout (bounds a 20s spawn timeout for the pre-fix hang case).
Extended reasoning...

Overview

Three files: packages/bun-uws/src/HttpContext.h splits the onEnd EOF handler so HTTP_NODE_TUNNEL_AFTER_BODY (Upgrade whose body never completed) now closes the socket instead of staying half-open like isConnectRequest; src/js/node/_http_server.ts adds a handle.closed fast-fail in _write and flushes #pendingCallback in #onClose; a new spawned-fixture test asserts the exact event sequence Node v26.3.0 produces and that server.close() completes.

Security risks

None identified. This narrows a half-open path to a close; no new input parsing, no auth/crypto surface.

Level of scrutiny

Moderate-to-high. The production diff is small (~30 lines) and the reasoning is well-grounded in Node's UpgradeStream behavior, but HttpContext::onEnd is on the hot path for every node:http server connection's EOF, and the interaction between HTTP_NODE_TUNNEL_AFTER_BODY, the body-completion flip to isConnectRequest in the data handler, and onClose's cleanup is a state machine that the uWS/node:http-compat owners (suggested reviewers: cirospaciari, Jarred-Sumner) are best placed to sign off on.

Other factors

I left four inline nits on earlier revisions (stderr-exact-empty assertion, wrong error code for the backpressured-then-closed callback, two past-tense bug-history comments) and all four were addressed in follow-up commits. The evidence block's "fails without fix" gate looks broken — the "release without fix" run reports b3f3f2f5d (a PR commit) passing and the ASAN run failed to build — so there is no automated proof the test fails on main, only the PR text's claim. That is a harness issue rather than a code issue, but it means the USE_SYSTEM_BUN=1-style check REVIEW.md asks for was not demonstrated here.

A CONNECT tunnel's backpressured write can already have reached the
kernel by the time the socket closes; erroring its callback with
ERR_STREAM_DESTROYED surfaced as an unexpected error log entry in
test-http-set-global-proxy-from-env-https-request.mjs and
test-https-proxy-request.mjs. The _write handle.closed guard covers the
write-after-native-close case this PR actually needs.

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

I didn't find any issues this pass, and all four earlier nits are resolved (the #onClose pending-write flush was dropped entirely in bb9d44c rather than re-coded, which is fine — that path is back to pre-PR behavior and out of scope for this fix). Deferring to a human reviewer since this changes the uWS onEnd FIN-handling state machine for node:http upgrade connections.

What was reviewed:

  • HttpContext::onEnd: confirmed onClose<IsNodeHttp> already runs the HTTP_NODE_TUNNEL_AFTER_BODY cleanup (socketData + inStream with last=true), so asyncSocket->close() here releases the body-read ref and pending-request count as the comment claims.
  • The body-completed path is unchanged: the data handler flips HTTP_NODE_TUNNEL_AFTER_BODY → isConnectRequest on fin, so a completed-body upgrade still stays half-open on FIN.
  • _write handle.closed guard: only reached inside the !flushed && handle.ondrain branch, so the normal write path is untouched; the stashed-then-destroyed callback case remains at pre-PR behavior after bb9d44c.
  • Test fixture: pipes drained concurrently, spawn timeout bounds the pre-fix hang, combined-object assertion with asymmetric stderr; the separate-file placement and per-test timeout were examined and are reasonable for a hang-guard subprocess test.
Extended reasoning...

Overview

Three files: packages/bun-uws/src/HttpContext.h splits onEnd's handling of HTTP_NODE_TUNNEL_AFTER_BODY (accepted Upgrade whose request body never completed) out of the isConnectRequest half-open branch and closes the socket instead, so onClose runs the tunnel-after-body cleanup and releases the stranded body-read ref / pending-request count. src/js/node/_http_server.ts adds a handle.closed guard in NodeHTTPServerSocket#_write so a write inside the upgraded socket's 'end' listener fails with ERR_STREAM_WRITE_AFTER_END instead of stashing a callback for a drain that will never fire. test/js/node/http/node-http-upgrade-body-fin.test.ts is a new spawned-fixture hang test that asserts the exact event sequence Node v26.3.0 produces and that server.close() completes.

Security risks

None identified. The change narrows behavior (closes a connection that previously stayed half-open) on a server-side FIN path; no new user input parsing, no auth/crypto surface.

Level of scrutiny

Medium-high. The diff is small and well-argued, but it edits the native uWS HTTP context's FIN-handling state machine for node:http compat — connection-lifecycle / refcount territory where a wrong branch strands refs or double-closes. The onEnd handler is templated on IsNodeHttp and this branch is inside the IsNodeHttp block, so Bun.serve is unaffected, but the interaction between HTTP_NODE_TUNNEL_AFTER_BODY, isConnectRequest, and the onClose cleanup contract is subtle enough that a human familiar with the uWS/node:http glue should sign off.

Other factors

Four rounds of my own nits (stderr assertion, #onClose flush error code, two comment-phrasing items) have all been addressed; the #onClose flush was ultimately dropped rather than re-coded to ERR_STREAM_DESTROYED, which just leaves that unrelated backpressure-then-peer-close path at its pre-PR behavior. CodeRabbit's remaining suggestion (exact-empty stderr) was correctly declined per REVIEW.md. The candidate issues raised and refuted this run (per-test timeout, standalone test file) are both defensible for a hang-guard subprocess test. CI for the latest commit (bb9d44c) is still building per the robobun status comment.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI on bb9d44c (build #77774): the new test node-http-upgrade-body-fin.test.ts passes on every lane, and the CONNECT proxy tests that the since-dropped #onClose flush regressed on the previous build are clean again. The remaining failures are unrelated to this diff (worker_threads stress SIGABRT, bun install proxy flakes, webview/fs/serve-protocols flakes), all either passed on retry or are in areas this change does not touch. Ready for review.

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.

2 participants