-
-
Notifications
You must be signed in to change notification settings - Fork 34.9k
Description
Version
v24.1.0 (also affects main; present since at least v20.x)
Platform
Darwin arm64 (but the platform is irrelevant to the issue)
Subsystem
tls
What steps will reproduce the bug?
Run the following script and observe difference between net.connect() and tls.connect() keepAlive options:
net.connect() - socket[kSetNoDelay]: true
net.connect() - socket[kSetKeepAlive]: true
net.connect() - socket[kSetKeepAliveInitialDelay]: 5
tls.connect() - socket[kSetNoDelay]: false
tls.connect() - socket[kSetKeepAlive]: false
tls.connect() - socket[kSetKeepAliveInitialDelay]: 0import tls from "node:tls";
import net from "node:net";
import assert from "node:assert";
import { execSync } from "node:child_process";
import fs from "node:fs";
execSync(
'openssl req -new -x509 -newkey rsa:2048 -nodes -keyout /tmp/k.pem -out /tmp/c.pem -days 1 -subj "/CN=localhost" 2>/dev/null',
);
const tmp = new net.Socket();
const syms = Object.getOwnPropertySymbols(tmp);
const kSetNoDelay = syms.find((s) => s.toString() === "Symbol(kSetNoDelay)");
const kSetKeepAlive = syms.find(
(s) => s.toString() === "Symbol(kSetKeepAlive)",
);
const kSetKeepAliveInitialDelay = syms.find(
(s) => s.toString() === "Symbol(kSetKeepAliveInitialDelay)",
);
tmp.destroy();
const opts = { keepAlive: true, keepAliveInitialDelay: 5000, noDelay: true };
const netServer = net.createServer((s) => s.end());
netServer.listen(0, () => {
const socket = net.connect({ port: netServer.address().port, ...opts });
socket.on("connect", () => {
console.log("net.connect() - socket[kSetNoDelay]:", socket[kSetNoDelay]); // true
console.log(
"net.connect() - socket[kSetKeepAlive]:",
socket[kSetKeepAlive], // true
);
console.log(
"net.connect() - socket[kSetKeepAliveInitialDelay]:",
socket[kSetKeepAliveInitialDelay], // 5s
);
socket.destroy();
netServer.close();
testTls();
});
});
function testTls() {
const srv = tls.createServer(
{
key: fs.readFileSync("/tmp/k.pem"),
cert: fs.readFileSync("/tmp/c.pem"),
},
(s) => s.end(),
);
srv.listen(0, () => {
const socket = tls.connect({
port: srv.address().port,
rejectUnauthorized: false,
...opts,
});
socket.on("secureConnect", () => {
console.log("tls.connect() - socket[kSetNoDelay]:", socket[kSetNoDelay]); // false
console.log(
"tls.connect() - socket[kSetKeepAlive]:",
socket[kSetKeepAlive], // false
);
console.log(
"tls.connect() - socket[kSetKeepAliveInitialDelay]:",
socket[kSetKeepAliveInitialDelay], // 0
);
socket.destroy();
srv.close();
});
});
}How often does it reproduce? Is there a required condition?
Always. Any call to tls.connect() with keepAlive, keepAliveInitialDelay, or noDelay options will silently ignore them.
Long-lived TLS connections through infrastructure with idle timeouts (load balancers, NAT gateways, firewalls, AWS PrivateLink) silently break when keepalive is not applied. The connection is closed by an intermediary during an idle period, and the response never reaches the client. This is difficult to diagnose because tls.connect() accepts the options without error.
What is the expected behavior? Why is that the expected behavior?
tls.connect() should honor keepAlive, keepAliveInitialDelay, and noDelay options, applying them to the underlying TCP socket (i.e., setsockopt(SO_KEEPALIVE) / setsockopt(TCP_NODELAY) should be called).
The documentation states that tls.connect() accepts:
...: Any socket.connect() option not already listed.
socket.connect() documents keepAlive, keepAliveInitialDelay, and noDelay as supported options. net.createConnection() with the same options works correctly.
What do you see instead?
The options are silently dropped. setsockopt(SO_KEEPALIVE) is never called on the underlying socket.
Additional information
The options are lost at two filtering points in lib/internal/tls/wrap.js:
tls.connect() → new TLSSocket()(lines 1743-1758)
tls.connect() constructs a TLSSocket with a hardcoded subset of options. keepAlive, keepAliveInitialDelay, and noDelay are not included:
const tlssock = new TLSSocket(options.socket, {
allowHalfOpen: options.allowHalfOpen,
pipe: !!options.path,
secureContext: context,
isServer: false,
requestCert: true,
rejectUnauthorized: options.rejectUnauthorized !== false,
session: options.session,
ALPNProtocols: options.ALPNProtocols,
requestOCSP: options.requestOCSP,
enableTrace: options.enableTrace,
pskCallback: options.pskCallback,
highWaterMark: options.highWaterMark,
onread: options.onread,
signal: options.signal,
// keepAlive, keepAliveInitialDelay, noDelay not forwarded
});Note: keepAlive is read at line 1727, but only to set singleUse - it is not forwarded to the socket layer.
TLSSocket constructor → net.Socketsuper call (lines 585-593)
Even if the options were forwarded to TLSSocket, the net.Socket super-constructor call also uses a hardcoded subset that omits them:
FunctionPrototypeCall(net.Socket, this, {
handle: ...,
allowHalfOpen: ...,
pauseOnCreate: tlsOptions.pauseOnConnect,
manualStart: true,
highWaterMark: tlsOptions.highWaterMark,
onread: !socket ? tlsOptions.onread : null,
signal: tlsOptions.signal,
// keepAlive, keepAliveInitialDelay, noDelay not forwarded
});The net.Socket constructor (lib/net.js:475-476) sets kSetNoDelay, kSetKeepAlive, and kSetKeepAliveInitialDelay from the options, and these are later applied in afterConnect(). Since the options are never passed through, the symbols remain false/0.
this[kSetNoDelay] = Boolean(options.noDelay);
this[kSetKeepAlive] = Boolean(options.keepAlive);At the same time net.createConnection() works: net.connect() creates new Socket(options) with the full options object, so the constructor picks up all three options and stores them as symbols that afterConnect applies.
Fix: Both filtering points need to forward keepAlive, keepAliveInitialDelay, and noDelay.