Skip to content

quic: add Http3Session, so you can explicitly pick the app protocol - #65993

Open
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:http3-attach
Open

quic: add Http3Session, so you can explicitly pick the app protocol#65993
pimterry wants to merge 1 commit into
nodejs:mainfrom
pimterry:http3-attach

Conversation

@pimterry

Copy link
Copy Markdown
Member

This extracts dynamic application attach from #63995. This is still very much under debate, so I'll try to explain thoroughly:

Terminology

  • Applications define the protocol behaviours for QUIC. This includes internal details like how writes are framed on the wire and creation & management of control streams, and external details like exposing different functionality on top of QUIC (like HTTP headers APIs).
  • A Session makes calls into its Application any time it needs anything protocol-specific.
  • I'm calling the point where you choose which Application a Session is using "attaching" the Application to the Session. Both now and with this PR, it happens once and permanently for each session.
  • By "dynamic" attach I mean choosing which Application is used from JS, not from static configuration, so you can implement whatever custom logic you like freely.

Current state

Right now, we have fixed rules that lock which Application is used according to the ALPN negotation on the socket. We use ALPN to make this decision and attach the Application at the earliest possible moment.

For clients that means in the Session constructor (clients currently only support one fixed ALPN) and for servers it means in OnClientHello, which is the moment we know which ALPN protocol we'll select.

Why add dynamic attach

  • It's useful to be able to do raw QUIC with a HTTP/3 ALPN. This lets you test weird behaviours (much like to how our test suite occasionally drives HTTP/2 over raw TCP) or use an alternative HTTP/3 implementation.
  • It's useful to be able to choose to do HTTP/3 with a non-HTTP/3 ALPN. For HTTP/2 this has been used quite a bit, e.g. weird internal protocols, gRPC ALPNs, etc. Even today for HTTP/3 it's been used for various HTTP/3 drafts as h3-*. It's perfectly valid to do this - if you agree on h3 via ALPN you should speak HTTP/3, but the inverse is not required.
  • Controlling this from JS provides a structure to support splitting the APIs themselves, so we can separate the HTTP/3 & QUIC APIs (soon: QuicStream vs Http3Stream) and make them both easier to use (e.g. no non-functional HTTP/3 methods on pure QUIC streams).
  • Dynamic attachment of the layers provides a staging point for any future work towards things like QMUX (HTTP/3 over QUIC-like APIs, not actual QUIC) where we'd need to split "application protocol" from "QUIC implementation" anyway.

How this works

This change drops the fixed Application selection & default ALPN configuration, and provides a new Http3Session API which lets JS control which Application is used directly. You wrap a QuicSession in an Http3Session and open your streams through the latter if you want to speak HTTP/3.

It does this without changing the fundamental steps involved, just by reordering the work within. There's no additional callbacks, and no extra boundary crossing anywhere. Almost everything here is deferring work we're already doing (by moving application selection to the last minute) and offering JS APIs to preconfigure the attach logic.

What happens is:

  • We no longer attach the application immediately. It can be unset for longer: until a stream is opened or datagram is sent locally, or until initial client hello processing has completed (server listen callback/client session.opened).
  • Before those points, JS can now call new Http3Session(quicSession) to use HTTP/3. This class:
    • Acts as a new wrapper class in JS, where we can put HTTP/3 specific APIs (right now this is mostly just an empty wrapper that passes through - these can diverge later).
    • Sets a flag in the shared session state, which the Session reads later when it needs an Application:
    state.applicationType = QUIC_APPLICATION_HTTP3 | QUIC_APPLICATION_PENDING
    
    • If HTTP/3 settings are provided here, they're validated JS side, then normalized & stored on the handle for C++ to read later when the attach happens.
  • Once stream creation or handshake completion happens and an application is actually needed, it calls session->EnsureApplication(). If no application has been attached yet, that checks state.applicationType. If there's a pending HTTP/3 flag, it attaches the HTTP/3 application. If not, it attaches the default raw QUIC application. Attachment itself is the same as before, it just happens later, and checks the state flags instead of ALPN.

