Skip to content

quic: reuse TLS pause machinery to drop event deferral & improve 0RTT - #65522

Open
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:pause-for-quic-0rtt
Open

quic: reuse TLS pause machinery to drop event deferral & improve 0RTT#65522
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:pause-for-quic-0rtt

Conversation

@pimterry

Copy link
Copy Markdown
Member

With this change, the TLS suspend mechanism I introduced in #64827 becomes generic and we reuse it in QUIC. This adds new client hello structures, which hide the OpenSSL vs BoringSSL differences, and expose an interface for each handshake flow to continue, suspend or fail the handshake. While suspended they can access the details and validate/prepare according to their needs.

There's one immediate advantage here: this allows QUIC to reuse this existing mechanism to largely replace its event deferral system. In QUIC, we need to defer some handshake events (mostly relating to resumption & 0RTT, see #64132) until the handshake is sufficiently complete so that at the session event we can do some setup with the data already available, but before the actual data processing within starts.

To do this currently we have a whole event deferral mechanism and multi-step session ticket extraction & validation flow. Directly suspending the handshake reusing the existing mechanism from node:tls lets us delete that completely, do the simple thing instead, and get the same result.

This then has another medium-term benefit: as part of #63995 we will be adding dynamic attach, where the application protocol is decided dynamically on session creation, which requires deferring session ticket processing even further (because we don't even know which protocol to validate for). This mechanism gives us the structure in which to easily do that, giving us 0RTT + dynamic attach.

As part of this, this changes the internal flow of how ALPN & SNI are resolved for QUIC, and extracts ALPN helpers to share more logic between TLS/QUIC/DTLS. Instead of calculating them synchronously within the OpenSSL callbacks, we now precalculate them at the suspend point, and then the OpenSSL callbacks later just use the precalculated values.

This is intended to only create visible changes to node:quic. Though it touches node:tls & node:dtls, it's just commonizing logic and preserves all existing behaviour (including callback order and other details). In future I'd like to make some small breaking changes to both to bring these further inline, but that's tricky and we can ignore it for now.

With this change, the TLS pause mechanisms used in node:tls are now made
generic, and used in QUIC to pause the handshake entirely for the
session event, thereby stopping almost all events being emitted
(everything except qlog) so that all the previously existing deferral
machinery is unnecessary.

This is useful immediately to clean up & simplify things, but also
medium term because this makes the 0RTT flow significantly clearer,
which will help with the upcoming dynamic attach for QUIC (which
requires deferring ticket processing & 0RTT delivery until after
user code).

As part of this, this changes the internal flow of how ALPN & SNI are
resolved. Instead of calculating them synchronously within the OpenSSL
callbacks, we precalculate them at the pause, and then the OpenSSL
callbacks just return the precalculated values.

This is intended to only create visible changes to node:quic. Though it
touches node:tls, it preserves all existing behaviour (including
callback order and other details).

Signed-off-by: Tim Perry <pimterry@gmail.com>
@pimterry
pimterry requested a review from jasnell August 24, 2026 18:54
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/crypto
  • @nodejs/gyp
  • @nodejs/quic

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 24, 2026
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.14%. Comparing base (f509cf1) to head (63deaf9).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/crypto/crypto_client_hello.h 89.79% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65522      +/-   ##
==========================================
- Coverage   90.14%   90.14%   -0.01%     
==========================================
  Files         751      752       +1     
  Lines      253585   253692     +107     
  Branches    47772    47773       +1     
==========================================
+ Hits       228596   228686      +90     
- Misses      16228    16255      +27     
+ Partials     8761     8751      -10     
Files with missing lines Coverage Δ
src/crypto/crypto_context.cc 71.67% <ø> (+0.01%) ⬆️
src/crypto/crypto_context.h 100.00% <ø> (ø)
src/crypto/crypto_tls.cc 78.74% <100.00%> (-0.04%) ⬇️
src/crypto/crypto_client_hello.h 89.79% <89.79%> (ø)

... and 34 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jasnell

jasnell commented Aug 24, 2026

Copy link
Copy Markdown
Member

Rather than focusing the PR description on the benefits, can you explain what the behavioral / structural differences here are?

