Skip to content

node: tls/https v26 compat wave 2 — allowHalfOpen close_notify, fetch setDefaultCACertificates, https.Server setSecureContext/keylog/TLSSocket (+6 tests) - #35535

Draft
cirospaciari wants to merge 7 commits into
claude/node-tls-v26bfrom
claude/node-tls-v26-wave2
Draft

node: tls/https v26 compat wave 2 — allowHalfOpen close_notify, fetch setDefaultCACertificates, https.Server setSecureContext/keylog/TLSSocket (+6 tests)#35535
cirospaciari wants to merge 7 commits into
claude/node-tls-v26bfrom
claude/node-tls-v26-wave2

Conversation

@cirospaciari

Copy link
Copy Markdown
Member

Stacked on #35386 (base branch claude/node-tls-v26b), which stacks on #34598 — review those first. Second TLS wave: the classes the first wave root-caused but could not fix because they live in code the #34598 rewrite owns. +6 upstream Node v26.3.0 tests vendored byte-verbatim.

TLS half-open: honor allowHalfOpen on the peer's close_notify (+1)

packages/bun-usockets/src/crypto/openssl.c scoped the TLS EOF dispatch (peer close_notify → SSL_ERROR_ZERO_RETURN, and the raw-FIN path in us_internal_ssl_on_end) to uWS HTTP server sockets; every other TLS socket kind force-closed the transport. A tls.connect({ allowHalfOpen: true }) client therefore lost its write side the moment the server half-closed — writing after 'end' failed instead of flushing.

The fixing line extends ssl_wants_eof_dispatch to BUN_SOCKET_KIND_BUN_SOCKET_TLS (Bun.connect/Bun.listen sockets, which node:tls rides on) for established sessions only, making the TLS EOF path mirror the plain-TCP allow_half_open branch in loop.c. An EOF mid-handshake keeps the historical force-close so it still surfaces as ECONNRESET "socket hang up" (test-tls-econnreset and test-tls-sni-servername regressed without the handshake gate; both re-verified 3x). The SENT_SHUTDOWN writable-dispatch suppression stays scoped to uWS HTTP sockets. HTTP client / WebSocket / DB driver sockets keep the historical force-close.

  • vendored: test-tls-connect-allow-half-open-option.js

fetch() honors tls.setDefaultCACertificates() (+2)

The override only updated a JS-side store consumed by node:tls/node:https; fetch()'s TLS contexts live on the HTTP client thread and never saw it. The normalized PEM set is now published process-wide (src/http/default_ca.rs, mutex + generation counter) and the HTTP thread rebuilds the default HTTPS SSL_CTX on the next connect when the generation moved (ensure_https_context_initHTTPContext::replace_ssl_ctx_with_default_ca). An empty array installs an explicitly empty trust store, like Node. The fresh SSL_CTX starts with an empty client session cache, so resumption cannot skip re-verification against the new set.

fetch's certificate-failure rejection now also carries .cause — a distinct error instance with the same code/message — matching the err.cause.code contract of Node's fetch. The top-level error shape (code/message/path) is unchanged.

Deliberate scope, matching Node/undici: keep-alive pooled connections established under the previous CA set are not flushed (the upstream test dodges its own session cache by switching hosts).

  • vendored: test-tls-set-default-ca-certificates-append-fetch.mjs, test-tls-set-default-ca-certificates-reset-fetch.mjs

