node:http2: check fd in respondWithFD() like node, after the stream state and the options - #43550
Conversation
…e options respondWithFD() checked the fd argument first, so the fd error won over ERR_HTTP2_INVALID_STREAM, ERR_HTTP2_HEADERS_SENT and the options errors. Node checks the stream state, then the options, then fd, then the headers. respondWithFD() and respondWithFile() now also throw ERR_HTTP2_INVALID_STREAM on a closed stream, as node does.
|
Status: ready for review. The fix and the test are pushed (3 commits). This PR is stacked on #43491 and needs it to merge first. All review threads are answered and resolved. CI (build 118552, ec43141, finished): 180 of 181 jobs passed. The four macOS test jobs passed. One job failed: Reproduction ( import http2 from "node:http2";
const d = fn => { try { fn(); return "no throw"; } catch (e) { return `${e.name} ${e.code}`; } };
const srv = http2.createServer();
srv.on("stream", st => {
st.on("error", () => {});
console.log("respondWithFD('x', {}, {offset: 'x'}) ", d(() => st.respondWithFD("x", {}, { offset: "x" })));
st.respond();
console.log("after respond(): respondWithFD('x') ", d(() => st.respondWithFD("x")));
st.end();
});
srv.listen(0, "127.0.0.1", () => {
const c = http2.connect("http://127.0.0.1:" + srv.address().port);
c.on("error", () => {});
const r = c.request();
r.on("error", () => {});
r.resume();
r.on("close", () => { c.destroy(); srv.close(); });
});node prints Overlap: #43526 also moves the |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked whether the new || this.closed guard could reject a live server stream: the Closed bit is only set by close(), the rst/error path in the stream-error handler, native stream state 7, and push streams after their writable side has already settled — none of these fires on a stream that has not yet responded, so a legitimate respondWithFD/respondWithFile call is not affected.
Extended reasoning...
Findings were reported inline, so this note only records what else was examined. I traced every markStreamClosed call site in /home/claude/bun/src/js/node/http2.ts (close() at ~2539, _destroy at ~2603, the stream error/rst path at ~3717, the native state-7 handler at ~4042/~5037, and the push-stream half-closed-local bookkeeping at ~2071/~2749). Each runs only after the stream has been explicitly closed, reset, or has already finished its response, so the tightened state check in respondWithFD/respondWithFile cannot reject a stream that node would accept. The inline findings (re-entrant fd getter ordering, respond() still lacking the same closed guard, asynchronous headersSent after respondWithFD, and the hand-rolled ERR_INVALID_ARG_TYPE) remain the reason a human should look.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js/node/http2.ts— Servers that call stream.respond() (or read stream.headersSent) right after stream.respondWithFD() send only headers and never the file; node throws ERR_HTTP2_HEADERS_SENT there. respondWithFD at src/js/node/http2.ts:3404-3414 ends the user writable synchronously via closeWritableForFileResponse but defers respond() to the fs.fstat callback, so headersSent stays false until fstat completes. A later respond() wins, and doSendFileFD then returns silently at :3022 because headersSent is true. Fix: when options.statCheck is undefined, mark the response as initiated synchronously (node runs processRespondWithFD synchronously) so headersSent is true when respondWithFD returns and a second respond() throws.Extended reasoning...
Handler calls stream.respondWithFD(fd) with no statCheck.
Line 3404-3408: closeWritableForFileResponse(this) runs stream.end() synchronously, but respond() is not called yet.
Line 3413: fs.fstat is asynchronous; headersSent remains false when respondWithFD returns.
Handler (or a generic error/timeout helper that checksif (!stream.headersSent)) calls stream.respond({':status': 500}) or stream.respond().
respond() at :3487 sees headersSent false and submits those headers; no throw.
fstat completes; doSendFileFD at :3020-3025 sees this.headersSent true and returns; the file is never sent.
The client receives the second response's headers with END_STREAM from the ended writable, no body.
Node's processRespondWithFD runs synchronously and sets headersSent, so the second respond() throws ERR_HTTP2_HEADERS_SENT and the file is sent.
The PR description itself shows this repro and marks it found-and-not-fixed; the code at :3404-3414 is identical on the base commit, so it is pre-existing, but it is a real divergence with a concrete trigger.
Remedy: set the headers-sent state…Verification: pre-existing (the base commit 4ed357f has byte-identical code for this tail of respondWithFD; the PR only moves the fd type check and adds
|| this.closed, and its own description lists this under "Found and not fixed here"). Trigger: a handler callsstream.respondWithFD(fd)withoutoptions.statCheckand then, synchronously or before fstat completes, callsstream.respond(...)(or any…
The fd check ran a user getter (a duck-typed fd.fd read) after the stream state checks. A getter that called respond() then got no error and the file was never sent. Node accepts a number or a FileHandle instance and reads no property of any other object. The error now comes from ERR_INVALID_ARG_TYPE, so its toString() carries the code like the other argument errors.
|
Updated 4:14 PM PT - Sep 19th, 2026
❌ @robobun, your commit ec43141 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 43550That installs a local version of the PR into your bun-43550 --bun |
There was a problem hiding this comment.
LGTM — the second push (aa85f1a) addresses the two actionable points from my earlier pass: only a real FileHandle is unwrapped, so no user getter runs after the state checks, and the fd error now goes through $ERR_INVALID_ARG_TYPE.
What was reviewed:
respondWithFDcheck order (state → options → fd → headers) and theinstanceof FileHandleunwrap now match node'slib/internal/http2/core.jsline for line;ERR_INVALID_ARG_TYPE("fd", ["number", "FileHandle"], ...)renders "of type number or an instance of FileHandle" in ErrorCode.cpp, matching the test's expected strings.doSendFileFDandfs.createReadStream({ fd })only ever see the numeric fd now, so dropping thefd instanceof FileHandlebranch at thefstatcall is behavior-preserving;receivedValueLabelstill has a caller (line 281), so nothing went dead.- Test: hermetic (
listen(0), awaitsreqclose,try/finallycleanup), every row is doubly-invalid so it can only pass with the new ordering, anddestroyedAfterClose: falseproves theclosedrows exercise the new check rather thandestroyed.
One small note: the PR description still says the fd block's "content is unchanged" and that #38644 rewrites it — after aa85f1a this PR does that rewrite itself, so the description/overlap notes are a bit stale.
Extended reasoning...
Overview
The PR touches two files: src/js/node/http2.ts (the ServerHttp2Stream.respondWithFile / respondWithFD argument validation) and test/js/node/http2/node-http2.test.js (one new test). In respondWithFD the fd check moves below the stream-state and options checks, unwraps only a real FileHandle ($data.FileHandle from node:fs/promises, imported at line 37), and throws via $ERR_INVALID_ARG_TYPE. Both methods now reject this.closed in addition to this.destroyed. The later fs.fstat(fd, ...) call is collapsed to the numeric fd, which is correct since the unwrap now always happens first.
Security risks
None identified. The change removes a path where a user-supplied getter (fd.fd on a duck-typed object) could run between the headersSent guard and the response, which was the main behavioral risk flagged on the previous push; it is now closed by the instanceof check. No new user-controlled data flows into native code — doSendFileFD receives the same numeric fd it previously did after the old duck-typed unwrap.
Level of scrutiny
Moderate. This is a node-compat ordering fix in built-in JS, small and self-contained, with a direct reference implementation (node's respondWithFD), which I compared against: node checks destroyed || closed, then headersSent, then assertIsObject(options) and the offset/length/statCheck value checks, then fd instanceof FileHandle ? fd = fd.fd : typeof fd !== 'number' -> ERR_INVALID_ARG_TYPE('fd', ['number','FileHandle'], fd), then headers. The diff matches that exactly. I also read ERR_INVALID_ARG_TYPE in src/jsc/bindings/ErrorCode.cpp to confirm ["number", "FileHandle"] renders as "of type number or an instance of FileHandle" and that a plain object renders as "Received an instance of Object", so the expected strings in the test are what this build will produce. I could not run the test locally (no debug build present in this checkout), so correctness of the expected strings rests on that reading plus the author's stated node v26.3.0 comparison.
Other factors
My prior-run inline findings were: (1) fd getter re-entry after the state checks — fixed by instanceof FileHandle; (2) respond() still lacking the closed check — pre-existing, explicitly deferred to #33380 in the description, not blocking; (3) hand-rolled TypeError with assigned .code — fixed by $ERR_INVALID_ARG_TYPE. receivedValueLabel remains live via its use at line 281. The new test is hermetic (listen(0, "127.0.0.1"), awaits the client request close event, try/finally cleanup of client and server), every row passes multiple bad arguments so the observed error proves which check ran first, and the destroyedAfterClose: false row demonstrates that the closed rows exercise the new predicate rather than the pre-existing destroyed one. No CODEOWNERS concern was identified for these paths, and there are no outstanding third-party objections in the timeline. The only remaining nit is a stale PR description ("fd block content unchanged" / "#38644 rewrites it") that no longer reflects the second commit.
Still open from earlier reviews (1):
- Unresolved: 1 minor or pre-existing.
|
Replies to the two automatic reviews and to the comment check. Two commits followed the first review: aa85f1a and ec43141. 1. A user 2. 3. The 4. Code comment above the Stale description. Correct at the time of the second review: the description still said that the content of the Checks I ran on aa85f1a (ec43141 only removes a comment, and the new test passes with it): the new test fails with the |
There was a problem hiding this comment.
LGTM — the two follow-up commits address the earlier feedback (the fd block now unwraps only a real FileHandle via instanceof and throws through $ERR_INVALID_ARG_TYPE), and the reordered checks match node's respondWithFD.
What was reviewed:
- Check order in
respondWithFD/respondWithFileat src/js/node/http2.ts:3314-3403 against node's core.js: state, headersSent, options, fd, headers — matches. $ERR_INVALID_ARG_TYPE("fd", ["number", "FileHandle"], fd)rendering: ErrorCode.cpp groupsFileHandleas a class name, so the message is node's verbatim;determineSpecificTypereads onlyconstructor, so a userfdgetter never runs.- Passing the unwrapped number (not the
FileHandle) intodoSendFileFD/createReadStream:kOwnsFdis false soautoClosestays off and the caller keeps ownership, same as node. receivedValueLabelstill has a caller at :281, so nothing became dead.
Extended reasoning...
Overview
The diff touches two files: src/js/node/http2.ts (the respondWithFile and respondWithFD methods of ServerHttp2Stream) and test/js/node/http2/node-http2.test.js (one new test). In respondWithFD, the hand-rolled fd TypeError that used to run first is replaced by node's fd instanceof FileHandle ? fd = fd.fd : typeof fd !== "number" → $ERR_INVALID_ARG_TYPE, positioned after the state/headersSent/options checks and before the headers check. Both file methods now also reject closed streams (this.destroyed || this.closed), and the duplicate instanceof FileHandle branch before fs.fstat is collapsed since fd is already a number at that point. The new test drives a server stream through doubly-invalid calls and asserts a single results object with exact code+message strings.
Security risks
None identified. The change narrows what respondWithFD accepts (only numbers and real FileHandle instances, which is node's contract) and removes the read of an arbitrary user object's fd property, so less user code runs during validation than before. FileHandle is captured at module load from require("node:fs/promises").$data, not from a user-overridable global. The $ERR_INVALID_ARG_TYPE message builder only reads constructor/name on the rejected value, the same as node's determineSpecificType.
Level of scrutiny
Low-to-moderate. This is a small, self-contained node-compat reorder in built-in JS, and the two commits since my prior review directly address the two points I raised then (user getter running after the state guards; hand-assigned err.code). I verified from the code, not the PR description, that both are fixed. I checked that unwrapping the FileHandle to a number before doSendFileFD does not change ownership semantics: kOwnsFd is set to false in respondWithFD, so createReadStream gets autoClose: false and the fd stays the caller's, matching node's own fd = fd.fd unwrap. The remaining pre-existing gap (respond() on a closed stream) was noted earlier as non-blocking and is explicitly scoped out in the PR description with references to other PRs that cover it.
Other factors
The new test asserts its precondition (destroyedAfterClose: false) so the closed rows genuinely exercise the new || this.closed clause, wires the client/req error events, awaits the request close event rather than sleeping, and releases resources in finally. The vendored node tests test-http2-respond-file-fd-errors.js, -fd-invalid.js, and -filehandle.js cover the same surface (every non-number type, options type matrix, real FileHandle), which gives additional guard against regressions in the message text and the FileHandle path. I did not build and run the tests in this session (no debug build present); my conclusion rests on reading the diff, the ErrorCode.cpp message renderer, and the doSendFileFD/close() paths. The PR overlaps textually with other open PRs on the same lines, which is a rebase/ordering matter rather than a correctness concern.
Problem
ServerHttp2Stream#respondWithFD(fd, headers, options)checksfdfirst (src/js/node/http2.ts, top ofrespondWithFD). A badfdthen hides every other error. Afterstream.respond(),stream.respondWithFD("x")throwsERR_INVALID_ARG_TYPE("fd"). Node v26.3.0 throwsERR_HTTP2_HEADERS_SENT.options, thenfd, then the headers (lib/internal/http2/core.js,respondWithFD).respondWithFD()andrespondWithFile()test onlythis.destroyed. Node also teststhis.closed. Afterstream.close(), node throwsERR_HTTP2_INVALID_STREAMand bun does not throw.Fix
fdcheck moves below theoptionschecks and above the headers checks.fdcheck is now node's: a number, or aFileHandleinstance. Before, any object with a numericfdpassed, and the read of it could run a user getter.ERR_HTTP2_INVALID_STREAMon a closed stream.test/js/node/http2/node-http2.test.js, with node v26.3.0's output as the expected strings. It fails without thesrc/change. All 22 pairs of failing checks I compared now equal node. Also ran the 22 vendored node tests that call these methods.Background
optionschecks above the headers checks. node:http2: prepare the final response headers like node #43526 (other base) also moves thefdcheck to this position, without theclosedandFileHandlechecks. See Notes.respondWithFD()sends a file from an open descriptor as the response body.respondWithFile()opens a path first. AFileHandleis whatfs.promises.open()returns.closedafterclose(). It isdestroyedlater, when teardown completes. Node refuses a response in both states.Notes
Review. Three review passes before the PR (correctness, test, shape), no blocker, two test findings applied. After the first push, the automatic review reported that the moved
fdblock let a user getter run after the state checks, and that the block built its error by hand. The second commit replaces the block with node's check. Measured after it: an object with anfdgetter getsERR_INVALID_ARG_TYPEas in node, and the getter does not run (test rowfdGetterRan).String(err)isTypeError [ERR_INVALID_ARG_TYPE]: ...as in node (before:TypeError: ...). A realFileHandlestill works (test-http2-respond-file-filehandle.js). The unreachablefd instanceof FileHandlebranch at the end of the method is removed.Behavior change to know about.
respondWithFD({ fd: 3 })with a plain object threw nothing before. It now throwsERR_INVALID_ARG_TYPE, as in node.Overlap with open PRs on the same lines.
fdblock inrespondWithFD()to the same position, as part of a change to how the response headers are prepared. Its test has two rows for that order. I found it only after I opened this PR. The two PRs change the same lines from different bases, so the second one to land needs a rebase there. Only this PR has: theclosedcheck in both methods, theFileHandlecheck, the rows for a badfdon a stream that already responded, is closed, or is destroyed, and the row foroptionsthat is not an object.fdblock, and does not move it. This PR now contains that half of it. Its other half (getUnpackedSettings()) is not touched here.|| this.closed || this.session === undefinedto the same state checks, and torespond().:statuslines below the headers check. No shared lines.assertIsObject(options, "options")at the place where node:http2: validate the headers and options arguments like node #43491 adds it.Not fixed here:
respond()on a closed stream.respond()still tests onlydestroyed. Measured on main: afterstream.close(),stream.respond()does not throw (node:ERR_HTTP2_INVALID_STREAM), and a bun client then fails the whole session withERR_HTTP2_ERROR: Stream was already closed or invalid, so later requests on it fail too. I did not add the check here becauserespond()is also called from thefs.fstatcallback of the file methods, before that callback testsclosed. A throw there is an uncaught exception. #43463 changes those call sites and #33380 adds the check.Not fixed here:
headersSentafterrespondWithFD(). No open PR covers it as far as I found. Node runsrespondWithFD()synchronously whenoptions.statCheckis not given, sostream.headersSentistruewhen the call returns and a laterrespond()throwsERR_HTTP2_HEADERS_SENT. Bun always waits forfs.fstat, soheadersSentisfalse,respond()does not throw, and the file is never sent. In my run the client request then never emitted'close'.