Skip to content

node:http2: respondWithFD() never calls options.onError, like node - #43587

Open
robobun wants to merge 5 commits into
mainfrom
robobun/eea7c8b8/http2-respondwithfd-onerror
Open

robobun wants to merge 5 commits into
mainfrom
robobun/eea7c8b8/http2-respondwithfd-onerror

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • ServerHttp2Stream#respondWithFD() calls options.onError when the fstat fails, when the descriptor is a directory, and when respond() rejects the headers. Node v26.3.0 never reads onError there and destroys the stream.
  • Without statCheck, a response that the onError handler sends never ends, so the client request hangs.
  • The cause is doSendFileFD (src/js/node/http2.ts:2957). respondWithFile() and respondWithFD() share it, and it reads options.onError for both.

Fix

  • respondWithFD() clears onError on its copy of the options, so doSendFileFD takes the arms without onError and destroys the stream.
  • Those arms no longer throw from the fstat callback when respond() fails (respondOrDestroy). The error destroys the stream.
  • Correct because Node's doSendFD and processRespondWithFD (lib/internal/http2/core.js, v26.3.0) never read onError. respondWithFile() does not change.
  • Verified: test/js/node/http2/node-http2.test.js (new test, stock bun fails it), and 23 vendored test-http2-* files that use these methods. Self-reviewed: 13 concerns raised, 11 addressed. Notes name the other 2 and the merge order.

Background

  • respondWithFile(path) opens the file itself. respondWithFD(fd) sends a descriptor that the caller owns. Each call copies options.
  • onError is an option of respondWithFile() only. Node calls it before any headers, for a failed open or fstat, or for a file that is not regular.
  • Without statCheck, respondWithFD() closes the writable side before it returns, like Node. Bun stats the descriptor after that, so the handler's stream.end() does nothing.
Notes

Repro. Run with node and with bun:

import http2 from "node:http2";
const badFd = 2 ** 30; // never a valid descriptor
const server = http2.createServer();
server.on("stream", stream => {
  stream.on("error", e => console.log("server stream error:", e.code));
  stream.respondWithFD(badFd, { ":status": 200 }, {
    onError(err) {
      console.log("onError:", err.code);
      stream.respond({ ":status": 404 });
      stream.end();
    },
  });
});
server.listen(0, "127.0.0.1", () => {
  const client = http2.connect("http://127.0.0.1:" + server.address().port);
  const req = client.request({ ":path": "/" });
  req.on("response", h => console.log("client 'response':", h[":status"]));
  req.on("error", e => console.log("client error:", e.message));
  req.on("close", () => { console.log("client close"); client.close(); server.close(); });
  req.resume();
  req.end();
});

Node v26.3.0 and this branch:

client 'response': 200
server stream error: ERR_HTTP2_STREAM_ERROR
client error: Stream closed with error code NGHTTP2_INTERNAL_ERROR
client close

Bun 1.4.3-canary.1+367d939d9 prints this and never closes the request:

onError: EBADF
client 'response': 404

Merge order. Read this before you merge.

What this PR leaves alone: a header error in respondWithFile(). Node destroys the stream there too and never calls onError (core.js lines 2714 to 2719). An earlier version of this PR did the same. I took it out, because respond() still rejects header values that Node accepts. Example: the pattern from the Node docs, respondWithFile(file, { "content-type": mime[ext] }, { onError }), with an unknown extension. Node skips the undefined value and serves the file. Bun throws ERR_HTTP2_INVALID_HEADER_VALUE, and today the onError handler answers with a 500. Without the handler call the stream is destroyed, and a stream with no 'error' listener ends the process. That arm can follow Node after #41614, which makes respond() accept what Node accepts.

Why the options copy and not the kOwnsFd flag. kOwnsFd is a field of the stream, and a later respondWithFile() or respondWithFD() on the same stream overwrites it before the first fstat returns. Node decides per call, because doSendFD and doSendFileFD are different functions. A first version of this change read kOwnsFd in doSendFileFD. With respondWithFile(dir, h, { onError }) followed by respondWithFD(fd) in the same tick it dropped the onError that Node and stock Bun call. The options copy is per call, so both orders now match Node. The bothInFlight case of the test pins this: it fails with a kOwnsFd check.