https.Server surface (+3)

  • setSecureContext(options) — new us_listen_socket_set_default_ssl_ctx swaps the listen socket's default SSL_CTX (the one accepted sockets SSL_new from), re-registering the listener-level SNI/select-cert callbacks on the new context. In-flight and established connections keep the old context through their own SSL refs, so an active response streams across the swap (the vendored test asserts exactly this). The JS method reuses the constructor's TLS-option processing (extracted to processServerTlsOptions, no behavior change) and is installed per-instance on TLS servers only, so plain http.Server keeps Node's surface.

  • socket instanceof tls.TLSSocket — encrypted server sockets are constructed with a prototype chain that splices the NodeHTTPServerSocket method table above TLSSocket.prototype (Reflect.construct with a prepared new.target). TLSSocket's constructor never runs — the NodeHTTP handle replaces its wrap machinery — and any name TLSSocket.prototype owns that the old NetSocket chain also provided keeps its previous resolution, so only genuinely TLS-only surface (getPeerCertificate, …) resolves through TLSSocket.prototype.

  • server 'keylog' — the first 'keylog' listener arms key-log parking on the listener (us_listen_socket_enable_keylog, checked at accept in us_internal_ssl_attach); the handshake's NSS lines are parked on each accepted socket's SSL (the same queue the node:tls client path uses) and drained into server.emit('keylog', line, socket) when the handshake-complete connection callback fires. Zero cost when no listener exists. Post-handshake lines (TLS 1.3 KeyUpdate) are not surfaced; commented inline.

  • socket.destroy() on a server socket now force-closesJSNodeHTTPServerSocket::close() closed with CLEAN_SHUTDOWN (0), which the TLS layer treats as graceful and defers the fd close until the peer's close_notify reply. An allowHalfOpen peer never sends one, so a destroyed server socket pinned the event loop forever — previously masked because such clients were themselves force-closed; exposed by the half-open fix above (test-https-set-timeout-server's idleTimeout hung). destroy is forceful: FAST_SHUTDOWN.

  • vendored: test-tls-set-secure-context.js, test-https-timeout-server-2.js, test-https-agent-keylog.js

Not fixed here (investigated; blocking mechanism identified)

  • test-https-resume-after-renew.js, test-tls-ticket-12.js — need server._sharedCreds.context.enableTicketKeyCallback() / server-side session-ticket key control: a full ticket-key callback bridge into BoringSSL's SSL_CTX_set_tlsext_ticket_key_cb plus the _sharedCreds object graph on the Bun.serve-backed server.
  • test-double-tls-client.js — TLS-over-TLS (tls.connect({ socket: tlsSocket })) fails during the inner handshake on the duplex-backed TLS path ("disconnected before secure TLS connection was established").
  • First-wave blockers unchanged: SSL_trace, PSK, internalBinding(tls_wrap/stream_wrap)-dependent files, per-record 'data' framing (max-send-fragment), --tls-cipher-list, NODE_DEBUG_NATIVE log-line asserts.

Verification

  • Each vendored test runs CI-style (BUN_GARBAGE_COLLECTOR_LEVEL=1 BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1 --config=bunfig.node-test.toml, one process per file): 3 consecutive green rounds on the final build, and each fails on the unfixed base build (allowHalfOpen: write-after-end error; CA fetch: UNABLE_TO_VERIFY_LEAF_SIGNATURE reject missing/.cause absent; setSecureContext: not a function; timeout-server-2: instanceof assert; keylog: 0 of 10 lines).
  • All 269 previously-vendored test-tls-*/test-https-* upstream files re-run one process per file against the fix; the two same-class regressions the first run caught (econnreset, sni-servername) are fixed by the handshake gate and re-verified; the full test-https-* subset (76 files) re-run green on the final build.
  • bun bd test test/js/node/tls/ 243 pass, test/js/node/net/ 220 pass, test/js/web/fetch/fetch.tls.test.ts 23 pass; test/js/node/http/ failures are identical on the unfixed base (missing test node_modules in a fresh worktree + a load-sensitive fault-injection timeout).
  • Silent-pass canary: 6 real, 0 hollow. test/expectations.txt untouched.

Note: #34432 also touches _http_server.ts/NodeHTTP.cpp for different defects (no overlap on these symptoms); textual merge conflicts are possible.

cirospaciari and others added 4 commits July 25, 2026 01:39
…ertificates to fetch

- uSockets: extend the TLS EOF dispatch (peer close_notify / raw FIN) to
  Bun.connect/Bun.listen TLS sockets so allow_half_open keeps the write
  side alive like the plain-TCP path in loop.c; the on_writable
  SENT_SHUTDOWN suppression stays scoped to uWS HTTP sockets
