Skip to content

node:http client: report req.destroy() mid EOF-delimited body as aborted, not a clean 'end' - #34360

Closed
robobun wants to merge 3 commits into
mainfrom
farm/c7c32d26/http-client-destroy-eof-delimited
Closed

robobun wants to merge 3 commits into
mainfrom
farm/c7c32d26/http-client-destroy-eof-delimited

Conversation

@robobun

@robobun robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

Calling req.destroy() while an EOF-delimited response body (no Content-Length, no Transfer-Encoding; RFC 9112 6.3 rule 8) is streaming delivers a clean 'end' on the response and never 'aborted' / ECONNRESET. A locally aborted, truncated download is indistinguishable from a complete one. The same destroy on a chunked body is already Node-identical.

import http from "node:http";
import net from "node:net";
const HEAD = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\npart1-";
const srv = net.createServer(s => { s.on("error", () => {}); s.on("data", () => s.write(HEAD)); });
srv.listen(0, "127.0.0.1", () => {
  const req = http.request({ port: srv.address().port, host: "127.0.0.1" }, res => {
    const ev = [];
    res.on("data", () => { ev.push("data"); req.destroy(); });
    res.on("end", () => ev.push("end"));
    res.on("aborted", () => ev.push("aborted"));
    res.on("error", e => ev.push(`error:${e.code}`));
    res.on("close", () => { console.log(ev.join(",")); srv.close(); });
  });
  req.end();
});
// node:  data,aborted,error:ECONNRESET
// bun:   data,end

This matters for abort/cancel plumbing (timeouts, AbortSignal-driven destroys, user cancellation) on EOF-delimited origins: caches store the truncated prefix as the full body, retry logic never retries, metrics count the abort as a completed transfer.

Why

req.destroy() destroys the socket, and the socket's 'end' reaches socketOnEnd before 'close' reaches socketCloseListener. socketOnEnd calls parser.finish(), which for an EOF-delimited body runs parserOnMessageComplete (sets res.complete = true and pushes EOF). By the time socketCloseListener checks if (!res.complete) the response already looks complete, so the res.destroy(ConnResetException("aborted")) path is skipped and res.push(null) delivers 'end'.

