Repository navigation
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
…test (#34163) ## Problem `test/js/node/tls/node-tls-cert.test.ts` went red on `:darwin: 26 aarch64` in [build 72915](https://buildkite.com/bun/bun/builds/72915): ``` error: expect(received).toBeLessThan(expected) Expected: < 12582912 Received: 21839872 ✗ server-side getPeerCertificate() should not leak ``` The same assertion also flaked in `node-tls-getpeercert-leak.test.ts` on `:darwin: 14 x64` in [build 72900](https://buildkite.com/bun/bun/builds/72900) (21008384 bytes, passed on retry). ## Cause The `server-side getPeerCertificate() should not leak` test exists twice: #29881 landed it as the standalone `node-tls-getpeercert-leak.test.ts`, and #29916 (merged a few hours later) appended an identical copy to `node-tls-cert.test.ts`. Both copies baseline RSS after four 5k-call warmup rounds and assert less than 12MB of growth afterwards. On the macOS tart runners the allocator's high-water mark can climb by ~21MB over that window with no leak present, while a probe on `darwin-test-arm64-3` with the current canary shows RSS flat within ~1MB once it has had a couple more rounds to settle, and the full test file passes 8/8 on that box. The copy inside `node-tls-cert.test.ts` is the worse of the two because it runs after 30 other TLS tests in the same process; in build 72915 the standalone file passed on the same commit in the same lane. There is no regression in the underlying `SSL_get_peer_certificate`/`computeRaw` paths: the growth is the same for 5 and 10 measurement rounds, which rules out the per-call leak the test guards against. ## Fix * Remove the duplicate from `node-tls-cert.test.ts` and leave a pointer to the standalone file. * In `node-tls-getpeercert-leak.test.ts`: * apply `skipIf(isDebug)` (the refinement #31155 had applied only to the duplicate) so the ASAN quarantine cannot mask the signal on debug builds, * lengthen the warmup from 4 to 8 rounds so RSS has settled before the baseline sample, * lengthen the measured window from 10 to 15 rounds, * raise the release threshold from 12MB to 32MB (40MB under the release ASAN lane). The unpatched BIO/X509 leak is ~800 bytes per call; over the 75k calls now measured that is ~60MB of growth, so the regression the test exists to catch remains well above the new threshold, while the ~21MB noise ceiling observed on the macOS runners sits comfortably below it. ## Verification ``` $ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-getpeercert-leak.test.ts (pass) server-side getPeerCertificate() should not leak [5150.55ms] 1 pass 0 fail $ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-cert.test.ts 27 pass 3 todo 0 fail $ bun bd test test/js/node/tls/node-tls-getpeercert-leak.test.ts (skip) server-side getPeerCertificate() should not leak ``` Ran the full `node-tls-cert.test.ts` eight times on `darwin-test-arm64-3` (macOS 26) against canary `cc0c1e835`; 8/8 pass. <!-- robobun:evidence:begin --> --- **[stamp-90s]** gate passed · iteration 8 · 2 files touched <details><summary>passes on PR (with fix)</summary> ```console Test-only change. Debug/ASAN (expected pass): $ bun bd test 'test/js/node/tls/node-tls-cert.test.ts' 'test/js/node/tls/node-tls-getpeercert-leak.test.ts' $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/node/tls/node-tls-cert.test.ts test/js/node/tls/node-tls-getpeercert-leak.test.ts info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (81bb980) test/js/node/tls/node-tls-getpeercert-leak.test.ts: (skip) server-side getPeerCertificate() should not leak test/js/node/tls/node-tls-cert.test.ts: (pass) complete cert chains sent to peer. [576.13ms] (pass) complete cert chains sent to peer, but without requesting client's cert. [162.21ms] (todo) Request cert from TLS1.2 client that doesn't have one. (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [107.76ms] (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [102.96ms] (pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [97.10ms] (pass) Fail to complete server's chain [83.14ms] (pass) Fail to complete client's chain. [103.92ms] (pass) rejects an unverifiable client certificate by default when requestCert is true [247.07ms] (pass) explicit rejectUnauthorized: false still admits an unverified client certificate [79.04ms] (pass) Fail to find CA for server. [151.43ms] (pass) Server sent their CA, but CA cannot be trusted if it is not locally known. [69.40ms] (pass) Server sent their CA, wrongly, but its OK since we know the CA locally. [83.05ms] (todo) Confirm client support for "BEGIN TRUSTED CERTIFICATE". (todo) Confirm server support for "BEGIN TRUSTED CERTIFICATE". (pass) Confirm client support for ... (truncated) Exit: 0 ``` </details> <details><summary>diff hotspot</summary> ``` test/js/node/tls/node-tls-cert.test.ts | 150 ++++++--------------- test/js/node/tls/node-tls-getpeercert-leak.test.ts | 127 +++++++++-------- 2 files changed, 107 insertions(+), 170 deletions(-) ``` </details> **gate history** · 3 passed · 1 rejected · iteration 8 <details><summary>evidence per changed file</summary> ``` file reads edits tests test/js/node/tls/node-tls-cert.test.ts 5 6 0 test/js/node/tls/node-tls-getpeercert-leak.test.ts 2 2 0 ``` </details> <!-- robobun:evidence:end -->
…et writes (#32088) Fixes #32087. Likely also the root cause of at least some reports in #5627 (length-prefixed protocol desync over `node:net`). ### Bug `write_or_end_buffered` (the native `$write`/`$end` path, `src/runtime/socket/socket_body.rs`) has a writev fast path used when new data arrives while `buffered_data_for_node_net` is non-empty. `write2(B, I)` is `writev(fd, [B, I])`: it writes the old buffered data `B` first, then the new chunk `I`, and returns the total bytes written. After a partial write, the number of bytes of `I` consumed is `written - B.len`, but the code computed the saturating subtraction in the reverse order: ```rust let remaining_in_input_data = &buffer.slice() [(buf_len.saturating_sub(written)).min(buffer.slice().len())..]; ``` With `b = B.len`, `n = I.len`, `w = written`: | case | I consumed | code kept `I[min(b -| w, n)..]` | result | |---|---|---|---| | `w < b` | 0 | `I[min(b - w, n)..]` | unsent prefix of I silently lost (all of I when `b - w >= n`) | | `w >= b` | `w - b` | `I[0..]` | already-written prefix of I re-buffered and sent twice | A second defect in the same block: when `w >= b`, the truncation branch was skipped entirely (it only ran for `written < buf_len`), so the buffer still held all of `B` even though it was already on the wire, and the next flush resent it. Both corruptions reproduce on main with plain `node:net` by calling `Socket.prototype._write` the way net.ts's own stream machinery does, with a backpressured peer: one run silently dropped 64 KiB (no error, FIN delivered), another put ~5.5 MB of duplicated bytes on the wire. For length-prefixed protocols (Postgres, MySQL, RESP, AMQP) this means desynced or stalled connections with no error attributable to the application. The bug shipped with the writev fast path in #14516 (v1.1.34); the `.zig` reference has the identical defect. ### Fix Keep exactly the unsent suffix of `B ++ I`: - slice the input remainder from `written.saturating_sub(buf_len)` (the reversed form is replaced), - `drain_front(written)` on the buffer, which keeps the unsent suffix for a write that stopped inside `B` and clears the buffer when `B` was fully written, - also add the bytes writev wrote to `bytes_written`; this was the only write site not doing so, and once the buffer is correctly truncated the omission becomes user-visible as `socket.bytesWritten` never reaching the submitted total. ### Verification Two new tests in `test/js/node/net/node-net.test.ts` drive both partial-write shapes through a byte-counting peer and assert exact per-fill-byte counts, run ordering, and `bytesWritten`: - loss (`w < b`): a multi-MB native remainder plus a 64 KiB follow-up write; unfixed, the server receives 0 of the 65536 `s` bytes. - dup (`w > b`): a small remainder, kernel drained while the event loop is blocked, then a 32 MB write; unfixed, the server sees runs `a,i,a,i` and ~5.5 MB of duplicated bytes. Both fail on the unfixed build (release and debug+ASAN, verified via `git stash push -- src/`) and pass with the fix. Node runs the identical client/server byte-perfect. The tests are skipped on Windows: the writev fast path under test is compiled only on POSIX, and how much data a send accepts on Windows is machine dependent, so the buffered-data precondition cannot be constructed reliably there. (The first CI run also surfaced a pre-existing un-awaited `expectMaxObjectTypeCount` in the earlier Windows-only named-pipe test whose floating assertion fails whatever test runs after it; tracked in #32092.) --- Rebased onto main (the net/tls rewrite in #31155 and the http2 rewrite in #31584 had moved the branch base). The src fix applied cleanly onto the new write path. The only conflict was additive in `node-net.test.ts` (main and this branch each appended a `describe` block), resolved by keeping both. One follow-up the rebase surfaced: #31155 changed `net.ts`'s `_final` from `socket.$end()` to `socket.shutdown()`. `$end()` set `END_AFTER_FLUSH` and deferred the close until `buffered_data_for_node_net` drained, whereas `shutdown()` half-closes the write side immediately and discards whatever is still buffered. In normal `node:net` usage the Writable serialization keeps that buffer empty at `_final`, so it is masked, but the test fixtures drive `_write` directly and hit it. The fixtures now wait for the final write's drain callback (fired only once the native buffer empties) before calling `end()`, so they exercise the writev arithmetic rather than the shutdown discard. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts <!-- robobun:evidence:end -->
…et writes (oven-sh#32088) Fixes oven-sh#32087. Likely also the root cause of at least some reports in oven-sh#5627 (length-prefixed protocol desync over `node:net`). ### Bug `write_or_end_buffered` (the native `$write`/`$end` path, `src/runtime/socket/socket_body.rs`) has a writev fast path used when new data arrives while `buffered_data_for_node_net` is non-empty. `write2(B, I)` is `writev(fd, [B, I])`: it writes the old buffered data `B` first, then the new chunk `I`, and returns the total bytes written. After a partial write, the number of bytes of `I` consumed is `written - B.len`, but the code computed the saturating subtraction in the reverse order: ```rust let remaining_in_input_data = &buffer.slice() [(buf_len.saturating_sub(written)).min(buffer.slice().len())..]; ``` With `b = B.len`, `n = I.len`, `w = written`: | case | I consumed | code kept `I[min(b -| w, n)..]` | result | |---|---|---|---| | `w < b` | 0 | `I[min(b - w, n)..]` | unsent prefix of I silently lost (all of I when `b - w >= n`) | | `w >= b` | `w - b` | `I[0..]` | already-written prefix of I re-buffered and sent twice | A second defect in the same block: when `w >= b`, the truncation branch was skipped entirely (it only ran for `written < buf_len`), so the buffer still held all of `B` even though it was already on the wire, and the next flush resent it. Both corruptions reproduce on main with plain `node:net` by calling `Socket.prototype._write` the way net.ts's own stream machinery does, with a backpressured peer: one run silently dropped 64 KiB (no error, FIN delivered), another put ~5.5 MB of duplicated bytes on the wire. For length-prefixed protocols (Postgres, MySQL, RESP, AMQP) this means desynced or stalled connections with no error attributable to the application. The bug shipped with the writev fast path in oven-sh#14516 (v1.1.34); the `.zig` reference has the identical defect. ### Fix Keep exactly the unsent suffix of `B ++ I`: - slice the input remainder from `written.saturating_sub(buf_len)` (the reversed form is replaced), - `drain_front(written)` on the buffer, which keeps the unsent suffix for a write that stopped inside `B` and clears the buffer when `B` was fully written, - also add the bytes writev wrote to `bytes_written`; this was the only write site not doing so, and once the buffer is correctly truncated the omission becomes user-visible as `socket.bytesWritten` never reaching the submitted total. ### Verification Two new tests in `test/js/node/net/node-net.test.ts` drive both partial-write shapes through a byte-counting peer and assert exact per-fill-byte counts, run ordering, and `bytesWritten`: - loss (`w < b`): a multi-MB native remainder plus a 64 KiB follow-up write; unfixed, the server receives 0 of the 65536 `s` bytes. - dup (`w > b`): a small remainder, kernel drained while the event loop is blocked, then a 32 MB write; unfixed, the server sees runs `a,i,a,i` and ~5.5 MB of duplicated bytes. Both fail on the unfixed build (release and debug+ASAN, verified via `git stash push -- src/`) and pass with the fix. Node runs the identical client/server byte-perfect. The tests are skipped on Windows: the writev fast path under test is compiled only on POSIX, and how much data a send accepts on Windows is machine dependent, so the buffered-data precondition cannot be constructed reliably there. (The first CI run also surfaced a pre-existing un-awaited `expectMaxObjectTypeCount` in the earlier Windows-only named-pipe test whose floating assertion fails whatever test runs after it; tracked in oven-sh#32092.) --- Rebased onto main (the net/tls rewrite in oven-sh#31155 and the http2 rewrite in oven-sh#31584 had moved the branch base). The src fix applied cleanly onto the new write path. The only conflict was additive in `node-net.test.ts` (main and this branch each appended a `describe` block), resolved by keeping both. One follow-up the rebase surfaced: oven-sh#31155 changed `net.ts`'s `_final` from `socket.$end()` to `socket.shutdown()`. `$end()` set `END_AFTER_FLUSH` and deferred the close until `buffered_data_for_node_net` drained, whereas `shutdown()` half-closes the write side immediately and discards whatever is still buffered. In normal `node:net` usage the Writable serialization keeps that buffer empty at `_final`, so it is masked, but the test fixtures drive `_write` directly and hit it. The fixtures now wait for the final write's drain callback (fired only once the native buffer empties) before calling `end()`, so they exercise the writev arithmetic rather than the shutdown discard. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts <!-- robobun:evidence:end -->
… end() only half-closes (Bun 1.4) startFactoryPrivateHttps's flush() cleared the request timer just before socket.end(), relying on end() to close the connection. On Bun 1.4, end() on a Bun.listen TLS socket closes only the sending side (BoringSSL has no TLS half-close, oven-sh/bun#31155; allowHalfOpen:false does not change it; the canary behaves the same), so a peer that kept its write side open retained the completed connection without bound. Red in the W12e full pool at 5670a39c6 (auto-extra-5): "a peer that keeps its write side open cannot retain a completed server connection" failed after 500 ms with 47 more chunks accepted (w12e-2/sub/private-https/repro-output.txt; w12e-2/https-fix/case2-red-1.4.2.log). The timer now stays armed after the response and terminates the connection after requestTimeoutMs (100 ms in that test); close() still clears it on a normal close. Green on 1.4.2 and 1.3.14; the 16 importer suites are green on 1.4.2 (w12e-2/https-fix/importer-*.log).
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.