From 37e20bc7082279d732c785ccd85278605f7c3c5a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:58:51 +0000 Subject: [PATCH 1/2] node:http: make setKeepAlive() act on the sockets of a server setKeepAlive() of a node:http server socket (req.socket, and the socket of 'connection', 'connect' and 'upgrade') was a stub that returned the socket. The native handle of the connection now has setKeepAlive(enable, ms). The socket class no longer overrides the method: net.Socket#setKeepAlive() finds the handle through an internal getter, so the call that changes nothing is skipped as for any net.Socket. A socket of a unix listener gives no handle: it has no keep-alive, as in Node.js. --- src/js/internal/net/symbols.ts | 2 + src/js/node/_http_server.ts | 8 +- src/js/node/net.ts | 8 +- .../node/JSNodeHTTPServerSocketPrototype.cpp | 23 ++ .../http/node-http-server-keepalive.node.mts | 89 +++++ .../http/node-http-server-keepalive.test.ts | 373 ++++++++++++++++++ 6 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 test/js/node/http/node-http-server-keepalive.node.mts create mode 100644 test/js/node/http/node-http-server-keepalive.test.ts diff --git a/src/js/internal/net/symbols.ts b/src/js/internal/net/symbols.ts index 0f05738b1e81..73919865b744 100644 --- a/src/js/internal/net/symbols.ts +++ b/src/js/internal/net/symbols.ts @@ -7,6 +7,8 @@ export default { // Internal handshake-settled signal: server-side sockets emit no user // 'secureConnect' (node parity), so internal deferrals park on this instead. kSecureConnectDone: Symbol("kSecureConnectDone"), + // On a net.Socket subclass with no `_handle` (a node:http server socket): the handle that setKeepAlive() acts on. + kSocketOptionHandle: Symbol("kSocketOptionHandle"), kVerifyError: Symbol("kVerifyError"), // net.Socket.prototype method behind the client-side `new tls.TLSSocket(socket)`. kUpgradeClientTLS: Symbol("kUpgradeClientTLS"), diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 1efd8553d6e3..c5a8fe274249 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -31,6 +31,7 @@ const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); +const { kSocketOptionHandle } = require("internal/net/symbols"); const { kInternalSocketData, serverSymbol, @@ -1708,6 +1709,8 @@ function getNodeHTTPServerSocket() { this._writableState.decodeStrings = true; this.server = server; this.#resetSupported = !encrypted && !listenerGeneration?.isUnix; + // A unix socket has no keep-alive: its handle gets no setKeepAlive(), as node's Pipe handle. + if (listenerGeneration?.isUnix) handle.setKeepAlive = undefined; this[kHandle] = handle; this._secureEstablished = !!handle?.secureEstablished; handle.onclose = this.#onClose.bind(this); @@ -2158,8 +2161,9 @@ function getNodeHTTPServerSocket() { return this; } - setKeepAlive(_enable = false, _initialDelay = 0) { - return this; + // What net.Socket#setKeepAlive() acts on. + get [kSocketOptionHandle]() { + return this[kHandle]; } setNoDelay(_noDelay = true) { diff --git a/src/js/node/net.ts b/src/js/node/net.ts index d7bb3f5e7b22..f85c0d8c8977 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -47,6 +47,7 @@ const { kDestroyOnRead, kPreHandshakeWrite, kSecureConnectDone, + kSocketOptionHandle, kStandaloneWrap, kUpgradeClientTLS, kVerifyError, @@ -2858,18 +2859,19 @@ Socket.prototype.setKeepAlive = function setKeepAlive(enable = false, initialDel // negatives and ~~ overflow match Node's no-validate behavior. const initialDelay = MathMax(0, ~~initialDelayMsecs); - if (!this._handle) { + const handle = this._handle || this[kSocketOptionHandle]; + if (!handle) { this[kSetKeepAlive] = enable; this[kSetKeepAliveInitialDelay] = initialDelay; return this; } - if (!this._handle.setKeepAlive) { + if (!handle.setKeepAlive) { return this; } if (enable !== this[kSetKeepAlive] || (enable && this[kSetKeepAliveInitialDelay] !== initialDelay)) { this[kSetKeepAlive] = enable; this[kSetKeepAliveInitialDelay] = initialDelay; - this._handle.setKeepAlive(enable, initialDelay); + handle.setKeepAlive(enable, initialDelay); } return this; }; diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index 7c9df9722654..59dc178522d0 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -7,12 +7,15 @@ #include "helpers.h" #include #include +#include #include +#include extern "C" EncodedJSValue us_socket_buffered_js_write(void* socket, bool is_ssl, bool ended, bool hold, bool flushesBufferOnDrain, us_socket_stream_buffer_t* streamBuffer, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue data, JSC::EncodedJSValue encoding); extern "C" uint64_t uws_res_get_remote_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" uint64_t uws_res_get_local_address_info(void* res, const char** dest, int* port, bool* is_ipv6); extern "C" void us_socket_shutdown(us_socket_t*); +extern "C" int us_socket_keepalive(us_socket_t*, int enabled, unsigned int delay); extern "C" JSC::EncodedJSValue Bun__socketReadErrorFromCloseCode(JSC::JSGlobalObject* globalObject, int code); namespace Bun { @@ -52,6 +55,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStartPipelinedResponse); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketStopParsing); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseWhenDrained); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetKeepAlive); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterResponse); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterRemoteAddress); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterLocalAddress); @@ -97,6 +101,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "stopParsing"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketStopParsing, 0 } }, { "closeWhenDrained"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseWhenDrained, 0 } }, { "closeIfIdle"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketCloseIfIdle, 0 } }, + { "setKeepAlive"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketSetKeepAlive, 2 } }, { "secureEstablished"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterIsSecureEstablished, noOpSetter } }, { "servername"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterServername, noOpSetter } }, { "authorizationError"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterAuthorizationError, noOpSetter } }, @@ -264,6 +269,24 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketCloseIfIdle, (JSC::JSGlob return JSValue::encode(JSC::jsBoolean(thisObject && thisObject->closeIfIdle())); } +// The delay is in milliseconds, as for the handle of Bun.Socket. No `upgraded` check: the connection of a WebSocket keeps its keep-alive, as in Node. +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketSetKeepAlive, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + bool enabled = callFrame->argument(0).toBoolean(globalObject); + double delayMs = callFrame->argument(1).toNumber(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + // After toNumber(): it can run JS that closes the connection. + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject || thisObject->isClosed()) [[unlikely]] { + return JSValue::encode(JSC::jsBoolean(false)); + } + // TCP_KEEPIDLE is in seconds. 0, also for NaN and a negative delay, keeps the kernel's value. + unsigned delaySeconds = delayMs >= 1000 ? static_cast(std::min(delayMs / 1000, static_cast(std::numeric_limits::max()))) : 0; + return JSValue::encode(JSC::jsBoolean(us_socket_keepalive(thisObject->socket, enabled, delaySeconds) == 0)); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); diff --git a/test/js/node/http/node-http-server-keepalive.node.mts b/test/js/node/http/node-http-server-keepalive.node.mts new file mode 100644 index 000000000000..73556dcae9f3 --- /dev/null +++ b/test/js/node/http/node-http-server-keepalive.node.mts @@ -0,0 +1,89 @@ +/** + * TCP keep-alive of a node:http server: what socket.setKeepAlive() gives back to JavaScript. + * + * All tests in this file must also pass in Node.js: node-http-server-keepalive.test.ts runs the file under Node.js + * and under Bun. The tests that read the socket options of the kernel are in that file. + */ +import assert from "node:assert"; +import { once } from "node:events"; +import http from "node:http"; +import net, { type AddressInfo } from "node:net"; +import { describe, test } from "node:test"; +import tls from "node:tls"; + +export type SocketEvent = "request" | "connection" | "connect" | "upgrade"; +export const requests: Record = { + request: "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", + connection: "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n", + connect: "CONNECT localhost:443 HTTP/1.1\r\nHost: localhost:443\r\n\r\n", + upgrade: "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: tunnel\r\n\r\n", +}; + +export async function listen(server: http.Server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +// A client that sent `request`. It is connected and, for TLS, the handshake is done. +export async function connect(port: number, secure: boolean, request: string) { + const client = secure + ? tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }) + : net.connect(port, "127.0.0.1"); + await once(client, secure ? "secureConnect" : "connect"); + client.write(request); + return client; +} + +// Runs `fn` with the socket that `server` gives to the listener of `event`, and gives the result of `fn`. +export async function withSocket( + server: http.Server, + secure: boolean, + event: SocketEvent, + fn: (socket: net.Socket) => T | Promise, +): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const run = (socket: net.Socket) => void Promise.try(fn, socket).then(resolve, reject); + if (event === "request") { + server.on("request", (req, res) => { + run(req.socket); + res.end(); + }); + } else if (event === "connection") { + server.on("connection", run); + server.on("request", (_req, res) => res.end()); + } else { + server.on(event, (_req, socket) => run(socket as net.Socket)); + } + const client = await connect(await listen(server), secure, requests[event]); + client.on("error", reject); + try { + return await promise; + } finally { + client.destroy(); + server.closeAllConnections(); + server.close(); + } +} + +describe("socket.setKeepAlive() of a server socket", () => { + test("returns a Proxy of the socket that it is called on", async () => { + const returned = await withSocket(http.createServer(), false, "request", socket => { + const proxy = new Proxy(socket, {}); + return proxy.setKeepAlive(true, 5000) === proxy; + }); + assert.strictEqual(returned, true); + }); + + for (const event of ["request", "connection", "connect", "upgrade"] as const) { + test(`returns the socket of '${event}' for any arguments, also after destroy()`, async () => { + const returned = await withSocket(http.createServer(), false, event, socket => { + const calls: unknown[][] = [[true, 5000], [true], [], [false], ["yes", "5000"], [true, NaN], [true, -5000]]; + const open = calls.map(args => socket.setKeepAlive(...(args as [boolean, number])) === socket); + socket.destroy(); + return [...open, socket.setKeepAlive(true, 5000) === socket]; + }); + assert.deepStrictEqual(returned, [true, true, true, true, true, true, true, true]); + }); + } +}); diff --git a/test/js/node/http/node-http-server-keepalive.test.ts b/test/js/node/http/node-http-server-keepalive.test.ts new file mode 100644 index 000000000000..c08763867616 --- /dev/null +++ b/test/js/node/http/node-http-server-keepalive.test.ts @@ -0,0 +1,373 @@ +/** + * TCP keep-alive of a node:http server: the socket options of the kernel after socket.setKeepAlive(). + * + * The expected values are what Node v26.3.0 gives (read there with getsockopt(2) from a helper process), except where + * a comment says that Bun differs. The tests that need no socket option are in node-http-server-keepalive.node.mts. + * The import below runs them here too, and the last test runs them under Node.js. + */ +import { dlopen, FFIType, ptr } from "bun:ffi"; +import { describe, expect, test } from "bun:test"; +import { + bunEnv, + isFreeBSD, + isLinux, + isMacOS, + isWindows, + libcPathForDlopen, + nodeExe, + tempDir, + tls as tlsCert, +} from "harness"; +import { once } from "node:events"; +import { readdirSync } from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import net from "node:net"; +import { join } from "node:path"; +import { WebSocketServer } from "ws"; +import { connect, listen, requests, withSocket } from "./node-http-server-keepalive.node.mts"; + +// Opened by the first test that reads a socket option. Those tests do not run on Windows: nothing there gives the +// SOCKET of a server connection to JavaScript. +const openLibc = () => + dlopen(libcPathForDlopen(), { + getsockopt: { args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr, FFIType.ptr], returns: FFIType.int }, + setsockopt: { args: [FFIType.int, FFIType.int, FFIType.int, FFIType.ptr, FFIType.u32], returns: FFIType.int }, + getsockname: { args: [FFIType.int, FFIType.ptr, FFIType.ptr], returns: FFIType.int }, + getpeername: { args: [FFIType.int, FFIType.ptr, FFIType.ptr], returns: FFIType.int }, + }); +let libc: ReturnType | undefined; +const symbols = () => (libc ??= openLibc()).symbols; +const isBSD = isMacOS || isFreeBSD; +const SOL_SOCKET = isBSD ? 0xffff : 1; +const SO_KEEPALIVE = isBSD ? 0x0008 : 9; +const IPPROTO_TCP = 6; +// Darwin names it TCP_KEEPALIVE. +const TCP_KEEPIDLE = isMacOS ? 0x10 : isFreeBSD ? 256 : 4; +const AF_UNIX = 1; +const AF_INET = 2; + +function intOption(fd: number, level: number, option: number) { + const value = new Int32Array(1); + const length = new Uint32Array([4]); + if (symbols().getsockopt(fd, level, option, ptr(value), ptr(length)) !== 0) { + throw new Error(`getsockopt(${fd}, ${level}, ${option}) failed`); + } + return value[0]; +} + +// The two ports of a connection of 127.0.0.1, as its server end sees them. A net.Socket of the server has both. +type Ports = { localPort?: number; remotePort?: number }; + +// The family of an address that getsockname() or getpeername() wrote. Linux has a 16-bit family. The BSDs have a +// length byte and then the family. +const familyOf = (address: Uint8Array) => (isBSD ? address[1] : address[0] | (address[1] << 8)); + +// The fds that this process has open. Linux and Darwin list them. Elsewhere the first 1024 numbers stand for them. +function openFds() { + try { + const names = readdirSync(isLinux ? "/proc/self/fd" : "/dev/fd"); + if (names.length > 3) return names.map(Number); + } catch {} + return Array.from({ length: 1024 }, (_, fd) => fd); +} + +// The fd of the server end of the connection, or -1. It needs both ports: the kernel can give a client the local +// port of a connection to another server that is still open. +function fdOf({ localPort, remotePort }: Ports) { + const address = new Uint8Array(128); + const length = new Uint32Array(1); + const { getsockname, getpeername } = symbols(); + // sin_port is bytes 2 and 3 of a sockaddr_in, in network order. A shorter address leaves the bytes of the fd before. + const port = () => (length[0] >= 8 && familyOf(address) === AF_INET ? (address[2] << 8) | address[3] : -1); + for (const fd of openFds()) { + length[0] = address.length; + if (getpeername(fd, ptr(address), ptr(length)) !== 0 || port() !== remotePort) continue; + length[0] = address.length; + if (getsockname(fd, ptr(address), ptr(length)) === 0 && port() === localPort) return fd; + } + return -1; +} + +// The fd of a connection that the unix listener at `path` accepted, or -1. +function fdOfUnixConnection(path: string) { + const address = new Uint8Array(256); + const length = new Uint32Array(1); + const { getsockname, getpeername } = symbols(); + for (const fd of openFds()) { + length[0] = address.length; + if (getsockname(fd, ptr(address), ptr(length)) !== 0 || familyOf(address) !== AF_UNIX) continue; + // The client end has no name: its address is the family alone, and the bytes after it are of the fd before. + const name = address.subarray(2, length[0]); + const end = name.indexOf(0); + if (Buffer.from(end === -1 ? name : name.subarray(0, end)).toString() !== path) continue; + // The listener has the name too, and no peer. + length[0] = address.length; + if (getpeername(fd, ptr(address), ptr(length)) === 0) return fd; + } + return -1; +} + +function kernelKeepAlive(ports: Ports) { + const fd = fdOf(ports); + if (fd === -1) throw new Error(`no connection from port ${ports.remotePort} to port ${ports.localPort}`); + return { + // Darwin answers with the flag bit, not with 1. + keepAlive: intOption(fd, SOL_SOCKET, SO_KEEPALIVE) !== 0, + // Seconds before the first probe. + keepIdle: intOption(fd, IPPROTO_TCP, TCP_KEEPIDLE), + }; +} + +// The native handle of a server socket, as node-http.test.ts finds it. +function handleOf(socket: net.Socket) { + const symbol = Object.getOwnPropertySymbols(socket).find(symbol => symbol.description === "handle"); + const handle = symbol && (socket as any)[symbol]; + if (typeof handle?.setKeepAlive !== "function") throw new Error("no native handle with setKeepAlive()"); + return handle as { setKeepAlive(...args: unknown[]): boolean }; +} + +const createServer = (secure: boolean, options: http.ServerOptions = {}) => + secure ? https.createServer({ ...tlsCert, ...options }) : http.createServer(options); + +describe("the native handle of a server socket", () => { + test("setKeepAlive() returns true when it set the option, and false for a closed connection or another `this`", async () => { + const result = await withSocket(http.createServer(), false, "connect", socket => { + const handle = handleOf(socket); + const calls: unknown[][] = [ + [true, 5000], + [true], + [false], + [], + [true, NaN], + [true, -5000], + [true, 999], + [true, "0"], + ]; + const open = calls.map(args => handle.setKeepAlive(...args)); + const otherThis = handle.setKeepAlive.call({}, true, 5000); + let thrown: unknown; + try { + handle.setKeepAlive(true, Symbol("delay")); + } catch (e) { + thrown = (e as Error).name; + } + const destroys = { + valueOf() { + socket.destroy(); + return 5000; + }, + }; + return { open, otherThis, thrown, closedByTheConversion: handle.setKeepAlive(true, destroys) }; + }); + expect(result).toEqual({ + open: [true, true, true, true, true, true, true, true], + otherThis: false, + thrown: "TypeError", + closedByTheConversion: false, + }); + }); +}); + +describe.skipIf(isWindows)("SO_KEEPALIVE after socket.setKeepAlive()", () => { + for (const secure of [false, true]) { + for (const event of ["request", "connection", "connect", "upgrade"] as const) { + test(`${secure ? "https" : "http"}: the socket of '${event}'`, async () => { + const result = await withSocket(createServer(secure), secure, event, socket => { + const accepted = kernelKeepAlive(socket).keepAlive; + socket.setKeepAlive(true, 5000); + const on = kernelKeepAlive(socket); + socket.setKeepAlive(false); + const off = kernelKeepAlive(socket).keepAlive; + // Without a delay, and with a delay under one second, the kernel keeps its time: the 5 seconds from before. + socket.setKeepAlive(true); + const withoutDelay = kernelKeepAlive(socket); + socket.setKeepAlive(true, 500); + return { accepted, on, off, withoutDelay, underOneSecond: kernelKeepAlive(socket) }; + }); + expect(result).toEqual({ + accepted: false, + on: { keepAlive: true, keepIdle: 5 }, + off: false, + withoutDelay: { keepAlive: true, keepIdle: 5 }, + underOneSecond: { keepAlive: true, keepIdle: 5 }, + }); + }); + } + } + + test("skips the call that changes nothing, as net.Socket does", async () => { + const result = await withSocket(http.createServer(), false, "request", socket => { + socket.setKeepAlive(true, 5000); + // A second call with the same values must not reach the kernel: it would set the 5 seconds again. + const fd = fdOf(socket); + symbols().setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, ptr(new Int32Array([77])), 4); + socket.setKeepAlive(true, 5000); + const same = kernelKeepAlive(socket); + socket.setKeepAlive(true, 6000); + return { same, changed: kernelKeepAlive(socket) }; + }); + expect(result).toEqual({ + same: { keepAlive: true, keepIdle: 77 }, + changed: { keepAlive: true, keepIdle: 6 }, + }); + }); + + test("acts through a Proxy of the socket", async () => { + const result = await withSocket(http.createServer(), false, "request", socket => { + new Proxy(socket, {}).setKeepAlive(true, 5000); + return kernelKeepAlive(socket); + }); + expect(result).toEqual({ keepAlive: true, keepIdle: 5 }); + }); + + test("a unix socket has no keep-alive, as in Node.js", async () => { + using dir = tempDir("sock", {}); + const path = join(String(dir), "http.sock"); + const { promise, resolve, reject } = Promise.withResolvers(); + const server = http.createServer((req, res) => { + try { + const returned = req.socket.setKeepAlive(true, 5000) === req.socket; + resolve({ returned, keepAlive: intOption(fdOfUnixConnection(path), SOL_SOCKET, SO_KEEPALIVE) !== 0 }); + } catch (e) { + reject(e); + } + res.end(); + }); + server.listen(path); + await once(server, "listening"); + const client = net.connect(path); + client.on("error", reject); + client.write(requests.request); + try { + expect(await promise).toEqual({ returned: true, keepAlive: false }); + } finally { + client.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + + for (const secure of [false, true]) { + test(`${secure ? "https" : "http"}: acts on a connection that the built-in ws adopted`, async () => { + const server = createServer(secure); + const wss = new WebSocketServer({ noServer: true }); + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("upgrade", (req, socket, head) => { + const { localPort, remotePort } = socket as net.Socket; + const ports = { localPort, remotePort }; + wss.handleUpgrade(req, socket, head, ws => { + try { + const returned = [(socket as net.Socket).setKeepAlive(true, 5000) === socket]; + const adopted = kernelKeepAlive(ports); + ws.terminate(); + returned.push((socket as net.Socket).setKeepAlive(false) === socket); + resolve({ returned, adopted }); + } catch (e) { + reject(e); + } + }); + }); + const upgrade = + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n"; + const client = await connect(await listen(server), secure, upgrade); + client.on("error", reject); + try { + expect(await promise).toEqual({ + returned: [true, true], + adopted: { keepAlive: true, keepIdle: 5 }, + }); + } finally { + client.destroy(); + wss.close(); + server.closeAllConnections(); + server.close(); + } + }); + } + + test("the native handle takes milliseconds, and keeps the time of the kernel for a delay that it cannot use", async () => { + const result = await withSocket(http.createServer(), false, "connect", socket => { + const handle = handleOf(socket); + const call = (...args: unknown[]) => [handle.setKeepAlive(...args), kernelKeepAlive(socket)]; + const on = call(true, 5000); + const kept = [NaN, -5000, 999, "0"].map(delay => call(true, delay)); + // The first delay that is a second. + const oneSecond = call(true, 1000); + // More seconds than an unsigned int holds. The kernel refuses that time. + const tooLong = [4294967301000, Infinity].map(delay => call(true, delay)); + // No arguments: off. + const off = call(); + let afterThrow: unknown; + try { + handle.setKeepAlive(true, Symbol("delay")); + } catch { + afterThrow = kernelKeepAlive(socket).keepAlive; + } + return { on, kept, oneSecond, tooLong, off, afterThrow }; + }); + const five = { keepAlive: true, keepIdle: 5 }; + const one = { keepAlive: true, keepIdle: 1 }; + expect(result).toEqual({ + on: [true, five], + kept: [ + [true, five], + [true, five], + [true, five], + [true, five], + ], + oneSecond: [true, one], + tooLong: [ + [false, one], + [false, one], + ], + off: [true, { keepAlive: false, keepIdle: 1 }], + afterThrow: false, + }); + }); +}); + +describe("socket.setKeepAlive() on a connection that the server just closed", () => { + test("returns the socket", async () => { + const accepted = Promise.withResolvers(); + const server = http.createServer((req, res) => { + accepted.resolve(req.socket); + res.end(); + }); + const client = await connect(await listen(server), false, requests.request); + try { + // The client has the response, so the connection is idle for the server. + await once(client, "data"); + const socket = await accepted.promise; + const closed = once(socket, "close"); + // The native connection closes now. The socket hears of it in a later task. + server.closeIdleConnections(); + expect([ + socket.destroyed, + socket.setKeepAlive(true, 5000) === socket, + socket.setKeepAlive(false) === socket, + ]).toEqual([false, true, true]); + await closed; + } finally { + client.destroy(); + server.close(); + } + }); +}); + +describe("node-http-server-keepalive.node.mts", () => { + const node = nodeExe(); + test.skipIf(!node)("its tests pass in Node.js", async () => { + // A direct run, not `node --test`: the runner mode forks a second node process per file. node:test still exits + // non-zero on any failure. + await using proc = Bun.spawn({ + cmd: [node!, "--v8-pool-size=1", join(import.meta.dir, "node-http-server-keepalive.node.mts")], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "2" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }).toEqual({ exitCode: 0, output: "" }); + }); +}); From b0794344183dae338453071a784dbe9ef06ddf95 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:58:54 +0000 Subject: [PATCH 2/2] node:http: give the keepAlive option of a server to each connection it accepts http.Server keeps keepAlive and keepAliveInitialDelay as own data properties, with the check of Node's net.Server. The accept callback reads them for each connection and sets keep-alive on the native handle, as net.Server's onconnection does. A plain http socket starts its setKeepAlive() state from them. An https socket starts with no state, as Node's TLSSocket. --- docs/runtime/nodejs-compat.mdx | 4 +- src/js/internal/net/symbols.ts | 3 + src/js/node/_http_server.ts | 33 ++- src/js/node/net.ts | 4 +- .../http/node-http-server-keepalive.node.mts | 100 ++++++- .../http/node-http-server-keepalive.test.ts | 275 +++++++++++++++++- 6 files changed, 411 insertions(+), 8 deletions(-) diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 2331b3a5a030..cfb222eb188d 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -45,11 +45,11 @@ We update this page regularly. It reflects the latest version of Bun's compatibi ### [`node:http`](https://nodejs.org/api/http.html) -🟢 Fully implemented. `http.Server` does not extend `net.Server`. Bun ignores `listen(handle)` and the `fd`, `ipv6Only` and `signal` options of `listen()`. `keepAlive`/`keepAliveInitialDelay` on the server are no-ops. +🟢 Fully implemented. `http.Server` does not extend `net.Server`. Bun ignores `listen(handle)` and the `fd`, `ipv6Only` and `signal` options of `listen()`. The sockets of a server always have `TCP_NODELAY` on: the `noDelay` option and `socket.setNoDelay(false)` have no effect. ### [`node:https`](https://nodejs.org/api/https.html) -🟡 `request`, `get`, `Agent` and `globalAgent` are implemented, including connection pooling. Client sockets are `tls.TLSSocket`s. `https.Server` is `http.Server` with TLS options rather than a `tls.Server`. Its request sockets (`req.socket`) are not `tls.TLSSocket`s: `encrypted`, `authorized` and `servername` work, but `getPeerCertificate()` and `getCipher()` are missing. `setSecureContext()`, `addContext()`, `SNICallback` and `handshakeTimeout` are not supported. +🟡 `request`, `get`, `Agent` and `globalAgent` are implemented, including connection pooling. Client sockets are `tls.TLSSocket`s. `https.Server` is `http.Server` with TLS options rather than a `tls.Server`. Its request sockets (`req.socket`) are not `tls.TLSSocket`s: `encrypted`, `authorized` and `servername` work, but `getPeerCertificate()` and `getCipher()` are missing. `setSecureContext()`, `addContext()`, `SNICallback` and `handshakeTimeout` are not supported. The `keepAlive` option takes effect when the TLS handshake completes, not when the connection is accepted. ### [`node:os`](https://nodejs.org/api/os.html) diff --git a/src/js/internal/net/symbols.ts b/src/js/internal/net/symbols.ts index 73919865b744..fc811a2c1459 100644 --- a/src/js/internal/net/symbols.ts +++ b/src/js/internal/net/symbols.ts @@ -7,6 +7,9 @@ export default { // Internal handshake-settled signal: server-side sockets emit no user // 'secureConnect' (node parity), so internal deferrals park on this instead. kSecureConnectDone: Symbol("kSecureConnectDone"), + // What net.Socket#setKeepAlive() last gave to the handle. The delay is in milliseconds. + kSetKeepAlive: Symbol("kSetKeepAlive"), + kSetKeepAliveInitialDelay: Symbol("kSetKeepAliveInitialDelay"), // On a net.Socket subclass with no `_handle` (a node:http server socket): the handle that setKeepAlive() acts on. kSocketOptionHandle: Symbol("kSocketOptionHandle"), kVerifyError: Symbol("kVerifyError"), diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index c5a8fe274249..70fe8fa9d27f 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -15,6 +15,7 @@ const { validateLinkHeaderValue, validateBoolean, validateInteger, + validateNumber, validateFunction, validateOneOf, } = require("internal/validators"); @@ -31,7 +32,7 @@ const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); const { addServerAbortSignalOption } = require("internal/net/server_abort_signal"); -const { kSocketOptionHandle } = require("internal/net/symbols"); +const { kSetKeepAlive, kSetKeepAliveInitialDelay, kSocketOptionHandle } = require("internal/net/symbols"); const { kInternalSocketData, serverSymbol, @@ -302,6 +303,8 @@ interface Server extends NodeHTTPServer { requireHostHeader: boolean; httpAllowHalfOpen: boolean; blockList?: import("node:net").BlockList; + keepAlive: boolean; + keepAliveInitialDelay: number; } function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); @@ -441,6 +444,15 @@ function Server(options, callback): void { this[optionsSymbol] = options; storeHTTPOptions.$call(this, options); + // Node's net.Server checks keepAliveInitialDelay and keeps it in seconds: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1850-L1858 and https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1881-L1882 + let keepAliveInitialDelay = options.keepAliveInitialDelay; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; + } + this.keepAlive = Boolean(options.keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); + if (callback) this.on("request", callback); return this; } @@ -751,6 +763,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort ) { if (!socket) { socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls, listenerGeneration); + if (server.keepAlive) setKeepAliveOfServer(server, socket, !!tls); } if (isPipelinedDispatch) { @@ -1241,6 +1254,21 @@ function defineMaxHeadersCount(server: Server) { }); } +// Node's net.Server gives keep-alive to each connection it accepts, from the two properties as they are then: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2361-L2365 +function setKeepAliveOfServer(server: Server, socket: NodeHTTPServerSocket, isTLS: boolean) { + const handle = socket[kHandle]; + if (!handle.setKeepAlive) return; + // Node gives the seconds to the handle as an unsigned int. This handle takes milliseconds. + const seconds = server.keepAliveInitialDelay; + const delay = (typeof seconds === "number" ? seconds >>> 0 : 0) * 1000; + // Node's https req.socket is a TLSSocket, which starts with no keep-alive state. Its http socket starts from what the server set. + if (!isTLS) { + socket[kSetKeepAlive] = true; + socket[kSetKeepAliveInitialDelay] = delay; + } + handle.setKeepAlive(true, delay); +} + // Native callback fired when the server accepts a connection (for TLS, when // its handshake completes), before any request bytes - like Node.js's // net.Server 'connection' / tls.Server 'secureConnection' events. @@ -1291,6 +1319,8 @@ function onServerConnection(this: Server, listenerGeneration, socketHandle) { } } + if (this.keepAlive) setKeepAliveOfServer(this, socket, isTLS); + // Node's connectionListener attaches the HTTPParser (socket.parser) before // emitting 'connection'; expose the shim here so listeners see it populated. socket.parser = createServerParserShim(socket); @@ -1367,6 +1397,7 @@ function onServerClientError( const existingDuplex = (socket as any).duplex; const nodeSocket = existingDuplex ?? new (getNodeHTTPServerSocket())(self, socket, ssl, listenerGeneration); if (!existingDuplex) { + if (self.keepAlive) setKeepAliveOfServer(self, nodeSocket, ssl); nodeSocket.parser = createServerParserShim(nodeSocket); self.emit("connection", nodeSocket); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index f85c0d8c8977..515112b6e74d 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -47,6 +47,8 @@ const { kDestroyOnRead, kPreHandshakeWrite, kSecureConnectDone, + kSetKeepAlive, + kSetKeepAliveInitialDelay, kSocketOptionHandle, kStandaloneWrap, kUpgradeClientTLS, @@ -299,9 +301,7 @@ const kReinitializeHandle = Symbol("kReinitializeHandle"); const kRealListen = Symbol("kRealListen"); const kSetNoDelay = Symbol("kSetNoDelay"); const kSetTOS = Symbol("kSetTOS"); -const kSetKeepAlive = Symbol("kSetKeepAlive"); const kSyncWriteFd = Symbol("kSyncWriteFd"); -const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); const kCloseRawConnection = Symbol("kCloseRawConnection"); diff --git a/test/js/node/http/node-http-server-keepalive.node.mts b/test/js/node/http/node-http-server-keepalive.node.mts index 73556dcae9f3..a1aca920dcff 100644 --- a/test/js/node/http/node-http-server-keepalive.node.mts +++ b/test/js/node/http/node-http-server-keepalive.node.mts @@ -1,5 +1,6 @@ /** - * TCP keep-alive of a node:http server: what socket.setKeepAlive() gives back to JavaScript. + * TCP keep-alive of a node:http server: what the `keepAlive` and `keepAliveInitialDelay` options of createServer() + * and socket.setKeepAlive() give back to JavaScript. * * All tests in this file must also pass in Node.js: node-http-server-keepalive.test.ts runs the file under Node.js * and under Bun. The tests that read the socket options of the kernel are in that file. @@ -7,6 +8,7 @@ import assert from "node:assert"; import { once } from "node:events"; import http from "node:http"; +import https from "node:https"; import net, { type AddressInfo } from "node:net"; import { describe, test } from "node:test"; import tls from "node:tls"; @@ -66,6 +68,102 @@ export async function withSocket( } } +export type KeepAliveProperties = { keepAlive: unknown; keepAliveInitialDelay: unknown }; +const keepAliveOf = (server: http.Server) => { + const { keepAlive, keepAliveInitialDelay } = server as unknown as KeepAliveProperties; + return [keepAlive, keepAliveInitialDelay]; +}; + +describe("server.keepAlive and server.keepAliveInitialDelay", () => { + // [options, [keepAlive, keepAliveInitialDelay]]. The delay is an option in milliseconds and a property in seconds. + const rows: [string, http.ServerOptions | undefined, unknown[]][] = [ + ["no options", undefined, [false, 0]], + ["{}", {}, [false, 0]], + ["{ keepAlive: true }", { keepAlive: true }, [true, 0]], + [ + "{ keepAlive: 1, keepAliveInitialDelay: 30000 }", + { keepAlive: 1 as any, keepAliveInitialDelay: 30000 }, + [true, 30], + ], + ["{ keepAlive: true, keepAliveInitialDelay: 1500 }", { keepAlive: true, keepAliveInitialDelay: 1500 }, [true, 1]], + ["{ keepAlive: true, keepAliveInitialDelay: -5000 }", { keepAlive: true, keepAliveInitialDelay: -5000 }, [true, 0]], + ["{ keepAlive: true, keepAliveInitialDelay: NaN }", { keepAlive: true, keepAliveInitialDelay: NaN }, [true, 0]], + [ + "{ keepAlive: true, keepAliveInitialDelay: Infinity }", + { keepAlive: true, keepAliveInitialDelay: Infinity }, + [true, 0], + ], + [ + "{ keepAlive: true, keepAliveInitialDelay: 2 ** 40 }", + { keepAlive: true, keepAliveInitialDelay: 2 ** 40 }, + [true, 1099511627], + ], + ["{ keepAliveInitialDelay: 5000 }", { keepAliveInitialDelay: 5000 }, [false, 5]], + ]; + for (const [label, options, expected] of rows) { + test(`every way to make a server: ${label}`, () => { + assert.deepStrictEqual( + { + "http.createServer()": keepAliveOf(http.createServer(options as http.ServerOptions)), + "new http.Server()": keepAliveOf(new http.Server(options as http.ServerOptions)), + // @ts-expect-error http.Server() without `new` + "http.Server()": keepAliveOf(http.Server(options)), + "https.createServer()": keepAliveOf(https.createServer(options as https.ServerOptions)), + }, + { + "http.createServer()": expected, + "new http.Server()": expected, + "http.Server()": expected, + "https.createServer()": expected, + }, + ); + }); + } + + test("a keepAliveInitialDelay that is not a number throws ERR_INVALID_ARG_TYPE", () => { + assert.throws(() => http.createServer({ keepAlive: true, keepAliveInitialDelay: "5" as any }), { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: `The "options.keepAliveInitialDelay" property must be of type number. Received type string ('5')`, + }); + assert.throws(() => https.createServer({ keepAliveInitialDelay: null as any }), { + name: "TypeError", + code: "ERR_INVALID_ARG_TYPE", + message: `The "options.keepAliveInitialDelay" property must be of type number. Received null`, + }); + }); + + test("the option does not go through a setKeepAlive() that a program put on net.Socket.prototype", async () => { + const original = net.Socket.prototype.setKeepAlive; + let calls = 0; + net.Socket.prototype.setKeepAlive = function (this: net.Socket, ...args: [boolean?, number?]) { + calls++; + return original.apply(this, args); + }; + try { + const options = { keepAlive: true, keepAliveInitialDelay: 1000 }; + await withSocket(http.createServer(options), false, "request", () => {}); + } finally { + net.Socket.prototype.setKeepAlive = original; + } + assert.strictEqual(calls, 0); + }); + + test("the two are own enumerable data properties", () => { + const server = http.createServer({ keepAlive: true, keepAliveInitialDelay: 5000 }); + assert.deepStrictEqual( + [ + Object.getOwnPropertyDescriptor(server, "keepAlive"), + Object.getOwnPropertyDescriptor(server, "keepAliveInitialDelay"), + ], + [ + { value: true, writable: true, enumerable: true, configurable: true }, + { value: 5, writable: true, enumerable: true, configurable: true }, + ], + ); + }); +}); + describe("socket.setKeepAlive() of a server socket", () => { test("returns a Proxy of the socket that it is called on", async () => { const returned = await withSocket(http.createServer(), false, "request", socket => { diff --git a/test/js/node/http/node-http-server-keepalive.test.ts b/test/js/node/http/node-http-server-keepalive.test.ts index c08763867616..b8b68600f231 100644 --- a/test/js/node/http/node-http-server-keepalive.test.ts +++ b/test/js/node/http/node-http-server-keepalive.test.ts @@ -1,5 +1,6 @@ /** - * TCP keep-alive of a node:http server: the socket options of the kernel after socket.setKeepAlive(). + * TCP keep-alive of a node:http server: the socket options of the kernel after socket.setKeepAlive(), and on the + * connections of a server with the `keepAlive` option. * * The expected values are what Node v26.3.0 gives (read there with getsockopt(2) from a helper process), except where * a comment says that Bun differs. The tests that need no socket option are in node-http-server-keepalive.node.mts. @@ -24,8 +25,9 @@ import http from "node:http"; import https from "node:https"; import net from "node:net"; import { join } from "node:path"; +import tls from "node:tls"; import { WebSocketServer } from "ws"; -import { connect, listen, requests, withSocket } from "./node-http-server-keepalive.node.mts"; +import { connect, listen, requests, withSocket, type KeepAliveProperties } from "./node-http-server-keepalive.node.mts"; // Opened by the first test that reads a socket option. Those tests do not run on Windows: nothing there gives the // SOCKET of a server connection to JavaScript. @@ -42,6 +44,7 @@ const isBSD = isMacOS || isFreeBSD; const SOL_SOCKET = isBSD ? 0xffff : 1; const SO_KEEPALIVE = isBSD ? 0x0008 : 9; const IPPROTO_TCP = 6; +const TCP_NODELAY = 1; // Darwin names it TCP_KEEPALIVE. const TCP_KEEPIDLE = isMacOS ? 0x10 : isFreeBSD ? 256 : 4; const AF_UNIX = 1; @@ -328,6 +331,274 @@ describe.skipIf(isWindows)("SO_KEEPALIVE after socket.setKeepAlive()", () => { }); }); +describe.skipIf(isWindows)("SO_KEEPALIVE of a connection of a server with keepAlive", () => { + const options = { keepAlive: true, keepAliveInitialDelay: 30000 }; + const thirty = { keepAlive: true, keepIdle: 30 }; + + // Changes the time of the kernel behind the back of the socket: a later call that reaches the kernel changes it again. + function setKeepIdle(ports: Ports, seconds: number) { + if (symbols().setsockopt(fdOf(ports), IPPROTO_TCP, TCP_KEEPIDLE, ptr(new Int32Array([seconds])), 4) !== 0) { + throw new Error("setsockopt(TCP_KEEPIDLE) failed"); + } + } + + for (const secure of [false, true]) { + test(`${secure ? "https" : "http"}: the connection has the options when 'connection' runs`, async () => { + const accepted = await withSocket(createServer(secure, options), secure, "connection", kernelKeepAlive); + expect(accepted).toEqual(thirty); + }); + } + + test("the delay is for keepAlive only, and one under a second keeps the time of the kernel", async () => { + const accepted = (options: http.ServerOptions) => + withSocket(http.createServer(options), false, "connection", kernelKeepAlive); + const [{ keepIdle }, ...withOptions] = await Promise.all([ + accepted({}), + accepted({ keepAlive: true }), + accepted({ keepAlive: true, keepAliveInitialDelay: 500 }), + accepted({ keepAliveInitialDelay: 5000 }), + ]); + expect(withOptions).toEqual([ + { keepAlive: true, keepIdle }, + { keepAlive: true, keepIdle }, + { keepAlive: false, keepIdle }, + ]); + }); + + test("http: setKeepAlive() starts from the options of the server", async () => { + const off = await withSocket(createServer(false, options), false, "request", socket => { + socket.setKeepAlive(false); + return kernelKeepAlive(socket).keepAlive; + }); + const on = await withSocket(createServer(false, options), false, "request", socket => { + setKeepIdle(socket, 77); + // The values of the options: the call must not reach the kernel. + socket.setKeepAlive(true, 30000); + const same = kernelKeepAlive(socket); + socket.setKeepAlive(true, 5000); + return { same, changed: kernelKeepAlive(socket) }; + }); + expect({ off, ...on }).toEqual({ + off: false, + same: { keepAlive: true, keepIdle: 77 }, + changed: { keepAlive: true, keepIdle: 5 }, + }); + }); + + // Node's req.socket of an https server is a TLSSocket. The server gave keep-alive to the net.Socket below it. + test("https: setKeepAlive() starts from nothing, as Node's TLSSocket", async () => { + const off = await withSocket(createServer(true, options), true, "request", socket => { + // Off is what the socket already has, so the call does not reach the kernel. + socket.setKeepAlive(false); + return kernelKeepAlive(socket); + }); + const on = await withSocket(createServer(true, options), true, "request", socket => { + setKeepIdle(socket, 77); + socket.setKeepAlive(true, 30000); + return kernelKeepAlive(socket); + }); + expect({ off, on }).toEqual({ off: thirty, on: thirty }); + }); + + test("an assignment to the properties is for the connections after it, also after a new listen()", async () => { + const server = http.createServer((_req, res) => res.end()); + const properties = server as unknown as KeepAliveProperties; + const clients: net.Socket[] = []; + // The next socket that the server accepts. + const accept = async (port: number) => { + const connection = once(server, "connection") as Promise<[net.Socket]>; + clients.push(await connect(port, false, requests.request)); + return (await connection)[0]; + }; + try { + properties.keepAlive = true; + properties.keepAliveInitialDelay = 7; + let port = await listen(server); + const first = await accept(port); + const beforeListen = kernelKeepAlive(first); + + properties.keepAliveInitialDelay = 9; + const second = await accept(port); + const afterListen = { second: kernelKeepAlive(second), first: kernelKeepAlive(first) }; + // The socket starts from the 9 seconds: the same values do not reach the kernel. + setKeepIdle(second, 77); + second.setKeepAlive(true, 9000); + const sameAsAssigned = kernelKeepAlive(second); + + for (const client of clients) client.destroy(); + server.closeAllConnections(); + server.close(); + await once(server, "close"); + port = await listen(server); + const afterNewListen = kernelKeepAlive(await accept(port)); + + properties.keepAlive = false; + const off = kernelKeepAlive(await accept(port)).keepAlive; + + expect({ beforeListen, afterListen, sameAsAssigned, afterNewListen, off }).toEqual({ + beforeListen: { keepAlive: true, keepIdle: 7 }, + afterListen: { second: { keepAlive: true, keepIdle: 9 }, first: { keepAlive: true, keepIdle: 7 } }, + sameAsAssigned: { keepAlive: true, keepIdle: 77 }, + afterNewListen: { keepAlive: true, keepIdle: 9 }, + off: false, + }); + } finally { + for (const client of clients) client.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + + test("a class field, and Object.defineProperty() after listen(), give the values", async () => { + class KeepAliveServer extends http.Server { + keepAlive = true; + keepAliveInitialDelay = 7; + } + const field = await withSocket(new KeepAliveServer(), false, "connection", kernelKeepAlive); + + const server = http.createServer((_req, res) => res.end()); + const port = await listen(server); + Object.defineProperty(server, "keepAlive", { value: true }); + Object.defineProperty(server, "keepAliveInitialDelay", { value: 8 }); + const connection = once(server, "connection") as Promise<[net.Socket]>; + const client = await connect(port, false, requests.request); + try { + const defined = kernelKeepAlive((await connection)[0]); + expect({ field, defined }).toEqual({ + field: { keepAlive: true, keepIdle: 7 }, + defined: { keepAlive: true, keepIdle: 8 }, + }); + } finally { + client.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + + test("an assigned keepAliveInitialDelay that is not a number, or that the kernel refuses, keeps the time of the kernel", async () => { + const server = http.createServer((_req, res) => res.end()); + const properties = server as unknown as KeepAliveProperties; + properties.keepAlive = true; + const port = await listen(server); + const clients: net.Socket[] = []; + // The keep-alive of the next connection, accepted with `delay` as the property. + const accepted = async (delay: unknown) => { + properties.keepAliveInitialDelay = delay; + const connection = once(server, "connection") as Promise<[net.Socket]>; + clients.push(await connect(port, false, requests.request)); + return kernelKeepAlive((await connection)[0]); + }; + const throws = { + valueOf() { + throw new Error("valueOf"); + }, + }; + try { + const { keepIdle } = await accepted(0); + const kernel = { keepAlive: true, keepIdle }; + const results: unknown[] = []; + // 4300000 seconds are more than Linux, Darwin and FreeBSD take, and more milliseconds than 32 bits hold. + for (const delay of [7, 7.9, NaN, "7", 5n, Symbol("delay"), throws, null, undefined, {}, 4300000]) { + results.push(await accepted(delay)); + } + expect(results).toEqual([ + { keepAlive: true, keepIdle: 7 }, + { keepAlive: true, keepIdle: 7 }, + kernel, + kernel, + kernel, + kernel, + kernel, + kernel, + kernel, + kernel, + kernel, + ]); + } finally { + for (const client of clients) client.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + + for (const secure of [false, true]) { + test(`${secure ? "https" : "http"}: a unix listener does not set it, as in Node.js`, async () => { + using dir = tempDir("sock", {}); + const path = join(String(dir), "http.sock"); + const server = createServer(secure, options); + server.on("request", (_req, res) => res.end()); + server.listen(path); + await once(server, "listening"); + const connection = once(server, "connection"); + const client = secure ? tls.connect({ path, rejectUnauthorized: false }) : net.connect(path); + try { + client.write(requests.request); + await Promise.race([connection, once(client, "error").then(([error]) => Promise.reject(error))]); + expect(intOption(fdOfUnixConnection(path), SOL_SOCKET, SO_KEEPALIVE) !== 0).toBe(false); + } finally { + client.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + } + + // Keep-alive is per listener, and off. TCP_NODELAY stays on for every connection: the `noDelay` option and + // socket.setNoDelay(false) have no effect on a server socket. + test("other listeners do not set it, and every connection has TCP_NODELAY on", async () => { + const optionsOf = (ports: Ports) => { + const fd = fdOf(ports); + if (fd === -1) throw new Error(`no connection from port ${ports.remotePort} to port ${ports.localPort}`); + return { + keepAlive: intOption(fd, SOL_SOCKET, SO_KEEPALIVE) !== 0, + noDelay: intOption(fd, IPPROTO_TCP, TCP_NODELAY) !== 0, + }; + }; + const served = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + served.resolve({ localPort: server.port, remotePort: server.requestIP(request)!.port }); + return new Response(); + }, + }); + const listened = Promise.withResolvers(); + using listener = Bun.listen({ + port: 0, + hostname: "127.0.0.1", + socket: { + open: socket => listened.resolve({ localPort: socket.localPort, remotePort: socket.remotePort }), + data() {}, + }, + }); + const clients = [await connect(server.port!, false, requests.request), await connect(listener.port, false, "")]; + try { + expect({ + "Bun.serve()": optionsOf(await served.promise), + "Bun.listen()": optionsOf(await listened.promise), + "http.createServer()": await withSocket(http.createServer(), false, "connection", optionsOf), + "http.createServer({ keepAlive: true, noDelay: false })": await withSocket( + http.createServer({ keepAlive: true, noDelay: false }), + false, + "request", + socket => { + socket.setNoDelay(false); + return optionsOf(socket); + }, + ), + }).toEqual({ + "Bun.serve()": { keepAlive: false, noDelay: true }, + "Bun.listen()": { keepAlive: false, noDelay: true }, + "http.createServer()": { keepAlive: false, noDelay: true }, + "http.createServer({ keepAlive: true, noDelay: false })": { keepAlive: true, noDelay: true }, + }); + } finally { + for (const client of clients) client.destroy(); + } + }); +}); + describe("socket.setKeepAlive() on a connection that the server just closed", () => { test("returns the socket", async () => { const accepted = Promise.withResolvers();