The actual timing of the calls to EnsureApplication() are what make this practical: it runs immediately after MakeCallback fires the listen(cb) callback for server sessions, or after client.opened resolves. That guarantees a synchronous+microtasks window where the handshake information is available to JS but the application hasn't attached yet. So you can do this:

await listen((quicSession) => {
  // Look at the session and synchronously decide which protocol to attach:
  const h3Session = new Http3Session(quicSession);
  // ... do server HTTP/3 things
}, { alpn: ['h3'], ... });

const clientQuicSession = await connect(address, {
  alpn: 'h3',
  ...
});

await clientQuicSession.opened;
// Synchronously attach here after `opened` resolves:
if (clientQuicSession.alpnProtocol === 'h3') {
  const h3Session = new Http3Session(clientQuicSession);
  // ... do client HTTP/3 things
}

This is possible strictly until the MakeCallback call for this existing event returns - there's no extra tick or delay introduced.

Performance

Benchmarks on my machine show no measurable impact on either the handshake (raw or H3) or H3 request (1RTT or 0RTT) benchmarks. Varying positive/negative on different runs, but always <0.5% and no significant results.

As noted above, this doesn't change when or how we cross between JS & C++. It primarily changes when the Application attach is done, not how. The implementation isn't literally free, but doesn't do anything substantial and I don't see any real-world impact in testing.

Other related changes

  • Specifying ALPN when creating a server or client is now required - there's no default (matching node:tls), so HTTP/3 is fully opt-in.
  • QuicSession create*Stream methods check if a non-default application is attached and reject direct access if so. If you attach an Http3Session, you can read state from QuicSession, but anything that actually creates streams or drives the session should happen through Http3Session (this is slightly contrived now, since it could work, but sets a clear model and becomes more useful later).
  • The application option for QuicSessions is moved to Http3Session as settings. This exclusively supports HTTP/3 settings, and makes no sense in its current form for anything else (QUIC can't use or validate it by itself even if it wanted to).

What this PR does not do

There's many things from #63995 not included here, which I would like to do later, including:

  • It doesn't change the QuicSession API. There's various features here that should (imo) move onto Http3Session eventually (SETTINGS callbacks, GOAWAY functionality, etc) since they can never be used with a non-HTTP/3 session. Now we have two classes, in future PRs we can make them each expose only the relevant things for their own protocols, so both will get simpler & clearer.
  • It doesn't change streams: Http3Session still exposes QuicStreams, which do vary automatically depending on the Application the session is using.
  • It doesn't refactor the Application internals - currently the generic interface exposes HTTP/3 specific details, and delegates parts of those to Session. That means the session has to look up and verify the supported features before every HTTP call, store bits of HTTP-only state, and general be a bit inelegant in thinking about both protocols at the same time.
  • It doesn't add connectHttp3 or listenHttp3. We could create API methods like this which preconfigure the ALPN & automatically attach the HTTP/3 session, as a more convenient API sugar. I think the conclusion was we'd rather not and focus on primitives instead, but personally I don't feel strongly either way.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance
  • @nodejs/quic
  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Sep 11, 2026
Previously, ALPN decided automatically which application protocol
implementation was used. QuicSession was used everywhere, but its
actual behaviour and API changes implicitly based on the wire traffic
involved.

Now, QuicSession is used for pure QUIC only, and Http3Session is used
for HTTP/3 sessions only. To do HTTP/3 on a connection, you enable it
explicitly by wrapping a QUIC session in Http3Session. Doing so
attaches the internal protocol application handling so everything is
HTTP/3 on that session from that point onwards.

For now, this only changes the application selection process and the
top-level APIs involved, but none of the details. In a future PR, we
can introduce Http3Stream and migrate other HTTP/3 specific
functionality (e.g. SETTINGS & GOAWAY handling) out of the QUIC API.

Signed-off-by: Tim Perry <pimterry@gmail.com>
@jasnell

jasnell commented Sep 12, 2026

Copy link
Copy Markdown
Member

Ok, so it's good to get this isolated. Trust me, I'm not trying to be difficult! :-) ... Before going through the code let's see if we can settle on this. And yes! I can be convinced but it might take some doing.

