Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions src/js/node/http2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ const bunHTTP2Native = Symbol.for("::bunhttp2native::");
const bunHTTP2Socket = Symbol.for("::bunhttp2socket::");
const bunHTTP2OriginSet = Symbol("::bunhttp2originset::");
const bunHTTP2StreamFinal = Symbol.for("::bunHTTP2StreamFinal::");
const bunHTTP2WaitForTrailers = Symbol("::bunhttp2waitfortrailers::");

const bunHTTP2StreamStatus = Symbol.for("::bunhttp2StreamStatus::");

Expand Down Expand Up @@ -2050,7 +2051,17 @@ class Http2Stream extends Duplex {
sensitiveNames[sensitives[i]] = true;
}
}
session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames);
// RFC 7540 §8.1: a trailer HEADERS frame must carry a valid header block.
// If the user asked to send an empty trailer object (which the compat
// Http2ServerResponse does unconditionally from onStreamTrailersReady),
// emit an empty DATA frame with END_STREAM instead — this matches how
// Node terminates the stream and avoids a zero-length HEADERS frame that
// strict peers (nghttp2/curl) reject as a callback failure.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (ObjectKeys(headers).length === 0) {
session[bunHTTP2Native]?.noTrailers(this.#id);
} else {
session[bunHTTP2Native]?.sendTrailers(this.#id, headers, sensitiveNames);
}
this.#sentTrailers = headers;
}

Expand Down Expand Up @@ -2198,6 +2209,34 @@ class Http2Stream extends Duplex {
const native = session[bunHTTP2Native];
if (native) {
this[bunHTTP2StreamStatus] |= StreamState.FinalCalled;
// When waitForTrailers is active, writing an empty DATA frame with
// close=true emits a bare empty DATA frame (flags=0) to the wire
// before the trailer/noTrailers path runs, which then emits ANOTHER
// empty DATA (with END_STREAM). Two consecutive empty DATA frames
// confuse strict peers (nghttp2 callback failure). Skip the empty
// writeStream and drive the wantTrailers path directly — the
// eventual `sendTrailers({})` → `noTrailers` call terminates the
// stream with a single empty DATA END_STREAM frame, matching Node.
if (this[bunHTTP2WaitForTrailers]) {
this[bunHTTP2WaitForTrailers] = false;
if ((this[bunHTTP2StreamStatus] & StreamState.WantTrailer) === 0) {
this[bunHTTP2StreamStatus] |= StreamState.WantTrailer;
if (this.listenerCount("wantTrailers") === 0) {
native.noTrailers(this.#id);
// Mark trailers as "sent" so a later stream.sendTrailers()
// call hits the ERR_HTTP2_TRAILERS_ALREADY_SENT guard instead
// of invoking native noTrailers() a second time on an
// already-half-closed stream. The emit("wantTrailers") path
// below reaches the same result via sendTrailers({}) which
// assigns #sentTrailers itself.
this.#sentTrailers = {};
} else {
this.emit("wantTrailers");
}
}
callback();
return;
Comment thread
robobun marked this conversation as resolved.
}
Comment thread
robobun marked this conversation as resolved.
native.writeStream(this.#id, "", "ascii", true, callback);
return;
}
Expand Down Expand Up @@ -2643,6 +2682,16 @@ class ServerHttp2Stream extends Http2Stream {
session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames);
} else {
session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames, options);
// Only track waitForTrailers when the HEADERS frame above did NOT end
// the stream. Status codes 204/205/304 and HEAD requests force
// endStream=true earlier in this method, which means the native
// request() already wrote END_STREAM on the HEADERS frame — driving
// the wantTrailers path from `_final` on such a stream would call
// `noTrailers`/`emit("wantTrailers")` on an already-half-closed
// stream and corrupt state.
if (options.waitForTrailers && !endStream) {
this[bunHTTP2WaitForTrailers] = true;
}
Comment thread
robobun marked this conversation as resolved.
Outdated
}
this.headersSent = true;
this[bunHTTP2Headers] = headers;
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -3069,7 +3118,13 @@ class ServerHttp2Session extends Http2Session {
this.#parser = new H2FrameParser({
native: nativeSocket,
context: this,
settings: { ...options, ...options?.settings },
// RFC 9113 §7.2.2: a server MUST NOT send SETTINGS_ENABLE_PUSH with a
// value other than 0 — any non-zero value is treated by a client as a
// PROTOCOL_ERROR (nghttp2 reports this as callback failure). This is
// unconditional at the protocol level, so `enablePush: false` is
// spread LAST to override any user-supplied setting and keep the
// server compliant regardless of caller configuration.
settings: { ...options, ...options?.settings, enablePush: false },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
type: 0, // server type
handlers: ServerHttp2Session.#Handlers,
});
Expand Down Expand Up @@ -3206,6 +3261,12 @@ class ServerHttp2Session extends Http2Session {
if (callback !== undefined && typeof callback !== "function") {
throw $ERR_INVALID_ARG_TYPE("callback", "function", callback);
}
// RFC 9113 §7.2.2: a server MUST NOT advertise SETTINGS_ENABLE_PUSH != 0.
// Force-override whatever the caller passes so a mid-connection SETTINGS
// frame stays compliant (the initial SETTINGS frame already clamps this
// in ServerHttp2Session's constructor). Clients still accept `enablePush`
// via their own `settings()` method.
settings = { ...settings, enablePush: false };
validateSettings(settings);
this.#pendingSettingsAck = true;
Comment thread
robobun marked this conversation as resolved.
this.#parser?.settings(settings);
Expand Down
Loading
Loading