respondOrDestroy. The two arms without onError called respond() with no guard. respond() throws when the stream is already destroyed and when it rejects the headers, and the throw left the fstat callback as an uncaught exception (the second point of #43448). respondWithFD() callers that pass onError now reach these arms, so this PR guards them. With the guard, a probe of 19 calls (bad descriptor, directory descriptor, rejected headers, destroy() right after the call, both methods on one stream) has no uncaught exception and no request that stays open. onError matches Node in every respondWithFD() call of the probe.

The test. It runs each failing respondWithFD() call two times, without onError and with it. It expects the same events both times, no onError call, and NGHTTP2_INTERNAL_ERROR on the client. The handler for these cases destroys the stream, so a call that must not happen fails an assertion and does not wait for the timeout. The test pins the server error code only for the header errors. #43463 and #43564 change the server error code and the 200 of the other cases, and the test passes before and after #43463. Two control cases check that respondWithFile() still calls onError for a missing file and for a directory, and that the handler's 404 arrives.

The 2 concerns I did not address here.

Suites run with the debug build. The new test (runs in a row, runs under load, runs with BUN_JSC_validateExceptionChecks=1, and a run on Windows x64 for the earlier head). The full node-http2.test.js (388 pass, 6 skip, 0 fail, on the earlier head). test-http2-respond-file*.js (17 files), test-http2-respond-with-file-connection-abort.js, test-http2-respond-no-data.js, test-worker-terminate-http2-respond-with-file.js, test-http2-error-order.js, test-http2-generic-streams-sendfile.js, and sequential/test-http2-timeout-large-write-file.js.


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

fails on main (without fix)
ASAN without fix: 1 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [753.89ms]
(pass) node none > Client Basics > should be able to send a POST request [503.91ms]
(pass) node none > Client Basics > constants [18.19ms]
(pass) node none > Client Basics > getDefaultSettings [6.63ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [16.08ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [4.89ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [2.90ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.61ms]
(pass) node none > Client Basics > should be able to send data using end [523.88ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [511.86ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release without fix: 1 failed, 6 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > constants [1.16ms]
(pass) node none > Client Basics > getDefaultSettings [0.17ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [0.37ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [0.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [0.04ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [0.08ms]
(pass) node none > Client Basics > is possible to abort request [1.97ms]
(pass) node none > Client Basics > aborted event should work with abortController [0.87ms]
(pass) node none > Client Basics > aborted event should work with aborted signal [0.77ms]
(pass) node none > Client Basics > signal validation matches node: non-signal objects throw, duck-typed { aborted } is accepted [0.84ms]
(pass) node none > Client Basics > headers cannot be bigger than 65536 bytes [38.61ms]
(skip) node none > Client Basics > should not leak memory
(pass) node none > Client Basics > should fail to con
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2.test.js"
bun test v1.4.3 (367d939d9)

test/js/node/http2/node-http2.test.js:
(pass) node none > Client Basics > should be able to send a GET request [737.59ms]
(pass) node none > Client Basics > should be able to send a POST request [498.20ms]
(pass) node none > Client Basics > constants [18.28ms]
(pass) node none > Client Basics > getDefaultSettings [7.06ms]
(pass) node none > Client Basics > getPackedSettings/getUnpackedSettings [16.67ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is too small [5.15ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a multiple of 6 bytes [3.02ms]
(pass) node none > Client Basics > getUnpackedSettings should throw if buffer is not a buffer [4.66ms]
(pass) node none > Client Basics > should be able to send data using end [524.04ms]
(pass) node none > Client Basics > should be able to mutiplex GET requests [513.87ms]
(pass) node none > Client Basics > http2 should receive remoteSettings when receiving d
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f36c3e4d70
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 1049ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 22 installs across 61 packages (no changes) [16.00ms]
[2/1250] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1250] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939d9)

Checked 111 installs across 104 packages (no changes) [6.00ms]
[4/1250] gen ErrorCode+*.h
[5/1250] gen bindgenv2
[6/1250] gen node-fallbacks/react-refresh.js
Bundled 1 module in 6ms

  react-refresh.js  4.81 KB  (entry point)

[7/1250] fetch zlib
[zlib] up to date
[8/1250] fetch tinycc
[tinycc] up to date
[9/1249] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[10/1249] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1222] gen .bind.ts 
... (truncated)
diff hotspot
src/js/node/http2.ts                  |  21 ++++--
 test/js/node/http2/node-http2.test.js | 119 ++++++++++++++++++++++++++++++++++
 2 files changed, 134 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/js/node/http2.ts                       7      4     61
test/js/node/http2/node-http2.test.js      3      3     60

doSendFileFD is shared by respondWithFile() and respondWithFD(). It called
options.onError for both. Node reads onError only in respondWithFile()
(afterOpen and doSendFileFD). Its doSendFD and processRespondWithFD never
read it. respondWithFD() now clears onError on its own copy of the options.

respondWithFD() without statCheck ends the writable side when it is called.
A response that the onError handler sent after that never ended, so the
client request did not close.

A header error from respond() now destroys the stream for respondWithFile()
too. Node's processRespondWithFD does the same for both entry points.
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Essentials

Run ID: bacbdab5-124e-402c-978e-642cffbdb27b

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7c982 and f36c3e4.

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

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


Walkthrough

The HTTP/2 implementation adds guarded response teardown and clears onError for respondWithFD(). Tests cover invalid descriptors, directories, rejected headers, concurrent calls, and respondWithFile() fallback behavior.

Changes

HTTP/2 response error handling

Layer / File(s) Summary
Response failure teardown
src/js/node/http2.ts
Adds respondOrDestroy, which destroys the stream when respond() throws. File-stat error responses use this helper.
File descriptor callback isolation and validation
src/js/node/http2.ts, test/js/node/http2/node-http2.test.js
respondWithFD() clears options.onError. Tests verify reset behavior for respondWithFD() failures and 404 fallback handling for respondWithFile() failures.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

🚥 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 identifies the primary change: preventing respondWithFD() from calling options.onError and matching Node behavior.
Description check ✅ Passed The description explains the problem, fix, background, merge considerations, and verification results. It does not use the template headings exactly, but it provides the required information in equiva…

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:28 AM PT - Sep 20th, 2026

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


🧪   To try this PR locally:

bunx bun-pr 43587

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

bun-43587 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for a maintainer. The diff is green. CI is red only for tests that this diff does not touch.

CI:

  • Build #118857 (current head f36c3e4d70) has 2 failures. test/bake/deinitialization.test.ts failed 4 of 4 attempts on alpine 3.23 aarch64, and it was flaky on the same lane in the two earlier builds. test/cli/install/bun-install-streaming-extract.test.ts failed 4 of 4 attempts on one macOS aarch64 job, where many other tests also timed out before the job itself timed out. Neither test uses node:http2.
  • test/js/node/http2/node-http2.test.js passed on every lane of that build: Linux glibc and musl (x64 and aarch64), x64-asan, Windows x64 and aarch64, macOS x64 and aarch64.
  • Build #118687 failed only on 4 x64-asan shards, in 4 leak and timing tests. Other PR builds from the same 10 minutes failed the same way, and the lane passed on the next build.

Before you merge: read the merge order in the PR notes (#43564 and #43526).

How I reproduced it: the script in the Notes block of the PR body, run with Node v26.3.0 and with Bun 1.4.3-canary.1+367d939d9 on Linux x64. Node never calls onError for respondWithFD() and resets the stream with NGHTTP2_INTERNAL_ERROR. Bun calls onError(EBADF), delivers the handler's 404 headers, and the request never closes. The new test in test/js/node/http2/node-http2.test.js fails on that build and passes on this branch.

PR: #43587

@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 findings, I also checked whether options.onError = undefined in respondWithFD() leaks to the caller's object — it does not, respondWithFD() spreads options into a fresh copy at src/js/node/http2.ts:3405 before the assignment. The onError local in doSendFileFD is still read on the fstat-error and non-regular-file arms, so respondWithFile() keeps calling the handler there as before.

Extended reasoning...

Inline findings were posted, so this note only records what else was examined. The per-call options copy at http2.ts:3405 precedes the new options.onError = undefined at 3422, so a caller-shared options object (e.g. reused across respondWithFD and a later respondWithFile) is unaffected. The header-rejection catch now destroys unconditionally for both entry points, which matches the cited Node lines; the earlier onError arms at 2966 and 2985 remain reachable only for respondWithFile, as intended. The unguarded this.respond() calls on those arms for respondWithFD callers and the fd-ownership mismatch are covered by the inline comments and not restated here.

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/js/node/http2.ts — Servers that pass onError to respondWithFD() can now crash with an uncaught exception where the base called their handler. With onError cleared at http2.ts:3422, an fstat error or a directory fd takes the arm at http2.ts:2968 / 2987, which calls this.respond() unguarded inside the fs callback. respond() throws ERR_HTTP2_INVALID_STREAM if the client reset the stream before fstat returned, or ERR_HTTP2_INVALID_PSEUDOHEADER for rejected headers; nothing catches it. Fix: guard both respond() calls (try/catch then this.destroy(err), or skip respond when this.destroyed), so every no-onError arm settles the stream instead of throwing from the callback. The PR notes this as #43448, but neither fix is in this tree.

    Extended reasoning...

    On the base branch respondWithFD(fd, headers, { onError }) reads onError at http2.ts:2959 and calls it at 2966 or 2985, so the throwing respond() call is never reached for those callers. After this change http2.ts:3422 sets options.onError = undefined for every respondWithFD call, so the same callers now take the else arm. That arm calls this.respond(headers, options) at http2.ts:2968 (fstat error) or 2987 (directory or non-regular fd). respond() at http2.ts:3505-3507 throws $ERR_HTTP2_INVALID_STREAM when this.destroyed is true. A client that sends RST_STREAM while fstat is pending destroys the stream before the callback runs, so a closed or bad descriptor plus a client reset throws from the fs.fstat callback. With headers containing a request pseudo-header respond() throws ERR_HTTP2_INVALID_PSEUDOHEADER on the same lines. A throw from an fs callback is an uncaughtException and terminates the process unless the user installed a handler. The header-error catch at http2.ts:3043-3053 only covers the success path respond() at 3044, not 2968 or 2987. The PR description acknowledges this as…

    Verification: normal, acknowledged in diff: the PR description's Notes say "respondWithFD() callers that pass it reach the respond() calls in the arms without onError. Those calls are not guarded... throws from the fs callback. That is #43448 ... #43463 and #43564 both fix it. Prefer to merge one of them first." The bound is accurate for onError-less callers (base already fails that way) but the claim that…

Comment thread test/js/node/http2/node-http2.test.js Outdated
Comment thread src/js/node/http2.ts
doSendFileFD calls respond() in the two arms that run without onError.
respond() throws when the stream is already destroyed and when it rejects
the headers. The throw left the fstat callback as an uncaught exception.
respondWithFD() callers that pass onError now reach these arms, so the
error destroys the stream there, like the arm that sends the file.

The test covers a bad descriptor together with rejected headers. Its
onError handler for the cases that must not call it destroys the stream,
so an unwanted call fails an assertion and does not wait for the timeout.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts 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.

I reviewed the updated push and found no bugs; the second commit addresses the hang-instead-of-fail nit from my earlier pass. Because this changes user-visible node:http2 semantics (when options.onError fires) and overlaps with the other open doSendFileFD PRs named in the description, a human look at the intended ordering is still worthwhile.

What was reviewed:

  • options.onError = undefined in respondWithFD() writes to the per-call copy made at src/js/node/http2.ts:3412, not the caller's object.
  • respondOrDestroy calling destroy(err) when respond() throws on an already-destroyed stream is a no-op (Duplex destroy is idempotent), so no double-error emit.
  • The respondWithFile control cases still work because only respondWithFD without statCheck closes the writable side before fstat returns.
  • Could not run the test here (no debug build in this checkout), so the pass/fail claims in the description are unverified by me.
Extended reasoning...

Overview

The PR touches two files: src/js/node/http2.ts (the shared doSendFileFD helper plus ServerHttp2Stream#respondWithFD) and test/js/node/http2/node-http2.test.js. The runtime change is three-fold: respondWithFD() clears onError on its options copy so the shared helper never calls it for that entry point; a new respondOrDestroy helper wraps the two bare respond() calls in the fstat-error and non-regular-file arms so a throw there destroys the stream rather than escaping the fs.fstat callback as an uncaught exception; and the catch around the main respond() now always destroys the stream instead of routing header errors to onError. The second commit (6b68372) is what added respondOrDestroy and changed the test's "must not run" handler to destroy the stream, which is the fix for the hang concern I raised in the earlier run.

Security risks

None identified. The change only affects which callback runs on a server-side error path and does not touch header parsing, TLS, auth, or any input that crosses a trust boundary. The mutation of options.onError is confined to the object created by { ...options } at line 3412, so no caller-visible mutation or prototype interaction is introduced.

Level of scrutiny

Moderate. The diff is small and each branch maps to a cited Node v26.3.0 line, but it is a deliberate behavioral change to a Node-compat API: code that passed onError to respondWithFD() and relied on it will now see the stream destroyed instead. That is the correct Node behavior per the cited source, but it is the kind of compat decision a maintainer should consciously accept, especially since the description names two other open PRs (#43463, #43564) rewriting the same function with stated merge-order preferences. Those ordering trade-offs are a human call, not something the code alone settles.

Other factors

The test covers the variant matrix reasonably: nine failing scenarios each run with and without onError, plus two positive controls for respondWithFile. Every awaited condition is an event (req 'close' and server stream 'close'), the unwanted-call handler now destroys the stream so a regression fails an assertion rather than waiting for the file timeout, and cleanup is in finally. The one caveat is runtime: it is a single it issuing 20 sequential requests on one connection, which is fine on release but will be slow under debug+ASAN; there is no per-test timeout set, so it stays within the file default. I was not able to execute the test in this environment (no debug build present and test execution was not permitted), so the description's pass/fail claims remain unverified by me. No CODEOWNERS entry covers the changed paths.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

The new test case starts respondWithFile() on a stream while the fstat of
respondWithFD() is pending. A check of the per-stream kOwnsFd flag in
doSendFileFD would call the onError of respondWithFD() there. The clear
on the options copy of the call does not.
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up to the review:

  • The respond() calls in the two arms without onError are now guarded (6b68372, respondOrDestroy). A throw there destroys the stream and does not leave the fstat callback. The test covers a bad descriptor together with rejected headers, with and without onError.
  • The test no longer waits for a timeout when onError runs where it must not (6b68372).
  • The kOwnsFd descriptor leak stays out of this PR. node:http2: make respondWithFD() follow node with and without a statCheck #43564 binds the ownership per call and has a test for the leak. This PR makes only onError per call, and the bothInFlight case pins that (fa960e6). With a kOwnsFd check in place of the clear on the options copy, that case fails with onError: ["EBADF"].
  • The comments that the comment check flagged are one line each now (fa960e6).

The guard conflicts with #43463 and #43478 in those two arms. The PR notes say how to resolve each one.

@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 reviewed the two newer pushes and found no bugs; the earlier hang concern is addressed (the "must not run" handler now destroys the stream, so an unexpected onError call fails the toEqual instead of stalling the file). Because this changes node:http2 response semantics inside a function that #43463 and #43564 also rewrite, a maintainer should still decide merge order.

What was reviewed:

  • options.onError = undefined in respondWithFD lands on the { ...options } copy made at http2.ts:3410, so the caller's object is untouched and afterOpen (respondWithFile) still reads its own copy's onError.
  • respondOrDestroy now guards both no-onError fallback arms; a respond() throw destroys with the header error and the false return skips the second destroy(), so the stream is not destroyed twice with different errors.
  • The bothInFlight case: respondWithFile flipping kOwnsFd before the pending fstat(badFd) returns cannot resurrect onError, since the flag is no longer what selects it; the EBADF arm still skips tryClose.
Extended reasoning...

Overview

The PR touches src/js/node/http2.ts (respondWithFD, doSendFileFD, and a new respondOrDestroy helper) and adds one test to test/js/node/http2/node-http2.test.js. Runtime changes are about 30 lines: respondWithFD clears onError on its per-call options copy, the two fallback arms in doSendFileFD that previously called this.respond() unguarded now go through respondOrDestroy, and the catch around the main respond() always destroys instead of routing to onError. Since my last review, commit 6b68372 added respondOrDestroy and the loud-failure onError handler in the test, and fa960e6 added the bothInFlight case and trimmed comments.

Security risks

None identified. The change only affects which error path a server-side stream takes when a file descriptor or header set is rejected; no user-controlled data reaches a new parser, path, or allocation, and the $call intrinsic is used for the helper as src/js/ requires.

Level of scrutiny

Moderate. The diff is small and each branch maps to a cited Node v26.3.0 line, and I traced the three paths in the summary (options aliasing, double-destroy, kOwnsFd race). However this is a behavioral change in a Node-compat module, and the PR description itself says two other open PRs (#43463, #43564) rewrite the same function, with a stated conflict against #43564. That sequencing decision, plus the still-open pre-existing fd-ownership thread on kOwnsFd, is why a human look is worthwhile rather than a full approve.

Other factors

The multi-agent hunt exited on dry_streak with no findings. I could not run the new test locally (no debug build present in this checkout), so the "stock bun fails it" claim is taken from reading the test: without the fix, respondWithFD(badFd, ok, { onError }) would push EBADF into events.onError and the toEqual({ ...withoutOnError, onError: [] }) assertion would fail, so the test is discriminating. The test uses port: 0, awaits real close events on both sides, and closes the fds and server in finally, matching the harness conventions.

respond() still rejects header values that node accepts, for example
undefined. A respondWithFile() caller with an onError handler gets that
error today and can answer with a 500. Without the handler call the
stream is destroyed, and a stream with no 'error' listener ends the
process. This arm can follow node after respond() accepts what node
accepts. respondWithFD() still never calls onError.
@robobun robobun changed the title node:http2: call options.onError only where node does node:http2: respondWithFD() never calls options.onError, like node Sep 20, 2026

@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 reviewed the latest push (through f36c3e4) and found no bugs, but a human look is still worthwhile: the last commit deliberately keeps onError for a respond() header error in respondWithFile() (src/js/node/http2.ts:3045), so the PR description's "A header error from respond() now destroys the stream for respondWithFile() too" no longer matches the code, and a maintainer should decide whether that retained Node deviation is acceptable and how this sequences with #43463/#43564.

What was reviewed:

  • options.onError = undefined in respondWithFD() mutates the copy made at http2.ts:3413, not the caller's object; doSendFileFD reads it once and both if (onError) / typeof onError === "function" checks handle undefined.
  • respondOrDestroy on the two no-onError arms: a throw from respond() on an already-destroyed stream maps to a no-op destroy(), so no double-destroy or uncaught exception from the fstat callback.
  • Test matrix pairs each failing respondWithFD call with and without onError, wires the must-not-run handler to stream.destroy() so a regression fails instead of hanging, and cleanup runs in finally.
Extended reasoning...

Overview

The change is confined to src/js/node/http2.ts (ServerHttp2Stream.respondWithFD() and the shared doSendFileFD helper plus a new respondOrDestroy helper) and one new test in test/js/node/http2/node-http2.test.js. respondWithFD() now clears onError on its private copy of options so the shared fstat callback never invokes it for that entry point, matching Node's doSendFD/processRespondWithFD. The two error arms that previously called this.respond() unguarded from inside the fs callback now go through respondOrDestroy, which converts a synchronous throw from respond() into this.destroy(err) and skips the follow-up destroy so the stream is not destroyed twice with different errors.

Security risks

None identified. The code path handles server-side file responses; the change does not alter fd ownership (the kOwnsFd logic is untouched), does not add any path handling, and the only new side effect is on an already-copied options object. The test opens a bad fd, a directory fd and the test file itself, all local, and closes them in finally.

Level of scrutiny

Moderate. This is Node compat code in a built-in module with a history of several overlapping in-flight PRs on the same function (the author names #43463, #43478 and #43564). The logic itself is small and I traced each arm: EBADF on a bad fd, directory fd, and rejected headers, each with and without statCheck, and the destroyed-stream case where respond() throws ERR_HTTP2_INVALID_STREAM and destroy() becomes a no-op. What warrants a human is not a bug but a judgment call: the final commit (f36c3e4) reversed the earlier version's Node-matching behavior for respondWithFile() header errors, keeping the onError call at http2.ts:3045 with a stated reason (Bun's respond() rejects header values Node accepts, and destroying a stream with no 'error' listener would crash the process). That is a reasonable interim choice, but it is a documented deviation from Node that the PR description now contradicts, and it removed the corresponding test row, so a maintainer should confirm they agree with it.

Other factors

The new test is well-formed: it awaits both the client 'close' and the server stream 'close', uses port: 0, routes the must-not-run handler to stream.destroy() so a regression fails an assertion rather than hanging, includes the bothInFlight ordering case, and keeps two positive controls for respondWithFile() (ENOENT, ERR_HTTP2_SEND_FILE) so the assertion set cannot pass vacuously. Prior reviews on this PR (an onError-hang nit and a pre-existing fd-leak note) were resolved by the author; the hang nit is addressed in the current test, and the fd leak is explicitly deferred to #43564, which is a scoping decision rather than a defect in this diff. The changed files are not covered by CODEOWNERS.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

The PR description was updated after f36c3e4, so that sentence is gone. The Fix section now says that respondWithFile() does not change. The notes have a section for the header error in respondWithFile(): what Node does, why this PR keeps the onError call for now (respond() rejects header values that Node accepts, #41614), and the merge order with #43564, #43463 and #43526.

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