Let's take your points:

It's useful to be able to do raw QUIC with a HTTP/3 ALPN. This lets you test weird behaviours (much like to how our test suite occasionally drives HTTP/2 over raw TCP) or use an alternative HTTP/3 implementation.

Useful in our tests does not mean useful in the general API That We Have To Support Indefinitely sense. Also, HTTP/2 is easier to do over raw TCP since it's just framing with no TLS, flow control, retransmission framing, etc.

That said, we can do raw QUIC with a HTTP/3 ALPN without this kind of architectural change. It could be as simple as a boolean configuration option that says "use the default application with h3" in which case we simply don't install the Http3Application.

Essentially, this point alone does not justify the re-architecture.

It's useful to be able to choose to do HTTP/3 with a non-HTTP/3 ALPN. For HTTP/2 this has been used quite a bit, e.g. weird internal protocols, gRPC ALPNs, etc. Even today for HTTP/3 it's been used for various HTTP/3 drafts as h3-*. It's perfectly valid to do this - if you agree on h3 via ALPN you should speak HTTP/3, but the inverse is not required.

This is also something that could just be a config option. "Treat ALPN {FOO} like H3" in which case we install the Http3Application.

Or if someone really wanted to implement all the H3 semantics themselves, see point one above... it could be as simple as a boolean configuration option. Still not a strong motivation for the re-architecture.

Controlling this from JS provides a structure to support splitting the APIs themselves, so we can separate the HTTP/3 & QUIC APIs (soon: QuicStream vs Http3Stream) and make them both easier to use (e.g. no non-functional HTTP/3 methods on pure QUIC streams).
Dynamic attachment of the layers provides a staging point for any future work towards things like QMUX (HTTP/3 over QUIC-like APIs, not actual QUIC) where we'd need to split "application protocol" from "QUIC implementation" anyway.

We can split QuicStream and Http3Stream purely at the JS level without any more C++ side redesign AND make it possible for applications that want to just handle raw QUIC but implement their own HTTP3 semantics to use Http3Stream without going with the dynamic attach path.

I'm fine with splitting things at the JS level. You'll not get much argument from me there, but I strongly prefer the existing DefaultApplication/Http3Application/QuicSession split. Sure, there are details and nits that could be refined but I think the architecture works well. I want to avoid re-architecting for the sake of re-architecting when what is there still meets the goal.

So my ask would be this: please take a moment to explain why the current architecture needs to be changed to meet the goals. What goals simply cannot be met with the current architecture or why does it make it harder? etc. What I'm not saying is that your suggested approach is wrong, not by any means... I just think there's more than one correct approach to achieve the goal and I haven't yet seen why the current approach isn't workable.

@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 70.05650% with 106 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.97%. Comparing base (c8b346e) to head (77cdc45).

Files with missing lines Patch % Lines
lib/internal/quic/http3.js 60.59% 106 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65993      +/-   ##
==========================================
- Coverage   89.98%   89.97%   -0.01%     
==========================================
  Files         784      785       +1     
  Lines      268410   268751     +341     
  Branches    51123    51135      +12     
==========================================
+ Hits       241520   241815     +295     
- Misses      17433    17484      +51     
+ Partials     9457     9452       -5     
Files with missing lines Coverage Δ
lib/internal/quic/quic.js 100.00% <100.00%> (ø)
lib/internal/quic/state.js 100.00% <100.00%> (ø)
lib/internal/quic/symbols.js 100.00% <100.00%> (ø)
src/node_builtins.cc 77.58% <ø> (+0.17%) ⬆️
lib/internal/quic/http3.js 60.59% <60.59%> (ø)

... and 33 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.

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

Labels

lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants