Compare commits

...

7 Commits

Author SHA1 Message Date
Marko Vejnovic
2e73dab7ad typo 2025-12-01 17:43:42 -08:00
Marko Vejnovic
a085373976 remove unused code 2025-12-01 17:43:08 -08:00
Marko Vejnovic
ac175e9471 Slightly DRYer code 2025-12-01 17:41:15 -08:00
Marko Vejnovic
6992897269 Implement socket pooling 2025-12-01 17:41:14 -08:00
Marko Vejnovic
aae06f93d0 Add some tests 2025-12-01 17:41:13 -08:00
Marko Vejnovic
ef7934d4d3 Claude generate some tests 2025-12-01 17:41:12 -08:00
Marko Vejnovic
c6e3778dfb Fix a couple other case-insensitive headers 2025-12-01 17:41:10 -08:00
2 changed files with 274 additions and 16 deletions

View File

@@ -593,6 +593,17 @@ fn getUserAgentHeader() picohttp.Header {
Global.user_agent };
}
fn connectionHeaderIsKeepAlive(connection_value: string) ?bool {
// > Connection options are case-insensitive.
// https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
return if (std.ascii.eqlIgnoreCase(connection_value, "close"))
false
else if (std.ascii.eqlIgnoreCase(connection_value, "keep-alive"))
true
else
null;
}
pub fn headerStr(this: *const HTTPClient, ptr: api.StringPointer) string {
return this.header_buf[ptr.offset..][0..ptr.length];
}
@@ -630,10 +641,8 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
hashHeaderConst("Connection") => {
override_connection_header = true;
const connection_value = this.headerStr(header_values[i]);
if (std.ascii.eqlIgnoreCase(connection_value, "close")) {
this.flags.disable_keepalive = true;
} else if (std.ascii.eqlIgnoreCase(connection_value, "keep-alive")) {
this.flags.disable_keepalive = false;
if (connectionHeaderIsKeepAlive(connection_value)) |conn_mod| {
this.state.flags.allow_keepalive = conn_mod;
}
},
hashHeaderConst("if-modified-since") => {
@@ -2230,7 +2239,7 @@ pub fn handleResponseMetadata(
}
},
hashHeaderConst("Content-Type") => {
if (strings.contains(header.value, "text/event-stream")) {
if (strings.containsCaseInsensitiveASCII(header.value, "text/event-stream")) {
is_server_sent_events = true;
}
},
@@ -2252,25 +2261,27 @@ pub fn handleResponseMetadata(
}
},
hashHeaderConst("Transfer-Encoding") => {
if (strings.eqlComptime(header.value, "gzip")) {
// > All transfer-coding names are case-insensitive
// https://datatracker.ietf.org/doc/html/rfc7230#section-4
if (strings.eqlCaseInsensitiveASCII(header.value, "gzip", true)) {
if (!this.flags.disable_decompression) {
this.state.transfer_encoding = Encoding.gzip;
}
} else if (strings.eqlComptime(header.value, "deflate")) {
} else if (strings.eqlCaseInsensitiveASCII(header.value, "deflate", true)) {
if (!this.flags.disable_decompression) {
this.state.transfer_encoding = Encoding.deflate;
}
} else if (strings.eqlComptime(header.value, "br")) {
} else if (strings.eqlCaseInsensitiveASCII(header.value, "br", true)) {
if (!this.flags.disable_decompression) {
this.state.transfer_encoding = .brotli;
}
} else if (strings.eqlComptime(header.value, "zstd")) {
} else if (strings.eqlCaseInsensitiveASCII(header.value, "zstd", true)) {
if (!this.flags.disable_decompression) {
this.state.transfer_encoding = .zstd;
}
} else if (strings.eqlComptime(header.value, "identity")) {
} else if (strings.eqlCaseInsensitiveASCII(header.value, "identity", true)) {
this.state.transfer_encoding = Encoding.identity;
} else if (strings.eqlComptime(header.value, "chunked")) {
} else if (strings.eqlCaseInsensitiveASCII(header.value, "chunked", true)) {
this.state.transfer_encoding = Encoding.chunked;
} else {
return error.UnsupportedTransferEncoding;
@@ -2281,11 +2292,8 @@ pub fn handleResponseMetadata(
},
hashHeaderConst("Connection") => {
if (response.status_code >= 200 and response.status_code <= 299) {
// HTTP headers are case-insensitive (RFC 7230)
if (std.ascii.eqlIgnoreCase(header.value, "close")) {
this.state.flags.allow_keepalive = false;
} else if (std.ascii.eqlIgnoreCase(header.value, "keep-alive")) {
this.state.flags.allow_keepalive = true;
if (connectionHeaderIsKeepAlive(header.value)) |conn_mod| {
this.state.flags.allow_keepalive = conn_mod;
}
}
},

View File

@@ -0,0 +1,250 @@
// Regression test for https://github.com/oven-sh/bun/issues/12053
// http.Agent connection pool was not reusing connections due to:
// 1. Case-insensitive header matching issue in src/http.zig
// 2. Property typo: keepalive vs keepAlive in src/js/node/_http_client.ts
//
// Note: Bun implements http.request via fetch() internally, so we test
// connection reuse at the network level (server-side socket tracking)
// rather than relying on agent.freeSockets or req.reusedSocket which
// are Node.js-specific socket pooling features.
import { describe, expect, test } from "bun:test";
import http from "node:http";
import net from "node:net";
describe("http.Agent connection reuse (#12053)", () => {
test.each([
{ keepAlive: true, expectedSockets: 1, description: "reuses TCP connection" },
{ keepAlive: false, expectedSockets: 2, description: "creates new TCP connection per request" },
])("agent with keepAlive: $keepAlive $description", async ({ keepAlive, expectedSockets }) => {
const agent = new http.Agent({ keepAlive });
const serverSockets: Set<net.Socket> = new Set();
// Track server-side sockets to verify connection reuse
const server = net.createServer(socket => {
serverSockets.add(socket);
socket.on("data", () => {
socket.write("HTTP/1.1 200 OK\r\n" + "Connection: keep-alive\r\n" + "Content-Length: 2\r\n" + "\r\n" + "OK");
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
http
.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
})
.on("error", reject);
});
try {
await makeRequest();
await makeRequest();
expect(serverSockets.size).toBe(expectedSockets);
} finally {
agent.destroy();
server.close();
}
});
describe("Connection header case-insensitivity", () => {
test.each(["keep-alive", "Keep-Alive", "KEEP-ALIVE"])(
'reuses connection when server sends Connection: "%s"',
async connectionValue => {
const agent = new http.Agent({ keepAlive: true });
const serverSockets: Set<net.Socket> = new Set();
// Use raw net.createServer to control exact header casing
const server = net.createServer(socket => {
serverSockets.add(socket);
socket.on("data", () => {
socket.write(`HTTP/1.1 200 OK\r\nConnection: ${connectionValue}\r\nContent-Length: 2\r\n\r\nOK`);
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
http
.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
})
.on("error", reject);
});
try {
await makeRequest();
await makeRequest();
// Both requests should reuse the same TCP connection
expect(serverSockets.size).toBe(1);
} finally {
agent.destroy();
server.close();
}
},
);
});
test.each(["close", "CLOSE"])('Connection: "%s" prevents TCP connection reuse', async connectionValue => {
const agent = new http.Agent({ keepAlive: true });
const serverSockets: Set<net.Socket> = new Set();
const server = net.createServer(socket => {
serverSockets.add(socket);
socket.on("data", () => {
socket.write(`HTTP/1.1 200 OK\r\nConnection: ${connectionValue}\r\nContent-Length: 2\r\n\r\nOK`);
socket.end();
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
http
.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
})
.on("error", reject);
});
try {
await makeRequest();
await makeRequest();
// Each request should create a new TCP connection due to Connection: close/CLOSE
expect(serverSockets.size).toBe(2);
} finally {
agent.destroy();
server.close();
}
});
test("multiple sequential requests reuse same TCP connection", async () => {
const agent = new http.Agent({ keepAlive: true });
const serverSockets: Set<net.Socket> = new Set();
const REQUEST_COUNT = 5;
const server = net.createServer(socket => {
serverSockets.add(socket);
socket.on("data", () => {
socket.write("HTTP/1.1 200 OK\r\n" + "Connection: keep-alive\r\n" + "Content-Length: 2\r\n" + "\r\n" + "OK");
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
http
.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
})
.on("error", reject);
});
try {
for (let i = 0; i < REQUEST_COUNT; i++) {
await makeRequest();
}
// All requests should reuse the same TCP connection
expect(serverSockets.size).toBe(1);
} finally {
agent.destroy();
server.close();
}
});
describe("JavaScript socket object reuse", () => {
test("same socket object is emitted for reused connections", async () => {
const agent = new http.Agent({ keepAlive: true });
const sockets: unknown[] = [];
const server = net.createServer(socket => {
socket.on("data", () => {
socket.write("HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nOK");
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
const req = http.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
});
req.on("socket", socket => {
sockets.push(socket);
});
req.on("error", reject);
});
try {
await makeRequest();
await makeRequest();
// Both requests should receive the same socket object
expect(sockets.length).toBe(2);
expect(sockets[0]).toBe(sockets[1]);
} finally {
agent.destroy();
server.close();
}
});
test("different socket objects when keepAlive is false", async () => {
const agent = new http.Agent({ keepAlive: false });
const sockets: unknown[] = [];
const server = net.createServer(socket => {
socket.on("data", () => {
socket.write("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nOK");
socket.end();
});
});
await new Promise<void>(resolve => server.listen(0, resolve));
const { port } = server.address() as { port: number };
const makeRequest = () =>
new Promise<void>((resolve, reject) => {
const req = http.get({ hostname: "localhost", port, agent, path: "/" }, res => {
res.on("data", () => {});
res.on("end", resolve);
});
req.on("socket", socket => {
sockets.push(socket);
});
req.on("error", reject);
});
try {
await makeRequest();
await makeRequest();
// Each request should receive a different socket object
expect(sockets.length).toBe(2);
expect(sockets[0]).not.toBe(sockets[1]);
} finally {
agent.destroy();
server.close();
}
});
});
});