Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions src/js/node/http2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2352,7 +2352,7 @@ class Http2Stream extends Duplex {
}

get sentInfoHeaders() {
return this[kInfoHeaders] || [];
return this[kInfoHeaders];
}

get sentTrailers() {
Expand Down Expand Up @@ -3489,13 +3489,13 @@ class ServerHttp2Stream extends Http2Stream {
}
const session = this[bunHTTP2Session];
assertSession(session);
// request() throws on an invalid header, before it sends anything.
session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames);
if (!this[kInfoHeaders]) {
this[kInfoHeaders] = [headers];
} else {
ArrayPrototypePush.$call(this[kInfoHeaders], headers);
}

session[bunHTTP2Native]?.request(this.id, undefined, headers, sensitiveNames);
}
respond(headers: any, options?: any) {
if (this.destroyed || this.session === undefined) {
Expand Down
76 changes: 76 additions & 0 deletions test/js/node/http2/http2-sent-info-headers.fixture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Collect Http2Stream#sentInfoHeaders at each point of a stream's life, on the client and on the
// server. Run as a script it prints the result as JSON: Node.js and Bun must agree on it.
const http2 = require("node:http2");

// Each row is the list of header blocks the server passes to additionalHeaders(), one call each.
const rows = {
"/plain": [],
"/invalid": [{ ":status": 103, "inv alid": "x" }],
"/info-then-invalid": [{ ":status": 102 }, { ":status": 103, "inv alid": "x" }],
};

// JSON drops undefined, so spell it out. The copy keeps a later push out of an earlier snapshot.
const snapshot = value => (value === undefined ? "undefined" : JSON.parse(JSON.stringify(value)));

async function collect() {
let serverSeen;
let onServerStreamClose;
const server = http2.createServer();
server.on("stream", (stream, headers) => {
const seen = (serverSeen = { onStream: snapshot(stream.sentInfoHeaders), additionalHeaders: [] });
for (const block of rows[headers[":path"]]) {
let result = "sent";
try {
stream.additionalHeaders(block);
} catch (err) {
result = err.code;
}
seen.additionalHeaders.push({ result, sentInfoHeaders: snapshot(stream.sentInfoHeaders) });
}
stream.respond({ ":status": 200 });
seen.afterRespond = snapshot(stream.sentInfoHeaders);
stream.on("close", () => {
seen.atClose = snapshot(stream.sentInfoHeaders);
onServerStreamClose();
});
stream.end("ok");
});

// One request on its own session. Resolves once the stream is closed on both sides.
async function run(path) {
const serverStreamClosed = new Promise(resolve => (onServerStreamClose = resolve));
const { promise: clientStreamClosed, resolve, reject } = Promise.withResolvers();
const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
try {
client.on("error", reject);
const req = client.request({ ":path": path });
const clientSeen = { afterRequest: snapshot(req.sentInfoHeaders) };
req.on("error", reject);
req.on("close", () => {
clientSeen.atClose = snapshot(req.sentInfoHeaders);
resolve();
});
req.resume();
req.end();
await Promise.all([clientStreamClosed, serverStreamClosed]);
return { path, client: clientSeen, server: serverSeen };
} finally {
client.close();
}
}

await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
try {
const results = [];
for (const path of Object.keys(rows)) results.push(await run(path));
return results;
} finally {
server.close();
}
}

module.exports = { collect };

if (require.main === module) {
collect().then(results => console.log(JSON.stringify(results)));
}
54 changes: 53 additions & 1 deletion test/js/node/http2/node-http2.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import tls from "node:tls";
import { Duplex, duplexPair } from "stream";
import http2utils from "./helpers";
import { nodeEchoServer, TLS_CERT, TLS_OPTIONS } from "./http2-helpers";
import { collect as collectSentInfoHeaders } from "./http2-sent-info-headers.fixture.js";
const { describe, expect, it, beforeAll, afterAll, createCallCheckCtx, mock } = createTest(import.meta.path);
// bun-debug ships with ASAN but isn't named bun-asan, so isASAN is false
// there; the 10k-request maxSessionMemory stress test takes ~105s under
Expand Down Expand Up @@ -888,7 +889,7 @@ for (const nodeExecutable of [nodeExe(), bunExe()]) {
":scheme": "https",
});
expect(req.sentTrailers).toBeUndefined();
expect(req.sentInfoHeaders.length).toBe(0);
expect(req.sentInfoHeaders).toBeUndefined();
expect(req.scheme).toBe("https");
let response_headers = null;
req.on("response", (headers, flags) => {
Expand Down Expand Up @@ -1832,6 +1833,57 @@ it("Symbol keys of the headers object are not sent as headers", async () => {
}
});

// node's getter returns the slot as is, and only a block that additionalHeaders() sends creates
// the array (https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L2192-L2194).
// The fixture collects the value at each point of a stream's life. Bun runs it in this process
// and Node.js runs it as a script: both must produce the same document.
it("stream.sentInfoHeaders lists only the 1xx blocks that were sent, like Node.js", async () => {
const unset = { afterRequest: "undefined", atClose: "undefined" };
const info = [{ ":status": 102 }];
const expected = [
{
path: "/plain",
client: unset,
server: { onStream: "undefined", additionalHeaders: [], afterRespond: "undefined", atClose: "undefined" },
},
{
path: "/invalid",
client: unset,
server: {
onStream: "undefined",
additionalHeaders: [{ result: "ERR_INVALID_HTTP_TOKEN", sentInfoHeaders: "undefined" }],
afterRespond: "undefined",
atClose: "undefined",
},
},
{
path: "/info-then-invalid",
client: unset,
server: {
onStream: "undefined",
additionalHeaders: [
{ result: "sent", sentInfoHeaders: info },
{ result: "ERR_INVALID_HTTP_TOKEN", sentInfoHeaders: info },
],
afterRespond: info,
atClose: info,
},
},
];

expect(await collectSentInfoHeaders()).toEqual(expected);

const node = nodeExe();
if (node) {
const fixture = path.join(import.meta.dir, "http2-sent-info-headers.fixture.js");
await using proc = Bun.spawn({ cmd: [node, fixture], env: bunEnv, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// A crash leaves stdout empty. Show stderr in the failure then.
expect(stdout ? JSON.parse(stdout) : stderr).toEqual(expected);
expect(exitCode).toBe(0);
}
});

it("http2 session.goaway() validates input types", async done => {
const { mustCall } = createCallCheckCtx(done);
const server = http2.createServer((req, res) => {
Expand Down
Loading