// entry, of type host_name, of a sane length and free of NULs. Anything
// else reads as absent here and is rejected outright by the TLS stack a
// moment later, once it parses extensions for itself.
inline std::string_view servername() const {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are rather large to be inline definitions. Would much prefer the impls to be moved out to crypto_client_hello.cc

// moment later, once it parses extensions for itself.
inline std::string_view servername() const {
auto ext = extension(TLSEXT_TYPE_server_name);
if (!ext.has_value()) return {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... this does not distinguish between not provided and zero length? Should it? e.g. by returning std::optional<std::string_view> instead.

}

static inline Result Encode(ClientHelloResult result) {
#ifdef OPENSSL_IS_BORINGSSL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could simplify this using defines in the same #ifdef that has using Result = to include #define ResultSuccess = ssl_select_cert_success; etc .. then you could eliminate the #ifdef here.

// Whether protocols, a list in ALPN wire format, contains protocol.
inline bool AlpnListContains(std::span<const uint8_t> protocols,
std::string_view protocol) {
for (size_t n = 0; n < protocols.size();) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I wonder if we should guard against protocols.size() being unreasonably large... someone could send a rather large payload of [1, 'a', 1, 'a', 1, 'a', ... ]. Some kind of defensive bound might be worthwhile.

Comment thread src/crypto/crypto_tls.cc
alpn_protos.size(),
in,
inlen);
auto selected =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The { }, { } here could use a code comment to make it a bit more readable

Comment thread src/quic/application.cc
Comment on lines +198 to +200
// CollectSessionTicketAppData writes just the application type byte, so by
// default all there is to check is that the ticket was issued by a session
// that negotiated this same application.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: negotiated by the same application type? or application instance? Comment is just a bit unclear.

Comment thread src/quic/endpoint.cc
Comment thread src/quic/session.cc
? SessionTicket::AppData::Status::TICKET_USE_RENEW
: SessionTicket::AppData::Status::TICKET_USE;
if (!has_application()) [[unlikely]] {
return SessionTicket::AppData::Status::TICKET_IGNORE;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, should this be TICKET_RENEW?

@jasnell

jasnell commented Aug 24, 2026

Copy link
Copy Markdown
Member

Ok, I had an agent summarize out the actual changes here. Let me step through them. The quoted bits here are from the agents summary:

The session event is now delivered before the TLS handshake proceeds past the ClientHello, rather than after the first packet is fully processed. No stream, datagram, keylog, origin, application, goaway or datagram-status event can precede it — this is now a protocol-level guarantee rather than a replay artifact.

I know this is the key goal here with your eventual dynamic attach goal.. essentially pause here and let the JS side set things up. My goal has been to defer calling out to JS until we absolutely need to, handling as much of the handshake as possible in C++ as we can.

I'm still not entirely sold on dynamic attach yet. We know at this point that the ALPN is either h3 or something else, and we know that h3 maps to Http3Application and something else maps to the default behavior. Why call out to JavaScript to have it select and install the application? That seems entirely unnecessary and not a performance win. But I guess that's an argument for the larger PR HTTP3/QUIC split PR.

Per-SNI-identity alpn lists are now honoured. Previously the default endpoint context's alpn was always used. (behaviour change

+1 ... this has been on my list to revisit for a while.

Keylog lines are emitted in real time rather than replayed, so their interleaving with other events changes.

Seems fine. We'll need to revisit keylog/qlog when all this is done anyway to ensure we're capturing everything correctly.

session.remoteTransportParams is populated at the session event (previously undefined).

+1

Session tickets with absent or malformed application data are now rejected rather than accepted optimistically

This is good. Absent/malformed application data should mean that the ticket is ignored and a renewed ticket is returned. If we just ignore, the client may continue to just keep trying to use the bad ticket.

When no ALPN is configured on the server, the failure mode moves from OnSelectAlpn returning NOACK to the ClientHello callback continuing with no Application.

This one I'm not convinced about. Currently we will fail the handshake if an ALPN that the server does not recognize is presented. With this, we proceed with the handshake and likely fail later. I'd think that's a regression.

A ClientHello that changes server_name across a HelloRetryRequest is now rejected with illegal_parameter. Previously it was OpenSSL's problem.

+1

Malformed server_name extensions now read as absent (may select the wildcard identity) instead of relying on OpenSSL's parse.

Hmm.. not sure about this one. Smells bad.

Alert selection for SNI failure moves from the servername callback to the ClientHello callback.

+1

Endpoint bytesReceived / packetsReceived under-count when JS closes a session inside the session callback.

This is a regression. The stats should be accurate at this point.

Beyond that, I think this PR could definitely have been better split into multiple commits but ok.

@jasnell

jasnell commented Aug 24, 2026

Copy link
Copy Markdown
Member

Overall, +1 but there are a few regressions to investigate.

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

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants