net,tls: port Node.js net/tls compatibility tests and fix the gaps they surface — half-open/reset/write semantics, server TLSSocket wrap, session/keylog, SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local binding (+305 tests)#31155
Conversation
|
Updated 5:07 PM PT - Jun 16th, 2026
❌ @cirospaciari, your commit ce06dce has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31155That installs a local version of the PR into your bun-31155 --bun |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
9302345 to
f36f956
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/tls.ts:671-673— Node only validatesSNICallbackfor server-side TLSSockets (thevalidateFunctioncall in_tls_wrap.jsis inside theif (options.isServer)block — SNI callbacks are inherently server-only), but this check runs unconditionally, sotls.connect({ ..., SNICallback: {} })ornew tls.TLSSocket(sock, { SNICallback: 42 })throwsERR_INVALID_ARG_TYPEin Bun while Node silently ignores it. One-token fix sincethis.isServeris set just above:if (this.isServer && options.SNICallback != null). Very narrow trigger (junk server-only option on a client socket), so just a nit.Extended reasoning...
What the bug is
The TLSSocket constructor (src/js/node/tls.ts:671-673) now does:
this.isServer = !!options.isServer; if (options.SNICallback != null) { validateFunction(options.SNICallback, "options.SNICallback"); }
This validates
SNICallbackregardless of whether the socket is server- or client-side. In Node.js, the equivalent check inTLSSocket.prototype._init(lib/_tls_wrap.js / lib/internal/tls/wrap.js) lives inside theif (options.isServer && options.SNICallback && ...)block — SNI callbacks are conceptually server-only (they let a server pick a certificate based on the client's SNI extension), so Node only type-checks the option when wrapping a server socket and silently ignores it on clients.Code path that triggers it
const tls = require('tls'); const net = require('net'); // Either of these throws ERR_INVALID_ARG_TYPE in Bun, no-op in Node: new tls.TLSSocket(new net.Socket(), { SNICallback: {} }); tls.connect({ port: 443, host: 'example.com', SNICallback: 42 });
For
tls.connect, the options object flows intonew TLSSocket(options)with noisServerkey, sothis.isServerisfalse, but the unconditional check on the next line still runsvalidateFunction({}, ...)and throws.Why existing tests don't catch it
The newly-ported
test-tls-snicallback-error.js(verbatim from upstream) only exercisestls.createServer({ SNICallback })andnew tls.TLSSocket(sock, { isServer: true, SNICallback })— both server-side — which is itself evidence that Node only validates on the server path. TheServerconstructor in this PR does its ownSNICallbackcheck (tls.ts:911-913), so the test passes without ever reaching the over-broad TLSSocket check on a client socket.Step-by-step proof
Step Node.js Bun (this PR) new tls.TLSSocket(sock, { SNICallback: 42 })(noisServer)_init:options.isServeris falsy → SNICallback branch skipped → no throwctor: this.isServer = false; next line:options.SNICallback != null→validateFunction(42, ...)→ throwsERR_INVALID_ARG_TYPEnew tls.TLSSocket(sock, { isServer: true, SNICallback: 42 })enters isServerbranch →validateFunction(42, ...)→ throwsvalidateFunction(42, ...)→ throws ✅ (matches)Impact
Minor Node-compat divergence in new code from this PR. The trigger is implausible in real code —
SNICallbackis documented as a server-only option, so passing a non-function value to a client socket is essentially garbage input; Bun is just stricter than Node here. No correct program is affected. The inline comment says "matches Node's TLSSocket init" but it's slightly stricter than Node. Filed as a nit since the fix is one token and the check is being added specifically for Node compat.Fix
Gate on
this.isServer, which is set on the line immediately above:if (this.isServer && options.SNICallback != null) { validateFunction(options.SNICallback, "options.SNICallback"); }
04333c4 to
0a25656
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/tls.ts:651-653—tls.setDefaultCACertificates([])makesgetCACertificates('default')return[]correctly, but actual TLS connections without an explicitcastill trust the full bundled root store — the injectedca: []collapses toca: undefinedat the native layer (handle_file_array()returnsNonefor empty arrays, SSLConfig.rs:338-340), so the per-SSL client attach at openssl.c:677-681 falls back tous_get_shared_default_ca_store(). In Node an empty array clears the defaults so such connections fail verification; here the user requests zero-trust and silently gets full-trust. Worth listing as a known limitation alongside the other deferredsetDefaultCACertificatesitems (a real fix needs a native 'empty store' sentinel); the ported-precedence-empty.js/-basic.jstests don't exercise this path.Extended reasoning...
What the bug is
The new
tls.setDefaultCACertificates()is implemented as a JS-side override:_defaultCACertificatesOverrideis stored,getCACertificates('default')returns it, andcreateSecureContext()injects it asoptions.cawhen no explicitcais given (tls.ts:651-653). For a non-empty override this works (modulo Bun's pre-existing additive-CA semantics). For an empty override it does not:ca: []is silently dropped on the way into the native SSL_CTX, so the connection ends up using the full bundled Mozilla root store +NODE_EXTRA_CA_CERTS, exactly as ifsetDefaultCACertificateshad never been called. Node's documented behavior is thatsetDefaultCACertificates([])clears the defaults so subsequent connections without their owncafail certificate verification.Code path
tls.setDefaultCACertificates([])→ the validation loop runs zero times →_defaultCACertificatesOverride = [].tls.connect({...})(noca) →TLSSocketctor →createSecureContext(options)._defaultCACertificatesOverride !== undefined && options.ca == nullis true →options = { ...options, ca: [] }.newNativeSecureContext→NativeSecureContext.intern→ bindgen →SSLConfig::from_js→handle_fileforca→handle_file_array():So// SSLConfig.rs:338-340 if elements.is_empty() { return Ok(None); }
result.ca = None— identical toca: undefined.as_usockets()→ctx_opts.ca = NULL,ca_count = 0.us_ssl_ctx_build_raw(openssl.c:543):options.ca && options.ca_count > 0is false.requestCertis set on the connect-timetlsobject (net.ts:1006), not on thecreateSecureContextoptions that built this SSL_CTX, sooptions.request_cert(openssl.c:561) is also false. All three branches skipped → SSL_CTX hasverify_mode == SSL_VERIFY_NONEand no cert store configured.us_internal_ssl_attach(openssl.c:677-681), client path:The per-socket override installs the shared bundled-roots store (Mozilla bundle +if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) { SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); X509_STORE *roots = us_get_shared_default_ca_store(); if (roots) SSL_set0_verify_cert_store(ssl, roots); }
NODE_EXTRA_CA_CERTS, see root_certs.cpp). The connection trusts everything Bun trusts by default.
Why the ported tests don't catch it
test-tls-set-default-ca-certificates-precedence-empty.jssets defaults to[]but then passes a per-connectionca: [fakeStartcomCert], which makesoptions.ca == nullfalse at tls.ts:651 — the override-injection branch is never taken; the test only proves per-connectioncastill works.test-tls-set-default-ca-certificates-basic.js/-array-buffer.jsonly round-trip throughgetCACertificates('default'), which reads the JS-side_defaultCACertificatesOverridearray directly and never builds a native context or opens a connection.
No test in this PR connects without an explicit
caaftersetDefaultCACertificates([])and asserts a verification failure.Step-by-step proof
const tls = require('tls'); tls.setDefaultCACertificates([]); console.log(tls.getCACertificates('default')); // [] ✓ — JS surface correct tls.connect(443, 'example.com', { servername: 'example.com' }, () => { console.log('connected, authorized =', this.authorized); });
Step Node.js Bun (this PR) getCACertificates('default')[][]✅native trust store for the connection empty X509_STORE us_get_shared_default_ca_store()(full bundle) ❌handshake against a public-CA-signed server fails verification ( UNABLE_TO_GET_ISSUER_CERTetc.)succeeds, authorized === true❌The SSL_CTX content-hash digest for
{ca: []}and{ca: undefined}is also identical (both feedca = Noneintocontent_hash()), so the interned context is literally the same object as the no-CA default.Impact / why nit
The failure mode is silently security-relevant — a user who calls
setDefaultCACertificates([])is explicitly asking for zero default trust and gets full default trust instead, with no error or warning. That said:setDefaultCACertificatesis brand-new in this PR (not a regression).- The implementation comment at tls.ts:1285-1287 already acknowledges "Bun has no equivalent native store override, so keep a JS-side override" — the JS-side
ca-injection approach inherently can't express "empty store" because of pre-existing native behavior (handle_file_array's empty→None and the per-SSL client fallback at openssl.c:677-681 are both unchanged by this PR). - The non-empty override has a related pre-existing limitation: openssl.c:547 starts from
us_get_default_ca_store()even whencais supplied, sosetDefaultCACertificates([myCA])trusts bundled+myCA rather than only myCA. The empty case is the starkest instance of a broader "override doesn't restrict trust" gap. - A proper fix requires native changes (sentinel for "install an empty X509_STORE", or removing the per-socket shared-store fallback when
cawas explicitly empty) that are out of scope for this compat PR.
Filed as nit: worth flagging in the PR description's deferred-items list and/or adding a TODO at the override-injection site, since the PR description currently says "
setDefaultCACertificates()implemented" without this caveat.Fix direction
Either:
- Special-case the empty override in
createSecureContextto pass a sentinel that the native layer interprets as "install an empty X509_STORE and set VERIFY_PEER" (so openssl.c:677 doesn't fall back to the shared store), or - Thread an explicit "override default roots" flag through to
us_internal_ssl_attachso it skipsus_get_shared_default_ca_store()when an override (even empty) is active.
Both require native changes; until then, documenting it as a known limitation is the honest option.
-
🟡
src/js/node/net.ts:2403-2407— The new comment says "a valid port takes precedence over path", but the implementation never clearspathafter validatingport— solisten({ port: 0, path: '/tmp/sock' })still falls intokRealListen'sif (path)branch (net.ts:2543) and listens on the unix socket, ignoring port. The path-wins behavior is pre-existing (not a regression), but since this block is rewritten under "Match Node's listen() option normalization" and the portedtest-net-server-listen-options.jsonly exercises{ port: -1, path }(which throws invalidatePortbefore reachingkRealListen), it'd be worth either addingpath = undefined;afterport = port | 0to actually match Node, or dropping the precedence clause from the comment.Extended reasoning...
What the issue is
The rewritten options-object branch of
Server.prototype.listen()adds, at net.ts:2403-2407:if (typeof port === "number" || typeof port === "string") { // validatePort coerces "0" -> 0 and throws ERR_SOCKET_BAD_PORT for // out-of-range/non-numeric values; a valid port takes precedence over path. validatePort(port, "options.port"); port = port | 0; } else if (isPipeName(path)) {
The comment (and the block's header at line 2397, "Match Node's listen() option normalization") states that a valid
porttakes precedence overpath. In Node that is true —lib/net.jsreturns from the port branch (callinglistenInClusterwith no pipe) before ever reaching the path branch. In Bun, however,pathis read at line 2379 and never cleared in the port branch. Bothportandpathare then passed throughlistenInCluster(..., path, ...)(line 2515) tokRealListen, which checksif (path)first (line 2543) and callsBun.listen({ unix: path }), ignoringportentirely. So in practice path takes precedence over port, the opposite of what the new comment claims.Step-by-step proof
For
net.createServer().listen({ port: 0, path: '/tmp/sock' }):Step Line State 1 2379 path = '/tmp/sock'2 2380 port = 03 2399 port === undefined? no;port === null? no → skip4 2403 typeof port === 'number'→ true, enter port branch5 2406 validatePort(0)→ ok6 2407 port = 0 | 0→0;pathstill'/tmp/sock'7 2515 listenInCluster(..., port=0, ..., path='/tmp/sock', ...)8 2543 kRealListen:if (path)→ true →Bun.listen({ unix: '/tmp/sock' })Result: listens on the unix socket. In Node the same call listens on TCP port 0 (random port).
Why existing tests don't catch it
The newly-ported
test-net-server-listen-options.jsdoes include a port-vs-path precedence assertion:// In listen(options, cb), port takes precedence over path assert.throws(() => { net.createServer().listen({ port: -1, path: common.PIPE }, common.mustNotCall()); }, assertPort());
But
port: -1throwsERR_SOCKET_BAD_PORTinsidevalidatePortat line 2406, beforekRealListenis reached — so it only proves that an invalid port is validated ahead of path, not that a valid port wins at listen time. There is no test for{ port: <valid>, path: <valid> }.Why this is a nit, not a regression
The underlying path-wins behavior predates this PR. The old code's
if (!Number.isSafeInteger(port) || port < 0)guard was also false forport = 0, leavingpathset, and the samekRealListenif (path)check applied. So no user-observable behavior changes here. What is new is:- The comment at line 2405 explicitly claiming Node's precedence, which the implementation doesn't honor.
- The block header at line 2397 ("Match Node's listen() option normalization") implying parity that isn't there for this case.
- The pre-existing doc-comment at line ~2442 ("path Will be ignored if port is specified") was already wrong, but this PR adds a second wrong comment rather than fixing either.
The trigger is also narrow — passing both
portandpathtolisten()is unusual.Fix
One line in the port branch:
if (typeof port === "number" || typeof port === "string") { validatePort(port, "options.port"); port = port | 0; path = undefined; // port takes precedence over path (Node returns early here) } else if (isPipeName(path)) {
Or, if matching Node's precedence is out of scope, drop "a valid port takes precedence over path" from the comment so it doesn't misdocument the implementation.
5ca55ec to
f49ffa0
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟣
src/bun_core/lib.rs:3347-3352— Pre-existing (already onmainvia #31116, not introduced by this PR): the musl/Android fallbackBun__captureStackTraceat line 3339 is stillpub extern "C" fn(safe) while the glibc/macOS/BSD and Windows definitions arepub unsafe extern "C" fn, so the shared wrapper'sunsafe { Bun__captureStackTrace(...) }here triggers theunused_unsafewarning on aarch64-musl / x64-musl / x64-android / aarch64-android / x64-musl-baseline (the robobun annotations atbun_core/lib.rs:3352in build #56711). It's warn-level only (unused_unsafeis not in the workspace deny list), so it doesn't block the build — just noting since it shows up in this PR's CI output; the one-keyword fix is to mark the fallbackunsafetoo (with a# Safetydoc) so all three cfg variants match.Extended reasoning...
What the issue is
Bun__captureStackTracehas three cfg-gated definitions insrc/bun_core/lib.rs:cfg line signature glibc / macOS / *BSD ~3233 pub unsafe extern "C" fnWindows ~3285 pub unsafe extern "C" fnfallback (musl, Android, …) 3339 pub extern "C" fn← notunsafeThe shared safe wrapper at line 3349-3352 unconditionally wraps the call:
pub fn capture_stack_trace(begin: usize, addrs: &mut [usize]) -> usize { // SAFETY: `addrs.as_mut_ptr()` is writable for `addrs.len()` slots; ... unsafe { Bun__captureStackTrace(begin, addrs.as_mut_ptr(), addrs.len()) } }
On targets where the fallback definition is selected (musl-libc Linux, Android — i.e. anything not in the
#[cfg(any(...))]list above),Bun__captureStackTraceis a safe function, so wrapping a call to it inunsafe { }triggers rustc'sunused_unsafelint at line 3352.Why this is pre-existing, not PR-introduced
Although the
unsafe-marking changes appear in this PR's GitHub diff, that's an artifact of the three-dot diff being computed against a stale merge-base.git log -Sshows theunsafemarkers + wrapperunsafe { }were introduced in commit21db6826("clippy: 45 deny lints + fix 2735 violations across workspace (#31116)"), andgit merge-base --is-ancestor 21db6826 <origin/main>confirms that commit is already on main. A two-dotgit diff main..HEAD -- src/bun_core/lib.rsshows no change toBun__captureStackTraceorcapture_stack_tracefrom this PR. The same warning fires onmainbuilds; robobun'sparseAnnotations(scripts/utils.mjs:2662) scrapes botherror:andwarning:lines from build output regardless of provenance, so it appears in build #56711's annotation list alongside other ambient noise likeclang++: argument unused during compilation: '-no-pie'.Why it's a warning, not a build failure
unused_unsafeis warn-by-default in rustc and is not in the PR's[workspace.lints.rust]deny list (which only promotesdead_code,unused_imports,unused_variables,unused_mut,unused_assignments,unused_macros,unreachable_code,unreachable_patterns). There is no#![deny(unused_unsafe)]inbun_core/lib.rsand no-D warningsinscripts/build/rust.ts. So this surfaces as a compiler warning, not a hard error — robobun's "5 failures" count for build #56711 doesn't include it (the actual failures are the threebuild-cppmusl entries plus the FreeBSDunreachable_pubitems).Step-by-step proof
- Compile
bun_corewith--target x86_64-unknown-linux-musl(oraarch64-linux-android, etc.). - cfg resolution:
target_os = "linux"buttarget_env = "musl"(not"gnu"), and not macOS/BSD/Windows → the#[cfg(not(any(...)))]fallback at line 3339 is selected. - The fallback is
pub extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize { let _ = (begin, out, cap); 0 }— nounsafekeyword, so it's a safe function (it never dereferencesout, so this is technically correct). capture_stack_traceat line 3352 evaluatesunsafe { Bun__captureStackTrace(...) }. The callee is safe → theunsafeblock contains no unsafe operations.- rustc emits
warning: unnecessary \unsafe` block→#[warn(unused_unsafe)]` (default level). - robobun build #56711 lists exactly this:
src/bun_core/lib.rs#L3352 - unnecessary \unsafe` blockon 🐧 aarch64-musl, x64-musl, x64-android, aarch64-android, x64-musl-baseline — the precise set of targets that match the fallback's#[cfg(not(...))]` gate.
Addressing the refutation
One reviewer argued for refuting on three grounds, two of which I agree with and have folded into the framing above: (a) "warning ≠ build failure" — correct; the original "fails build-rust" claim was overstated, and this comment is filed accordingly. (b) "not from this PR" — correct; verified via
git merge-base --is-ancestorand the empty two-dot diff, hence filed as pre-existing. (c) "the fallback being safe is arguably correct" — also true (it ignoresoutentirely), but that just shifts where the fix goes: either mark the fallbackunsafefor signature consistency, or#[allow(unused_unsafe)]the wrapper with a note that one cfg variant is safe. Either resolves the warning; the former keeps the three definitions interchangeable.Impact
None functionally — a warn-level lint on five non-tier-1 targets. Flagged only because it shows up in this PR's CI annotations and is a one-keyword fix; entirely fine to defer to whichever PR next touches the
unused_unsafefamily inbun_core.Fix
/// # Safety /// `out` must be writable for `cap` `usize` slots (or null/`cap == 0`). /// This fallback never dereferences `out`; `unsafe` is for signature parity /// with the other cfg-gated definitions so the shared wrapper's `unsafe { }` /// is not flagged `unused_unsafe` on musl/Android. #[unsafe(no_mangle)] pub unsafe extern "C" fn Bun__captureStackTrace(begin: usize, out: *mut usize, cap: usize) -> usize { let _ = (begin, out, cap); 0 }
- Compile
| // context is transferred to the socket in afterConnectMultiple. | ||
| if (hasObserver("net")) { | ||
| startPerf(context, kPerfHooksNetConnectContext, { | ||
| type: "net", | ||
| name: "connect", | ||
| detail: { host: address, port, addressType }, |
There was a problem hiding this comment.
🟡 The startPerf detail at line 2743 includes addressType, but the sibling single-address path at line 2588 (also new in this PR) and Node's lib/net.js both use detail: { host, port } only — and the inline comment at line 2736 says 'Match the single-address path (and Node)' while the detail object matches neither. One-token fix: drop , addressType.
Extended reasoning...
What the gap is
Both startPerf calls in net.ts are new in this PR. The single-address internalConnect path at line 2588 builds:
startPerf(self, kPerfHooksNetConnectContext, {
type: "net",
name: "connect",
detail: { host: address, port },
});The multiple-address internalConnectMultiple path at line 2743 builds:
startPerf(context, kPerfHooksNetConnectContext, {
type: "net",
name: "connect",
detail: { host: address, port, addressType },
});— with an extra addressType key. The inline comment at line 2736 explicitly says 'Match the single-address path (and Node)', but the detail object matches neither: the single-address path omits addressType, and Node's lib/net.js uses detail: { host: address, port } for internalConnect and detail: { host: req.address, port: req.port } for internalConnectMultiple — no addressType in either.
Why CI doesn't catch it
The newly-ported test-net-perf_hooks.js only asserts assert.strictEqual(!!entry.detail.host, true) and assert.strictEqual(!!entry.detail.port, true) (lines 57-58); an extra own key on detail is invisible. And in practice the test takes the single-address path anyway (the toAttempt.length === 1 branch in lookupAndConnectMultiple falls back to plain internalConnect when localhost resolves to one address).
Step-by-step proof
net.connect({ host: 'dual-stack-host', port })withautoSelectFamily: trueand a host that resolves to both IPv4 and IPv6 →lookupAndConnectMultiple→internalConnectMultiple(context).- The first attempt dispatches
kConnectTcp;err === 0. - Line 2739:
hasObserver('net')→ true (a PerformanceObserver is watching'net'). - Line 2740-2744:
startPerf(context, kPerfHooksNetConnectContext, { type: 'net', name: 'connect', detail: { host: address, port, addressType } })—addressTypeis4or6. - The attempt wins →
afterConnectMultipletransferscontext[kPerfHooksNetConnectContext]to the socket →afterConnectcallsstopPerf, which builds the entry withdetail: ctx.detail(shared.tsstopPerf). - The observer's callback receives an entry with
entry.detail = { host, port, addressType }— an extra own key that neither Node nor the single-address path produces.
| Path | entry.detail |
|
|---|---|---|
Node internalConnect |
{ host, port } |
|
Node internalConnectMultiple |
{ host, port } |
|
Bun internalConnect (line 2588, this PR) |
{ host, port } |
✅ |
Bun internalConnectMultiple (line 2743, this PR) |
{ host, port, addressType } |
❌ |
Impact / why nit
An extra own property on a PerformanceEntry.detail object that almost nobody inspects directly — consumers that read .host/.port are unaffected. Only code doing Object.keys(entry.detail) or assert.deepStrictEqual(entry.detail, { host, port }) would observe the difference. Both startPerf calls are new code added by this PR, so this is an internal inconsistency in code the PR adds, and the inline comment at line 2736 misstates the match. Same flavor of cosmetic-shape nit as the ~30 error-shape gaps already accepted on this PR.
Fix
Drop the extra key so the comment is true:
detail: { host: address, port },| // Node only enforces client-cert verification (and the resulting destroy) | ||
| // when the server actually requested a cert; a server without requestCert | ||
| // leaves `authorized` false but keeps the connection open. | ||
| if (self._rejectUnauthorized && self._requestCert) { |
There was a problem hiding this comment.
🟡 The added && self._requestCert at line 793 is dead code — it sits inside the outer if (self._requestCert) block at line 785, so it's always true. The new comment ('Node only enforces client-cert verification … when the server actually requested a cert') describes a guard the outer if already provides; one-token cleanup is to drop && self._requestCert. (Line 801's else if (self._requestCert) has the same redundancy but is unchanged pre-PR context.)
Extended reasoning...
What the issue is
In ServerHandlers.handshake (src/js/node/net.ts:785-803), the PR changes the inner condition from if (self._rejectUnauthorized) to if (self._rejectUnauthorized && self._requestCert) and adds a comment explaining that Node 'only enforces client-cert verification … when the server actually requested a cert; a server without requestCert leaves authorized false but keeps the connection open'. But this line is already inside the outer if (self._requestCert) { block at line 785 (unchanged context in the diff), so self._requestCert is always truthy at line 793 and the added && self._requestCert is dead code.
Step-by-step proof
785 if (self._requestCert) { // ← outer guard (unchanged context)
786 if (verifyError) {
787 self.authorized = false;
788 self.authorizationError = verifyError.code || verifyError.message;
789 server?.emit('tlsClientError', verifyError, self);
790 // Node only enforces client-cert verification (and the resulting destroy)
791 // when the server actually requested a cert; a server without requestCert
792 // leaves `authorized` false but keeps the connection open.
793 if (self._rejectUnauthorized && self._requestCert) { // ← && self._requestCert always true| Step | self._requestCert |
Reaches line 793? | && self._requestCert evaluates to |
|---|---|---|---|
| truthy | passes line 785 | yes | always true (already checked) |
| falsy | line 785 is false → block skipped | no | unreachable |
So in every case where line 793 executes, self._requestCert is truthy and the added conjunct is a no-op. The pre-PR code was just if (self._rejectUnauthorized), which was already correct given the outer guard.
On the 'defensive re-read' counterargument
Line 789 (server?.emit('tlsClientError', verifyError, self)) runs user JS between the outer check and line 793, so a listener could theoretically mutate self._requestCert. Three reasons this isn't the intent:
_requestCertis an internal config flag set once at accept time (onconnectiondoes_socket._requestCert = requestCert), never documented as mutable mid-handshake.- The new comment frames the guard as 'a server without requestCert' — i.e. the static configuration the outer
ifalready covers — not as a defensive re-read after user JS. - Line 801 (
} else if (self._requestCert), unchanged pre-PR context) has the same redundancy with no intervening user JS between line 785 and it, so the pattern is clearly an editing artifact rather than deliberate.
Why nothing catches it
An always-true conjunct compiles fine and behaves identically to the pre-PR code. No test can observe the difference because there isn't one.
Impact
Zero runtime impact — pure dead code with a misleading comment in PR-added code. This is the same flavor of editing-artifact cleanup the author has accepted ~10 times on this PR (resolved 3295640306: redundant ksecureContext early-assignment + unused validateInt32 import; 3285120525: dangling comment fragment; 3291686001: misplaced #[inline]; etc.). Filed as a nit.
Fix
Drop the redundant conjunct so the code matches the comment's intent (which the outer guard already implements):
if (self._rejectUnauthorized) {(Line 801's else if (self._requestCert) → else is the same cleanup but is pre-existing unchanged context, so out of scope.)
… [build images] When session.goaway() is called without a lastStreamID (or with the JS-default 0), the auto-filled value was the highest stream id seen in either direction — for a client that is its own (odd) request id. RFC 9113 §6.8 says GOAWAY's last-stream-id refers to streams the RECEIVER initiated; nghttp2 servers reject a wrong-parity id with NGHTTP2_ERR_PROTO (-505) and tear the connection down. Track the highest peer-initiated id separately (odd for a server, even for a client) and use it for the auto value — node's last_proc_stream_id semantics. Verified: with the fix, the spawned-node fixture in node-http2.test.js no longer hits the -505 (10/10 clean; was 5/10).
…ey surface [build images] Half-open/reset/write semantics, server TLSSocket wrap, session/keylog, SNICallback/ALPNCallback, pfx, OpenSSL error shapes, addCACert, local binding (+305 tests).
6aac31a to
731609d
Compare
…tls-tests-2 [build images]
faefb4a to
3c54213
Compare
…re borrow, register ERR_TLS_ALPN_CALLBACK_INVALID_RESULT, drop redundant requestCert guards, drop addressType from connect perf detail [build images]
Resolved conflict in src/runtime/socket/Handlers.rs: #31155 added four new handler callbacks (session, keylog, serverName, alpnCallback). Folded them into the validate-before-construct scheme so they are type-checked before the Handlers struct exists and assigned via the infallible single-arg macro, keeping the error path abort-free.
… path The rebase picked up #31155, which added a third upgradeTLS site: Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")], reached via new tls.TLSSocket(acceptedSocket, { isServer: true }) (server-side STARTTLS). The native upgradeTLS honors the isServer option and still enables the ssl_raw_tap ciphertext hook, and the raw half keeps the accepted socket's ServerHandlers, so post-upgrade ciphertext was re-pushed as cleartext data on the original accepted socket, the same class as the client-side #32239 bug. Set kupgradedToTLS on the accepted connection at the server upgrade site and guard ServerHandlers.data (after the idle-timer/byteRead update, like the client handlers). Add a server-side STARTTLS regression test.
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.
The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.
Two changes:
- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
already does), so a paused stream with nothing buffered still schedules
'end' and can auto-destroy instead of stalling.
- The native close handlers force teardown via a new destroyAfterClose(): when
there is nothing left to read (readableLength === 0) and the socket is not
already destroyed, schedule destroy() on setImmediate. Deferring past the
nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
"write EBADF" callback _write scheduled for a write that raced the close
fire first (test-net-socket-close-after-end.js,
test-net-socket-write-after-close.js). Readers with data still buffered are
left alone so they can consume it and emit 'end' before 'close', as Node
does. No error is passed: real read errors already surface through the
dedicated error paths in SocketEmitEndNT, and the passive peer close that
lands here is benign.
Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.
Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.
The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.
Changes:
- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
already does), so a paused stream with nothing buffered still schedules
'end' and can auto-destroy instead of stalling.
- SocketEmitEndNT's pending-write flush now also gates on self[kclosed] so a
clean close (no error) still fails the in-flight write — otherwise a paused
reader with buffered data plus a backpressured write leaves kWriting set and
autoDestroy can never fire even after the reader drains.
- The native close handlers force teardown via destroyAfterClose(): when
there is nothing left to read (readableLength === 0) and the socket is not
already destroyed, schedule destroy() on setImmediate. Deferring past the
nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
"write EBADF" callback _write scheduled for a write that raced the close
fire first (test-net-socket-close-after-end.js,
test-net-socket-write-after-close.js). Readers with data still buffered are
left alone so they can consume it and emit 'end' before 'close', as Node
does. No error is passed: real read errors already surface through the
dedicated error paths in SocketEmitEndNT, and the passive peer close that
lands here is benign.
Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.
Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
A node:net socket whose peer disappears while the socket has a stuck half
(write-backpressure that can never flush, or a paused readable with nothing
buffered that never consumes the queued EOF) never emitted 'close'. The
process hung forever, spinning on the half-closed fd; server.close() never
completed.
The socket is a Duplex with { emitClose:false, autoDestroy:true }, so 'close'
only fires from _destroy, and autoDestroy only runs _destroy once both halves
finish. When a half is stuck it never does: the socket lingers as a zombie
(destroyed=false), server._connections never decrements, and a peer that keeps
the fd hot spins the loop.
Changes:
- SocketEmitEndNT now follows push(null) with read(0) (as SocketHandlers2.close
already does), so a paused stream with nothing buffered still schedules
'end' and can auto-destroy instead of stalling.
- SocketEmitEndNT's pending-write flush now also gates on self[kclosed] so a
clean close (no error) still fails the in-flight write — otherwise a paused
reader with buffered data plus a backpressured write leaves kWriting set and
autoDestroy can never fire even after the reader drains.
- The native close handlers force teardown via destroyAfterClose(): when
there is nothing left to read (readableLength === 0) and the socket is not
already destroyed, schedule destroy() on setImmediate. Deferring past the
nextTick queue lets the pending 'end' (from push(null)+read(0)) and any
"write EBADF" callback _write scheduled for a write that raced the close
fire first (test-net-socket-close-after-end.js,
test-net-socket-write-after-close.js). Readers with data still buffered are
left alone so they can consume it and emit 'end' before 'close', as Node
does. No error is passed: real read errors already surface through the
dedicated error paths in SocketEmitEndNT, and the passive peer close that
lands here is benign.
Two regression tests: (1) two Bun processes over a UDS, survivor builds
backpressure, peer SIGKILLed mid-flight, survivor must get one 'close' and
exit; (2) a paused server socket whose peer ends, 'close' fires and
server.close() completes. Both hang on the baked bun. All 141 test-net-*
Node parallel tests pass under bun bd (ASAN), along with node-net-server
(18/18), the http/tls parallel tests that previous iterations regressed,
and regression/issue/12117.
Rebased onto #31155, which substantially reworked these close handlers
(ECONNRESET-shaped destroy in SocketEmitEndNT, kwriteCallback flush); those
changes are kept and destroyAfterClose sits after them as the stuck-writable
fallback they leave open.
Brings node:net and node:tls compatibility in line with Node by porting upstream tests verbatim and fixing the native gaps they expose.
305 verbatim upstream tests added.
Behavior changes
socket.end()now half-closes (FIN) instead of full-closing, so a server's response after a clientend()is delivered before close.socket.resetAndDestroy(),server.close({ resetConnections }), write-after-end / write-after-destroy errors, and the post-write callback contract match Node.read ECONNRESETshape when an error listener is attached and a clean EOF has not already been delivered; a codeless close error that carries an errno derives itscodefrom it. A reset that lands after the exchange already ended cleanly stays a graceful close.net.connect({ localAddress, localPort })binds before connecting on every connect path including deferred DNS resolution.'session'and'keylog'events, end-to-end through the native handler-slot chain; the--tls-keylogflag.code/library/function/reasonproperties (ERR_SSL_<REASON>,ERR_OSSL_<LIB>_<REASON>).secureContext.context.addCACert()extends the full default trust set (bundled roots,NODE_EXTRA_CA_CERTS, system CAs when enabled) on the context's own store, and chain verification then uses that store;tls.createSecureContext()returns a context that owns its SSL_CTX exclusively so a CA appended to one cannot affect another (the internal connect/listen paths keep the per-digest cache).tls.setDefaultCACertificates()applies on every secure-context construction path (plaintls.connect(),addContext,setSecureContext), not just the publiccreateSecureContext().SNICallbackandALPNCallbackserver dispatches, resolved per connection with Node's semantics;tlsSocket.setKeyCert();ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS.PSK+HIGH,!aNULL,@SECLEVEL, …) is accepted/rejected the way BoringSSL evaluates it; a mixed EC/RSA multi-identity configuration is rejected with Node's decomposedKEY_TYPE_MISMATCH.pfxoption: PKCS#12 blobs (single or array, per-entry passphrases) are parsed into key/cert with Node's error messages; CAs embedded in the bundle extend the trust set (they are not treated as an explicitcareplacement).TLSSocketwrapping of an accepted socket andtls.connect({ socket: duplex })over a generic Duplex, includingconnectingparity and synchronous teardown of the wrapped duplex on destroy.netperf_hooks observer,options.handle,pause()/unref()accounting,SocketAddress/BlockList parity, theautoSelectFamilyflag plumbing.Known limitations / follow-ups
SNICallbacknow suspends the handshake (BoringSSL select-certificate retry) until its callback resolves - the upstreamtest-tls-sni-option.jspasses; synchronous callbacks behave as before.server.close()waits longer than it should in that edge case; (2) fixing it by dispatching the handshake failure from the close path requires first aligningtlsHandshakeError's no-listener behavior with Node's silent-destroy semantics, otherwise routine client-initiated mid-handshake teardowns (h2 connection management) surface as spurious ECONNRESET errors.SNICallbackthat reports an error, returns something that is not a SecureContext, or throws aborts the handshake (synchronously or asynchronously): the connection is dropped without a TLS alert and the server emits'tlsClientError'with the callback's error, matching Node.setKeyCert()from insideALPNCallbackis too late under BoringSSL's TLS 1.3 (the credential is already chosen); calling it fromSNICallbackworks.tls.DEFAULT_MIN/MAX_VERSIONare now honored at context-construction time (assignment through the module exports works the way Node's does), and a TLS socket that ends first keeps reading the peer's in-flight data - BoringSSL has no TLS half-close, soend()defers the close_notify (flushing pending session tickets first) and half-closes at the TCP level instead.test-net-perf_hooks.jsis intermittently divergent on Ubuntu 25.04 (dual-stacklocalhostresolution).us_socket_write_check_error) that fails the pending write and closes the socket instead.'error'listener swallowsECONNRESETtransport teardown noise (destroying the session quietly) instead of crashing the process the way Node's EventEmitter contract would; all other unobserved errors still surface.Fixes #28638
Fixes #28641
Fixes #26418
Fixes #20642
Origin (consolidated from #31148)
What
Ports 11
net/tlstests from the Node.js test suite intotest/js/node/test/parallel/(verbatim) and fixes the runtime divergences they surfaced.Tests added (verbatim from upstream Node)
net:
test-net-pause-resume-connecting,test-net-server-keepalive,test-net-server-nodelay,test-net-socket-setnodelay,test-net-connect-memleaktls:
test-tls-basic-validations,test-tls-buffersize,test-tls-client-reject,test-tls-net-socket-keepalive,test-tls-secure-session,test-tls-connect-memleakFixes
net.Socket/net.ServerkeepAlive,noDelay,allowHalfOpenandhighWaterMark; theSocketconstructor honorsoptions.handle.allowHalfOpen(defaultfalsecloses on peer FIN so'close'fires;truekeeps the writable side open), matching Node/libuv.pause()is honored while a socket is still connecting.setNoDelay()forwards to the handle;ServercoercesnoDelaywithBoolean()likekeepAlive._writedefers its callback to the next tick only for TLS sockets (the SSL engine batches, sobufferSize/writableLengthreflect the queued bytes); plain TCP completes synchronously so a tightwrite()loop backpressures at the kernel rather than the JShighWaterMark.tlsvalidateSecureContextOptions(ciphers / passphrase / ecdhCurve / min-max version / ticketKeys / sessionTimeout) and a simplifiedconvertALPNProtocols.'session'event.Errors
validateBufferreports"must be an instance of Buffer, TypedArray, or DataView"to match Node. Two existing tests that asserted the old wording are re-synced to upstream.Comments that mirror Node behavior link the corresponding upstream source.
Testing
Validated locally with the debug build before pushing:
test-{net,http,tls}-*parallel sweep: 0 failures.test/js/node/http/node-http.test.ts(incl. HTTP server security tests): 78 pass, 0 fail.Notes
The strictly Node-correct half-close
_final(shutdown()rather than$end()) is left for a follow-up: it exposes a uWS TLS-handshake edge case where a successful handshake immediately followed by aclose_notifyis reported asECONNRESET. Until that's addressed,_finaluses$end().This PR consolidates the original test-porting branch (
claude/port-node-net-tls-tests, #31148) and the follow-up branch (claude/port-node-net-tls-tests-2); both branches now point to the same squashed content.