Node never reaches socketOnEnd after req.destroy() because its socket.destroy() emits only 'close', so parser.finish() runs at the bottom of socketCloseListener, after the abort check. (#33256 fixes the underlying net.Socket spurious 'end'; this change is independent of it.)

Fix

Guard socketOnEnd on !req.destroyed: an 'end' that arrives after the request was already destroyed is our own teardown, not a server FIN, so defer parser finalisation to socketCloseListener. req.destroyed is only set ahead of socketOnEnd by ClientRequest.prototype.destroy (the other setters remove the listener first or run after 'close').

Verification

New parametrised test in test/js/node/http/node-http.test.ts covers both EOF-delimited and chunked framing and asserts the full event sequence plus res.complete against Node v26.3.0. Fails on bun 1.4.0 (["data","end"]), passes with this change. All 67 test/js/node/test/parallel/test-http-client-*.js and the abort/keep-alive/response-close tests pass.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http-client-destroy.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (83ef4755d)

test/js/node/http/node-http-client-destroy.test.ts:
39 |       });
40 |       req.on("error", reject);
41 |       req.end();
42 | 
43 |       const { events, complete } = await promise;
44 |       expect({ events, complete }).toEqual({
                                        ^
error: expect(received).toEqual(expected)

  {
    "complete": true,
    "events": [
      "data",
-     "aborted",
-     "error:ECONNRESET",
+     "end",
    ],
  }

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/http/node-http-client-destroy.test.ts:44:36)
(fail) req.destroy() mid-body reports the response as aborted > EOF-delimited [701.01ms]
(pass) req.destroy() mid-body reports the response 
... (truncated)

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

test/js/node/http/node-http-client-destroy.test.ts:
(pass) req.destroy() mid-body reports the response as aborted > EOF-delimited [12.31ms]
(pass) req.destroy() mid-body reports the response as aborted > chunked [2.29ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [161.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-client-destroy.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (83ef4755d)

test/js/node/http/node-http-client-destroy.test.ts:
(pass) req.destroy() mid-body reports the response as aborted > EOF-delimited [771.82ms]
(pass) req.destroy() mid-body reports the response as aborted > chunked [129.31ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [3.73s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 715ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/20] gen JS modules (bundle-modules)
Preprocess modules (6956ms)
Bundle modules (50ms)
Postprocesss modules (28ms)
Bundle Functions (760ms)
Generate Code (80ms)

[7.89s] Bundled "src/js" for production
  1968 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version:
... (truncated)
diff hotspot
src/js/node/_http_client.ts                        |  8 ++++
 test/js/node/http/node-http-client-destroy.test.ts | 52 ++++++++++++++++++++++
 2 files changed, 60 insertions(+)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                                reads  edits  tests
src/js/node/_http_client.ts                             3      4      0
test/js/node/http/node-http-client-destroy.test.ts      0      1      0

…ted, not a clean 'end'

When req.destroy() is called while an EOF-delimited response body (no
Content-Length, no Transfer-Encoding) is streaming, the socket teardown
delivers an 'end' to socketOnEnd, which calls parser.finish(). For an
EOF-delimited body that runs parserOnMessageComplete, so res.complete is
already true by the time socketCloseListener checks it and the locally
aborted, truncated download is handed to the user as a clean 'end' with
no 'aborted' or ECONNRESET. Chunked bodies were already correct because
parser.finish() does not complete a mid-chunk message.

Node never reaches socketOnEnd after req.destroy() (its socket.destroy()
emits only 'close'), so parser.finish() runs at the bottom of
socketCloseListener, after the "!res.complete" abort check. Guard
socketOnEnd on !req.destroyed so a teardown-driven 'end' defers parser
finalisation to socketCloseListener the same way.
@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:54 AM PT - Jul 16th, 2026

❌ @robobun, your commit 83ef475 has 3 failures in Build #73925 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34360

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

bun-34360 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. onerror event not triggered during req abort in proxy-agent #31476 - onerror event not triggered during req.abort() in proxy-agent: the reporter aborts a client-side http.get mid-response and the expected 'error' event with "socket hang up" never fires — the socketOnEnd guard for req.destroyed addresses this class of bug when the upstream response is EOF-delimited.

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

Fixes #31476

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

HTTP client teardown

Layer / File(s) Summary
Handle and validate mid-body request destruction
src/js/node/_http_client.ts, test/js/node/http/node-http.test.ts
socketOnEnd now tears down immediately when the request is destroyed; tests cover data, aborted, and ECONNRESET events for EOF-delimited and chunked responses, including res.complete.

Possibly related PRs

  • oven-sh/bun#31587: Refactors the same Node HTTP client lifecycle involved in request destruction and socket end handling.

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 clearly states the main node:http client abort-handling change and is concise.
Description check ✅ Passed The description covers the change and verification clearly, though it uses custom headings instead of the template's exact ones.

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: 2

🤖 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 `@src/js/node/_http_client.ts`:
- Around line 602-608: Update the comment above the req.destroyed check in
socketCloseListener to state that this is local teardown after req.destroy(),
and that the response remains incomplete and is reported as aborted with
ECONNRESET rather than receiving a clean end event.

In `@test/js/node/http/node-http.test.ts`:
- Around line 3905-3908: Replace the parameterized it.each block around the HTTP
response scenarios with describe.each(), placing a normal it() test inside each
generated case. Preserve both EOF-delimited and chunked scenario inputs and
their expectedComplete values.
🪄 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: f0244a43-76ff-4b4f-989c-55cf1bbfbe33

📥 Commits

Reviewing files that changed from the base of the PR and between aca54d5 and 94c6539.

📒 Files selected for processing (2)
  • src/js/node/_http_client.ts
  • test/js/node/http/node-http.test.ts

Comment thread src/js/node/_http_client.ts Outdated
Comment thread test/js/node/http/node-http.test.ts Outdated
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Re the bot's suggestion about #31476: checked, and that one is unrelated. The basic req.abort()-before-headers path already emits socket hang up in Bun (same as Node) with and without this change; #31476 only reproduces through proxy-agent's custom socket path, so leaving it off this PR.

@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 bugs, but this changes event-emission ordering in the node:http client teardown path, which interacts with keep-alive/agent reuse and the recent #32488 lifecycle work — worth a quick human look.

What was reviewed:

  • Traced every setter of req.destroyed — the other three (socketCloseListener, responseKeepAlive, upgrade path) either run after 'close' or remove socketOnEnd first, so the guard only trips on ClientRequest.prototype.destroy.
  • Confirmed the skipped freeParser is still reached at the bottom of socketCloseListener, so no parser leak.
  • Checked the legitimate-server-FIN path (no req.destroy()): req.destroyed is false, existing behavior unchanged.
  • Test's expectedComplete: true for EOF-delimited matches Node (parser.finish() runs after the abort check in socketCloseListener).
Extended reasoning...

Overview

Adds an early-return guard at the top of socketOnEnd in src/js/node/_http_client.ts: when req.destroyed is already true, skip parser.finish() / freeParser() and just socket.destroy(), deferring finalization to socketCloseListener. Paired with an it.each test in test/js/node/http/node-http.test.ts covering EOF-delimited and chunked framing, asserting the exact event sequence (data, aborted, error:ECONNRESET) and res.complete against Node v26.3.0.

Security risks

None. No untrusted-input parsing, no auth/crypto, no new external surface. The guard reads a boolean already set by user-initiated req.destroy().

Level of scrutiny

Medium-high. The diff is 8 lines, but it sits in the node:http client socket lifecycle — a state machine where 'end'/'close' ordering, res.complete, keep-alive pooling, and agent socket reuse all interact. This file was just heavily reworked in #32488 for Node v26.3.0 compat, and the PR itself notes the fix is compensating for a Bun-specific net.Socket behavior (spurious 'end' on destroy, tracked separately in #33256). A human familiar with that work should confirm this guard doesn't regress any of the keep-alive / half-close / agent-free paths that #32488 stabilized.

Other factors

  • I verified the PR's central claim that req.destroyed can only be true in socketOnEnd via ClientRequest.prototype.destroy: responseKeepAlive and the upgrade handler both removeListener('end', socketOnEnd) before setting it, and socketCloseListener runs on 'close' which fires after 'end'.
  • The early return skips freeParser, but socketCloseListener unconditionally calls parser.finish() + freeParser() at the end, so there's no leak — and this matches the Node ordering the PR is restoring (finish after the !res.complete abort check).
  • Test imports (once, AddressInfo, createNetServer) are already present in the file; it.each matches existing local convention. Both CodeRabbit threads are resolved.
  • The PR states all 67 test/js/node/test/parallel/test-http-client-*.js plus abort/keep-alive/response-close tests pass; CI build #73920 is in progress.

node-http.test.ts has an unrelated environment-dependent failure (the
issue#4295 proxy test's server listens on ::1 while the client resolves
localhost to 127.0.0.1 in some containers); keep the new coverage in a
self-contained file so it runs clean everywhere.

@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 bugs. The guard is narrow and the root-cause analysis holds up, but this is node:http client socket/parser lifecycle — event-ordering-sensitive code where regressions are subtle — so it's worth a human sign-off.

Checked that req.destroyed can only be true ahead of socketOnEnd via ClientRequest.prototype.destroy (the other four setters either run on 'close', remove the 'end' listener first, or never attach it). Verified the skipped parser.finish()/freeParser() are still run at the tail of socketCloseListener, so no parser leak. The test's expectedComplete: true for the EOF-delimited case matches Node because parser.finish() in socketCloseListener runs after the abort check.

Extended reasoning...

Overview

Adds an early-return guard in socketOnEnd (src/js/node/_http_client.ts) so that a socket 'end' arriving after req.destroy() skips parser.finish() and defers finalization to socketCloseListener. Without this, an EOF-delimited body is marked complete before the close listener's abort check, so a locally-aborted download surfaces as a clean 'end' instead of 'aborted' + ECONNRESET. A new test file covers both EOF-delimited and chunked framing and asserts the exact event sequence and res.complete against Node v26.3.0.

Security risks

None. No new input parsing, no auth/TLS/permissions surface. The guard only reorders which existing teardown path runs the parser finalizer.

Level of scrutiny

Medium-high. The diff is 8 source lines, but it sits inside the node:http client socket-event lifecycle, where the ordering of 'end'/'close', parser finalization, and res.complete interacts with keep-alive reuse, upgrade/CONNECT, informational responses, and abort paths. The PR itself notes this is compensating for a Bun-specific net.Socket divergence (spurious 'end' on destroy(), tracked separately in #33256). Someone familiar with the recent _http_client.ts refactors should confirm this doesn't regress any of the adjacent lifecycle paths.

Other factors

  • Traced every write to req.destroyed in the file: socketCloseListener (runs on 'close', after 'end'), the upgrade branch and responseKeepAlive (both remove the socketOnEnd listener before setting it), and onSocketNT (never attaches the listener on that branch). So the guard is only reachable via explicit ClientRequest.prototype.destroy, matching the PR's claim.
  • The skipped parser.finish() / freeParser() are not leaked — socketCloseListener runs them unconditionally at its tail after socket.destroy() fires 'close'.
  • Author reports the full test/js/node/test/parallel/test-http-client-*.js suite plus abort/keep-alive/response-close tests pass.
  • CodeRabbit's two nits (comment wording, it.each vs describe.each) were addressed/resolved; no outstanding review threads.

@robobun

robobun commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI on 83ef475 (build 73925, 283/286 passed): node-http-client-destroy.test.ts passes on every lane. The three red jobs are unrelated to this change:

  • debian x64-asan: timer-heap-race.test.ts (ASAN leak in ConcurrentTask/JSCScheduler during worker VM teardown) and the pre-existing test-worker-message-port-transfer-terminate.js SIGABRT
  • darwin 14 aarch64: require-cache.test.ts (also flaky on darwin 26)

None of these touch node:http; all three are being tracked separately. Ready for review.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this in favor of #33256.

The diagnosis here is good and the test is unusually careful — it matches Node exactly, including the counterintuitive complete: true on the EOF-delimited case (the abort check in socketCloseListener runs before its trailing parser.finish() flips complete). But the fix is at the wrong layer, for two reasons.

Verified against a real node v26.5.0 binary and against the CI builds of both PRs (bunx bun-pr 33256 / bunx bun-pr 34360).

1. "independent of #33256" doesn't hold — #33256 alone already fixes this

Running this PR's own test against the CI build of #33256, with none of this PR's _http_client.ts change:

              EOF-delimited                                       chunked
node 26.5.0 : ["data","aborted","error:ECONNRESET"] complete=true  [...] complete=false
bun #33256  : ["data","aborted","error:ECONNRESET"] complete=true  [...] complete=false

Byte-identical to Node on both framings. That follows from the diagnosis in the description: once the socket stops synthesizing an EOF on local destroy, socketOnEnd is never reached with req.destroyed === true, parser.finish() lands at the bottom of socketCloseListener after the abort check, and the response reports as aborted on its own. The guard is dead code at that point — which is why Node doesn't have one at lib/_http_client.js:762.

2. On the one path where the guard is live, it diverges from Node

socketOnEnd can still be reached with req.destroyed === true when the socket isn't a bun net.Socket — a custom createConnection, i.e. the proxy-agent-shaped path pointed at in the #31476 comment above. Node's answer there is a clean 'end':

import { request } from "node:http";
import { Duplex } from "node:stream";
const HEAD = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\npart1-";
class FakeSocket extends Duplex {
  constructor() { super(); this.setNoDelay = this.setTimeout = this.ref = this.unref = () => this; }
  _read() {}
  _write(_c, _e, cb) { setImmediate(() => this.push(HEAD)); cb(); }
  destroy(err) { if (!this.destroyed) this.push(null); return super.destroy(err); } // emits 'end' after destroy
}
const log = [];
const req = request({ createConnection: () => new FakeSocket(), host: "x", port: 80 }, res => {
  res.on("data", () => { log.push("data"); req.destroy(); });
  res.on("end", () => log.push("end"));
  res.on("aborted", () => log.push("aborted"));
  res.on("error", e => log.push("error:" + e.code));
  res.on("close", () => console.log(log.join(",") + "  | complete=" + res.complete));
});
req.end();
node 26.5.0 : data,end                        | complete=true
bun main    : data,end                        | complete=true
bun #33256  : data,end                        | complete=true
bun #34360  : data,aborted,error:ECONNRESET   | complete=true   <-- diverges from node

Whatever one thinks of Node's behavior there, main and #33256 both match it and this PR doesn't. So the guard is either unreachable (real sockets, post-#33256) or wrong (custom sockets).

Worth salvaging

node-http-client-destroy.test.ts should move to #33256 as-is. It fails on main and passes on #33256 unmodified, so it's a real http-level regression test for the net change — and #33256 could use it, since its own verification note defers its net tests to CI.

robobun added a commit that referenced this pull request Jul 17, 2026
… case

req.destroy() while an EOF-delimited response body is streaming must
surface as 'aborted' + ECONNRESET on the response, never a clean 'end'.
Before this change the spurious socket 'end' reached socketOnEnd and
finished the parser, so socketCloseListener saw the body as complete and
delivered 'end' for a locally aborted, truncated download. The chunked
case is the node-identical control.

Moved from #34360 per review; fails on main, passes with the net-layer
destroyed guard here, and matches Node v26.5.0 exactly (including
res.complete === true on the EOF-delimited case).
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on both points. The custom-createConnection case is the clincher: a Duplex that emits 'end' inside its own destroy() is the one path where the guard stays live, and Node delivers data,end there, so the guard would move Bun away from Node exactly where it currently matches.

Pushed node-http-client-destroy.test.ts to #33256 as aadce4f. Verified locally on that branch: fails without the net-layer destroyed guard (EOF-delimited case shows ["data","end"]), passes with it on both framings.

robobun added a commit that referenced this pull request Jul 29, 2026
… case

req.destroy() while an EOF-delimited response body is streaming must
surface as 'aborted' + ECONNRESET on the response, never a clean 'end'.
Before this change the spurious socket 'end' reached socketOnEnd and
finished the parser, so socketCloseListener saw the body as complete and
delivered 'end' for a locally aborted, truncated download. The chunked
case is the node-identical control.

Moved from #34360 per review; fails on main, passes with the net-layer
destroyed guard here, and matches Node v26.5.0 exactly (including
res.complete === true on the EOF-delimited case).
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