mirror of
https://github.com/oven-sh/bun
synced 2026-02-12 03:48:56 +00:00
### What does this PR do? Enables the `net.Server → Http2SecureServer` connection upgrade pattern used by libraries like [http2-wrapper](https://github.com/szmarczak/http2-wrapper), [crawlee](https://github.com/apify/crawlee), and custom HTTP/2 proxy servers. This pattern works by accepting raw TCP connections on a `net.Server` and forwarding them to an `Http2SecureServer` via `h2Server.emit('connection', rawSocket)`. #### Bug fixes **SSLWrapper use-after-free (Zig)** Two use-after-free bugs in `ssl_wrapper.zig` are fixed: 1. **`flush()` stale pointer** — `flush()` captured the `ssl` pointer *before* calling `handleTraffic()`, which can trigger a close callback that frees the SSL object via `deinit`. The pointer was then used after being freed. Fix: read `this.ssl` *after* `handleTraffic()` returns. 2. **`handleReading()` null dereference** — `handleReading()` called `triggerCloseCallback()` after `triggerDataCallback()` without checking whether the data callback had already closed the connection. This led to a null function pointer dereference. Fix: check `this.ssl == null || this.flags.closed_notified` before calling the close callback. ### How did you verify your code works? - Added **13 in-process tests** (`node-http2-upgrade.test.mts`) covering the `net.Server → Http2SecureServer` upgrade path: - GET/POST requests through upgraded connections - Sequential requests sharing a single H2 session - `session` event emission - Concurrent clients with independent sessions - Socket close ordering (rawSocket first vs session first) — no crash - ALPN protocol negotiation (`h2`) - Varied status codes (200, 302, 404) - Client disconnect mid-response (stream destroyed early) - Three independent clients producing three distinct sessions - Tests use `node:test` + `node:assert` and **pass in both Bun and Node.js** - Ported `test-http2-socket-close.js` from the Node.js test suite, verifying no segfault when the raw socket is destroyed before the H2 session is closed --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
/**
|
|
* Tests for the net.Server → Http2SecureServer upgrade path
|
|
* (upgradeRawSocketToH2 in _http2_upgrade.ts).
|
|
*
|
|
* This pattern is used by http2-wrapper, crawlee, and other libraries that
|
|
* accept raw TCP connections and upgrade them to HTTP/2 via
|
|
* `h2Server.emit('connection', rawSocket)`.
|
|
*
|
|
* Works with both:
|
|
* bun bd test test/js/node/http2/node-http2-upgrade.test.ts
|
|
* node --experimental-strip-types --test test/js/node/http2/node-http2-upgrade.test.ts
|
|
*/
|
|
import assert from "node:assert";
|
|
import fs from "node:fs";
|
|
import http2 from "node:http2";
|
|
import net from "node:net";
|
|
import path from "node:path";
|
|
import { afterEach, describe, test } from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const FIXTURES_PATH = path.join(__dirname, "..", "test", "fixtures", "keys");
|
|
|
|
const TLS = {
|
|
key: fs.readFileSync(path.join(FIXTURES_PATH, "agent1-key.pem")),
|
|
cert: fs.readFileSync(path.join(FIXTURES_PATH, "agent1-cert.pem")),
|
|
ALPNProtocols: ["h2"],
|
|
};
|
|
|
|
function createUpgradeServer(
|
|
handler: (req: http2.Http2ServerRequest, res: http2.Http2ServerResponse) => void,
|
|
opts: { onSession?: (session: http2.Http2Session) => void } = {},
|
|
): Promise<{ netServer: net.Server; h2Server: http2.Http2SecureServer; port: number }> {
|
|
return new Promise(resolve => {
|
|
const h2Server = http2.createSecureServer(TLS, handler);
|
|
h2Server.on("error", () => {});
|
|
if (opts.onSession) h2Server.on("session", opts.onSession);
|
|
|
|
const netServer = net.createServer(socket => {
|
|
h2Server.emit("connection", socket);
|
|
});
|
|
|
|
netServer.listen(0, "127.0.0.1", () => {
|
|
resolve({ netServer, h2Server, port: (netServer.address() as net.AddressInfo).port });
|
|
});
|
|
});
|
|
}
|
|
|
|
function connectClient(port: number): http2.ClientHttp2Session {
|
|
const client = http2.connect(`https://127.0.0.1:${port}`, { rejectUnauthorized: false });
|
|
client.on("error", () => {});
|
|
return client;
|
|
}
|
|
|
|
function request(
|
|
client: http2.ClientHttp2Session,
|
|
method: string,
|
|
reqPath: string,
|
|
body?: string,
|
|
): Promise<{ status: number; headers: http2.IncomingHttpHeaders; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = client.request({ ":method": method, ":path": reqPath });
|
|
let responseBody = "";
|
|
let responseHeaders: http2.IncomingHttpHeaders = {};
|
|
req.on("response", hdrs => {
|
|
responseHeaders = hdrs;
|
|
});
|
|
req.setEncoding("utf8");
|
|
req.on("data", (chunk: string) => {
|
|
responseBody += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
resolve({
|
|
status: responseHeaders[":status"] as unknown as number,
|
|
headers: responseHeaders,
|
|
body: responseBody,
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
if (body !== undefined) {
|
|
req.end(body);
|
|
} else {
|
|
req.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
describe("HTTP/2 upgrade via net.Server", () => {
|
|
let servers: { netServer: net.Server }[] = [];
|
|
let clients: http2.ClientHttp2Session[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const c of clients) c.close();
|
|
for (const s of servers) s.netServer.close();
|
|
clients = [];
|
|
servers = [];
|
|
});
|
|
|
|
test("GET request succeeds with 200 and custom headers", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(200, { "x-upgrade-test": "yes" });
|
|
res.end("hello from upgraded server");
|
|
});
|
|
servers.push(srv);
|
|
|
|
const client = connectClient(srv.port);
|
|
clients.push(client);
|
|
|
|
const result = await request(client, "GET", "/");
|
|
assert.strictEqual(result.status, 200);
|
|
assert.strictEqual(result.headers["x-upgrade-test"], "yes");
|
|
assert.strictEqual(result.body, "hello from upgraded server");
|
|
});
|
|
|
|
test("POST request with body echoed back", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
let body = "";
|
|
_req.on("data", (chunk: string) => {
|
|
body += chunk;
|
|
});
|
|
_req.on("end", () => {
|
|
res.writeHead(200);
|
|
res.end("echo:" + body);
|
|
});
|
|
});
|
|
servers.push(srv);
|
|
|
|
const client = connectClient(srv.port);
|
|
clients.push(client);
|
|
|
|
const result = await request(client, "POST", "/echo", "test payload");
|
|
assert.strictEqual(result.status, 200);
|
|
assert.strictEqual(result.body, "echo:test payload");
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — multiple requests on one connection", () => {
|
|
test("three sequential requests share the same session", async () => {
|
|
let count = 0;
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
count++;
|
|
res.writeHead(200);
|
|
res.end(String(count));
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
|
|
const r1 = await request(client, "GET", "/");
|
|
const r2 = await request(client, "GET", "/");
|
|
const r3 = await request(client, "GET", "/");
|
|
|
|
assert.strictEqual(r1.body, "1");
|
|
assert.strictEqual(r2.body, "2");
|
|
assert.strictEqual(r3.body, "3");
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — session event", () => {
|
|
test("h2Server emits session event", async () => {
|
|
let sessionFired = false;
|
|
const srv = await createUpgradeServer(
|
|
(_req, res) => {
|
|
res.writeHead(200);
|
|
res.end("ok");
|
|
},
|
|
{
|
|
onSession: () => {
|
|
sessionFired = true;
|
|
},
|
|
},
|
|
);
|
|
|
|
const client = connectClient(srv.port);
|
|
|
|
await request(client, "GET", "/");
|
|
|
|
assert.strictEqual(sessionFired, true);
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — concurrent clients", () => {
|
|
test("two clients get independent sessions", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(200);
|
|
res.end(_req.url);
|
|
});
|
|
|
|
const c1 = connectClient(srv.port);
|
|
const c2 = connectClient(srv.port);
|
|
|
|
const [r1, r2] = await Promise.all([request(c1, "GET", "/from-client-1"), request(c2, "GET", "/from-client-2")]);
|
|
|
|
assert.strictEqual(r1.body, "/from-client-1");
|
|
assert.strictEqual(r2.body, "/from-client-2");
|
|
|
|
c1.close();
|
|
c2.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — socket close ordering", () => {
|
|
test("no crash when rawSocket.destroy() precedes session.close()", async () => {
|
|
let rawSocket: net.Socket | undefined;
|
|
let h2Session: http2.Http2Session | undefined;
|
|
|
|
const h2Server = http2.createSecureServer(TLS, (_req, res) => {
|
|
res.writeHead(200);
|
|
res.end("done");
|
|
});
|
|
h2Server.on("error", () => {});
|
|
h2Server.on("session", s => {
|
|
h2Session = s;
|
|
});
|
|
|
|
const netServer = net.createServer(socket => {
|
|
rawSocket = socket;
|
|
h2Server.emit("connection", socket);
|
|
});
|
|
|
|
const port = await new Promise<number>(resolve => {
|
|
netServer.listen(0, "127.0.0.1", () => resolve((netServer.address() as net.AddressInfo).port));
|
|
});
|
|
|
|
const client = connectClient(port);
|
|
await request(client, "GET", "/");
|
|
|
|
const socketClosed = Promise.withResolvers<void>();
|
|
rawSocket!.once("close", () => socketClosed.resolve());
|
|
rawSocket!.destroy();
|
|
await socketClosed.promise;
|
|
if (h2Session) h2Session.close();
|
|
|
|
client.close();
|
|
netServer.close();
|
|
});
|
|
|
|
test("no crash when session.close() precedes rawSocket.destroy()", async () => {
|
|
let rawSocket: net.Socket | undefined;
|
|
let h2Session: http2.Http2Session | undefined;
|
|
|
|
const h2Server = http2.createSecureServer(TLS, (_req, res) => {
|
|
res.writeHead(200);
|
|
res.end("done");
|
|
});
|
|
h2Server.on("error", () => {});
|
|
h2Server.on("session", s => {
|
|
h2Session = s;
|
|
});
|
|
|
|
const netServer = net.createServer(socket => {
|
|
rawSocket = socket;
|
|
h2Server.emit("connection", socket);
|
|
});
|
|
|
|
const port = await new Promise<number>(resolve => {
|
|
netServer.listen(0, "127.0.0.1", () => resolve((netServer.address() as net.AddressInfo).port));
|
|
});
|
|
|
|
const client = connectClient(port);
|
|
await request(client, "GET", "/");
|
|
|
|
if (h2Session) h2Session.close();
|
|
const socketClosed = Promise.withResolvers<void>();
|
|
rawSocket!.once("close", () => socketClosed.resolve());
|
|
rawSocket!.destroy();
|
|
await socketClosed.promise;
|
|
|
|
client.close();
|
|
netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — ALPN negotiation", () => {
|
|
test("alpnProtocol is h2 after upgrade", async () => {
|
|
let observedAlpn: string | undefined;
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
const session = _req.stream.session;
|
|
if (session && session.socket) {
|
|
observedAlpn = (session.socket as any).alpnProtocol;
|
|
}
|
|
res.writeHead(200);
|
|
res.end("alpn-ok");
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
await request(client, "GET", "/");
|
|
|
|
assert.strictEqual(observedAlpn, "h2");
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — varied status codes", () => {
|
|
test("404 response with custom header", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(404, { "x-reason": "not-found" });
|
|
res.end("not found");
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
const result = await request(client, "GET", "/missing");
|
|
|
|
assert.strictEqual(result.status, 404);
|
|
assert.strictEqual(result.headers["x-reason"], "not-found");
|
|
assert.strictEqual(result.body, "not found");
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
|
|
test("302 redirect response", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(302, { location: "/" });
|
|
res.end();
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
const result = await request(client, "GET", "/redirect");
|
|
|
|
assert.strictEqual(result.status, 302);
|
|
assert.strictEqual(result.headers["location"], "/");
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
|
|
test("large response body (8KB) through upgraded socket", async () => {
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(200);
|
|
res.end("x".repeat(8192));
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
const result = await request(client, "GET", "/large");
|
|
|
|
assert.strictEqual(result.body.length, 8192);
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — client disconnect mid-response", () => {
|
|
test("server does not crash when client destroys stream early", async () => {
|
|
const streamClosed = Promise.withResolvers<void>();
|
|
|
|
const srv = await createUpgradeServer((_req, res) => {
|
|
res.writeHead(200);
|
|
const interval = setInterval(() => {
|
|
if (res.destroyed || res.writableEnded) {
|
|
clearInterval(interval);
|
|
return;
|
|
}
|
|
res.write("chunk\n");
|
|
}, 5);
|
|
_req.stream.on("close", () => {
|
|
clearInterval(interval);
|
|
streamClosed.resolve();
|
|
});
|
|
});
|
|
|
|
const client = connectClient(srv.port);
|
|
|
|
const streamReady = Promise.withResolvers<http2.ClientHttp2Stream>();
|
|
const req = client.request({ ":method": "GET", ":path": "/" });
|
|
req.on("response", () => streamReady.resolve(req));
|
|
req.on("error", () => {});
|
|
|
|
const stream = await streamReady.promise;
|
|
stream.destroy();
|
|
|
|
await streamClosed.promise;
|
|
|
|
client.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
|
|
describe("HTTP/2 upgrade — independent upgrade per connection", () => {
|
|
test("three clients produce three distinct sessions", async () => {
|
|
const sessions: http2.Http2Session[] = [];
|
|
|
|
const srv = await createUpgradeServer(
|
|
(_req, res) => {
|
|
res.writeHead(200);
|
|
res.end("ok");
|
|
},
|
|
{ onSession: s => sessions.push(s) },
|
|
);
|
|
|
|
const c1 = connectClient(srv.port);
|
|
const c2 = connectClient(srv.port);
|
|
const c3 = connectClient(srv.port);
|
|
|
|
await Promise.all([request(c1, "GET", "/"), request(c2, "GET", "/"), request(c3, "GET", "/")]);
|
|
|
|
assert.strictEqual(sessions.length, 3);
|
|
assert.notStrictEqual(sessions[0], sessions[1]);
|
|
assert.notStrictEqual(sessions[1], sessions[2]);
|
|
|
|
c1.close();
|
|
c2.close();
|
|
c3.close();
|
|
srv.netServer.close();
|
|
});
|
|
});
|
|
if (typeof Bun !== "undefined") {
|
|
describe("Node.js compatibility", () => {
|
|
test("tests should run on node.js", async () => {
|
|
await using proc = Bun.spawn({
|
|
cmd: [Bun.which("node") || "node", "--test", import.meta.filename],
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
stdin: "ignore",
|
|
});
|
|
assert.strictEqual(await proc.exited, 0);
|
|
});
|
|
});
|
|
}
|