- bridge tls.setDefaultCACertificates() to the HTTP client thread: the
  normalized PEM set is published process-wide and the default HTTPS
  SSL_CTX is rebuilt on the next connect (empty set = empty trust store)
- fetch: certificate failures now carry a .cause error with the same
  code/message, matching the err.cause.code contract of Node's fetch

Vendored passing upstream tests:
  test-tls-connect-allow-half-open-option.js
  test-tls-set-default-ca-certificates-append-fetch.mjs
  test-tls-set-default-ca-certificates-reset-fetch.mjs
…S EOF dispatch on completed handshake

- https.Server#setSecureContext(): new us_listen_socket_set_default_ssl_ctx
  swaps the listener's default SSL_CTX (what accepted sockets SSL_new from),
  re-registering the listener-level SNI callbacks on the new context;
  established connections keep the old context through their own SSL refs.
  The JS method reuses the constructor's TLS-option processing (extracted to
  processServerTlsOptions) and is installed on TLS servers only
- encrypted server sockets are built with the NodeHTTPServerSocket method
  table spliced above TLSSocket.prototype (Reflect.construct with a prepared
  new.target), so socket instanceof tls.TLSSocket holds like Node without
  running TLSSocket's constructor
- server 'keylog': the first listener arms per-listener key-log parking
  (checked at accept in us_internal_ssl_attach); parked lines stay queued
  for uWS sockets (no keylog dispatch) and are drained into
  server.emit('keylog', line, socket) by the connection callback
- the TLS EOF dispatch added for allowHalfOpen now requires a COMPLETED
  handshake for Bun sockets: an EOF mid-handshake keeps the historical
  force-close so it surfaces as ECONNRESET 'socket hang up'
  (test-tls-econnreset, test-tls-sni-servername regressed without this)

Vendored passing upstream tests:
  test-tls-set-secure-context.js
  test-https-timeout-server-2.js
  test-https-agent-keylog.js
…icing TLSSocket.prototype

- JSNodeHTTPServerSocket::close() (the socket.destroy() path) closed with
  CLEAN_SHUTDOWN, which the TLS layer treats as graceful: it defers the fd
  close until the peer's close_notify reply. An allowHalfOpen peer never
  sends one, so the destroyed server socket pinned the event loop forever
  (test-https-set-timeout-server's idleTimeout hung). destroy is forceful:
  close with FAST_SHUTDOWN
- the spliced TLS server-socket prototype now pins the previous resolution
  for any name TLSSocket.prototype owns that the NetSocket chain also
  provided, so only genuinely TLS-only surface resolves through
  TLSSocket.prototype
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator
Updated 12:20 AM PT - Aug 4th, 2026

@robobun, your commit 9b53c58 has 1 failures in Build #88601 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35535

That installs a local version of the PR into your bun-35535 executable, so you can run:

bun-35535 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. node:https Request socket is not a TLSSocket #16834 - Encrypted server sockets now pass instanceof tls.TLSSocket and expose TLS-specific methods like getPeerCertificate
  2. Provide way to create SSLKEYLOGFILE #16331 - HTTPS servers now emit 'keylog' events for TLS key material logging (SSLKEYLOGFILE support)
  3. Bun.serve() function to fetch TLS certs #4463 - https.Server.setSecureContext(options) allows rotating TLS certificates on a running server without restarting
  4. Ability to set NODE_EXTRA_CA_CERTS in standalone executable #13868 - tls.setDefaultCACertificates() now propagates to fetch(), providing a runtime API to set custom CA certificates programmatically

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #16834
Fixes #16331
Fixes #4463
Fixes #13868

🤖 Generated with Claude Code

robobun added 3 commits August 3, 2026 20:51
…e/node-tls-v26-wave2

# Conflicts:
#	packages/bun-usockets/src/crypto/openssl.c
#	packages/bun-usockets/src/libusockets.h
#	src/runtime/server/server_body.rs
#	src/runtime/webcore/fetch/FetchTasklet.rs
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Cross-reference: the socket instanceof tls.TLSSocket part of this PR covers the instanceof half of #16834 (node:https request sockets). #37255 overlaps it, so two notes for sequencing:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants