From 0cab3390e688bba854ccdd82eca3eb5444b56ba2 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 9 Jun 2026 23:18:12 +0200 Subject: [PATCH 01/15] quic: add support for custom app ticket data config & 0RTT validation Signed-off-by: Tim Perry --- lib/internal/quic/quic.js | 13 +++ src/quic/application.cc | 28 ++++- src/quic/application.h | 7 +- src/quic/bindingdata.h | 1 + src/quic/session.cc | 35 +++++- src/quic/session.h | 7 ++ test/parallel/test-quic-appticketdata.mjs | 134 ++++++++++++++++++++++ 7 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 test/parallel/test-quic-appticketdata.mjs diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index ac3f50ddd34b8b..1fed2bb0903491 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -421,6 +421,10 @@ const endpointRegistry = new SafeSet(); * prior session, used to resume that session (client only). * @property {ArrayBufferView} [token] An opaque address validation token * previously received from the server via `onnewtoken` (client only). + * @property {ArrayBufferView} [appTicketData] Opaque application data to embed + * in issued session tickets (server only). This is written into new tickets, + * and validated against received 0-RTT early data tickets to confirm if they + * can be accepted (anything but an exact match is rejected). * @property {bigint|number} [handshakeTimeout] The handshake timeout * @property {bigint|number} [initialRtt] The initial round-trip time estimate in milliseconds. * Used for PTO computation and initial pacing before the first RTT sample. Default uses @@ -5148,6 +5152,7 @@ function processSessionOptions(options, config = kEmptyObject) { qlog = false, sessionTicket, token, + appTicketData, maxPayloadSize, unacknowledgedPacketThreshold = 0, handshakeTimeout, @@ -5202,6 +5207,13 @@ function processSessionOptions(options, config = kEmptyObject) { } } + if (appTicketData !== undefined) { + if (!isArrayBufferView(appTicketData)) { + throw new ERR_INVALID_ARG_TYPE('options.appTicketData', + ['ArrayBufferView'], appTicketData); + } + } + if (cc !== undefined) { validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } @@ -5284,6 +5296,7 @@ function processSessionOptions(options, config = kEmptyObject) { maxWindow, sessionTicket, token, + appTicketData, cc, datagramDropPolicy, drainingPeriodMultiplier, diff --git a/src/quic/application.cc b/src/quic/application.cc index ce5d5e12154d8a..e0a7ea0678accc 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -225,8 +225,13 @@ std::optional Session::Application::ParseTicketData( auto app_type = static_cast(reinterpret_cast(data.base)[0]); switch (app_type) { - case Type::DEFAULT: - return DefaultTicketData{}; + case Type::DEFAULT: { + // Everything after the leading type byte is opaque application data. + DefaultTicketData dtd; + const auto* p = reinterpret_cast(data.base); + if (data.len > 1) dtd.data.assign(p + 1, p + data.len); + return dtd; + } case Type::HTTP3: return ParseHttp3TicketData(data); default: @@ -734,6 +739,25 @@ class DefaultApplication final : public Session::Application { } } + void CollectSessionTicketAppData( + SessionTicket::AppData* app_data) const override { + const auto& atd = session().config().options.app_ticket_data; + if (!atd.has_value() || atd->length() == 0) { + // No app data configured — write just the type byte (base behaviour). + Session::Application::CollectSessionTicketAppData(app_data); + return; + } + // Layout: [type byte][opaque app data]. + uv_buf_t bytes = *atd; + std::vector buf; + buf.reserve(1 + bytes.len); + buf.push_back(static_cast(type())); // Type::DEFAULT + const auto* p = reinterpret_cast(bytes.base); + buf.insert(buf.end(), p, p + bytes.len); + app_data->Set( + uv_buf_init(reinterpret_cast(buf.data()), buf.size())); + } + bool ApplySessionTicketData(const PendingTicketAppData& data) override { return std::holds_alternative(data); } diff --git a/src/quic/application.h b/src/quic/application.h index 0df9b9f0a0e68d..cb5165cd11b028 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -4,6 +4,7 @@ #include #include +#include #include "base_object.h" #include "bindingdata.h" @@ -17,7 +18,11 @@ namespace node::quic { // Parsed session ticket application data, produced by // Application::ParseTicketData() before ALPN negotiation and consumed // by Application::ApplySessionTicketData() after. -struct DefaultTicketData {}; +struct DefaultTicketData { + // The opaque application data carried in the ticket (after the type byte), + // byte-matched against the server's current `app_ticket_data` to gate 0-RTT. + std::vector data; +}; struct Http3TicketData { uint64_t max_field_section_size; uint64_t qpack_max_dtable_capacity; diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 7879220e02b482..4184cf20ea5328 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -72,6 +72,7 @@ class SessionManager; V(active_connection_id_limit, "activeConnectionIDLimit") \ V(address_lru_size, "addressLRUSize") \ V(allow, "allow") \ + V(app_ticket_data, "appTicketData") \ V(application, "application") \ V(authoritative, "authoritative") \ V(bbr, "bbr") \ diff --git a/src/quic/session.cc b/src/quic/session.cc index 8380e477c01e80..7924581396d731 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -710,6 +710,18 @@ Maybe Session::Options::From(Environment* env, } } + // Parse the optional opaque application data to embed in session tickets. + Local app_ticket_data_val; + if (params->Get(env->context(), state.app_ticket_data_string()) + .ToLocal(&app_ticket_data_val) && + app_ticket_data_val->IsArrayBufferView()) { + Store app_ticket_data_store; + if (Store::From(app_ticket_data_val.As()) + .To(&app_ticket_data_store)) { + options.app_ticket_data = std::move(app_ticket_data_store); + } + } + return Just(options); } @@ -2880,15 +2892,26 @@ SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( if (!parsed.has_value()) { return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - // Pre-validate the ticket data against the current application options. - // If the stored settings are more permissive than the current config - // (e.g., a feature was enabled when the ticket was issued but is now - // disabled), reject the ticket so 0-RTT is not used. This must happen + // Pre-validate the ticket data against the current configuration. If it + // does not match, reject the ticket so 0-RTT is not used. This must happen // here (during TLS ticket processing) rather than in SetApplication, // because by SetApplication time the TLS layer has already accepted // the ticket and told the client 0-RTT is ok. - if (!Application::ValidateTicketData(*parsed, - config().options.application_options)) { + if (std::holds_alternative(*parsed)) { + // Generic opaque app-data (raw QUIC / non-h3): byte-match the stored + // bytes against the server's currently-configured `app_ticket_data`. + const auto& dtd = std::get(*parsed); + const auto& atd = config().options.app_ticket_data; + uv_buf_t cur = + atd.has_value() ? static_cast(*atd) : uv_buf_init(nullptr, 0); + if (dtd.data.size() != cur.len || + (cur.len > 0 && memcmp(dtd.data.data(), cur.base, cur.len) != 0)) { + Debug(this, "Session ticket app data does not match configured value"); + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + } else if (!Application::ValidateTicketData( + *parsed, config().options.application_options)) { + // Typed app-data (HTTP/3): the application's own asymmetric validation. Debug(this, "Session ticket app data incompatible with current settings"); return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } diff --git a/src/quic/session.h b/src/quic/session.h index 0caeb764ba56c8..cad0b89b523ffc 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -240,6 +240,13 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // to skip address validation. Client-side only. std::optional token; + // Opaque application data to embed in issued session tickets. On the + // server this is both written into new tickets and used to validate + // 0-RTT on resume (a resumed ticket whose app-data does not byte-match + // this value has its early data rejected). Protocol-agnostic; the + // built-in HTTP/3 application uses its own typed ticket data instead. + std::optional app_ticket_data; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(Session::Options) SET_SELF_SIZE(Options) diff --git a/test/parallel/test-quic-appticketdata.mjs b/test/parallel/test-quic-appticketdata.mjs new file mode 100644 index 00000000000000..1578d9e5167e31 --- /dev/null +++ b/test/parallel/test-quic-appticketdata.mjs @@ -0,0 +1,134 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: the generic `appTicketData` server option. Opaque application data +// configured on the server is embedded in issued session tickets and used to +// byte-match-validate 0-RTT on resume. Covers: +// - Accept: resuming against an unchanged `appTicketData` accepts 0-RTT (the +// embed + parse + byte-match round-trip works and does not break resumption). +// - Reject: resuming against a different `appTicketData` refuses 0-RTT (the +// byte-match fails) and falls back to a full 1-RTT handshake. Two endpoints +// sharing the cert + tokenSecret are used so the ticket still decrypts and +// the token validates; only the appTicketData differs. +// - Argument validation: `appTicketData` must be an ArrayBufferView. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, strictEqual, rejects } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); +const { randomBytes } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const encoder = new TextEncoder(); +const appTicketData = new Uint8Array([1, 2, 3, 4, 5]); + +// --- Accept path: appTicketData unchanged → 0-RTT accepted --- +const serverEndpoint = await listen((serverSession) => { + serverSession.onstream = async (stream) => { + try { + await bytes(stream); + stream.writer.endSync(); + await stream.closed; + } catch { /* connection winding down */ } + }; +}, { appTicketData }); + +let savedTicket; +let savedToken; +const gotTicket = Promise.withResolvers(); +const gotToken = Promise.withResolvers(); + +const cs1 = await connect(serverEndpoint.address, { + onsessionticket: mustCall((ticket) => { + savedTicket ??= ticket; + gotTicket.resolve(); + }, 2), + onnewtoken: mustCall((token) => { + savedToken ??= token; + gotToken.resolve(); + }), +}); +await cs1.opened; +await Promise.all([gotTicket.promise, gotToken.promise]); + +const s1 = await cs1.createBidirectionalStream({ body: encoder.encode('first') }); +for await (const _ of s1) { /* drain */ } // eslint-disable-line no-unused-vars +await s1.closed; +await cs1.close(); + +// Resume against the same endpoint (same appTicketData) — 0-RTT accepted. +const cs2 = await connect(serverEndpoint.address, { + sessionTicket: savedTicket, + token: savedToken, +}); +const s2 = await cs2.createBidirectionalStream({ body: encoder.encode('early') }); +s2.closed.catch(() => {}); +const info2 = await cs2.opened; +strictEqual(info2.earlyDataAttempted, true); +strictEqual(info2.earlyDataAccepted, true); + +for await (const _ of s2) { /* drain */ } // eslint-disable-line no-unused-vars +await cs2.close(); +await serverEndpoint.close(); + +// --- Reject path: a different appTicketData → 0-RTT rejected --- +{ + const tokenSecret = randomBytes(16); + const handler = (session) => { + session.onstream = async (stream) => { + try { + await bytes(stream); + stream.writer.endSync(); + await stream.closed; + } catch { /* connection winding down */ } + }; + }; + + const ep1 = await listen(handler, { + appTicketData: new Uint8Array([1, 2, 3]), + endpoint: { tokenSecret }, + }); + let ticket; + let token; + const haveTicket = Promise.withResolvers(); + const haveToken = Promise.withResolvers(); + const c1 = await connect(ep1.address, { + onsessionticket: mustCall((t) => { ticket ??= t; haveTicket.resolve(); }, 2), + onnewtoken: mustCall((t) => { token ??= t; haveToken.resolve(); }), + }); + await c1.opened; + await Promise.all([haveTicket.promise, haveToken.promise]); + const rs = await c1.createBidirectionalStream({ body: encoder.encode('a') }); + for await (const _ of rs) { /* drain */ } // eslint-disable-line no-unused-vars + await c1.close(); + await ep1.close(); + + const ep2 = await listen(handler, { + appTicketData: new Uint8Array([9, 9, 9]), + endpoint: { tokenSecret }, + }); + const c2 = await connect(ep2.address, { sessionTicket: ticket, token }); + const es = await c2.createBidirectionalStream({ body: encoder.encode('b') }); + es.closed.catch(() => {}); + const info = await c2.opened; + strictEqual(info.earlyDataAttempted, true); + strictEqual(info.earlyDataAccepted, false); + for await (const _ of es) { /* drain */ } // eslint-disable-line no-unused-vars + await c2.close(); + await ep2.close(); +} + +// --- Argument validation: appTicketData must be an ArrayBufferView --- +await rejects(connect('127.0.0.1:1', { appTicketData: 'nope' }), { + code: 'ERR_INVALID_ARG_TYPE', +}); +await rejects(listen(() => {}, { appTicketData: 123 }), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +ok(true); From a51593c01aa3717a21c0bb640d3b34fa918f6572 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 9 Jun 2026 23:52:00 +0200 Subject: [PATCH 02/15] quic: extract packet transport logic from Application to Session --- src/quic/application.cc | 442 ------------------------------------- src/quic/application.h | 56 ----- src/quic/http3.cc | 12 +- src/quic/session.cc | 476 +++++++++++++++++++++++++++++++++++++++- src/quic/session.h | 49 +++++ 5 files changed, 533 insertions(+), 502 deletions(-) diff --git a/src/quic/application.cc b/src/quic/application.cc index e0a7ea0678accc..6a761873cdb426 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -165,24 +165,6 @@ MaybeLocal Session::Application_Options::ToObject( // ============================================================================ -std::string Session::Application::StreamData::ToString() const { - DebugIndentScope indent; - - size_t total_bytes = 0; - for (size_t n = 0; n < count; n++) { - total_bytes += data[n].len; - } - - auto prefix = indent.Prefix(); - std::string res("{"); - res += prefix + "count: " + std::to_string(count); - res += prefix + "id: " + std::to_string(id); - res += prefix + "fin: " + std::to_string(fin); - res += prefix + "total: " + std::to_string(total_bytes); - res += indent.Close(); - return res; -} - Session::Application::Application(Session* session, const Options& options) : session_(session) {} @@ -259,11 +241,6 @@ bool Session::Application::ValidateTicketData( return true; } -Packet::Ptr Session::Application::CreateStreamDataPacket() { - return session_->endpoint().CreatePacket( - session_->remote_address(), session_->max_packet_size(), "stream data"); -} - void Session::Application::ReceiveStreamClose(Stream* stream, QuicError&& error) { DCHECK_NOT_NULL(stream); @@ -282,425 +259,6 @@ void Session::Application::ReceiveStreamReset(Stream* stream, stream->ReceiveStreamReset(final_size, std::move(error)); } -// Attempts to pack a pending datagram into the current packet. -// Returns the nwrite value from ngtcp2_conn_writev_datagram. -// On fatal error, closes the session and returns the error code. -// The caller should check: -// > 0: packet is complete, send it (pos was NOT advanced — caller -// must add nwrite to pos and send) -// NGTCP2_ERR_WRITE_MORE: datagram packed, room for more -// 0: congestion controlled or doesn't fit, datagram stays in queue -// < 0 (other): fatal error, session already closed -ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path, - uint8_t* dest, - size_t destlen, - uint64_t ts) { - CHECK(session_->HasPendingDatagrams()); - auto max_attempts = session_->config().options.max_datagram_send_attempts; - - // Skip datagrams that have already exceeded the send attempt limit - // from a previous SendPendingData cycle. - while (session_->HasPendingDatagrams()) { - auto& front = session_->PeekPendingDatagram(); - if (front.send_attempts < max_attempts) break; - Debug(session_, - "Datagram %" PRIu64 " abandoned after %u attempts", - front.id, - front.send_attempts); - session_->DatagramStatus(front.id, DatagramStatus::ABANDONED); - session_->PopPendingDatagram(); - } - - if (!session_->HasPendingDatagrams()) return 0; - auto& dg = session_->PeekPendingDatagram(); - ngtcp2_vec dgvec = dg.data; - int accepted = 0; - int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; - - // PacketInfo for the datagram path. When libuv gains per-socket ECN - // marking, the value from ngtcp2 should be forwarded to the send path. - PacketInfo dg_pi; - ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*session_, - &path->path, - dg_pi, - dest, - destlen, - &accepted, - dg_flags, - dg.id, - &dgvec, - 1, - ts); - - if (accepted) { - // Nice, the datagram was accepted! - Debug(session_, "Datagram %" PRIu64 " accepted into packet", dg.id); - session_->DatagramSent(dg.id); - session_->PopPendingDatagram(); - } else { - Debug(session_, "Datagram %" PRIu64 " not accepted into packet", dg.id); - } - - switch (dg_nwrite) { - case 0: { - // If dg_nwrite is 0, we are either congestion controlled or - // there wasn't enough room in the packet for the datagram or - // we aren't in a state where we can send. - // We'll skip this attempt and return 0. - CHECK(!accepted); - dg.send_attempts++; - return 0; - } - case NGTCP2_ERR_WRITE_MORE: { - // There's still room left in the packet! - return NGTCP2_ERR_WRITE_MORE; - } - case NGTCP2_ERR_INVALID_STATE: - case NGTCP2_ERR_INVALID_ARGUMENT: { - // Non-fatal error cases. Peer either does not support datagrams - // or the datagram is too large for the peer's max. - // Abandon the datagram and signal skip by returning std::nullopt. - session_->DatagramStatus(dg.id, DatagramStatus::ABANDONED); - session_->PopPendingDatagram(); - return 0; - } - default: { - // Fatal errors: PKT_NUM_EXHAUSTED, CALLBACK_FAILURE, NOMEM, etc. - Debug(session_, "Fatal datagram error: %zd", dg_nwrite); - session_->SetLastError(QuicError::ForNgtcp2Error(dg_nwrite)); - session_->Close(CloseMethod::SILENT); - return dg_nwrite; - } - } - UNREACHABLE(); -} - -// the SendPendingData method is the primary driver for sending data from the -// application layer. It loops through available stream data and pending -// datagrams and generates packets to send until there is either no more -// data to send or we hit the maximum number of packets to send in one go. -// This method is extremely delicate. A bug in this method can break the -// entire QUIC implementation; so be very careful when making changes here -// and make sure to test thoroughly. When in doubt... don't change it. -void Session::Application::SendPendingData() { - DCHECK(!session().is_destroyed()); - if (!session().can_send_packets()) [[unlikely]] { - return; - } - // Upper bound on packets per SendPendingData call. ngtcp2's send quantum - // is typically 64 KB, which at 1200-byte minimum packet size is ~53 - // packets. 64 covers the worst case with headroom. The actual count per - // call is dynamically capped by ngtcp2_conn_get_send_quantum(). - static constexpr size_t kMaxPackets = 64; - Debug(session_, "Application sending pending data"); - // Cache the timestamp once for the entire send loop. ngtcp2 does not - // require nanosecond-accurate monotonicity within a single burst — - // a single timestamp per SendPendingData call is what other QUIC - // implementations use (e.g., quiche, msquic). When kernel-level - // packet pacing becomes available via libuv, this timestamp becomes - // the base for computing per-packet transmit timestamps. - const uint64_t ts = uv_hrtime(); - PathStorage path; - StreamData stream_data; - - bool closed = false; - - // Batch accumulation: packets are collected here and flushed via - // Session::SendBatch when the loop exits, the batch is full, or - // on early return. This enables synchronous batched delivery via - // uv_udp_try_send2 (sendmmsg) from the deferred flush path. - Packet::Ptr batch[kMaxPackets]; - PathStorage batch_paths[kMaxPackets]; - size_t batch_count = 0; - - auto flush_batch = [&] { - if (batch_count == 0) return; - session_->SendBatch(batch, batch_paths, batch_count); - batch_count = 0; - }; - - auto update_stats = OnScopeLeave([&] { - if (closed) return; - // Flush any remaining accumulated packets before updating stats. - flush_batch(); - if (session().is_destroyed()) [[unlikely]] - return; - - // Get a strong pointer to protect against potential destruction during - // updating the time and data stats. - BaseObjectPtr s(session_); - s->UpdatePacketTxTime(); - s->UpdateTimer(); - s->UpdateDataStats(); - }); - - // The maximum size of packet to create. - const size_t max_packet_size = session_->max_packet_size(); - - // The maximum number of packets to send in this call to SendPendingData. - const size_t max_packet_count = std::min( - kMaxPackets, ngtcp2_conn_get_send_quantum(*session_) / max_packet_size); - if (max_packet_count == 0) return; - - // The number of packets that have been prepared in this call. - size_t packet_send_count = 0; - - Packet::Ptr packet; - - auto ensure_packet = [&] { - if (!packet) { - packet = CreateStreamDataPacket(); - if (!packet) [[unlikely]] - return false; - } - DCHECK(packet); - return true; - }; - - // Accumulate a completed packet into the batch. - auto enqueue_packet = - [&](Packet::Ptr& pkt, size_t len, const PacketInfo& pi) { - Debug(session_, "Enqueuing packet with %zu bytes into batch", len); - pkt->Truncate(len); - pkt->set_pkt_info(pi); - path.CopyTo(&batch_paths[batch_count]); - batch[batch_count++] = std::move(pkt); - }; - - // We're going to enter a loop here to prepare and send no more than - // max_packet_count packets. - for (;;) { - // ndatalen is the amount of stream data that was accepted into the packet. - ssize_t ndatalen = 0; - - // Make sure we have a packet to write data into. - if (!ensure_packet()) [[unlikely]] { - Debug(session_, "Failed to create packet for stream data"); - // Doh! Could not create a packet. Time to bail. - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // The stream_data is the next block of data from the application stream. - if (GetStreamData(&stream_data) < 0) { - Debug(session_, "Application failed to get stream data"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // If we got here, we were at least successful in checking for stream data. - // There might not be any stream data to send. If there is no stream data, - // that's perfectly fine, we still need to serialize any frames we do have - // (pings, acks, datagrams, etc) so we'll just keep going. - if (stream_data.id >= 0) { - Debug(session_, "Application using stream data: %s", stream_data); - } else { - Debug(session_, "No stream data to send"); - } - if (session_->HasPendingDatagrams()) { - Debug(session_, "There are pending datagrams to send"); - } - - // Awesome, let's write our packet! - PacketInfo pi; - ssize_t nwrite = WriteVStream(&path, - &pi, - packet->data(), - &ndatalen, - packet->length(), - stream_data, - ts); - - // When ndatalen is > 0, that's our indication that stream data was accepted - // in to the packet. Yay! - if (ndatalen > 0) { - Debug(session_, - "Application accepted %zu bytes from stream %" PRIi64 - " into packet", - ndatalen, - stream_data.id); - if (!StreamCommit(&stream_data, ndatalen)) { - // Data was accepted into the packet, but for some reason adjusting - // the stream's committed data failed. Treat as fatal. - Debug(session_, "Failed to commit accepted bytes in stream"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - } else if (stream_data.id >= 0) { - Debug(session_, - "Application did not accept any bytes from stream %" PRIi64 - " into packet", - stream_data.id); - } - - // When nwrite is zero, it means we are congestion limited or it is - // just not our turn to send something. Re-schedule the stream if it - // had unsent data (payload or FIN) so the next timer-triggered - // SendPendingData retries it. Without this, a FIN-only send that - // hits nwrite=0 is lost forever — the stream already returned EOS - // from Pull and won't be re-scheduled by anyone else. - // We call Application::ResumeStream directly (not Session::ResumeStream) - // to avoid creating a SendPendingDataScope — we're already inside - // SendPendingData and re-entering would just hit nwrite=0 again. - if (nwrite == 0) { - Debug(session_, "Congestion or not our turn to send"); - if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) { - ResumeStream(stream_data.id); - } - return; - } - - // A negative nwrite value indicates either an error or that there is more - // data to write into the packet. - if (nwrite < 0) { - switch (nwrite) { - case NGTCP2_ERR_STREAM_DATA_BLOCKED: { - // We could not write any data for this stream into the packet because - // the flow control for the stream itself indicates that the stream - // is blocked. We'll skip and move on to the next stream. - // ndatalen = -1 means that no stream data was accepted into the - // packet, which is what we want here. - DCHECK_EQ(ndatalen, -1); - // We should only have received this error if there was an actual - // stream identified in the stream data, but let's double check. - DCHECK_GE(stream_data.id, 0); - session_->StreamDataBlocked(stream_data.id); - continue; - } - case NGTCP2_ERR_STREAM_SHUT_WR: { - // Indicates that the writable side of the stream should be closed - // locally or the stream is being reset. In either case, we can't send - // data for this stream! - Debug(session_, - "Closing stream %" PRIi64 " for writing", - stream_data.id); - // ndatalen = -1 means that no stream data was accepted into the - // packet, which is what we want here. - DCHECK_EQ(ndatalen, -1); - // We should only have received this error if there was an actual - // stream identified in the stream data, but let's double check. - DCHECK_GE(stream_data.id, 0); - if (stream_data.stream) [[likely]] { - stream_data.stream->EndWritable(); - } - // Notify the application that the stream's write side is shut - // so it stops queuing data. Without this, GetStreamData would - // keep returning the same stream and we'd loop forever. - StreamWriteShut(stream_data.id); - continue; - } - case NGTCP2_ERR_WRITE_MORE: { - Debug(session_, "Packet buffer not full, coalesce more data into it"); - // Room for more in this packet. Try to pack a pending datagram - // if there is one. Otherwise just loop around and keep going. - if (session_->HasPendingDatagrams()) { - auto result = TryWritePendingDatagram( - &path, packet->data(), packet->length(), ts); - // When result is 0, either the datagram was congestion controlled, - // didn't fit in the packet, or was abandoned. Skip and continue. - - // When result is > 0, the packet is done and the result is the - // completed size of the packet we're sending. - if (result > 0) { - size_t len = result; - Debug(session_, "Sending packet with %zu bytes", len); - enqueue_packet(packet, len, pi); - if (++packet_send_count == max_packet_count) return; - } else if (result < 0) { - // Any negative result other than NGTCP2_ERR_WRITE_MORE - // at this point is fatal. The session will have been - // closed. - if (result != NGTCP2_ERR_WRITE_MORE) return; - } - } - continue; - } - case NGTCP2_ERR_CALLBACK_FAILURE: { - // This case really should not happen. It indicates that the - // ngtcp2 callback failed for some reason. This would be a - // bug in our code. - Debug(session_, "Internal failure with ngtcp2 callback"); - session_->SetLastError( - QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - } - - // Some other type of error happened. - DCHECK_EQ(ndatalen, -1); - Debug(session_, - "Application encountered error while writing packet: %s", - ngtcp2_strerror(nwrite)); - session_->SetLastError(QuicError::ForNgtcp2Error(nwrite)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // At this point we have a packet prepared to send. The nwrite - // is the size of the packet we are sending. - size_t len = nwrite; - Debug(session_, "Sending packet with %zu bytes", len); - enqueue_packet(packet, len, pi); - if (++packet_send_count == max_packet_count) return; - - // If there are pending datagrams, try sending them in a fresh packet. - // This is necessary because ngtcp2_conn_writev_stream only returns - // NGTCP2_ERR_WRITE_MORE when there is actual stream data — when no - // streams are active, the coalescing path above is never reached and - // datagrams would never be sent. - if (session_->HasPendingDatagrams()) { - if (!ensure_packet()) [[unlikely]] { - Debug(session_, "Failed to create packet for datagram"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - auto result = - TryWritePendingDatagram(&path, packet->data(), packet->length(), ts); - if (result > 0) { - Debug(session_, "Sending datagram packet with %zd bytes", result); - enqueue_packet(packet, static_cast(result), PacketInfo()); - if (++packet_send_count == max_packet_count) return; - } else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) { - // Fatal error — session already closed by TryWritePendingDatagram. - return; - } - // If result == 0 (congestion) or NGTCP2_ERR_WRITE_MORE (datagram - // packed but room for more), the loop continues normally. - } - } -} - -ssize_t Session::Application::WriteVStream(PathStorage* path, - PacketInfo* pi, - uint8_t* dest, - ssize_t* ndatalen, - size_t max_packet_size, - const StreamData& stream_data, - uint64_t ts) { - DCHECK_LE(stream_data.count, kMaxVectorCount); - uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; - if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; - // The PacketInfo out-param is populated by ngtcp2 with the ECN codepoint - // to apply when sending this packet. When libuv gains per-socket ECN - // marking, the value should be forwarded to the send path. - return ngtcp2_conn_writev_stream(*session_, - &path->path, - *pi, - dest, - max_packet_size, - ndatalen, - flags, - stream_data.id, - stream_data, - stream_data.count, - ts); -} - // ============================================================================ // The DefaultApplication is the default implementation of Session::Application // that is used for all unrecognized ALPN identifiers. diff --git a/src/quic/application.h b/src/quic/application.h index cb5165cd11b028..5306cb1b67d7b6 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -210,10 +210,6 @@ class Session::Application : public MemoryRetainer { return false; } - // Signals to the Application that it should serialize and transmit any - // pending session and stream packets it has accumulated. - void SendPendingData(); - // Returns true if the application protocol supports sending and // receiving headers on streams (e.g. HTTP/3). Applications that // do not support headers should return false (the default). @@ -248,10 +244,6 @@ class Session::Application : public MemoryRetainer { return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; } - // The StreamData struct is used by the application to pass pending stream - // data to the session for transmission. - struct StreamData; - virtual int GetStreamData(StreamData* data) = 0; virtual bool StreamCommit(StreamData* data, size_t datalen) = 0; @@ -267,57 +259,9 @@ class Session::Application : public MemoryRetainer { } private: - Packet::Ptr CreateStreamDataPacket(); - - // Tries to pack a pending datagram into the current packet buffer. - // If < 0 is returned, either NGTCP2_ERR_WRITE_MORE or a fatal error is - // returned; the caller must check. If > 0 is returned, the packet is done - // and the value is the size of the finalized packet. If 0 is returned, - // the datagram is either congestion limited or was abandoned - ssize_t TryWritePendingDatagram(PathStorage* path, - uint8_t* dest, - size_t destlen, - uint64_t ts); - - // Write the given stream_data into the buffer. The PacketInfo out-param - // is populated by ngtcp2 with per-packet metadata (e.g., ECN codepoint) - // that should be applied when sending the packet. - ssize_t WriteVStream(PathStorage* path, - PacketInfo* pi, - uint8_t* buf, - ssize_t* ndatalen, - size_t max_packet_size, - const StreamData& stream_data, - uint64_t ts); - Session* session_ = nullptr; }; -struct Session::Application::StreamData final { - // The actual number of vectors in the struct, up to kMaxVectorCount. - size_t count = 0; - // The stream identifier. If this is a negative value then no stream is - // identified. - stream_id id = -1; - int fin = 0; - ngtcp2_vec data[kMaxVectorCount]{}; - BaseObjectPtr stream; - - static_assert(sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && - alignof(ngtcp2_vec) == alignof(nghttp3_vec) && - offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && - offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), - "ngtcp2_vec and nghttp3_vec must have identical layout"); - inline operator nghttp3_vec*() { - return reinterpret_cast(data); - } - - inline operator const ngtcp2_vec*() const { return data; } - inline operator ngtcp2_vec*() { return data; } - - std::string ToString() const; -}; - // Create a DefaultApplication for the given session. std::unique_ptr CreateDefaultApplication( Session* session, const Session::Application_Options& options); diff --git a/src/quic/http3.cc b/src/quic/http3.cc index bc479f96990577..c077716b7dc0f4 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -688,12 +688,22 @@ class Http3ApplicationImpl final : public Session::Application { } int GetStreamData(StreamData* data) override { + static_assert( + sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && + alignof(ngtcp2_vec) == alignof(nghttp3_vec) && + offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && + offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), + "ngtcp2_vec and nghttp3_vec must have identical layout"); data->count = kMaxVectorCount; ssize_t ret = 0; Debug(&session(), "HTTP/3 application getting stream data"); if (conn_ && session().max_data_left()) { ret = nghttp3_conn_writev_stream( - *this, &data->id, &data->fin, *data, data->count); + *this, + &data->id, + &data->fin, + reinterpret_cast(data->data), + data->count); // A negative return value indicates an error. if (ret < 0) { return static_cast(ret); diff --git a/src/quic/session.cc b/src/quic/session.cc index 7924581396d731..9913c6c9877e53 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -1742,10 +1742,480 @@ Session::SendPendingDataScope::~SendPendingDataScope() { Debug(session, "Send Scope Depth %zu", session->impl_->send_scope_depth_); if (--session->impl_->send_scope_depth_ == 0 && session->impl_->application_ && !session->impl_->handshake_deferred_) { - session->application().SendPendingData(); + session->SendPendingData(); } } +// ============================================================================ + +std::string StreamData::ToString() const { + DebugIndentScope indent; + + size_t total_bytes = 0; + for (size_t n = 0; n < count; n++) { + total_bytes += data[n].len; + } + + auto prefix = indent.Prefix(); + std::string res("{"); + res += prefix + "count: " + std::to_string(count); + res += prefix + "id: " + std::to_string(id); + res += prefix + "fin: " + std::to_string(fin); + res += prefix + "total: " + std::to_string(total_bytes); + res += indent.Close(); + return res; +} + +Packet::Ptr Session::CreateStreamDataPacket() { + return endpoint().CreatePacket( + remote_address(), max_packet_size(), "stream data"); +} + +// Attempts to pack a pending datagram into the current packet. +// Returns the nwrite value from ngtcp2_conn_writev_datagram. +// On fatal error, closes the session and returns the error code. +// The caller should check: +// > 0: packet is complete, send it (pos was NOT advanced — caller +// must add nwrite to pos and send) +// NGTCP2_ERR_WRITE_MORE: datagram packed, room for more +// 0: congestion controlled or doesn't fit, datagram stays in queue +// < 0 (other): fatal error, session already closed +ssize_t Session::TryWritePendingDatagram(PathStorage* path, + uint8_t* dest, + size_t destlen, + uint64_t ts) { + CHECK(HasPendingDatagrams()); + auto max_attempts = config().options.max_datagram_send_attempts; + + // Skip datagrams that have already exceeded the send attempt limit + // from a previous SendPendingData cycle. + while (HasPendingDatagrams()) { + auto& front = PeekPendingDatagram(); + if (front.send_attempts < max_attempts) break; + Debug(this, + "Datagram %" PRIu64 " abandoned after %u attempts", + front.id, + front.send_attempts); + DatagramStatus(front.id, DatagramStatus::ABANDONED); + PopPendingDatagram(); + } + + if (!HasPendingDatagrams()) return 0; + auto& dg = PeekPendingDatagram(); + ngtcp2_vec dgvec = dg.data; + int accepted = 0; + int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; + + // PacketInfo for the datagram path. When libuv gains per-socket ECN + // marking, the value from ngtcp2 should be forwarded to the send path. + PacketInfo dg_pi; + ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*this, + &path->path, + dg_pi, + dest, + destlen, + &accepted, + dg_flags, + dg.id, + &dgvec, + 1, + ts); + + if (accepted) { + // Nice, the datagram was accepted! + Debug(this, "Datagram %" PRIu64 " accepted into packet", dg.id); + DatagramSent(dg.id); + PopPendingDatagram(); + } else { + Debug(this, "Datagram %" PRIu64 " not accepted into packet", dg.id); + } + + switch (dg_nwrite) { + case 0: { + // If dg_nwrite is 0, we are either congestion controlled or + // there wasn't enough room in the packet for the datagram or + // we aren't in a state where we can send. + // We'll skip this attempt and return 0. + CHECK(!accepted); + dg.send_attempts++; + return 0; + } + case NGTCP2_ERR_WRITE_MORE: { + // There's still room left in the packet! + return NGTCP2_ERR_WRITE_MORE; + } + case NGTCP2_ERR_INVALID_STATE: + case NGTCP2_ERR_INVALID_ARGUMENT: { + // Non-fatal error cases. Peer either does not support datagrams + // or the datagram is too large for the peer's max. + // Abandon the datagram and signal skip by returning std::nullopt. + DatagramStatus(dg.id, DatagramStatus::ABANDONED); + PopPendingDatagram(); + return 0; + } + default: { + // Fatal errors: PKT_NUM_EXHAUSTED, CALLBACK_FAILURE, NOMEM, etc. + Debug(this, "Fatal datagram error: %zd", dg_nwrite); + SetLastError(QuicError::ForNgtcp2Error(dg_nwrite)); + Close(CloseMethod::SILENT); + return dg_nwrite; + } + } + UNREACHABLE(); +} + +// the SendPendingData method is the primary driver for sending data from the +// application layer. It loops through available stream data and pending +// datagrams and generates packets to send until there is either no more +// data to send or we hit the maximum number of packets to send in one go. +// This method is extremely delicate. A bug in this method can break the +// entire QUIC implementation; so be very careful when making changes here +// and make sure to test thoroughly. When in doubt... don't change it. +void Session::SendPendingData() { + DCHECK(!is_destroyed()); + if (!can_send_packets()) [[unlikely]] { + return; + } + // Upper bound on packets per SendPendingData call. ngtcp2's send quantum + // is typically 64 KB, which at 1200-byte minimum packet size is ~53 + // packets. 64 covers the worst case with headroom. The actual count per + // call is dynamically capped by ngtcp2_conn_get_send_quantum(). + static constexpr size_t kMaxPackets = 64; + Debug(this, "Session sending pending data"); + // Cache the timestamp once for the entire send loop. ngtcp2 does not + // require nanosecond-accurate monotonicity within a single burst — + // a single timestamp per SendPendingData call is what other QUIC + // implementations use (e.g., quiche, msquic). When kernel-level + // packet pacing becomes available via libuv, this timestamp becomes + // the base for computing per-packet transmit timestamps. + const uint64_t ts = uv_hrtime(); + PathStorage path; + StreamData stream_data; + + bool closed = false; + + // Batch accumulation: packets are collected here and flushed via + // Session::SendBatch when the loop exits, the batch is full, or + // on early return. This enables synchronous batched delivery via + // uv_udp_try_send2 (sendmmsg) from the deferred flush path. + Packet::Ptr batch[kMaxPackets]; + PathStorage batch_paths[kMaxPackets]; + size_t batch_count = 0; + + auto flush_batch = [&] { + if (batch_count == 0) return; + SendBatch(batch, batch_paths, batch_count); + batch_count = 0; + }; + + auto update_stats = OnScopeLeave([&] { + if (closed) return; + // Flush any remaining accumulated packets before updating stats. + flush_batch(); + if (is_destroyed()) [[unlikely]] + return; + + // Get a strong pointer to protect against potential destruction during + // updating the time and data stats. + BaseObjectPtr s(this); + s->UpdatePacketTxTime(); + s->UpdateTimer(); + s->UpdateDataStats(); + }); + + // The maximum size of packet to create. + const size_t max_pktlen = max_packet_size(); + + // The maximum number of packets to send in this call to SendPendingData. + const size_t max_packet_count = + std::min(kMaxPackets, ngtcp2_conn_get_send_quantum(*this) / max_pktlen); + if (max_packet_count == 0) return; + + // The number of packets that have been prepared in this call. + size_t packet_send_count = 0; + + Packet::Ptr packet; + + auto ensure_packet = [&] { + if (!packet) { + packet = CreateStreamDataPacket(); + if (!packet) [[unlikely]] + return false; + } + DCHECK(packet); + return true; + }; + + // Accumulate a completed packet into the batch. + auto enqueue_packet = + [&](Packet::Ptr& pkt, size_t len, const PacketInfo& pi) { + Debug(this, "Enqueuing packet with %zu bytes into batch", len); + pkt->Truncate(len); + pkt->set_pkt_info(pi); + path.CopyTo(&batch_paths[batch_count]); + batch[batch_count++] = std::move(pkt); + }; + + // We're going to enter a loop here to prepare and send no more than + // max_packet_count packets. + for (;;) { + // ndatalen is the amount of stream data that was accepted into the packet. + ssize_t ndatalen = 0; + + // Make sure we have a packet to write data into. + if (!ensure_packet()) [[unlikely]] { + Debug(this, "Failed to create packet for stream data"); + // Doh! Could not create a packet. Time to bail. + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // The stream_data is the next block of data from the application stream. + if (application().GetStreamData(&stream_data) < 0) { + Debug(this, "Application failed to get stream data"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // If we got here, we were at least successful in checking for stream data. + // There might not be any stream data to send. If there is no stream data, + // that's perfectly fine, we still need to serialize any frames we do have + // (pings, acks, datagrams, etc) so we'll just keep going. + if (stream_data.id >= 0) { + Debug(this, "Session using stream data: %s", stream_data); + } else { + Debug(this, "No stream data to send"); + } + if (HasPendingDatagrams()) { + Debug(this, "There are pending datagrams to send"); + } + + // Awesome, let's write our packet! + PacketInfo pi; + ssize_t nwrite = WriteVStream(&path, + &pi, + packet->data(), + &ndatalen, + packet->length(), + stream_data, + ts); + + // When ndatalen is > 0, that's our indication that stream data was + // accepted in to the packet. Yay! + if (ndatalen > 0) { + Debug(this, + "Session accepted %zu bytes from stream %" PRIi64 " into packet", + ndatalen, + stream_data.id); + if (!application().StreamCommit(&stream_data, ndatalen)) { + // Data was accepted into the packet, but for some reason adjusting + // the stream's committed data failed. Treat as fatal. + Debug(this, "Failed to commit accepted bytes in stream"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + // StreamCommit can call into JS, so a user callback could + // have destroyed the session: + if (is_destroyed()) [[unlikely]] { + closed = true; + return; + } + } else if (stream_data.id >= 0) { + Debug(this, + "Session did not accept any bytes from stream %" PRIi64 + " into packet", + stream_data.id); + } + + // When nwrite is zero, it means we are congestion limited or it is + // just not our turn to send something. Re-schedule the stream if it + // had unsent data (payload or FIN) so the next timer-triggered + // SendPendingData retries it. Without this, a FIN-only send that + // hits nwrite=0 is lost forever — the stream already returned EOS + // from Pull and won't be re-scheduled by anyone else. + // We call Application::ResumeStream directly (not Session::ResumeStream) + // to avoid creating a SendPendingDataScope — we're already inside + // SendPendingData and re-entering would just hit nwrite=0 again. + if (nwrite == 0) { + Debug(this, "Congestion or not our turn to send"); + if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) { + application().ResumeStream(stream_data.id); + } + + // nwrite == 0 also occurs on an otherwise-idle connection (no + // stream data and no frames due) and writev_stream never covers + // DATAGRAM frames: a datagram queued while the connection is idle + // still needs its own packet. TryWritePendingDatagram applies + // congestion control itself (result 0 when limited), so genuine + // congestion still ends the loop below. + if (HasPendingDatagrams()) { + auto result = TryWritePendingDatagram( + &path, packet->data(), packet->length(), ts); + if (result > 0) { + Debug(this, "Sending datagram packet with %zd bytes", result); + enqueue_packet(packet, static_cast(result), PacketInfo()); + if (++packet_send_count == max_packet_count) return; + continue; + } + if (result == NGTCP2_ERR_WRITE_MORE) continue; + if (result < 0) { + // Fatal error — session already closed + return; + } + // result == 0: congestion limited; fall through and return. + } + + return; + } + + // A negative nwrite value indicates either an error or that there is more + // data to write into the packet. + if (nwrite < 0) { + switch (nwrite) { + case NGTCP2_ERR_STREAM_DATA_BLOCKED: { + // We could not write any data for this stream into the packet + // because the flow control for the stream itself indicates that + // the stream is blocked. We'll skip and move on to the next stream. + // ndatalen = -1 means that no stream data was accepted into the + // packet, which is what we want here. + DCHECK_EQ(ndatalen, -1); + // We should only have received this error if there was an actual + // stream identified in the stream data, but let's double check. + DCHECK_GE(stream_data.id, 0); + StreamDataBlocked(stream_data.id); + continue; + } + case NGTCP2_ERR_STREAM_SHUT_WR: { + // Indicates that the writable side of the stream should be closed + // locally or the stream is being reset. In either case, we can't + // send data for this stream! + Debug(this, "Closing stream %" PRIi64 " for writing", stream_data.id); + // ndatalen = -1 means that no stream data was accepted into the + // packet, which is what we want here. + DCHECK_EQ(ndatalen, -1); + // We should only have received this error if there was an actual + // stream identified in the stream data, but let's double check. + DCHECK_GE(stream_data.id, 0); + if (stream_data.stream) [[likely]] { + stream_data.stream->EndWritable(); + } + // Notify the application that the stream's write side is shut + // so it stops queuing data. Without this, GetStreamData would + // keep returning the same stream and we'd loop forever. + application().StreamWriteShut(stream_data.id); + continue; + } + case NGTCP2_ERR_WRITE_MORE: { + Debug(this, "Packet buffer not full, coalesce more data into it"); + // Room for more in this packet. Try to pack a pending datagram + // if there is one. Otherwise just loop around and keep going. + if (HasPendingDatagrams()) { + auto result = TryWritePendingDatagram( + &path, packet->data(), packet->length(), ts); + // When result is 0, either the datagram was congestion controlled, + // didn't fit in the packet, or was abandoned. Skip and continue. + + // When result is > 0, the packet is done and the result is the + // completed size of the packet we're sending. + if (result > 0) { + size_t len = result; + Debug(this, "Sending packet with %zu bytes", len); + enqueue_packet(packet, len, pi); + if (++packet_send_count == max_packet_count) return; + } else if (result < 0) { + // Any negative result other than NGTCP2_ERR_WRITE_MORE + // at this point is fatal. The session will have been + // closed. + if (result != NGTCP2_ERR_WRITE_MORE) return; + } + } + continue; + } + case NGTCP2_ERR_CALLBACK_FAILURE: { + // This case really should not happen. It indicates that the + // ngtcp2 callback failed for some reason. This would be a + // bug in our code. + Debug(this, "Internal failure with ngtcp2 callback"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + } + + // Some other type of error happened. + DCHECK_EQ(ndatalen, -1); + Debug(this, + "Session encountered error while writing packet: %s", + ngtcp2_strerror(nwrite)); + SetLastError(QuicError::ForNgtcp2Error(nwrite)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // At this point we have a packet prepared to send. The nwrite + // is the size of the packet we are sending. + size_t len = nwrite; + Debug(this, "Sending packet with %zu bytes", len); + enqueue_packet(packet, len, pi); + if (++packet_send_count == max_packet_count) return; + + // If there are pending datagrams, try sending them in a fresh packet. + // This is necessary because ngtcp2_conn_writev_stream only returns + // NGTCP2_ERR_WRITE_MORE when there is actual stream data — when no + // streams are active, the coalescing path above is never reached and + // datagrams would never be sent. + if (HasPendingDatagrams()) { + if (!ensure_packet()) [[unlikely]] { + Debug(this, "Failed to create packet for datagram"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + auto result = + TryWritePendingDatagram(&path, packet->data(), packet->length(), ts); + if (result > 0) { + Debug(this, "Sending datagram packet with %zd bytes", result); + enqueue_packet(packet, static_cast(result), PacketInfo()); + if (++packet_send_count == max_packet_count) return; + } else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) { + // Fatal error — session already closed by TryWritePendingDatagram. + return; + } + // If result == 0 (congestion) or NGTCP2_ERR_WRITE_MORE (datagram + // packed but room for more), the loop continues normally. + } + } +} + +ssize_t Session::WriteVStream(PathStorage* path, + PacketInfo* pi, + uint8_t* dest, + ssize_t* ndatalen, + size_t max_pktlen, + const StreamData& stream_data, + uint64_t ts) { + DCHECK_LE(stream_data.count, kMaxVectorCount); + uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; + if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + // The PacketInfo out-param is populated by ngtcp2 with the ECN codepoint + // to apply when sending this packet. When libuv gains per-socket ECN + // marking, the value should be forwarded to the send path. + return ngtcp2_conn_writev_stream(*this, + &path->path, + *pi, + dest, + max_pktlen, + ndatalen, + flags, + stream_data.id, + stream_data, + stream_data.count, + ts); +} + // ============================================================================ BaseObjectPtr Session::Create( Endpoint* endpoint, @@ -2481,7 +2951,7 @@ void Session::FlushPendingData() { // Prefer synchronous sends during the deferred flush to avoid the // one-tick latency of async uv_udp_send from the uv_check callback. flags_.prefer_try_send = true; - application().SendPendingData(); + SendPendingData(); flags_.prefer_try_send = false; } } @@ -3180,7 +3650,7 @@ void Session::OnTimeout() { // which can synchronously destroy the session. Guard before proceeding. if (is_destroyed()) return; if (NGTCP2_OK(ret) && !is_in_closing_period() && !is_in_draining_period()) { - application().SendPendingData(); + SendPendingData(); if (is_destroyed()) return; CheckStreamIdleTimeout(uv_hrtime()); return; diff --git a/src/quic/session.h b/src/quic/session.h index cad0b89b523ffc..2e4ae55777ed09 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -27,6 +27,25 @@ namespace node::quic { class Endpoint; +// A block of pending outbound stream data, passed between the application +// layer (which fills it via GetStreamData) and the send pump (which hands +// it to ngtcp2_conn_writev_stream and commits the accepted length). +struct StreamData final { + // The actual number of vectors in the struct, up to kMaxVectorCount. + size_t count = 0; + // The stream identifier. If this is a negative value then no stream is + // identified. + stream_id id = -1; + int fin = 0; + ngtcp2_vec data[kMaxVectorCount]{}; + BaseObjectPtr stream; + + inline operator const ngtcp2_vec*() const { return data; } + inline operator ngtcp2_vec*() { return data; } + + std::string ToString() const; +}; + // A Session represents one half of a persistent connection between two QUIC // peers. Every Session is established first by performing a TLS handshake in // which the client sends an initial packet to the server containing a TLS @@ -424,6 +443,36 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // bindingdata.cc doesn't need the full Application type definition. void FlushPendingData(); + // The send pump: the primary driver for serializing outbound data. + // Loops through available stream data (pulled from the application + // layer via GetStreamData/StreamCommit) and pending datagrams, + // generating packets until there is no more data to send or the + // packet budget for this call is exhausted. + void SendPendingData(); + + Packet::Ptr CreateStreamDataPacket(); + + // Tries to pack a pending datagram into the current packet buffer. + // If < 0 is returned, either NGTCP2_ERR_WRITE_MORE or a fatal error is + // returned; the caller must check. If > 0 is returned, the packet is done + // and the value is the size of the finalized packet. If 0 is returned, + // the datagram is either congestion limited or was abandoned. + ssize_t TryWritePendingDatagram(PathStorage* path, + uint8_t* dest, + size_t destlen, + uint64_t ts); + + // Write the given stream_data into the buffer. The PacketInfo out-param + // is populated by ngtcp2 with per-packet metadata (e.g., ECN codepoint) + // that should be applied when sending the packet. + ssize_t WriteVStream(PathStorage* path, + PacketInfo* pi, + uint8_t* buf, + ssize_t* ndatalen, + size_t max_packet_size, + const StreamData& stream_data, + uint64_t ts); + // Send a batch of packets accumulated by SendPendingData. Uses // Endpoint::SendBatch (uv_udp_try_send2 / sendmmsg) for synchronous // batched delivery when called from the deferred flush path. From 175e113baa9f124b1ea8ac7b66b543facf4a231a Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 10 Jun 2026 09:39:58 +0200 Subject: [PATCH 03/15] quic: add transport accessor methods to Session for HTTP/3 --- src/quic/http3.cc | 65 +++++++++++++------------------------ src/quic/session.cc | 24 ++++++++++++++ src/quic/session.h | 14 ++++++++ src/quic/transportparams.cc | 10 ++++++ src/quic/transportparams.h | 3 ++ 5 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/quic/http3.cc b/src/quic/http3.cc index c077716b7dc0f4..135ce085bf88e9 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -209,15 +209,15 @@ class Http3ApplicationImpl final : public Session::Application { started_ = true; Debug(&session(), "Starting HTTP/3 application."); - auto params = ngtcp2_conn_get_remote_transport_params(session()); - if (params == nullptr) [[unlikely]] { + const auto params = session().remote_transport_params(); + if (!params) [[unlikely]] { // The params are not available yet. Cannot start. Debug(&session(), "Cannot start HTTP/3 application yet. No remote transport params"); return false; } - if (params->initial_max_streams_uni < 3) { + if (params.initial_max_streams_uni() < 3) { // HTTP3 requires 3 unidirectional control streams to be opened in each // direction in additional to the bidirectional streams that are used to // actually carry request and response payload back and forth. @@ -225,8 +225,9 @@ class Http3ApplicationImpl final : public Session::Application { // https://nghttp2.org/nghttp3/programmers-guide.html#binding-control-streams Debug(&session(), "Cannot start HTTP/3 application. Initial max " - "unidirectional streams [%zu] is too low. Must be at least 3", - params->initial_max_streams_uni); + "unidirectional streams [%" PRIu64 + "] is too low. Must be at least 3", + params.initial_max_streams_uni()); return false; } @@ -235,17 +236,14 @@ class Http3ApplicationImpl final : public Session::Application { // of requests that the client can actually created. if (session().is_server()) { nghttp3_conn_set_max_client_streams_bidi( - *this, params->initial_max_streams_bidi); + *this, params.initial_max_streams_bidi()); } Debug(&session(), "Creating and binding HTTP/3 control streams"); bool ret = - ngtcp2_conn_open_uni_stream(session(), &control_stream_id_, nullptr) == - 0 && - ngtcp2_conn_open_uni_stream( - session(), &qpack_enc_stream_id_, nullptr) == 0 && - ngtcp2_conn_open_uni_stream( - session(), &qpack_dec_stream_id_, nullptr) == 0 && + session().OpenUni(&control_stream_id_) && + session().OpenUni(&qpack_enc_stream_id_) && + session().OpenUni(&qpack_dec_stream_id_) && nghttp3_conn_bind_control_stream(*this, control_stream_id_) == 0 && nghttp3_conn_bind_qpack_streams( *this, qpack_enc_stream_id_, qpack_dec_stream_id_) == 0; @@ -306,8 +304,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "Extending stream and connection offset by %zd bytes", nread); - session().ExtendStreamOffset(id, nread); - session().ExtendOffset(nread); + session().Consume(id, nread); } // If this data arrived as 0-RTT, mark the stream. We set it after @@ -365,24 +362,11 @@ class Http3ApplicationImpl final : public Session::Application { case EndpointLabel::LOCAL: return; case EndpointLabel::REMOTE: { - switch (direction) { - case Direction::BIDIRECTIONAL: { - Debug(&session(), - "HTTP/3 application extending max bidi streams by %" PRIu64, - max_streams); - ngtcp2_conn_extend_max_streams_bidi( - session(), static_cast(max_streams)); - break; - } - case Direction::UNIDIRECTIONAL: { - Debug(&session(), - "HTTP/3 application extending max uni streams by %" PRIu64, - max_streams); - ngtcp2_conn_extend_max_streams_uni( - session(), static_cast(max_streams)); - break; - } - } + Debug(&session(), + "HTTP/3 application extending max %s streams by %" PRIu64, + direction == Direction::BIDIRECTIONAL ? "bidi" : "uni", + max_streams); + session().ExtendMaxStreams(direction, max_streams); } } } @@ -530,8 +514,7 @@ class Http3ApplicationImpl final : public Session::Application { return; } - session().SetLastError( - QuicError::ForApplication(nghttp3_err_infer_quic_app_error_code(rv))); + session().SetError(nghttp3_err_infer_quic_app_error_code(rv)); session().Close(); } @@ -548,8 +531,7 @@ class Http3ApplicationImpl final : public Session::Application { return; } - session().SetLastError( - QuicError::ForApplication(nghttp3_err_infer_quic_app_error_code(rv))); + session().SetError(nghttp3_err_infer_quic_app_error_code(rv)); session().Close(); } @@ -730,8 +712,7 @@ class Http3ApplicationImpl final : public Session::Application { // nghttp3 tracks its own offset via add_write_offset. int err = nghttp3_conn_add_write_offset(*this, data->id, datalen); if (err != 0) { - session().SetLastError(QuicError::ForApplication( - nghttp3_err_infer_quic_app_error_code(err))); + session().SetError(nghttp3_err_infer_quic_app_error_code(err)); return false; } // Raw application bytes are committed to the stream's outbound @@ -1221,10 +1202,10 @@ class Http3ApplicationImpl final : public Session::Application { void* conn_user_data, void* stream_user_data) { NGHTTP3_CALLBACK_SCOPE(app); - auto& session = app.session(); - Debug(&session, "HTTP/3 application deferred consume %zu bytes", consumed); - session.ExtendStreamOffset(id, consumed); - session.ExtendOffset(consumed); + Debug(&app.session(), + "HTTP/3 application deferred consume %zu bytes", + consumed); + app.session().Consume(id, consumed); return NGTCP2_SUCCESS; } diff --git a/src/quic/session.cc b/src/quic/session.cc index 9913c6c9877e53..780b50620d49eb 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -3452,6 +3452,30 @@ uint64_t Session::max_data_left() const { return ngtcp2_conn_get_max_data_left(*this); } +bool Session::OpenUni(stream_id* id) { + return ngtcp2_conn_open_uni_stream(*this, id, nullptr) == 0; +} + +void Session::ExtendMaxStreams(Direction direction, uint64_t max) { + switch (direction) { + case Direction::BIDIRECTIONAL: + ngtcp2_conn_extend_max_streams_bidi(*this, static_cast(max)); + break; + case Direction::UNIDIRECTIONAL: + ngtcp2_conn_extend_max_streams_uni(*this, static_cast(max)); + break; + } +} + +void Session::Consume(stream_id id, size_t len) { + ExtendStreamOffset(id, len); + ExtendOffset(len); +} + +void Session::SetError(error_code app_error_code) { + SetLastError(QuicError::ForApplication(app_error_code)); +} + uint64_t Session::max_local_streams_uni() const { DCHECK(!is_destroyed()); return ngtcp2_conn_get_streams_uni_left(*this); diff --git a/src/quic/session.h b/src/quic/session.h index 2e4ae55777ed09..d9c7a40d7f6c28 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -542,6 +542,20 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void SetLastError(QuicError&& error); uint64_t max_data_left() const; + // Transport operations that protocol applications (e.g. HTTP/3) invoke on + // the session, encapsulating ngtcp2 transport details here. + + // Open a unidirectional stream, setting *id on success, or returning false + bool OpenUni(stream_id* id); + + void ExtendMaxStreams(Direction direction, uint64_t max); + + // Signal that we've consumed $len bytes on stream $id to update flow control + void Consume(stream_id id, size_t len); + + // Record an application-level error on the connection without closing it. + void SetError(error_code app_error_code); + PendingStream::PendingStreamQueue& pending_bidi_stream_queue() const; PendingStream::PendingStreamQueue& pending_uni_stream_queue() const; diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 183ba973ac1823..b49e527b038069 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -506,6 +506,16 @@ TransportParams::operator bool() const { return ptr_ != nullptr; } +uint64_t TransportParams::initial_max_streams_bidi() const { + DCHECK_NOT_NULL(ptr_); + return ptr_->initial_max_streams_bidi; +} + +uint64_t TransportParams::initial_max_streams_uni() const { + DCHECK_NOT_NULL(ptr_); + return ptr_->initial_max_streams_uni; +} + void TransportParams::Initialize(Environment* env, Local target) { NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_STREAM_DATA); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_DATA); diff --git a/src/quic/transportparams.h b/src/quic/transportparams.h index 46724574611aff..bae20b9ac0d4f2 100644 --- a/src/quic/transportparams.h +++ b/src/quic/transportparams.h @@ -150,6 +150,9 @@ class TransportParams final { operator bool() const; + uint64_t initial_max_streams_bidi() const; + uint64_t initial_max_streams_uni() const; + // Returns a Store containing the encoded transport parameters. // If an error occurs during encoding, or if the parameters could // not be encoded, an empty Store will be returned. From f881f7dabc3c7fb4e49b373987e2e69895acbacd Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Wed, 10 Jun 2026 16:10:37 +0200 Subject: [PATCH 04/15] quic: Create standalone Http3Session APIs around QUIC --- lib/internal/quic/http3.js | 221 ++++++++++++++++++++ test/parallel/test-quic-h3-http3session.mjs | 84 ++++++++ 2 files changed, 305 insertions(+) create mode 100644 lib/internal/quic/http3.js create mode 100644 test/parallel/test-quic-h3-http3session.mjs diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js new file mode 100644 index 00000000000000..fc6321bd785384 --- /dev/null +++ b/lib/internal/quic/http3.js @@ -0,0 +1,221 @@ +'use strict'; + +// Internal, experimental HTTP/3 consumer layer over node:quic. +// +// Http3Session wraps a QuicSession and surfaces incoming/outgoing request +// streams as Http3Stream objects. + +const { + SymbolAsyncIterator, +} = primordials; + +const { + connect: quicConnect, + listen: quicListen, + QuicSession, + QuicStream, +} = require('internal/quic/quic'); + +const { + validateFunction, + validateObject, +} = require('internal/validators'); + +const { + codes: { + ERR_INVALID_ARG_TYPE, + }, +} = require('internal/errors'); + +const kEmptyObject = { __proto__: null }; +const kHttp3Alpn = 'h3'; + +function isQuicSession(value) { + return value instanceof QuicSession; +} + +function isQuicStream(value) { + return value instanceof QuicStream; +} + +class Http3Stream { + #stream; + #session; + + /** + * @param {QuicStream} stream the underlying QUIC stream + * @param {Http3Session} session the owning session wrapper + */ + constructor(stream, session) { + if (!isQuicStream(stream)) { + throw new ERR_INVALID_ARG_TYPE('stream', 'QuicStream', stream); + } + this.#stream = stream; + this.#session = session; + } + + /** @type {Http3Session} */ + get session() { return this.#session; } + + /** @type {bigint} */ + get id() { return this.#stream.id; } + + /** @type {'bidi'|'uni'} */ + get direction() { return this.#stream.direction; } + + // Received header block (after onheaders fires). + get headers() { return this.#stream.headers; } + + get onheaders() { return this.#stream.onheaders; } + set onheaders(fn) { this.#stream.onheaders = fn; } + + get oninfo() { return this.#stream.oninfo; } + set oninfo(fn) { this.#stream.oninfo = fn; } + + get ontrailers() { return this.#stream.ontrailers; } + set ontrailers(fn) { this.#stream.ontrailers = fn; } + + get onwanttrailers() { return this.#stream.onwanttrailers; } + set onwanttrailers(fn) { this.#stream.onwanttrailers = fn; } + + get onreset() { return this.#stream.onreset; } + set onreset(fn) { this.#stream.onreset = fn; } + + get onerror() { return this.#stream.onerror; } + set onerror(fn) { this.#stream.onerror = fn; } + + sendHeaders(headers, options = kEmptyObject) { + return this.#stream.sendHeaders(headers, options); + } + + sendInformationalHeaders(headers) { + return this.#stream.sendInformationalHeaders(headers); + } + + sendTrailers(headers) { + return this.#stream.sendTrailers(headers); + } + + // Outbound body writer (stream/iter push writer). + get writer() { return this.#stream.writer; } + + // Inbound body bytes; ends at FIN. + [SymbolAsyncIterator]() { + return this.#stream[SymbolAsyncIterator](); + } + + /** @type {Promise} */ + get closed() { return this.#stream.closed; } + + get destroyed() { return this.#stream.destroyed; } + + destroy(error, options) { return this.#stream.destroy(error, options); } + + stopSending(code) { return this.#stream.stopSending(code); } + + resetStream(code) { return this.#stream.resetStream(code); } +} + +class Http3Session { + #session; + #onstream = undefined; + + /** + * Wraps an existing (built-in) QuicSession. Must be constructed + * synchronously in the frame the session is delivered, before any I/O + * tick, so that no incoming stream is missed. + * @param {QuicSession} session the QUIC session to wrap + */ + constructor(session) { + if (!isQuicSession(session)) { + // Foreign QuicLike transports take the slow path; that adapter is not + // wired up yet, so only built-in sessions are accepted for now. + throw new ERR_INVALID_ARG_TYPE('session', 'QuicSession', session); + } + this.#session = session; + session.onstream = (stream) => { + const wrapped = new Http3Stream(stream, this); + this.#onstream?.(wrapped); + }; + } + + // The underlying QUIC session (transitional escape hatch). + get session() { return this.#session; } + + get onstream() { return this.#onstream; } + set onstream(fn) { + if (fn !== undefined) validateFunction(fn, 'onstream'); + this.#onstream = fn; + } + + get ongoaway() { return this.#session.ongoaway; } + set ongoaway(fn) { this.#session.ongoaway = fn; } + + get onorigin() { return this.#session.onorigin; } + set onorigin(fn) { this.#session.onorigin = fn; } + + get onerror() { return this.#session.onerror; } + set onerror(fn) { this.#session.onerror = fn; } + + /** @type {Promise} */ + get opened() { return this.#session.opened; } + + /** @type {Promise} */ + get closed() { return this.#session.closed; } + + /** + * Opens a request stream: a bidirectional stream carrying the given + * request headers (and optional body via options.body). + * @param {object} headers the request header block + * @param {object} [options] stream options (body, callbacks, ...) + * @returns {Promise} + */ + async createRequestStream(headers, options = kEmptyObject) { + validateObject(headers, 'headers'); + validateObject(options, 'options'); + const stream = await this.#session.createBidirectionalStream({ + ...options, + headers, + }); + return new Http3Stream(stream, this); + } + + close(options) { return this.#session.close(options); } + + destroy(error, options) { return this.#session.destroy(error, options); } +} + +/** + * Connects a built-in QUIC session with ALPN h3 and wraps it. + * @returns {Promise} + */ +async function connect(address, options = kEmptyObject) { + validateObject(options, 'options'); + const session = await quicConnect(address, { + ...options, + alpn: kHttp3Alpn, + }); + return new Http3Session(session); +} + +/** + * Listens with ALPN h3; onsession receives an Http3Session, wrapped + * synchronously in the delivery frame per the attach contract. + * @param {Function} onsession invoked with each new Http3Session + * @param {object} [options] quic listen options + * @returns {Promise} the listening QuicEndpoint + */ +async function listen(onsession, options = kEmptyObject) { + validateFunction(onsession, 'onsession'); + validateObject(options, 'options'); + return quicListen((session) => { + onsession(new Http3Session(session)); + }, { ...options, alpn: kHttp3Alpn }); +} + +module.exports = { + Http3Session, + Http3Stream, + connect, + listen, +}; diff --git a/test/parallel/test-quic-h3-http3session.mjs b/test/parallel/test-quic-h3-http3session.mjs new file mode 100644 index 00000000000000..1ab13ec9d4d404 --- /dev/null +++ b/test/parallel/test-quic-h3-http3session.mjs @@ -0,0 +1,84 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings --expose-internals + +// A request/response round-trip through http3.connect/listen, exercising +// onstream delivery (wrapped streams), createRequestStream, header events, +// and bodies in both directions. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, ok } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { connect, listen, Http3Session, Http3Stream } = + require('internal/quic/http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((session) => { + ok(session instanceof Http3Session); + session.onstream = mustCall((stream) => { + ok(stream instanceof Http3Stream); + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':path'], '/hello'); + strictEqual(headers[':method'], 'GET'); + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + const w = stream.writer; + w.writeSync('hello h3'); + w.endSync(); + }); + stream.closed.then(mustCall(() => { + session.close(); + serverDone.resolve(); + })); + }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +ok(clientSession instanceof Http3Session); +const info = await clientSession.opened; +strictEqual(info.protocol, 'h3'); + +const responseHeaders = Promise.withResolvers(); +const stream = await clientSession.createRequestStream({ + ':method': 'GET', + ':path': '/hello', + ':scheme': 'https', + ':authority': 'localhost', +}, { body: encoder.encode('') }); +ok(stream instanceof Http3Stream); +stream.onheaders = mustCall((headers) => { + strictEqual(headers[':status'], '200'); + responseHeaders.resolve(); +}); + +const body = decoder.decode(await bytes(stream)); +strictEqual(body, 'hello h3'); +await responseHeaders.promise; + +await serverDone.promise; +await clientSession.closed; +await serverEndpoint.close(); From e09782f0598425a13c5a48b3be27fe5905a88298 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 11 Jun 2026 00:41:39 +0200 Subject: [PATCH 05/15] quic: split out full http3 module with separate request() API --- lib/http3.js | 20 ++ lib/internal/bootstrap/realm.js | 2 + lib/internal/modules/cjs/loader.js | 2 +- lib/internal/process/pre_execution.js | 1 + lib/internal/quic/http3.js | 46 ++++- src/node_builtins.cc | 4 +- test/parallel/test-process-get-builtin.mjs | 1 + .../parallel/test-quic-h3-callback-errors.mjs | 138 +++++++------- test/parallel/test-quic-h3-close-behavior.mjs | 54 +++--- .../test-quic-h3-concurrent-requests.mjs | 35 ++-- test/parallel/test-quic-h3-datagram.mjs | 175 ------------------ test/parallel/test-quic-h3-error-codes.mjs | 52 +++--- test/parallel/test-quic-h3-goaway.mjs | 81 ++++---- .../test-quic-h3-handshake-failure.mjs | 7 +- .../test-quic-h3-header-validation.mjs | 112 ++++++----- test/parallel/test-quic-h3-http3session.mjs | 23 ++- .../test-quic-h3-informational-headers.mjs | 53 +++--- test/parallel/test-quic-h3-origin.mjs | 52 +++--- test/parallel/test-quic-h3-pending-stream.mjs | 35 ++-- .../parallel/test-quic-h3-post-filehandle.mjs | 33 ++-- test/parallel/test-quic-h3-post-request.mjs | 54 +++--- test/parallel/test-quic-h3-priority.mjs | 105 +++++------ test/parallel/test-quic-h3-qpack-settings.mjs | 55 +++--- .../test-quic-h3-request-response.mjs | 76 ++++---- test/parallel/test-quic-h3-settings.mjs | 111 ++++++----- ...st-quic-h3-stream-destroy-with-headers.mjs | 14 +- .../test-quic-h3-stream-idle-timeout.mjs | 48 +++-- .../test-quic-h3-trailing-headers.mjs | 59 +++--- .../test-quic-h3-zero-rtt-bogus-ticket.mjs | 2 +- ...est-quic-h3-zero-rtt-rejected-settings.mjs | 39 ++-- test/parallel/test-quic-h3-zero-rtt.mjs | 42 ++--- test/parallel/test-require-resolve.js | 2 + 32 files changed, 685 insertions(+), 848 deletions(-) create mode 100644 lib/http3.js delete mode 100644 test/parallel/test-quic-h3-datagram.mjs diff --git a/lib/http3.js b/lib/http3.js new file mode 100644 index 00000000000000..2f09aa66342ac1 --- /dev/null +++ b/lib/http3.js @@ -0,0 +1,20 @@ +'use strict'; + +const { + emitExperimentalWarning, +} = require('internal/util'); +emitExperimentalWarning('http3'); + +const { + connect, + listen, + Http3Session, + Http3Stream, +} = require('internal/quic/http3'); + +module.exports = { + connect, + listen, + Http3Session, + Http3Stream, +}; diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 8a4d179806aa53..5549ba35e18356 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -126,6 +126,7 @@ const legacyWrapperList = new SafeSet([ const schemelessBlockList = new SafeSet([ 'dtls', 'ffi', + 'http3', 'sea', 'sqlite', 'quic', @@ -137,6 +138,7 @@ const schemelessBlockList = new SafeSet([ const experimentalModuleList = new SafeSet([ 'dtls', 'ffi', + 'http3', 'quic', 'sqlite', 'stream/iter', diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 219618da8a8a93..d7f98b9454f534 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -496,7 +496,7 @@ function initializeCJS() { modules = modules.filter((i) => i !== 'node:dtls'); } if (!getOptionValue('--experimental-quic')) { - modules = modules.filter((i) => i !== 'node:quic'); + modules = modules.filter((i) => i !== 'node:quic' && i !== 'node:http3'); } if (!getOptionValue('--experimental-ffi')) { modules = modules.filter((i) => i !== 'node:ffi'); diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 38ea6675928ad8..7e9ac9a02eb4a1 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -430,6 +430,7 @@ function setupQuic() { const { BuiltinModule } = require('internal/bootstrap/realm'); BuiltinModule.allowRequireByUsers('quic'); + BuiltinModule.allowRequireByUsers('http3'); } function setupVfs() { diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index fc6321bd785384..fbf07b0c2aa85e 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -45,13 +45,28 @@ class Http3Stream { /** * @param {QuicStream} stream the underlying QUIC stream * @param {Http3Session} session the owning session wrapper + * @param {object} [callbacks] creation-time stream callbacks */ - constructor(stream, session) { + constructor(stream, session, callbacks = kEmptyObject) { if (!isQuicStream(stream)) { throw new ERR_INVALID_ARG_TYPE('stream', 'QuicStream', stream); } this.#stream = stream; this.#session = session; + const { + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + } = callbacks; + if (onheaders !== undefined) this.onheaders = onheaders; + if (oninfo !== undefined) this.oninfo = oninfo; + if (ontrailers !== undefined) this.ontrailers = ontrailers; + if (onwanttrailers !== undefined) this.onwanttrailers = onwanttrailers; + if (onreset !== undefined) this.onreset = onreset; + if (onerror !== undefined) this.onerror = onerror; } /** @type {Http3Session} */ @@ -66,6 +81,13 @@ class Http3Stream { // Received header block (after onheaders fires). get headers() { return this.#stream.headers; } + // True when the stream's data was received as 0-RTT early data. + get early() { return this.#stream.early; } + + get priority() { return this.#stream.priority; } + + setPriority(options) { return this.#stream.setPriority(options); } + get onheaders() { return this.#stream.onheaders; } set onheaders(fn) { this.#stream.onheaders = fn; } @@ -157,6 +179,7 @@ class Http3Session { get onerror() { return this.#session.onerror; } set onerror(fn) { this.#session.onerror = fn; } + /** @type {Promise} */ get opened() { return this.#session.opened; } @@ -165,19 +188,22 @@ class Http3Session { /** * Opens a request stream: a bidirectional stream carrying the given - * request headers (and optional body via options.body). - * @param {object} headers the request header block + * request headers. If not specified, they can be sent later with + * stream.sendHeaders() instead. + * + * Stream callbacks (onheaders, oninfo, ontrailers, onwanttrailers, + * onreset, onerror) are passed in options so they are + * attached before any event can be delivered. + * @param {object} [headers] the request header block * @param {object} [options] stream options (body, callbacks, ...) * @returns {Promise} */ - async createRequestStream(headers, options = kEmptyObject) { - validateObject(headers, 'headers'); + async request(headers, options = kEmptyObject) { + if (headers !== undefined) validateObject(headers, 'headers'); validateObject(options, 'options'); - const stream = await this.#session.createBidirectionalStream({ - ...options, - headers, - }); - return new Http3Stream(stream, this); + const stream = await this.#session.createBidirectionalStream( + headers === undefined ? options : { ...options, headers }); + return new Http3Stream(stream, this, options); } close(options) { return this.#session.close(options); } diff --git a/src/node_builtins.cc b/src/node_builtins.cc index c19fe65cb102fe..c9bc85dd6c694c 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -138,7 +138,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { #endif // !HAVE_OPENSSL #ifndef OPENSSL_NO_QUIC "internal/quic/quic", "internal/quic/symbols", "internal/quic/stats", - "internal/quic/state", + "internal/quic/state", "internal/quic/diagnostics", + "internal/quic/http3", #endif // !OPENSSL_NO_QUIC #if HAVE_DTLS "internal/dtls/dtls", "internal/dtls/symbols", "internal/dtls/stats", @@ -149,6 +150,7 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { #endif // !HAVE_FFI "dtls", // Experimental. "ffi", // Experimental. + "http3", // Experimental. "quic", // Experimental. "sqlite", // Experimental. "stream/iter", // Experimental. diff --git a/test/parallel/test-process-get-builtin.mjs b/test/parallel/test-process-get-builtin.mjs index fa92dc52f96916..b714ad58d04a98 100644 --- a/test/parallel/test-process-get-builtin.mjs +++ b/test/parallel/test-process-get-builtin.mjs @@ -40,6 +40,7 @@ if (!hasIntl) { publicBuiltins.delete('node:dtls'); // TODO(@jasnell): Remove this once node:quic graduates from unflagged. publicBuiltins.delete('node:quic'); +publicBuiltins.delete('node:http3'); // Remove this once node:vfs graduates from unflagged. publicBuiltins.delete('node:vfs'); diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index 226a3f6b96fbd5..a7f95cdd0525ba 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -18,17 +18,21 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); const encoder = new TextEncoder(); -async function makeServer(onheadersHandler, extraOpts = {}) { +async function makeServer(onheadersHandler, extraCallbacks = {}) { const done = Promise.withResolvers(); const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall((stream) => { + stream.onheaders = (headers) => onheadersHandler(stream, headers); + if (extraCallbacks.onwanttrailers) { + stream.onwanttrailers = () => extraCallbacks.onwanttrailers(stream); + } // The server completes its response before the client's // callback throws, so the server stream always resolves. stream.closed.then(mustCall()); @@ -38,8 +42,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), { sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, - onheaders: onheadersHandler, - ...extraOpts, }); return { ep, done }; } @@ -47,10 +49,10 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in onheaders callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); }), ); @@ -61,14 +63,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function() { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall(() => { throw new Error('onheaders sync error'); }), }); @@ -87,10 +88,10 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Async rejection in onheaders callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); }), ); @@ -101,14 +102,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(async function() { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall(async () => { throw new Error('onheaders async error'); }), }); @@ -127,14 +127,14 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in ontrailers callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('body')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('body')); + stream.writer.endSync(); }), { - onwanttrailers: mustCall(function() { - this.sendTrailers({ 'x-trailer': 'value' }); + onwanttrailers: mustCall((stream) => { + stream.sendTrailers({ 'x-trailer': 'value' }); }), }, ); @@ -146,17 +146,16 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), - ontrailers: mustCall(function() { + ontrailers: mustCall(() => { throw new Error('ontrailers sync error'); }), }); @@ -175,6 +174,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in onorigin callback destroys the session. { const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = (stream) => { + stream.onheaders = (headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.endSync(); + }; + }; await ss.closed; }), { sni: { @@ -182,10 +187,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { 'example.com': { keys: [key], certs: [cert] }, }, transportParams: { maxIdleTimeout: 1 }, - onheaders(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.endSync(); - }, }); const clientSession = await connect(serverEndpoint.address, { @@ -201,13 +202,11 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'example.com', }); // The session is destroyed by the callback error, which @@ -231,6 +230,14 @@ async function makeServer(onheadersHandler, extraOpts = {}) { const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('body')); + stream.writer.endSync(); + }); + stream.onwanttrailers = mustCall(() => { + throw new Error('onwanttrailers error'); + }); // The server stream rejects because onwanttrailers threw. await rejects(stream.closed, mustCall((err) => { strictEqual(err.message, 'onwanttrailers error'); @@ -243,14 +250,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), { sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('body')); - this.writer.endSync(); - }), - onwanttrailers: mustCall(function() { - throw new Error('onwanttrailers error'); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -260,14 +259,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index 02b34945087267..93be483de55174 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -32,23 +32,21 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; - ss.onstream = mustCall(2); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall((headers, stream) => { - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync(headers[':path']); - stream.writer.endSync(); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(headers[':path']); + stream.writer.endSync(); - // Close after both responses are written. The - // close is deferred to exit the nghttp3 callback scope. - if (++requestCount === 2) { - setImmediate(mustCall(() => { + // Close after both responses are written. + if (++requestCount === 2) { serverSession.close(); serverDone.resolve(); - })); - } - }, 2), + } + }); + }, 2); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, }); const clientSession = await connect(serverEndpoint.address, { @@ -57,25 +55,23 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/one', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/one', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/two', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/two', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index 6f0aa50b7f02ae..e96aee5e091f79 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -19,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +34,16 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + const path = headers[':path']; + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + const w = stream.writer; + w.writeSync(`response for ${path}`); + w.endSync(); + }); stream.closed.then(mustCall(() => { if (++serverStreamsCompleted === REQUEST_COUNT) { serverSession.close(); @@ -43,16 +53,6 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }, REQUEST_COUNT); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - const path = headers[':path']; - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - const w = this.writer; - w.writeSync(`response for ${path}`); - w.endSync(); - }, REQUEST_COUNT), }); const clientSession = await connect(serverEndpoint.address, { @@ -67,13 +67,12 @@ const paths = Array.from({ length: REQUEST_COUNT }, (_, i) => `/path/${i}`); const requests = paths.map(mustCall(async (path) => { const headersReceived = Promise.withResolvers(); - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': path, - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); headersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs deleted file mode 100644 index ea00cec42bc8f4..00000000000000 --- a/test/parallel/test-quic-h3-datagram.mjs +++ /dev/null @@ -1,175 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: HTTP/3 datagrams with SETTINGS_H3_DATAGRAM negotiation. -// Verifies that QUIC datagrams work correctly with H3 sessions, including -// the SETTINGS_H3_DATAGRAM negotiation required by RFC 9297. -// 1. Both sides enableDatagrams: true — datagrams work alongside H3 streams -// 2. Server enableDatagrams: false — client should not be able to send -// datagrams (peer's SETTINGS_H3_DATAGRAM=0) - -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { ok, strictEqual } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); -const { bytes } = await import('stream/iter'); -const { setTimeout: sleep } = await import('timers/promises'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); -const decoder = new TextDecoder(); - -// Test 1: H3 datagrams with enableDatagrams: true on both sides. -// Datagrams work alongside H3 request/response. -{ - const serverGotDatagram = Promise.withResolvers(); - const clientGotDatagram = Promise.withResolvers(); - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - await stream.closed; - }); - await serverGotDatagram.promise; - await sleep(50); - ss.close(); - serverDone.resolve(); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - // Server echoes received datagram back to client. - ondatagram: mustCall(function(data) { - ok(data instanceof Uint8Array); - strictEqual(data.byteLength, 3); - strictEqual(data[0], 10); - strictEqual(data[1], 20); - strictEqual(data[2], 30); - // Echo it back. - this.sendDatagram(new Uint8Array([42, 43, 44])); - serverGotDatagram.resolve(); - }), - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - // Client receives datagram from server. - ondatagram: mustCall(function(data) { - ok(data instanceof Uint8Array); - strictEqual(data.byteLength, 3); - strictEqual(data[0], 42); - strictEqual(data[1], 43); - strictEqual(data[2], 44); - clientGotDatagram.resolve(); - }), - }); - await clientSession.opened; - - // Datagrams work alongside H3 request/response. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/with-datagram', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); - }), - }); - - // Send datagram from client. - await clientSession.sendDatagram(new Uint8Array([10, 20, 30])); - - // H3 response body is received. - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'ok'); - await stream.closed; - - // Both sides received their datagram. - await Promise.all([serverGotDatagram.promise, clientGotDatagram.promise]); - - await serverDone.promise; - await clientSession.close(); - await serverEndpoint.close(); -} - -// Test 2: Server has enableDatagrams: false. The peer's H3 SETTINGS -// should indicate SETTINGS_H3_DATAGRAM=0. The client's datagram send -// should return 0 (no datagram sent) because the peer doesn't support -// H3 datagrams. -{ - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - await stream.closed; - ss.close(); - serverDone.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - // Server explicitly disables H3 datagrams. - application: { enableDatagrams: false }, - // But transport-level datagrams ARE supported. - transportParams: { maxDatagramFrameSize: 100 }, - // Server should NOT receive any datagrams. - ondatagram: mustNotCall(), - onheaders: mustCall((headers, stream) => { - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync('no-dgram'); - stream.writer.endSync(); - }), - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - }); - await clientSession.opened; - - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/no-datagram', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); - }), - }); - - // The H3 request triggers SETTINGS exchange. After the server's - // SETTINGS (with h3_datagram=0) arrive, the client should know - // the peer doesn't support H3 datagrams. - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'no-dgram'); - - // Attempt to send a datagram. Since the peer's H3 SETTINGS - // indicate h3_datagram=0, this should return 0 (not sent). - const dgId = await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); - strictEqual(dgId, 0n); - - await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); - await serverEndpoint.close(); -} diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index f9aebadc85cfd2..2051ef06b7bf94 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,6 +30,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; // Close with an explicit H3 application error code. ss.close({ code: 0x101, type: 'application' }); @@ -37,11 +42,6 @@ const decoder = new TextDecoder(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -50,14 +50,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -82,17 +81,17 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -101,14 +100,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index 7542849f35eeed..628cffadc74b19 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -22,7 +22,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -47,26 +47,27 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + const path = headers[':path']; + stream.sendHeaders({ ':status': '200' }); + + if (path === '/first') { + // Respond immediately to the first request. + stream.writer.writeSync(encoder.encode('first')); + stream.writer.endSync(); + } else if (path === '/second') { + // Hold the second response until signaled. + pendingSecondStream = stream; + completeSecondResponse.promise.then(mustCall(() => { + pendingSecondStream.writer.writeSync(encoder.encode('second')); + pendingSecondStream.writer.endSync(); + })); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - const path = headers[':path']; - this.sendHeaders({ ':status': '200' }); - - if (path === '/first') { - // Respond immediately to the first request. - this.writer.writeSync(encoder.encode('first')); - this.writer.endSync(); - } else if (path === '/second') { - // Hold the second response until signaled. - pendingSecondStream = this; - completeSecondResponse.promise.then(mustCall(() => { - pendingSecondStream.writer.writeSync(encoder.encode('second')); - pendingSecondStream.writer.endSync(); - })); - } - }, 2), }); const clientSession = await connect(serverEndpoint.address, { @@ -87,25 +88,19 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { } }, 2); - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/first', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: onClientHeaders, - }); + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/first', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: onClientHeaders }); - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/second', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: onClientHeaders, - }); + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/second', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: onClientHeaders }); // First stream completes immediately. const body1 = await bytes(stream1); @@ -125,13 +120,11 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { // After GOAWAY, new stream creation should fail. await rejects( - clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/new', - ':scheme': 'https', - ':authority': 'localhost', - }, + clientSession.request({ + ':method': 'GET', + ':path': '/new', + ':scheme': 'https', + ':authority': 'localhost', }), { code: 'ERR_INVALID_STATE' }, ); diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs index 640f7e54c40209..e29a7662cb491e 100644 --- a/test/parallel/test-quic-h3-handshake-failure.mjs +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -22,17 +22,20 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); +const onheaders = mustNotCall(); const serverEndpoint = await listen(async (serverSession) => { + serverSession.onstream = (stream) => { + stream.onheaders = onheaders; + }; await serverSession.closed; }, { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustNotCall(), }); // Connect then immediately close the session before the handshake completes. diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 43673e0cc00f1e..3db72af5cbafe1 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -24,7 +24,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -40,39 +40,39 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // H3V-01: All header names should be lowercase regardless + // of how the client sent them. + for (const name of Object.keys(headers)) { + strictEqual(name, name.toLowerCase(), + `Header name "${name}" should be lowercase`); + } + + // Verify specific headers arrived lowercased. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/test'); + strictEqual(headers['x-custom-header'], 'Value1'); + strictEqual(headers['content-type'], 'text/plain'); + strictEqual(headers['x-mixed-case'], 'MixedValue'); + + // Verify values are NOT lowercased — only names are. + strictEqual(headers['x-custom-header'], 'Value1'); + + stream.sendHeaders({ + // Response with mixed-case names — should be lowercased. + ':status': '200', + 'Content-Type': 'text/html', + 'X-Response-Header': 'ResponseValue', + }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // H3V-01: All header names should be lowercase regardless - // of how the client sent them. - for (const name of Object.keys(headers)) { - strictEqual(name, name.toLowerCase(), - `Header name "${name}" should be lowercase`); - } - - // Verify specific headers arrived lowercased. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/test'); - strictEqual(headers['x-custom-header'], 'Value1'); - strictEqual(headers['content-type'], 'text/plain'); - strictEqual(headers['x-mixed-case'], 'MixedValue'); - - // Verify values are NOT lowercased — only names are. - strictEqual(headers['x-custom-header'], 'Value1'); - - this.sendHeaders({ - // Response with mixed-case names — should be lowercased. - ':status': '200', - 'Content-Type': 'text/html', - 'X-Response-Header': 'ResponseValue', - }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -81,18 +81,17 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - // Mixed-case names — should be lowercased by buildNgHeaderString. - ':method': 'GET', - ':path': '/test', - ':scheme': 'https', - ':authority': 'localhost', - 'X-Custom-Header': 'Value1', - 'Content-Type': 'text/plain', - 'X-Mixed-Case': 'MixedValue', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + // Mixed-case names — should be lowercased by buildNgHeaderString. + ':method': 'GET', + ':path': '/test', + ':scheme': 'https', + ':authority': 'localhost', + 'X-Custom-Header': 'Value1', + 'Content-Type': 'text/plain', + 'X-Mixed-Case': 'MixedValue', + }, { + onheaders: mustCall((headers) => { // Client should also receive lowercased response header names. strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/html'); @@ -119,22 +118,22 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // All four required pseudo-headers present. + ok(headers[':method']); + ok(headers[':path']); + ok(headers[':scheme']); + ok(headers[':authority']); + + stream.sendHeaders({ ':status': '204' }); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // All four required pseudo-headers present. - ok(headers[':method']); - ok(headers[':path']); - ok(headers[':scheme']); - ok(headers[':authority']); - - this.sendHeaders({ ':status': '204' }); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -143,13 +142,12 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/api/data', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/api/data', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '204'); }), diff --git a/test/parallel/test-quic-h3-http3session.mjs b/test/parallel/test-quic-h3-http3session.mjs index 1ab13ec9d4d404..be59b4c05a0e9a 100644 --- a/test/parallel/test-quic-h3-http3session.mjs +++ b/test/parallel/test-quic-h3-http3session.mjs @@ -1,7 +1,7 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings --expose-internals +// Flags: --experimental-quic --experimental-stream-iter --no-warnings // A request/response round-trip through http3.connect/listen, exercising -// onstream delivery (wrapped streams), createRequestStream, header events, +// onstream delivery (wrapped streams), request(), header events, // and bodies in both directions. import { hasQuic, skip, mustCall } from '../common/index.mjs'; @@ -15,10 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { createRequire } = await import('node:module'); -const require = createRequire(import.meta.url); -const { connect, listen, Http3Session, Http3Stream } = - require('internal/quic/http3'); +const { connect, listen, Http3Session, Http3Stream } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -63,17 +60,19 @@ const info = await clientSession.opened; strictEqual(info.protocol, 'h3'); const responseHeaders = Promise.withResolvers(); -const stream = await clientSession.createRequestStream({ +const stream = await clientSession.request({ ':method': 'GET', ':path': '/hello', ':scheme': 'https', ':authority': 'localhost', -}, { body: encoder.encode('') }); -ok(stream instanceof Http3Stream); -stream.onheaders = mustCall((headers) => { - strictEqual(headers[':status'], '200'); - responseHeaders.resolve(); +}, { + body: encoder.encode(''), + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + responseHeaders.resolve(); + }), }); +ok(stream instanceof Http3Stream); const body = decoder.decode(await bytes(stream)); strictEqual(body, 'hello h3'); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 6fa950b7bccbd6..4cf2253703d5ee 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -22,7 +22,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -50,29 +50,29 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Send 103 Early Hints before the final response. + stream.sendInformationalHeaders({ + ':status': '103', + 'link': '; rel=preload; as=style', + }); + + // Send final response headers + body. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + const w = stream.writer; + w.writeSync(responseBody); + w.endSync(); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Send 103 Early Hints before the final response. - this.sendInformationalHeaders({ - ':status': '103', - 'link': '; rel=preload; as=style', - }); - - // Send final response headers + body. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - const w = this.writer; - w.writeSync(responseBody); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -84,19 +84,18 @@ await clientSession.opened; const clientInfoReceived = Promise.withResolvers(); const clientHeadersReceived = Promise.withResolvers(); -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/page', - ':scheme': 'https', - ':authority': 'localhost', - }, - oninfo: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/page', + ':scheme': 'https', + ':authority': 'localhost', +}, { + oninfo: mustCall((headers) => { strictEqual(headers[':status'], '103'); strictEqual(headers.link, '; rel=preload; as=style'); clientInfoReceived.resolve(); }), - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 4f1fdf50e58d6d..0ad4898ab45775 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -16,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +34,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -46,11 +51,6 @@ const decoder = new TextDecoder(); 'example.com': { keys: [key], certs: [cert] }, 'api.example.com': { keys: [key], certs: [cert] }, }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -72,14 +72,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'example.com', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -102,6 +101,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -124,11 +128,6 @@ const decoder = new TextDecoder(); // Authoritative defaults to true when omitted. 'default-auth.example.com': { keys: [key], certs: [cert] }, }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -168,14 +167,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'custom-port.example.com', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'custom-port.example.com', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index fd269e64e0543a..f187ea97f8732b 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -31,22 +31,22 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Headers were enqueued before the stream opened + // and should arrive correctly. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/pending'); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Headers were enqueued before the stream opened - // and should arrive correctly. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/pending'); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -56,13 +56,12 @@ const decoder = new TextDecoder(); // Create the stream BEFORE awaiting opened. The stream is pending // until the handshake completes and the QUIC stream can be opened. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/pending', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/pending', + ':scheme': 'https', + ':authority': 'localhost', + }, { // Priority set at creation time. priority: 'high', incremental: true, diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index 46e30d8376d6cd..7afe32b99afc23 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -40,6 +40,14 @@ writeFileSync(testFile, testContent); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + strictEqual(headers[':path'], '/upload'); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); const body = await bytes(stream); strictEqual(decoder.decode(body), testContent); @@ -49,14 +57,6 @@ writeFileSync(testFile, testContent); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - strictEqual(headers[':path'], '/upload'); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -70,15 +70,14 @@ writeFileSync(testFile, testContent); const clientHeadersReceived = Promise.withResolvers(); const fh = await open(testFile, 'r'); - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/upload', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/upload', + ':scheme': 'https', + ':authority': 'localhost', + }, { body: fh, - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index a12458ef10df30..eefdbc4fb6757a 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -35,6 +35,25 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + strictEqual(headers[':path'], '/submit'); + + // Echo the request body back in the response. + // At this point, request body hasn't arrived yet — we read it + // below. But we can send response headers immediately. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + // Write echoed body after reading it below. For simplicity, + // we write a fixed response here and verify the request body + // separately below. + const w = stream.writer; + w.writeSync(encoder.encode('echo:' + requestBody)); + w.endSync(); + }); + // Read the full request body from the client. const body = await bytes(stream); const text = decoder.decode(body); @@ -46,24 +65,6 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - strictEqual(headers[':path'], '/submit'); - - // Echo the request body back in the response. - // At this point, request body hasn't arrived yet — we use onstream - // to read it. But we can send response headers immediately. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - // Write echoed body after reading it in onstream. For simplicity, - // we write a fixed response here and verify the request body - // separately in onstream. - const w = this.writer; - w.writeSync(encoder.encode('echo:' + requestBody)); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -78,15 +79,14 @@ const clientHeadersReceived = Promise.withResolvers(); // Send a POST request with body. When body is provided, terminal is NOT // set on the HEADERS frame (body follows). -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/submit', - ':scheme': 'https', - ':authority': 'localhost', - }, +const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/submit', + ':scheme': 'https', + ':authority': 'localhost', +}, { body: encoder.encode(requestBody), - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index fc7ca231f0d63a..b4c5314f10e29f 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -33,24 +33,27 @@ const decoder = new TextDecoder(); let requestCount = 0; const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (ss) => { + const serverEndpoint = await listen(mustCall((ss) => { ss.onstream = mustCall((stream) => { // Server sees priority on the stream. const pri = stream.priority; strictEqual(typeof pri, 'object'); strictEqual(typeof pri.level, 'string'); strictEqual(typeof pri.incremental, 'boolean'); + + // Attach onheaders synchronously in the onstream frame, before + // any await. + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 4) { + serverDone.resolve(); + } + }); }, 4); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 4) { - serverDone.resolve(); - } - }, 4), }); const clientSession = await connect(serverEndpoint.address, { @@ -60,13 +63,12 @@ const decoder = new TextDecoder(); await clientSession.opened; // Priority set at creation time via options. - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/high', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/high', + ':scheme': 'https', + ':authority': 'localhost', + }, { priority: 'high', incremental: false, onheaders: mustCall(function(headers) { @@ -78,13 +80,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream1.priority, { level: 'high', incremental: false }); // Priority 'low' + incremental at creation. - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/low-inc', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/low-inc', + ':scheme': 'https', + ':authority': 'localhost', + }, { priority: 'low', incremental: true, onheaders: mustCall(function(headers) { @@ -94,13 +95,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream2.priority, { level: 'low', incremental: true }); // Default priority at creation. - const stream3 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/default', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream3 = await clientSession.request({ + ':method': 'GET', + ':path': '/default', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); }), @@ -108,13 +108,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream3.priority, { level: 'default', incremental: false }); // setPriority after creation. - const stream4 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/changed', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream4 = await clientSession.request({ + ':method': 'GET', + ':path': '/changed', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); }), @@ -167,6 +166,14 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + // Attach onheaders synchronously in the onstream frame, before + // any await. + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); + // Read the request body — this acts as a signal that the // client's PRIORITY_UPDATE has been sent. The control stream // (carrying PRIORITY_UPDATE) is processed before bidi stream @@ -186,11 +193,6 @@ const decoder = new TextDecoder(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -202,20 +204,19 @@ const decoder = new TextDecoder(); // Create stream with default priority and a body. The body serves // as a signal — by the time it arrives at the server, the // PRIORITY_UPDATE (sent on the control stream) will have been - // processed. setPriority is called BEFORE createBidirectionalStream + // processed. setPriority is called BEFORE request() // so the PRIORITY_UPDATE is queued before the stream data. // - // Note: setPriority must be called after createBidirectionalStream + // Note: setPriority must be called after request() // because the stream handle is needed. But the PRIORITY_UPDATE // travels on the control stream which nghttp3 processes before // bidi stream data, so the ordering is guaranteed. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/pri-update', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/pri-update', + ':scheme': 'https', + ':authority': 'localhost', + }, { body: encoder.encode('signal'), onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 6ca5671ef5b91f..60331e48fd1e77 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,14 +30,13 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); async function makeRequest(clientSession, path) { - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': path, - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -54,19 +53,20 @@ async function makeRequest(clientSession, path) { let requestCount = 0; const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 2) { + serverDone.resolve(); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, // Server disables QPACK dynamic table. application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 2) { - serverDone.resolve(); - } - }, 2), }); const clientSession = await connect(serverEndpoint.address, { @@ -93,18 +93,19 @@ async function makeRequest(clientSession, path) { let requestCount = 0; const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 2) { + serverDone.resolve(); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 2) { - serverDone.resolve(); - } - }, 2), }); const clientSession = await connect(serverEndpoint.address, { diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index 1610f8deec1d41..f81e5f52bc5ed8 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -33,52 +33,43 @@ const responseBody = 'Hello from H3 server'; const serverDone = Promise.withResolvers(); -// The onheaders callback signature is (headers, kind) with `this` bound -// to the stream. A regular function is used so `this` is accessible. -// safeCallbackInvoke(fn, owner, ...args) consumes the owner for error -// handling and forwards only ...args to fn. -const serverEndpoint = await listen(mustCall(async (serverSession) => { +const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { + // Attach onheaders synchronously in the onstream frame, before any + // await — header delivery follows stream creation. + stream.onheaders = mustCall((headers) => { + // Verify request pseudo-headers. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/index.html'); + strictEqual(headers[':scheme'], 'https'); + strictEqual(headers[':authority'], 'localhost'); + + // After onheaders, stream.headers returns the initial headers. + strictEqual(stream.headers[':method'], 'GET'); + + // Send response headers (terminal: false is the default — body + // follows). + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + // Write response body and close the write side. + const w = stream.writer; + w.writeSync(encoder.encode(responseBody)); + w.endSync(); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - // Default ALPN is h3 — omitted intentionally to exercise the default. - // - // onheaders is provided via listen options so it is applied to - // incoming streams (via kStreamCallbacks) BEFORE onstream fires. - // For H3, onheaders must be set because the H3 application delivers - // headers and stream[kHeaders] asserts the callback exists. - onheaders: mustCall(function(headers) { - // Verify request pseudo-headers. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/index.html'); - strictEqual(headers[':scheme'], 'https'); - strictEqual(headers[':authority'], 'localhost'); - - // After onheaders, stream.headers returns the initial headers. - // `this` is the stream (bound by the onheaders setter). - strictEqual(this.headers[':method'], 'GET'); - - // Send response headers (terminal: false is the default — body follows). - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - // Write response body and close the write side. - const w = this.writer; - w.writeSync(encoder.encode(responseBody)); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', - // Default ALPN is h3. }); const info = await clientSession.opened; @@ -88,14 +79,13 @@ const clientHeadersReceived = Promise.withResolvers(); // Send a GET request. With body omitted, the terminal flag is set // automatically (END_STREAM on the HEADERS frame). -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/index.html', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/index.html', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index d954813c9c2564..8629e0b1147757 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +34,20 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/limited'); + strictEqual(headers[':scheme'], 'https'); + strictEqual(headers[':authority'], 'localhost'); + // x-first is the 5th pair — accepted. + strictEqual(headers['x-first'], 'one'); + // x-second would be the 6th pair — dropped. + strictEqual(headers['x-second'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -42,20 +56,6 @@ const decoder = new TextDecoder(); sni: { '*': { keys: [key], certs: [cert] } }, // Allow 5 header pairs: 4 pseudo-headers + 1 custom. application: { maxHeaderPairs: 5 }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/limited'); - strictEqual(headers[':scheme'], 'https'); - strictEqual(headers[':authority'], 'localhost'); - // x-first is the 5th pair — accepted. - strictEqual(headers['x-first'], 'one'); - // x-second would be the 6th pair — dropped. - strictEqual(headers['x-second'], undefined); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -64,16 +64,15 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-first': 'one', - 'x-second': 'two', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-first': 'one', + 'x-second': 'two', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -96,6 +95,16 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/length-limited'); + // x-long should be dropped — would push total over 100 bytes. + strictEqual(headers['x-long'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -105,16 +114,6 @@ const decoder = new TextDecoder(); // Limit total header bytes. The 4 pseudo-headers fit within 100 // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. application: { maxHeaderLength: 100 }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/length-limited'); - // x-long should be dropped — would push total over 100 bytes. - strictEqual(headers['x-long'], undefined); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -123,15 +122,14 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/length-limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-long': longValue, - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/length-limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-long': longValue, + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -150,6 +148,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('settings-ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -157,11 +160,6 @@ const decoder = new TextDecoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, application: { enableConnectProtocol: true, enableDatagrams: true }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('settings-ok')); - this.writer.endSync(); - }), onapplication: mustCall((appopt) => { strictEqual(appopt.enableDatagrams, true); strictEqual(appopt.enableConnectProtocol, false); @@ -180,14 +178,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/settings', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/settings', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs index 67286d9a5ad6a9..957e274cfbf2e7 100644 --- a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); @@ -39,13 +39,11 @@ const clientSession = await connect(serverEndpoint.address, { await clientSession.opened; // Create a stream with headers, then immediately destroy it. -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/destroyed', - ':scheme': 'https', - ':authority': 'localhost', - }, +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/destroyed', + ':scheme': 'https', + ':authority': 'localhost', }); // Destroy the stream before headers can be sent/processed. diff --git a/test/parallel/test-quic-h3-stream-idle-timeout.mjs b/test/parallel/test-quic-h3-stream-idle-timeout.mjs index 5c851caca42465..4d7b59ca87a5b7 100644 --- a/test/parallel/test-quic-h3-stream-idle-timeout.mjs +++ b/test/parallel/test-quic-h3-stream-idle-timeout.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); @@ -34,6 +34,8 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + // Receive headers but do nothing — let the stream go idle. + stream.onheaders = () => {}; // Don't read — let the stream sit idle after the initial headers. // The stream idle timeout should destroy it, rejecting stream.closed. await rejects(stream.closed, { @@ -44,9 +46,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 100, - onheaders() { - // Receive headers but do nothing — let the stream go idle. - }, }); const clientSession = await connect(serverEndpoint.address, { @@ -59,15 +58,12 @@ const encoder = new TextEncoder(); // Send a POST request with a body byte so the server creates the // stream, then stop sending (don't end the write side). - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders() {}, - }); + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: () => {} }); const writer = stream.writer; writer.writeSync(encoder.encode('x')); @@ -91,6 +87,11 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + // Send response headers so the stream is fully established. + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); const data = await text(stream); strictEqual(data, 'xy'); serverGotData.resolve(); @@ -99,11 +100,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 500, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - // Send response headers so the stream is fully established. - this.sendHeaders({ ':status': '200' }, { terminal: true }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -112,8 +108,10 @@ const encoder = new TextEncoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - onheaders() {}, + // This test needs to send headers separately with an explicit terminal + // flag, so open the request stream without headers and send them later. + const stream = await clientSession.request(undefined, { + onheaders: () => {}, }); stream.sendHeaders({ ':method': 'POST', @@ -141,6 +139,9 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); const data = await text(stream); strictEqual(data, 'xy'); streamSurvived.resolve(); @@ -151,9 +152,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 0, // Disabled - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }, { terminal: true }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -162,8 +160,8 @@ const encoder = new TextEncoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - onheaders() {}, + const stream = await clientSession.request(undefined, { + onheaders: () => {}, }); stream.sendHeaders({ ':method': 'POST', diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index 436cb243b3dd99..fcd0b5d31640c8 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -21,7 +21,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -52,32 +52,32 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Send response headers. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + // Write body and close. + const w = stream.writer; + w.writeSync(encoder.encode(responseBody)); + w.endSync(); + }); + // Fires after the body is fully sent (EOF + NO_END_STREAM). + // The server provides trailing headers here. + stream.onwanttrailers = mustCall(() => { + stream.sendTrailers({ + 'x-checksum': 'abc123', + 'x-request-id': '42', + }); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Send response headers. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - // Write body and close. - const w = this.writer; - w.writeSync(encoder.encode(responseBody)); - w.endSync(); - }), - // Fires after the body is fully sent (EOF + NO_END_STREAM). - // The server provides trailing headers here. - onwanttrailers: mustCall(function() { - this.sendTrailers({ - 'x-checksum': 'abc123', - 'x-request-id': '42', - }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -89,18 +89,17 @@ await clientSession.opened; const clientHeadersReceived = Promise.withResolvers(); const clientTrailersReceived = Promise.withResolvers(); -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/with-trailers', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/with-trailers', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), - ontrailers: mustCall(function(trailers) { + ontrailers: mustCall((trailers) => { strictEqual(trailers['x-checksum'], 'abc123'); strictEqual(trailers['x-request-id'], '42'); clientTrailersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs b/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs index e724b6b04a485b..d90da39eba471c 100644 --- a/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs @@ -16,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey, randomBytes } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index 41f77a63a1f980..3875b14c1e3435 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey, randomBytes } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -38,17 +38,17 @@ async function getTicket(endpointOptions) { const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); }); }), { sni, ...endpointOptions, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const cs = await connect(ep.address, { @@ -69,14 +69,13 @@ async function getTicket(endpointOptions) { await cs.opened; await Promise.all([gotTicket.promise, gotToken.promise]); - const s = await cs.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/ticket', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const s = await cs.request({ + ':method': 'GET', + ':path': '/ticket', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -114,13 +113,11 @@ async function attemptRejected0RTT(endpointOptions, ticket, token) { // or datagram is sent. When 0-RTT is rejected, the stream is // destroyed by EarlyDataRejected — its closed promise rejects // with an application error. - const s = await cs.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/rejected', - ':scheme': 'https', - ':authority': 'localhost', - }, + const s = await cs.request({ + ':method': 'GET', + ':path': '/rejected', + ':scheme': 'https', + ':authority': 'localhost', }); await rejects(s.closed, { code: 'ERR_QUIC_APPLICATION_ERROR', diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index 4e51958d7c864a..4a927993b8961d 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -37,6 +37,11 @@ const secondDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall((ss) => { const num = ++serverSessionCount; ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + }); if (num === 2) { // Resolve with the stream so we can check stream.early after // data has been received (the early flag is set after @@ -48,11 +53,6 @@ const serverEndpoint = await listen(mustCall((ss) => { }); }, 2), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - }, 2), }); // --- First connection: establish H3 session, receive ticket --- @@ -78,14 +78,13 @@ strictEqual(info1.earlyDataAccepted, false); await Promise.all([gotTicket.promise, gotToken.promise]); -const s1 = await cs1.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/first', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const s1 = await cs1.request({ + ':method': 'GET', + ':path': '/first', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -106,14 +105,13 @@ const cs2 = await connect(serverEndpoint.address, { }); // Send H3 request BEFORE handshake completes — true 0-RTT. -const s2 = await cs2.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/early', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const s2 = await cs2.request({ + ':method': 'GET', + ':path': '/early', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-require-resolve.js b/test/parallel/test-require-resolve.js index 44422706e186cc..049f90a68c9ddd 100644 --- a/test/parallel/test-require-resolve.js +++ b/test/parallel/test-require-resolve.js @@ -62,6 +62,8 @@ require(fixtures.path('resolve-paths', 'default', 'verify-paths.js')); builtinModules.forEach((mod) => { // TODO(@jasnell): Remove once node:quic is no longer flagged if (mod === 'node:quic') return; + // TODO: Remove once node:http3 is no longer flagged + if (mod === 'node:http3') return; // TODO: Remove once node:ffi is no longer flagged if (mod === 'node:ffi') return; // Remove once node:vfs is no longer flagged From 1ed41b75273414490408ab9db6221e0caf297d63 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 11 Jun 2026 12:08:56 +0200 Subject: [PATCH 06/15] quic: defer server session emit until TLS ClientHello is processed This ensures we don't fire session events for totally invalid TLS handshakes - fundamental errors, bad SNI/ALPN values, or anything else that our TLS config would reject. Instead, it means servers can access servername & alpnProtocol synchronously as soon as the event is fired - all key session data is available and it's immediately usable. We don't want to defer further to handshake completed, since that'd be an extra RT, and defeat 0RTT benefits entirely. ClientHello processed without errors is sufficient for now. This isn't a security mechanism. Existing structures will defer actually sending & receiving anything that's not marked explicitly as early data until the handshake completes. --- lib/internal/quic/http3.js | 4 + lib/internal/quic/quic.js | 26 +++++ src/quic/endpoint.cc | 29 +++--- src/quic/session.cc | 97 +++++++++++++++++++ src/quic/session.h | 18 ++++ src/quic/tlscontext.cc | 1 + test/parallel/test-quic-alpn-mismatch.mjs | 17 ++-- .../test-quic-session-emit-ordering.mjs | 48 +++++++++ test/parallel/test-quic-sni-mismatch.mjs | 24 ++--- test/parallel/test-quic-zero-rtt.mjs | 6 +- 10 files changed, 231 insertions(+), 39 deletions(-) create mode 100644 test/parallel/test-quic-session-emit-ordering.mjs diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index fbf07b0c2aa85e..db9503fbf1bcaa 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -164,6 +164,10 @@ class Http3Session { // The underlying QUIC session (transitional escape hatch). get session() { return this.#session; } + get servername() { return this.#session.servername; } + + get alpnProtocol() { return this.#session.alpnProtocol; } + get onstream() { return this.#onstream; } set onstream(fn) { if (fn !== undefined) validateFunction(fn, 'onstream'); diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 1fed2bb0903491..68ee1ecf9e4ba7 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -747,6 +747,12 @@ setCallbacks({ * Called when the QuicEndpoint C++ handle receives a new server-side session * @param {object} session The QuicSession C++ handle */ + /** + * Called when a new server session is surfaced. The emit happens after + * the session's first datagram (the ClientHello) has been processed, + * so the session's servername/protocol getters are already readable. + * @param {object} session The session handle + */ onSessionNew(session) { debug('new server session callback', this[kOwner], session); this[kOwner][kNewSession](session); @@ -2877,6 +2883,26 @@ class QuicSession { } } + /** + * The SNI servername, or undefined when none was sent. + * @type {string|undefined} + */ + get servername() { + assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#handle.getServername(); + } + + /** + * The negotiated ALPN protocol. + * @type {string|undefined} + */ + get alpnProtocol() { + assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#handle.getAlpnProtocol(); + } + /** @type {OnDatagramCallback} */ get ondatagram() { assertIsQuicSession(this); diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 5a728a0a2a147e..dfce2fbb20ec06 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -814,8 +814,8 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr session) { // For server sessions, associate the client's original DCID (ocid) so // that 0-RTT packets arriving in a separate UDP datagram can be routed // to this session. This must happen after the session is added (so - // FindSession can resolve the mapping) but before EmitNewSession (which - // runs JS and may yield to libuv, allowing the 0-RTT packet to arrive). + // FindSession can resolve the mapping) and before any JS runs (which + // may yield to libuv, allowing the 0-RTT packet to arrive). if (session->is_server() && session->config().ocid) { AssociateCID(session->config().ocid, session->config().scid); } @@ -825,22 +825,21 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr session) { if (session->is_server() && session->config().retry_scid) { AssociateCID(session->config().retry_scid, session->config().scid); } - // Increment the primary session count and ref the handle BEFORE - // EmitNewSession. EmitNewSession calls into JS, which may close/destroy - // the session synchronously. The session's ~Impl calls RemoveSession - // which decrements the count. If we increment after EmitNewSession, - // RemoveSession would see count=0 and the count would be permanently - // off by one. + // Increment the primary session count and ref the handle BEFORE any + // JS can run for this session (the deferred EmitNewSession, or packet + // processing callbacks). JS may close/destroy the session + // synchronously; the session's ~Impl calls RemoveSession which + // decrements the count. If we incremented after, RemoveSession would + // see count=0 and the count would be permanently off by one. if (primary_session_count_++ == 0) { idle_timer_.Stop(); udp_.Ref(); } if (session->is_server()) { STAT_INCREMENT(Stats, server_sessions); - // We only emit the new session event for server sessions. - EmitNewSession(session); - // It is important to note that the session may be closed/destroyed - // when it is emitted here. + // Note that we don't emit new sessions here - that's deferred until + // the first datagram is processed (see accept path) to expose only + // sessions that successfully complete their TLS handshake. } else { STAT_INCREMENT(Stats, client_sessions); } @@ -1980,6 +1979,12 @@ void Endpoint::EmitNewSession(const BaseObjectPtr& session) { // the call to MakeCallback. If that's the case, the session object still // exists but it is in a destroyed state. Care should be taken accessing // session after this point. + + // Deliver any stream events that were held until the stream was setup, + // e.g. 0-RTT streams from the first flight. + if (!session->is_destroyed()) { + session->ReplayDeferredEmits(); + } } void Endpoint::EmitClose(CloseContext context, int status) { diff --git a/src/quic/session.cc b/src/quic/session.cc index 780b50620d49eb..4ccdc8c8607c99 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -184,6 +184,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { #define SESSION_JS_METHODS(V) \ V(Destroy, destroy, SIDE_EFFECT) \ V(GetRemoteAddress, getRemoteAddress, NO_SIDE_EFFECT) \ + V(GetServername, getServername, NO_SIDE_EFFECT) \ + V(GetAlpnProtocol, getAlpnProtocol, NO_SIDE_EFFECT) \ V(GetLocalAddress, getLocalAddress, NO_SIDE_EFFECT) \ V(GetCertificate, getCertificate, NO_SIDE_EFFECT) \ V(GetEphemeralKeyInfo, getEphemeralKey, NO_SIDE_EFFECT) \ @@ -793,6 +795,8 @@ struct Session::Impl final : public MemoryRetainer { SocketAddress remote_address_; std::unique_ptr application_; StreamsMap streams_; + // Emits deferred until after session setup is completed + std::vector> deferred_emits_; TimerWrapHandle timer_; size_t send_scope_depth_ = 0; QuicError last_error_; @@ -1013,6 +1017,36 @@ struct Session::Impl final : public MemoryRetainer { session->Destroy(); } + // The SNI servername from the TLS handshake; empty (-> undefined) only if + // none was sent. + JS_METHOD(GetServername) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return; + auto sn = session->tls_session().servername(); + if (sn.empty()) return; + Local ret; + if (ToV8Value(env->context(), sn).ToLocal(&ret)) { + args.GetReturnValue().Set(ret); + } + } + + // The negotiated ALPN protocol. Undefined only for clients before the + // handshake is completed, as ALPN is mandatory for QUIC. + JS_METHOD(GetAlpnProtocol) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return; + auto proto = session->tls_session().protocol(); + if (proto.empty()) return; + Local ret; + if (ToV8Value(env->context(), proto).ToLocal(&ret)) { + args.GetReturnValue().Set(ret); + } + } + JS_METHOD(GetRemoteAddress) { auto env = Environment::GetCurrent(args); Session* session; @@ -2672,6 +2706,13 @@ const Session::Options& Session::options() const { void Session::EmitQlog(uint32_t flags, std::string_view data) { if (!env()->can_call_into_js()) return; + if (!is_destroyed() && must_defer_emits()) { + auto held = std::string(data); + impl_->deferred_emits_.emplace_back( + [this, flags, held]() { EmitQlog(flags, held); }); + return; + } + bool fin = (flags & NGTCP2_QLOG_WRITE_FLAG_FIN) != 0; // Fun fact... ngtcp2 does not emit the final qlog statement until the @@ -2794,6 +2835,16 @@ bool Session::ReadPacket(const uint8_t* data, // Process deferred operations that couldn't run inside callback // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). application().PostReceive(); + // Surface a server session to JS once its ClientHello has been + // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). + // Held first-flight events - including 0-RTT request streams - replay + // at emit. The !wrapped guard makes this fire exactly once, on + // whichever packet completes the ClientHello (so a multi-datagram + // ClientHello is handled correctly). + if (is_server() && hello_processed_ && !impl_->state()->wrapped && + !is_destroyed()) { + endpoint().EmitNewSession(BaseObjectPtr(this)); + } } return true; } @@ -3492,6 +3543,28 @@ void Session::set_wrapped() { impl_->state()->wrapped = 1; } +bool Session::must_defer_emits() const { + // Server sessions are surfaced to JS (via the deferred new-session + // emit) only after their first datagram has been processed; anything + // emitted before then has no JS wrapper to receive it and must be + // held for replay. + return is_server() && !impl_->state()->wrapped; +} + +void Session::ReplayDeferredEmits() { + // Once we're firing events, the server session is active: + active_ = true; + if (is_destroyed()) return; + DCHECK(impl_->state()->wrapped); + // Runs synchronously immediately after the new-session callback + // returns (still within first-datagram processing). + auto emits = std::move(impl_->deferred_emits_); + for (auto& emit : emits) { + if (is_destroyed()) return; + emit(); + } +} + void Session::set_priority_supported(bool on) { DCHECK(!is_destroyed()); impl_->state()->priority_supported = on ? 1 : 0; @@ -3801,6 +3874,9 @@ bool Session::HandshakeCompleted() { Debug(this, "Session handshake completed"); impl_->state()->handshake_completed = 1; + // For a client, once the handshake is completed, we're active. The server + // is active earlier, as 0RTT etc can fire before handshake completion. + active_ = true; STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); @@ -4015,6 +4091,14 @@ void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { DCHECK(!is_destroyed()); if (!env()->can_call_into_js()) return; + if (must_defer_emits()) { + auto held = std::make_shared(std::move(datagram)); + impl_->deferred_emits_.emplace_back([this, held, flag]() { + EmitDatagram(std::move(*held), flag); + }); + return; + } + CallbackScope cbv_scope(this); Local argv[] = {datagram.ToUint8Array(env()), @@ -4259,6 +4343,12 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) { void Session::EmitStream(const BaseObjectWeakPtr& stream) { DCHECK(!is_destroyed()); + if (must_defer_emits()) { + impl_->deferred_emits_.emplace_back( + [this, stream]() { EmitStream(stream); }); + return; + } + if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -4342,6 +4432,13 @@ void Session::EmitKeylog(const char* line) { if (!env()->can_call_into_js()) return; auto str = std::string(line); + + if (must_defer_emits()) { + impl_->deferred_emits_.emplace_back( + [this, str]() { EmitKeylog(str.c_str()); }); + return; + } + Local argv[] = {Undefined(env()->isolate())}; if (!ToV8Value(env()->context(), str).ToLocal(&argv[0])) { Debug(this, "Failed to convert keylog line to V8 string"); diff --git a/src/quic/session.h b/src/quic/session.h index d9c7a40d7f6c28..6cdb7ea0809398 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -366,6 +366,10 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { TLSSession& tls_session() const; bool has_application() const; Application& application() const; + + // True once the session has started delivering events to JS, which we use + // as a gate for attaching an Application - it has to happen before this. + bool is_active() const { return active_; } const Config& config() const; const Options& options() const; const SocketAddress& remote_address() const; @@ -606,11 +610,21 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // before the handshake completes. void PopulateEarlyTransportParamsState(); + void set_hello_processed() { hello_processed_ = true; } + // It's a terrible name but "wrapped" here means that the Session has been // passed out to JavaScript and should be "wrapped" by whatever handler is // defined there to manage it. void set_wrapped(); + // True while JS emits must be held for later replay, before the handshake + // is complete and the server session event has been emitted. + bool must_defer_emits() const; + + // Replays, in order, any emits held while must_defer_emits() was true. + // Called synchronously right after the new-session emit. + void ReplayDeferredEmits(); + enum class CloseMethod : uint8_t { // Immediate close with a roundtrip through JavaScript, causing all // currently opened streams to be closed. An attempt will be made to @@ -726,6 +740,10 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { }; Flags flags_; + bool hello_processed_ = false; + + bool active_ = false; + QuicConnectionPointer connection_; std::unique_ptr tls_session_; friend struct NgTcp2CallbackScope; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index c0a1610540ed3a..a5c0563d01bd80 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -375,6 +375,7 @@ int TLSContext::OnSelectAlpn(SSL* ssl, return SSL_TLSEXT_ERR_NOACK; } session.SetApplication(std::move(app)); + session.set_hello_processed(); return SSL_TLSEXT_ERR_OK; } diff --git a/test/parallel/test-quic-alpn-mismatch.mjs b/test/parallel/test-quic-alpn-mismatch.mjs index 64d8e1f0006380..f97b11b919de9f 100644 --- a/test/parallel/test-quic-alpn-mismatch.mjs +++ b/test/parallel/test-quic-alpn-mismatch.mjs @@ -8,7 +8,7 @@ // 0x100 | . For `no_application_protocol` (alert 120 / 0x78) this // is 0x178 == 376. -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; const { rejects, strictEqual, match } = assert; @@ -29,16 +29,15 @@ const onerror = mustCall((err) => { strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR'); strictEqual(err.errorCode, 376n); match(err.message, /no application protocol/); -}, 2); +}); const transportParams = { maxIdleTimeout: 1 }; -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await rejects(serverSession.opened, expected); - await rejects(serverSession.closed, expected); -}), { - transportParams, - onerror, -}); +// The handshake fails so the session is never surfaced to JS +const serverEndpoint = await listen( + mustNotCall('server session must not be surfaced for a failed handshake'), + { + transportParams, + }); // Client requests an ALPN the server doesn't offer. const clientSession = await connect(serverEndpoint.address, { diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs new file mode 100644 index 00000000000000..59fb521183bda9 --- /dev/null +++ b/test/parallel/test-quic-session-emit-ordering.mjs @@ -0,0 +1,48 @@ +// Flags: --experimental-quic --no-warnings --expose-internals + +// Check that server `onsession` emit fires only after the session's +// ClientHello has been processed & validated. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, notStrictEqual, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); +const { listen, connect } = await import('../common/quic.mjs'); + +const sessionSeen = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + // All assertions run synchronously in the onsession emit frame. + + // The TLS details from the ClientHello are readable on the session. + strictEqual(serverSession.servername, 'localhost'); + strictEqual(serverSession.alpnProtocol, 'quic-test'); + + // The client's transport params arrived in the first flight and have + // been processed by the time the session is surfaced. + const params = serverSession.remoteTransportParams; + notStrictEqual(params, undefined); + notStrictEqual(params, null); + ok(params.initialMaxStreamsBidi >= 0n); + + // ALPN negotiation has completed: headers support is resolved (2 = + // unsupported, confirming non-h3 test ALPN) + strictEqual(getQuicSessionState(serverSession).headersSupported, 2); + + sessionSeen.resolve(); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; +await sessionSeen.promise; + +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-sni-mismatch.mjs b/test/parallel/test-quic-sni-mismatch.mjs index dbb2de4c011a30..e27059222a8ece 100644 --- a/test/parallel/test-quic-sni-mismatch.mjs +++ b/test/parallel/test-quic-sni-mismatch.mjs @@ -5,7 +5,7 @@ // and no wildcard is configured. The handshake should fail with a // TLS alert (unrecognized_name). -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; @@ -24,21 +24,15 @@ const cert = readKey('agent1-cert.pem'); // Server only has an entry for 'specific.example.com', no wildcard. // Connections to any other hostname will be rejected at the TLS level. -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await rejects(serverSession.opened, { - code: 'ERR_QUIC_TRANSPORT_ERROR', +// The handshake fails while the server processes the client's first +// flight, so the session is never surfaced to JS. +const serverEndpoint = await listen( + mustNotCall('server session must not be surfaced for a failed handshake'), + { + sni: { 'specific.example.com': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], + transportParams: { maxIdleTimeout: 1 }, }); - await rejects(serverSession.closed, { - code: 'ERR_QUIC_TRANSPORT_ERROR', - }); -}), { - sni: { 'specific.example.com': { keys: [key], certs: [cert] } }, - alpn: ['quic-test'], - transportParams: { maxIdleTimeout: 1 }, - onerror: mustCall((err) => { - strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR'); - }), -}); // Client connects with a different servername — no matching identity. const clientSession = await connect(serverEndpoint.address, { diff --git a/test/parallel/test-quic-zero-rtt.mjs b/test/parallel/test-quic-zero-rtt.mjs index ef1a345541045b..e979bb7f898bf2 100644 --- a/test/parallel/test-quic-zero-rtt.mjs +++ b/test/parallel/test-quic-zero-rtt.mjs @@ -90,15 +90,15 @@ const cs2 = await connect(serverEndpoint.address, { }); // Send data BEFORE the handshake completes — true 0-RTT. -const s2 = await cs2.createBidirectionalStream({ - body: encoder.encode('early data'), -}); +const s2 = await cs2.createBidirectionalStream(); +await s2.writer.write(encoder.encode('early data')); // Now wait for handshake completion. const info2 = await cs2.opened; strictEqual(info2.earlyDataAttempted, true); strictEqual(info2.earlyDataAccepted, true); +await s2.writer.end(); for await (const _ of s2) { /* drain */ } // eslint-disable-line no-unused-vars await s2.closed; From 15d69966681d1ae9164c68838254b00a9cb98049 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 11 Jun 2026 13:59:57 +0200 Subject: [PATCH 07/15] quic: use 'kApplication' not ALPN to explicitly connect QUIC to H3 --- lib/internal/quic/http3.js | 4 +- lib/internal/quic/quic.js | 66 +++++++++++-------- src/quic/application.cc | 36 ++++++++++ src/quic/application.h | 12 ++++ src/quic/bindingdata.cc | 2 + src/quic/bindingdata.h | 1 + src/quic/http3.cc | 4 ++ src/quic/http3.h | 2 + src/quic/session.cc | 19 +++--- src/quic/session.h | 10 +-- src/quic/tlscontext.cc | 2 +- src/quic/tlscontext.h | 2 +- test/parallel/test-quic-alpn-h3.mjs | 25 ++++++- test/parallel/test-quic-alpn.mjs | 15 ++++- ...quic-internal-endpoint-listen-defaults.mjs | 8 +-- .../test-quic-session-stream-lifecycle.mjs | 3 +- 16 files changed, 154 insertions(+), 57 deletions(-) diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index db9503fbf1bcaa..4b78ed4029810c 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -14,6 +14,7 @@ const { listen: quicListen, QuicSession, QuicStream, + kApplication, } = require('internal/quic/quic'); const { @@ -224,6 +225,7 @@ async function connect(address, options = kEmptyObject) { const session = await quicConnect(address, { ...options, alpn: kHttp3Alpn, + [kApplication]: 'http3', }); return new Http3Session(session); } @@ -240,7 +242,7 @@ async function listen(onsession, options = kEmptyObject) { validateObject(options, 'options'); return quicListen((session) => { onsession(new Http3Session(session)); - }, { ...options, alpn: kHttp3Alpn }); + }, { ...options, alpn: kHttp3Alpn, [kApplication]: 'http3' }); } module.exports = { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 68ee1ecf9e4ba7..1a90f96315129a 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -4974,35 +4974,35 @@ function processTlsOptions(tls, forServer) { validateBoolean(tlsTrace, 'options.tlsTrace'); // Encode the ALPN option to wire format (length-prefixed protocol names). - // Server: array of protocol names. Client: single protocol name. - // If not specified, the C++ default (h3) is used. - let encodedAlpn; - if (alpn !== undefined) { - const protocols = forServer ? - (ArrayIsArray(alpn) ? alpn : [alpn]) : - [alpn]; - if (!forServer) { - validateString(alpn, 'options.alpn'); - } - let totalLen = 0; - for (let i = 0; i < protocols.length; i++) { - validateString(protocols[i], `options.alpn[${i}]`); - if (protocols[i].length === 0 || protocols[i].length > 255) { - throw new ERR_INVALID_ARG_VALUE(`options.alpn[${i}]`, protocols[i], - 'must be between 1 and 255 characters'); - } - totalLen += 1 + protocols[i].length; - } - // Build wire format: [len1][name1][len2][name2]... - const buf = Buffer.allocUnsafe(totalLen); - let offset = 0; - for (let i = 0; i < protocols.length; i++) { - buf[offset++] = protocols[i].length; - buf.write(protocols[i], offset, 'ascii'); - offset += protocols[i].length; - } - encodedAlpn = buf.toString('latin1'); - } + if (alpn === undefined) { + throw new ERR_INVALID_ARG_VALUE( + 'options.alpn', alpn, + 'is required'); + } + const protocols = forServer ? + (ArrayIsArray(alpn) ? alpn : [alpn]) : + [alpn]; + if (!forServer) { + validateString(alpn, 'options.alpn'); + } + let totalLen = 0; + for (let i = 0; i < protocols.length; i++) { + validateString(protocols[i], `options.alpn[${i}]`); + if (protocols[i].length === 0 || protocols[i].length > 255) { + throw new ERR_INVALID_ARG_VALUE(`options.alpn[${i}]`, protocols[i], + 'must be between 1 and 255 characters'); + } + totalLen += 1 + protocols[i].length; + } + // Build wire format: [len1][name1][len2][name2]... + const buf = Buffer.allocUnsafe(totalLen); + let offset = 0; + for (let i = 0; i < protocols.length; i++) { + buf[offset++] = protocols[i].length; + buf.write(protocols[i], offset, 'ascii'); + offset += protocols[i].length; + } + const encodedAlpn = buf.toString('latin1'); if (ca !== undefined) { const caInputs = ArrayIsArray(ca) ? ca : [ca]; @@ -5195,6 +5195,7 @@ function processSessionOptions(options, config = kEmptyObject) { // HTTP/3 application-specific options. Nested under `application` // to separate protocol-specific settings from transport-level ones. application = kEmptyObject, + // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately // after the handshake. @@ -5226,6 +5227,10 @@ function processSessionOptions(options, config = kEmptyObject) { targetAddress, } = config; + const applicationName = options[kApplication]; + assert(applicationName === undefined || typeof applicationName === 'string', + 'options[kApplication] must be a registered application name'); + if (token !== undefined) { if (!isArrayBufferView(token)) { throw new ERR_INVALID_ARG_TYPE('options.token', @@ -5329,6 +5334,7 @@ function processSessionOptions(options, config = kEmptyObject) { maxDatagramSendAttempts, streamIdleTimeout, application, + applicationName, onerror, onstream, ondatagram, @@ -5465,6 +5471,8 @@ module.exports = { CC_ALGO_BBR, DEFAULT_CIPHERS, DEFAULT_GROUPS, + // Internal only for http3+quic integration + kApplication, // These are exported only for internal testing purposes. getQuicStreamState, getQuicSessionState, diff --git a/src/quic/application.cc b/src/quic/application.cc index 6a761873cdb426..46ebaafdfa0493 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -10,6 +10,9 @@ #include #include #include +#include +#include +#include #include "application.h" #include "defs.h" #include "endpoint.h" @@ -165,6 +168,39 @@ MaybeLocal Session::Application_Options::ToObject( // ============================================================================ +namespace { +struct ApplicationFactoryEntry { + std::string name; + ApplicationFactory factory; +}; +// Process-wide registry. Registered once per process at binding +// initialization (registration is idempotent for repeated binding inits, +// e.g. workers); intentionally leaked, process lifetime. +std::mutex application_factories_mutex; +std::vector* application_factories = nullptr; +} // namespace + +void RegisterApplicationFactory(std::string_view name, + ApplicationFactory factory) { + std::lock_guard lock(application_factories_mutex); + if (application_factories == nullptr) { + application_factories = new std::vector(); + } + for (const auto& entry : *application_factories) { + if (entry.factory == factory) return; + } + application_factories->push_back({std::string(name), factory}); +} + +ApplicationFactory FindApplicationFactory(std::string_view name) { + std::lock_guard lock(application_factories_mutex); + if (application_factories == nullptr) return nullptr; + for (const auto& entry : *application_factories) { + if (entry.name == name) return entry.factory; + } + return nullptr; +} + Session::Application::Application(Session* session, const Options& options) : session_(session) {} diff --git a/src/quic/application.h b/src/quic/application.h index 5306cb1b67d7b6..c9f1a900ece3d2 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include +#include #include #include @@ -266,6 +267,17 @@ class Session::Application : public MemoryRetainer { std::unique_ptr CreateDefaultApplication( Session* session, const Session::Application_Options& options); +// A factory for protocol-specific Session::Application implementations. +// Protocols register themselves under a name at binding initialization +// (e.g. "http3"); a session installs one only when its options request +// that name explicitly. +using ApplicationFactory = std::unique_ptr (*)( + Session* session, const Session::Application_Options& options); +void RegisterApplicationFactory(std::string_view name, + ApplicationFactory factory); +// Returns the factory registered under name, or nullptr. +ApplicationFactory FindApplicationFactory(std::string_view name); + } // namespace node::quic #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index 31467a8477a792..6ad52b8c14af55 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -7,6 +7,7 @@ #include #include #include +#include "http3.h" #include #include #include @@ -303,6 +304,7 @@ BindingData::BindingData(Realm* realm, Local object) MakeWeak(); // Unref so the check handle doesn't keep the event loop alive on its own. flush_check_.Unref(); + RegisterHttp3Application(); } SessionManager& BindingData::session_manager() { diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 4184cf20ea5328..94c381262a0ce7 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -88,6 +88,7 @@ class SessionManager; V(disable_stateless_reset, "disableStatelessReset") \ V(draining_period_multiplier, "drainingPeriodMultiplier") \ V(enable_connect_protocol, "enableConnectProtocol") \ + V(applicationName, "applicationName") \ V(enable_early_data, "enableEarlyData") \ V(enable_datagrams, "enableDatagrams") \ V(enable_tls_trace, "tlsTrace") \ diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 135ce085bf88e9..b28cc0271d091a 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -1426,6 +1426,10 @@ std::unique_ptr CreateHttp3Application( return std::make_unique(session, options); } +void RegisterHttp3Application() { + RegisterApplicationFactory("http3", CreateHttp3Application); +} + } // namespace quic } // namespace node #endif // OPENSSL_NO_QUIC diff --git a/src/quic/http3.h b/src/quic/http3.h index f1a1b674d96903..ee3232cdebbba2 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -15,6 +15,8 @@ namespace node::quic { std::unique_ptr CreateHttp3Application( Session* session, const Session::Application_Options& options); +void RegisterHttp3Application(); + // Parse HTTP/3 specific session ticket app data. Called from // Application::ParseTicketData() when the type byte is HTTP3. // The data includes the type byte prefix. diff --git a/src/quic/session.cc b/src/quic/session.cc index 4ccdc8c8607c99..2feb4a428f8abb 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -23,7 +23,6 @@ #include "data.h" #include "defs.h" #include "endpoint.h" -#include "http3.h" #include "ncrypto.h" #include "packet.h" #include "preferredaddress.h" @@ -249,8 +248,6 @@ SessionStatsArena& GetSessionStatsArena(BindingData& binding) { // ============================================================================ -class Http3Application; - namespace { constexpr std::string to_string(PreferredAddress::Policy policy) { switch (policy) { @@ -617,6 +614,7 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || + !SET(applicationName) || !SET(handshake_timeout) || !SET(initial_rtt) || !SET(keep_alive_timeout) || !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || @@ -2279,8 +2277,7 @@ Session::Session(Endpoint* endpoint, // known upfront from the options. For servers, application_ stays // null until OnSelectAlpn fires during the TLS handshake. if (config.side == Side::CLIENT) { - auto app = - SelectApplicationFromAlpn(DecodeAlpn(config.options.tls_options.alpn)); + auto app = SelectApplication(); if (app) SetApplication(std::move(app)); } @@ -2632,12 +2629,12 @@ std::string_view Session::DecodeAlpn(std::string_view wire) { return {}; } -std::unique_ptr Session::SelectApplicationFromAlpn( - std::string_view alpn) { - // h3 and h3-XX variants use Http3ApplicationImpl. - // Everything else uses DefaultApplication. - if (alpn == "h3" || (alpn.size() > 3 && alpn.substr(0, 3) == "h3-")) { - return CreateHttp3Application(this, config().options.application_options); +std::unique_ptr Session::SelectApplication() { + const auto& name = config().options.applicationName; + if (!name.empty()) { + auto factory = FindApplicationFactory(name); + CHECK_NOT_NULL(factory); + return factory(this, config().options.application_options); } return CreateDefaultApplication(this, config().options.application_options); } diff --git a/src/quic/session.h b/src/quic/session.h index 6cdb7ea0809398..691461c23ca4d5 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -126,10 +126,10 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // Decode the first ALPN protocol name from wire format (length-prefixed). static std::string_view DecodeAlpn(std::string_view wire); - // Select the Application implementation based on the negotiated ALPN. - // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map - // to DefaultApplication. Sets the application_type state field. - std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); + // Select the Application implementation: the factory registered under + // options.applicationName when set (see application.h), otherwise the default + // raw-stream application. + std::unique_ptr SelectApplication(); // Install the Application on the session. Called at construction for // clients (ALPN known upfront) or from OnSelectAlpn for servers @@ -174,6 +174,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // ALPN selects Http3ApplicationImpl). Application_Options application_options = Application_Options::kDefault; + std::string applicationName; + // When true, QLog output will be enabled for the session. bool qlog = false; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index a5c0563d01bd80..daafb3df0ded44 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -367,7 +367,7 @@ int TLSContext::OnSelectAlpn(SSL* ssl, std::string(negotiated).c_str()); auto& session = tls_session.session(); - auto app = session.SelectApplicationFromAlpn(negotiated); + auto app = session.SelectApplication(); if (!app) { Debug(&session, "Failed to create Application for ALPN %s", diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index ba502cf1f868df..ffeced88810c4a 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -184,7 +184,7 @@ class TLSContext final : public MemoryRetainer, // The ALPN protocol identifier(s) in wire format (length-prefixed, // concatenated). For clients this is a single entry. For servers // this may contain multiple entries in preference order. - std::string alpn = NGHTTP3_ALPN_H3; + std::string alpn; // The list of TLS ciphers to use for this session. std::string ciphers = DEFAULT_CIPHERS; diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index e9adca59d1aeca..831ca11c99dc61 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -1,4 +1,4 @@ -// Flags: --experimental-quic --no-warnings +// Flags: --experimental-quic --no-warnings --expose-internals import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -17,30 +17,49 @@ const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); -// Test h3 ALPN negotiation with Http3ApplicationImpl. -// Both server and client use the default ALPN (h3). +// Test that the h3 ALPN negotiates normally but doesn't auto-activate +// HTTP/3. ALPN is reported, not type-changing. HTTP/3 is used via +// the 'node:http3' module explicitly. +// Both server and client explicitly configure the h3 ALPN. + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); const serverOpened = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { + // ALPN negotiated h3, but with no application requested this is a RAW + // session: the h3 application is not installed (headersSupported === + // 2, i.e. unsupported). + strictEqual(serverSession.alpnProtocol, 'h3'); + strictEqual(getQuicSessionState(serverSession).headersSupported, 2); const info = await serverSession.opened; strictEqual(info.protocol, 'h3'); serverOpened.resolve(); serverSession.close(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); notStrictEqual(serverEndpoint.address, undefined); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', + // Application config is internal-only (kApplication) - user options are ignored + // Applications integrate natively with their consumers so custom options + // would reliably break things. + applicationName: 'http3', }); async function checkClient() { const info = await clientSession.opened; strictEqual(info.protocol, 'h3'); + // Still a raw session: + strictEqual(getQuicSessionState(clientSession).headersSupported, 2); } await Promise.all([serverOpened.promise, checkClient()]); diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs index 020fea3d308a86..73649c09ee87ed 100644 --- a/test/parallel/test-quic-alpn.mjs +++ b/test/parallel/test-quic-alpn.mjs @@ -1,10 +1,10 @@ // Flags: --experimental-quic --no-warnings -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { notStrictEqual, strictEqual } = assert; +const { notStrictEqual, strictEqual, rejects } = assert; const { readKey } = fixtures; if (!hasQuic) { @@ -46,4 +46,15 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([serverOpened.promise, checkSession(clientSession)]); await clientSession.close(); + +// ALPN is required: node:quic is transport-only and assumes no application +// protocol, so a session created without one is rejected with a clear error +// rather than silently defaulting. +await rejects(listen(mustNotCall(), { + sni: { '*': { keys: [key], certs: [cert] } }, +}), { code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/ }); +await rejects(connect(serverEndpoint.address, { verifyPeer: 'manual' }), { + code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/, +}); + await serverEndpoint.close(); diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs index 7dda0a6f28d865..5f496e74967dcd 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs @@ -29,7 +29,7 @@ ok(!state.isListening); strictEqual(endpoint.address, undefined); -await rejects(listen(123, { sni, endpoint }), { +await rejects(listen(123, { alpn: ['h3'], sni, endpoint }), { code: 'ERR_INVALID_ARG_TYPE', }); // Buffer is not detached. @@ -39,11 +39,11 @@ await rejects(listen(mustNotCall(), 123), { code: 'ERR_INVALID_ARG_TYPE', }); -await listen(mustNotCall(), { sni, endpoint }); +await listen(mustNotCall(), { alpn: ['h3'], sni, endpoint }); // Buffer is not detached. strictEqual(cert.buffer.detached, false); -await rejects(listen(mustNotCall(), { sni, endpoint }), { +await rejects(listen(mustNotCall(), { alpn: ['h3'], sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. @@ -67,7 +67,7 @@ strictEqual(endpoint.closed, endpoint.close()); await endpoint.closed; ok(endpoint.destroyed); -await rejects(listen(mustNotCall(), { sni, endpoint }), { +await rejects(listen(mustNotCall(), { alpn: ['h3'], sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. diff --git a/test/parallel/test-quic-session-stream-lifecycle.mjs b/test/parallel/test-quic-session-stream-lifecycle.mjs index a4bd287cdc6dcd..f1d7ebc36a191d 100644 --- a/test/parallel/test-quic-session-stream-lifecycle.mjs +++ b/test/parallel/test-quic-session-stream-lifecycle.mjs @@ -34,7 +34,7 @@ const serverEndpoint = await quic.listen(mustCall(async (serverSession) => { serverDone.resolve(); serverSession.close(); -}), { sni: { '*': { keys, certs } } }); +}), { alpn: ['h3'], sni: { '*': { keys, certs } } }); strictEqual(serverEndpoint.busy, false); strictEqual(serverEndpoint.closing, false); @@ -53,6 +53,7 @@ ok(epStats.createdAt > 0n); // Connect with a client const clientSession = await quic.connect(serverEndpoint.address, { + alpn: 'h3', verifyPeer: 'manual', }); From edcca84c0ea7ffead5a48669eb435546c4439268 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 16 Jun 2026 22:47:58 +0200 Subject: [PATCH 08/15] quic: make Applications optional, with raw stream support in sessions node:quic no longer requires an Application. When none is set, the session schedules streams on its own send queue and pulls/commits their data directly. This lets us drop DefaultApplication, to support the imminent HTTP 3 app changes coming. The Session also gains the data-plane dispatchers (GetStreamData/StreamCommit/ReceiveStream*/ScheduleStream) that route to an installed application of present or a native path otherwise, with the ngtcp2 callbacks guarding on whether an application is installed. HTTP/3 is unchanged (for now). --- src/quic/application.cc | 215 --------- src/quic/session.cc | 411 ++++++++++++++---- src/quic/session.h | 32 +- src/quic/streams.h | 2 +- src/quic/tlscontext.cc | 22 +- test/parallel/test-quic-alpn-h3.mjs | 67 --- .../parallel/test-quic-h3-headers-support.mjs | 78 ---- test/parallel/test-quic-h3-settings.mjs | 197 --------- .../test-quic-session-application-options.mjs | 105 ----- .../test-quic-session-emit-ordering.mjs | 48 -- 10 files changed, 381 insertions(+), 796 deletions(-) delete mode 100644 test/parallel/test-quic-alpn-h3.mjs delete mode 100644 test/parallel/test-quic-h3-headers-support.mjs delete mode 100644 test/parallel/test-quic-h3-settings.mjs delete mode 100644 test/parallel/test-quic-session-application-options.mjs delete mode 100644 test/parallel/test-quic-session-emit-ordering.mjs diff --git a/src/quic/application.cc b/src/quic/application.cc index 46ebaafdfa0493..5b2ddeed074ba6 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -295,221 +295,6 @@ void Session::Application::ReceiveStreamReset(Stream* stream, stream->ReceiveStreamReset(final_size, std::move(error)); } -// ============================================================================ -// The DefaultApplication is the default implementation of Session::Application -// that is used for all unrecognized ALPN identifiers. -class DefaultApplication final : public Session::Application { - public: - // Marked NOLINT because the cpp linter gets confused about this using - // statement not being sorted with the using v8 statements at the top - // of the namespace. - DefaultApplication(Session* session, const Options& options) - : Session::Application(session, options), options_(options) {} - - const Options& options() const override { return options_; } - - Session::Application::Type type() const override { - return Session::Application::Type::DEFAULT; - } - - error_code GetNoErrorCode() const override { return 0; } - - // Raw QUIC has no application-defined "general failure" code, so - // fall back to the QUIC transport-level INTERNAL_ERROR (0x1) used - // by ngtcp2 for unspecified failures. - error_code GetInternalErrorCode() const override { - return NGTCP2_INTERNAL_ERROR; - } - - void EarlyDataRejected() override { - // Destroy all open streams — ngtcp2 has already discarded their - // internal state when it rejected the early data. Use the - // application's internal error code since this is an error - // condition (code 0 would be treated as a clean close). - session().DestroyAllStreams( - QuicError::ForApplication(GetInternalErrorCode())); - if (!session().is_destroyed()) { - session().EmitEarlyDataRejected(); - } - } - - void CollectSessionTicketAppData( - SessionTicket::AppData* app_data) const override { - const auto& atd = session().config().options.app_ticket_data; - if (!atd.has_value() || atd->length() == 0) { - // No app data configured — write just the type byte (base behaviour). - Session::Application::CollectSessionTicketAppData(app_data); - return; - } - // Layout: [type byte][opaque app data]. - uv_buf_t bytes = *atd; - std::vector buf; - buf.reserve(1 + bytes.len); - buf.push_back(static_cast(type())); // Type::DEFAULT - const auto* p = reinterpret_cast(bytes.base); - buf.insert(buf.end(), p, p + bytes.len); - app_data->Set( - uv_buf_init(reinterpret_cast(buf.data()), buf.size())); - } - - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - return std::holds_alternative(data); - } - - bool ReceiveStreamOpen(stream_id id) override { - auto stream = session().CreateStream(id); - if (!stream || session().is_destroyed()) [[unlikely]] { - return !session().is_destroyed(); - } - return true; - } - - bool ReceiveStreamData(stream_id id, - const uint8_t* data, - size_t datalen, - const Stream::ReceiveDataFlags& flags, - void* stream_user_data) override { - BaseObjectPtr stream; - if (stream_user_data == nullptr) { - // This is the first time we're seeing this stream. Implicitly create it. - stream = session().CreateStream(id); - if (!stream || session().is_destroyed()) [[unlikely]] { - // We couldn't create the stream, or the session was destroyed - // during the onstream callback (via MakeCallback re-entrancy). - return false; - } - } else { - stream = BaseObjectPtr(Stream::From(stream_user_data)); - if (!stream) { - Debug(&session(), - "Default application failed to get existing stream " - "from user data"); - return false; - } - } - - CHECK(stream); - - // Now we can actually receive the data! Woo! - stream->ReceiveData(data, datalen, flags); - return true; - } - - int GetStreamData(StreamData* stream_data) override { - // Reset the state of stream_data before proceeding... - stream_data->id = -1; - stream_data->count = 0; - stream_data->fin = 0; - stream_data->stream.reset(); - Debug(&session(), "Default application getting stream data"); - DCHECK_NOT_NULL(stream_data); - // If the queue is empty, there aren't any streams with data yet - - // If the connection-level flow control window is exhausted, - // there is no point in pulling stream data. - if (!session().max_data_left()) return 0; - if (stream_queue_.IsEmpty()) return 0; - - Stream* stream = stream_queue_.PopFront(); - CHECK_NOT_NULL(stream); - stream_data->stream.reset(stream); - stream_data->id = stream->id(); - auto next = - [&](int status, const ngtcp2_vec* data, size_t count, bob::Done done) { - switch (status) { - case bob::Status::STATUS_BLOCK: - // Fall through - case bob::Status::STATUS_WAIT: - return; - case bob::Status::STATUS_EOS: - stream_data->fin = 1; - } - - // It is possible that the data pointers returned are not actually - // the data pointers in the stream_data. If that's the case, we need - // to copy over the pointers. - count = std::min(count, kMaxVectorCount); - ngtcp2_vec* dest = *stream_data; - if (dest != data) { - for (size_t n = 0; n < count; n++) { - dest[n] = data[n]; - } - } - - stream_data->count = count; - - if (count > 0) { - stream->Schedule(&stream_queue_); - } - - // Not calling done here because we defer committing - // the data until after we're sure it's written. - }; - - if (!stream->is_eos()) [[likely]] { - int ret = stream->Pull(std::move(next), - bob::Options::OPTIONS_SYNC, - stream_data->data, - arraysize(stream_data->data), - kMaxVectorCount); - if (ret == bob::Status::STATUS_EOS) { - stream_data->fin = 1; - } - } else { - stream_data->fin = 1; - } - - return 0; - } - - void ResumeStream(stream_id id) override { ScheduleStream(id); } - - void BlockStream(stream_id id) override { - if (auto stream = session().FindStream(id)) [[likely]] { - // Remove the stream from the send queue. It will be re-scheduled - // via ExtendMaxStreamData when the peer grants more flow control. - // Without this, SendPendingData would repeatedly pop and retry - // the same blocked stream in an infinite loop. - stream->Unschedule(); - stream->EmitBlocked(); - } - } - - void ExtendMaxStreamData(Stream* stream, uint64_t max_data) override { - // The peer granted more flow control for this stream. Re-schedule - // it so SendPendingData will resume writing. - DCHECK_NOT_NULL(stream); - stream->Schedule(&stream_queue_); - } - - bool StreamCommit(StreamData* stream_data, size_t datalen) override { - DCHECK_NOT_NULL(stream_data); - CHECK(stream_data->stream); - stream_data->stream->Commit(datalen, stream_data->fin); - return true; - } - - SET_SELF_SIZE(DefaultApplication) - SET_MEMORY_INFO_NAME(DefaultApplication) - SET_NO_MEMORY_INFO() - - private: - void ScheduleStream(stream_id id) { - if (auto stream = session().FindStream(id)) [[likely]] { - stream->Schedule(&stream_queue_); - } - } - - Options options_; - - Stream::Queue stream_queue_; -}; - -std::unique_ptr CreateDefaultApplication( - Session* session, const Session::Application_Options& options) { - return std::make_unique(session, options); -} - } // namespace quic } // namespace node diff --git a/src/quic/session.cc b/src/quic/session.cc index 2feb4a428f8abb..61998dc582df19 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -10,13 +10,14 @@ #include #include #include -#include #include #include #include #include #include #include +#include +#include #include "application.h" #include "bindingdata.h" #include "cid.h" @@ -805,11 +806,17 @@ struct Session::Impl final : public MemoryRetainer { PendingStream::PendingStreamQueue pending_bidi_stream_queue_; PendingStream::PendingStreamQueue pending_uni_stream_queue_; - // Session ticket app data parsed before ALPN negotiation. - // Validated and applied in SetApplication() after ALPN selects - // the application type. + // Application-typed session ticket app data parsed (and validated) by + // the registered application factory's ticket hook early in the + // handshake (at ticket decryption). Applied in SetApplication() once + // the application is installed. Opaque to the session. std::optional pending_ticket_data_; + // The native send queue used when no application is installed: streams + // with pending outbound data are scheduled here and the send pump + // pulls from them directly. + Stream::Queue stream_queue_; + // When true, the handshake is deferred until the first stream or // datagram is sent. This is set for client sessions with a session // ticket, enabling 0-RTT: the first send triggers the handshake @@ -1277,7 +1284,7 @@ struct Session::Impl final : public MemoryRetainer { // the datalen as our accounting does not track the offset and // acknowledges should never come out of order here. if (datalen == 0) return NGTCP2_SUCCESS; - return session->application().AcknowledgeStreamData(stream_id, datalen) + return session->AcknowledgeStreamData(stream_id, datalen) ? NGTCP2_SUCCESS : NGTCP2_ERR_CALLBACK_FAILURE; } @@ -1358,7 +1365,13 @@ struct Session::Impl final : public MemoryRetainer { void* stream_user_data) { NGTCP2_CALLBACK_SCOPE(session) if (auto* stream = Stream::From(stream_user_data)) { - session->application().ExtendMaxStreamData(stream, max_data); + if (session->impl_->application_) { + session->application().ExtendMaxStreamData(stream, max_data); + } else { + // Native path: the peer granted more flow control for this + // stream. Re-schedule it so SendPendingData resumes writing. + stream->Schedule(&session->impl_->stream_queue_); + } } return NGTCP2_SUCCESS; } @@ -1452,6 +1465,10 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS; + // With no application installed there is nothing to start; the + // native raw-stream path needs no startup. + if (!session->impl_->application_) return NGTCP2_SUCCESS; + // If the application was already started via on_receive_tx_key // (0-RTT path), this is a no-op. if (session->application().is_started()) return NGTCP2_SUCCESS; @@ -1500,18 +1517,17 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_STREAM_DATA_FLAG_0RTT, }; - // We received data for a stream! What we don't know yet at this point - // is whether the application wants us to treat this as a control stream - // data (something the application will handle on its own) or a user stream - // data (something that we should create a Stream handle for that is passed - // out to JavaScript). HTTP3, for instance, will generally create three - // control stream in either direction and we want to make sure those are - // never exposed to users and that we don't waste time creating Stream - // handles for them. So, what we do here is pass the stream data on to the - // application for processing. If it ends up being a user stream, the - // application will handle creating the Stream handle and passing that off - // to the JavaScript side. - if (!session->application().ReceiveStreamData( + // We received data for a stream! When an application is installed, + // the data is passed to it for processing: the application decides + // whether this is control stream data (something it handles on its + // own) or user stream data (something we should create a Stream + // handle for that is passed out to JavaScript). HTTP3, for instance, + // will generally create three control streams in either direction + // and we want to make sure those are never exposed to users and that + // we don't waste time creating Stream handles for them. With no + // application installed, the native path delivers every stream's + // data straight to its Stream handle. + if (!session->ReceiveStreamData( stream_id, data, datalen, data_flags, stream_user_data)) { return NGTCP2_ERR_CALLBACK_FAILURE; } @@ -1535,10 +1551,16 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_0RTT) return NGTCP2_SUCCESS; } - // application_ may be null if ALPN selection hasn't happened yet - // (e.g., ALPN mismatch causes the handshake to fail during key - // installation). Without an application, we can't start. - if (!session->impl_->application_) return NGTCP2_ERR_CALLBACK_FAILURE; + // With no application installed there are two cases. If none was + // requested, the native raw-stream path needs no startup. If one + // was requested but is absent here, installation failed (e.g. ALPN + // mismatch, or the session ticket data was rejected) and the + // handshake must fail. + if (!session->impl_->application_) { + return session->config().options.applicationName.empty() + ? NGTCP2_SUCCESS + : NGTCP2_ERR_CALLBACK_FAILURE; + } Debug(session, "Receiving TX key for level %s for dcid %s", @@ -1585,18 +1607,22 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) { - session->application().ReceiveStreamClose( - stream, QuicError::ForApplication(app_error_code)); + QuicError error = + (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) + ? QuicError::ForApplication(app_error_code) + : QuicError(); + if (session->impl_->application_) { + session->application().ReceiveStreamClose(stream, std::move(error)); } else { - session->application().ReceiveStreamClose(stream); + // Native path: just destroy the stream. + stream->Destroy(std::move(error)); } return NGTCP2_SUCCESS; } static int on_stream_open(ngtcp2_conn* conn, stream_id id, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - if (!session->application().ReceiveStreamOpen(id)) { + if (!session->ReceiveStreamOpen(id)) { return NGTCP2_ERR_CALLBACK_FAILURE; } return NGTCP2_SUCCESS; @@ -1611,8 +1637,13 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - session->application().ReceiveStreamReset( - stream, final_size, QuicError::ForApplication(app_error_code)); + if (session->impl_->application_) { + session->application().ReceiveStreamReset( + stream, final_size, QuicError::ForApplication(app_error_code)); + } else { + stream->ReceiveStreamReset(final_size, + QuicError::ForApplication(app_error_code)); + } return NGTCP2_SUCCESS; } @@ -1624,8 +1655,12 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - session->application().ReceiveStreamStopSending( - stream, QuicError::ForApplication(app_error_code)); + if (session->impl_->application_) { + session->application().ReceiveStreamStopSending( + stream, QuicError::ForApplication(app_error_code)); + } else { + stream->ReceiveStopSending(QuicError::ForApplication(app_error_code)); + } return NGTCP2_SUCCESS; } @@ -1641,6 +1676,16 @@ struct Session::Impl final : public MemoryRetainer { Debug(session, "Early data was rejected"); if (session->impl_->application_) { session->application().EarlyDataRejected(); + } else { + // Native path: destroy all streams opened during the 0-RTT phase + // (ngtcp2 has already discarded their internal state) and notify + // JS. Use the internal error code since this is an error condition + // (code 0 would be treated as a clean close). + session->DestroyAllStreams( + QuicError::ForApplication(NGTCP2_INTERNAL_ERROR)); + if (!session->is_destroyed()) { + session->EmitEarlyDataRejected(); + } } return NGTCP2_SUCCESS; } @@ -1773,7 +1818,8 @@ Session::SendPendingDataScope::~SendPendingDataScope() { DCHECK_GE(session->impl_->send_scope_depth_, 1); Debug(session, "Send Scope Depth %zu", session->impl_->send_scope_depth_); if (--session->impl_->send_scope_depth_ == 0 && - session->impl_->application_ && !session->impl_->handshake_deferred_) { + session->can_send_pending_data() && + !session->impl_->handshake_deferred_) { session->SendPendingData(); } } @@ -2003,8 +2049,10 @@ void Session::SendPendingData() { return Close(CloseMethod::SILENT); } - // The stream_data is the next block of data from the application stream. - if (application().GetStreamData(&stream_data) < 0) { + // The stream_data is the next block of data from the application + // layer (or, with no application installed, from the session's own + // native send queue). + if (GetStreamData(&stream_data) < 0) { Debug(this, "Application failed to get stream data"); SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); closed = true; @@ -2041,7 +2089,7 @@ void Session::SendPendingData() { "Session accepted %zu bytes from stream %" PRIi64 " into packet", ndatalen, stream_data.id); - if (!application().StreamCommit(&stream_data, ndatalen)) { + if (!StreamCommit(&stream_data, ndatalen)) { // Data was accepted into the packet, but for some reason adjusting // the stream's committed data failed. Treat as fatal. Debug(this, "Failed to commit accepted bytes in stream"); @@ -2068,13 +2116,17 @@ void Session::SendPendingData() { // SendPendingData retries it. Without this, a FIN-only send that // hits nwrite=0 is lost forever — the stream already returned EOS // from Pull and won't be re-scheduled by anyone else. - // We call Application::ResumeStream directly (not Session::ResumeStream) - // to avoid creating a SendPendingDataScope — we're already inside + // We resume the stream directly (not via Session::ResumeStream) to + // avoid creating a SendPendingDataScope - we're already inside // SendPendingData and re-entering would just hit nwrite=0 again. if (nwrite == 0) { Debug(this, "Congestion or not our turn to send"); if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) { - application().ResumeStream(stream_data.id); + if (impl_->application_) { + application().ResumeStream(stream_data.id); + } else { + ScheduleStream(stream_data.id); + } } // nwrite == 0 also occurs on an otherwise-idle connection (no @@ -2136,8 +2188,11 @@ void Session::SendPendingData() { } // Notify the application that the stream's write side is shut // so it stops queuing data. Without this, GetStreamData would - // keep returning the same stream and we'd loop forever. - application().StreamWriteShut(stream_data.id); + // keep returning the same stream and we'd loop forever. The + // native path has no framing layer to notify. + if (impl_->application_) { + application().StreamWriteShut(stream_data.id); + } continue; } case NGTCP2_ERR_WRITE_MORE: { @@ -2248,6 +2303,150 @@ ssize_t Session::WriteVStream(PathStorage* path, ts); } +int Session::GetStreamData(StreamData* stream_data) { + if (impl_->application_) { + return application().GetStreamData(stream_data); + } + + // The native raw-stream path: pull the next block of data from the + // first stream scheduled on the session's own send queue. + + // Reset the state of stream_data before proceeding... + stream_data->id = -1; + stream_data->count = 0; + stream_data->fin = 0; + stream_data->stream.reset(); + Debug(this, "Session getting stream data (native path)"); + DCHECK_NOT_NULL(stream_data); + + // If the connection-level flow control window is exhausted, + // there is no point in pulling stream data. + if (!max_data_left()) return 0; + // If the queue is empty, there aren't any streams with data yet. + if (impl_->stream_queue_.IsEmpty()) return 0; + + Stream* stream = impl_->stream_queue_.PopFront(); + CHECK_NOT_NULL(stream); + stream_data->stream.reset(stream); + stream_data->id = stream->id(); + auto next = + [&](int status, const ngtcp2_vec* data, size_t count, bob::Done done) { + switch (status) { + case bob::Status::STATUS_BLOCK: + // Fall through + case bob::Status::STATUS_WAIT: + return; + case bob::Status::STATUS_EOS: + stream_data->fin = 1; + } + + // It is possible that the data pointers returned are not actually + // the data pointers in the stream_data. If that's the case, we need + // to copy over the pointers. + count = std::min(count, kMaxVectorCount); + ngtcp2_vec* dest = *stream_data; + if (dest != data) { + for (size_t n = 0; n < count; n++) { + dest[n] = data[n]; + } + } + + stream_data->count = count; + + if (count > 0) { + stream->Schedule(&impl_->stream_queue_); + } + + // Not calling done here because we defer committing + // the data until after we're sure it's written. + }; + + if (!stream->is_eos()) [[likely]] { + int ret = stream->Pull(std::move(next), + bob::Options::OPTIONS_SYNC, + stream_data->data, + arraysize(stream_data->data), + kMaxVectorCount); + if (ret == bob::Status::STATUS_EOS) { + stream_data->fin = 1; + } + } else { + stream_data->fin = 1; + } + + return 0; +} + +bool Session::StreamCommit(StreamData* stream_data, size_t datalen) { + if (impl_->application_) { + return application().StreamCommit(stream_data, datalen); + } + DCHECK_NOT_NULL(stream_data); + CHECK(stream_data->stream); + stream_data->stream->Commit(datalen, stream_data->fin); + return true; +} + +bool Session::AcknowledgeStreamData(stream_id id, size_t datalen) { + if (impl_->application_) { + return application().AcknowledgeStreamData(id, datalen); + } + if (auto stream = FindStream(id)) [[likely]] { + stream->Acknowledge(datalen); + } + // Returning true even when the stream is not found is intentional. + // After a stream is destroyed, the peer can still ACK data that was + // previously sent. This is benign and should not be treated as an error. + return true; +} + +bool Session::ReceiveStreamOpen(stream_id id) { + if (impl_->application_) { + return application().ReceiveStreamOpen(id); + } + // Native path: create (and announce) a Stream for every remote stream. + auto stream = CreateStream(id); + if (!stream || is_destroyed()) [[unlikely]] { + // We couldn't create the stream, or the session was destroyed + // during the onstream callback (via MakeCallback re-entrancy). + return !is_destroyed(); + } + return true; +} + +bool Session::ReceiveStreamData(stream_id id, + const uint8_t* data, + size_t datalen, + const Stream::ReceiveDataFlags& flags, + void* stream_user_data) { + if (impl_->application_) { + return application().ReceiveStreamData( + id, data, datalen, flags, stream_user_data); + } + + // Native path: deliver the data straight to the Stream handle, + // implicitly creating it if this is the first time we see the stream. + BaseObjectPtr stream; + if (stream_user_data == nullptr) { + stream = CreateStream(id); + if (!stream || is_destroyed()) [[unlikely]] { + // We couldn't create the stream, or the session was destroyed + // during the onstream callback (via MakeCallback re-entrancy). + return false; + } + } else { + stream = BaseObjectPtr(Stream::From(stream_user_data)); + if (!stream) { + Debug(this, "Failed to get existing stream from user data"); + return false; + } + } + + CHECK(stream); + stream->ReceiveData(data, datalen, flags); + return true; +} + // ============================================================================ BaseObjectPtr Session::Create( Endpoint* endpoint, @@ -2273,9 +2472,20 @@ Session::Session(Endpoint* endpoint, DCHECK(impl_); STAT_RECORD_TIMESTAMP(Stats, created_at); - // For clients, select the Application immediately — the ALPN is + // The native (no application) error codes. With no application + // installed, "no error" is 0x0 and the non-specific failure code is + // the QUIC transport-level INTERNAL_ERROR (0x1) used by ngtcp2 for + // unspecified failures. An installed application overrides these in + // SetApplication(). + impl_->state()->no_error_code = NGTCP2_NO_ERROR; + impl_->state()->internal_error_code = NGTCP2_INTERNAL_ERROR; + + + // For clients, install the requested Application immediately - it is // known upfront from the options. For servers, application_ stays - // null until OnSelectAlpn fires during the TLS handshake. + // null until OnSelectAlpn fires during the TLS handshake. Sessions + // that request no application never install one and run the native + // raw-stream path. if (config.side == Side::CLIENT) { auto app = SelectApplication(); if (app) SetApplication(std::move(app)); @@ -2443,11 +2653,11 @@ void Session::Close(CloseMethod method) { } impl_->state()->graceful_close = 1; - // application_ may be null for server sessions if close() is called - // before the TLS handshake selects the ALPN. Without an application - // we cannot do a graceful shutdown (GOAWAY, CONNECTION_CLOSE etc.), - // so fall through to a silent close. - if (!impl_->application_) { + // A session that requested an application but has not installed it + // yet (a server before the TLS handshake completes ALPN) cannot do + // an application-level graceful shutdown (GOAWAY, CONNECTION_CLOSE + // etc.), so fall through to a silent close. + if (!impl_->application_ && !config().options.applicationName.empty()) { impl_->state()->silent_close = 1; return FinishClose(); } @@ -2459,9 +2669,12 @@ void Session::Close(CloseMethod method) { // Signal application-level graceful shutdown (e.g., HTTP/3 GOAWAY). // BeginShutdown can trigger callbacks that re-enter JS and destroy - // this session, so check is_destroyed() after it returns. - application().BeginShutdown(); - if (is_destroyed()) return; + // this session, so check is_destroyed() after it returns. With no + // application installed there is no shutdown signaling to do. + if (impl_->application_) { + application().BeginShutdown(); + if (is_destroyed()) return; + } // If there are no open streams, then we can close immediately and // not worry about waiting around. @@ -2476,11 +2689,11 @@ void Session::Close(CloseMethod method) { // the graceful close hangs. Streams still actively receiving data // are left alone to complete naturally. // - // When the application manages stream FIN (HTTP/3), skip this — a + // When the application manages stream FIN (HTTP/3), skip this - a // writable stream with a closed read side is the normal request/ // response pattern (server received full request, still sending // response). The application protocol handles stream completion. - if (!application().stream_fin_managed_by_application()) { + if (!stream_fin_managed_by_application()) { Session::SendPendingDataScope send_scope(this); for (auto& [id, stream] : impl_->streams_) { if (stream->is_writable() && !stream->is_readable()) { @@ -2618,15 +2831,23 @@ Session::Application& Session::application() const { return *impl_->application_; } -std::string_view Session::DecodeAlpn(std::string_view wire) { - // ALPN wire format is length-prefixed: [len][name]. Extract the first entry. - if (wire.size() >= 2) { - uint8_t len = static_cast(wire[0]); - if (len > 0 && static_cast(len + 1) <= wire.size()) { - return wire.substr(1, len); - } - } - return {}; +bool Session::can_send_pending_data() const { + // A session that requested an application must not run the send pump + // before the application is installed: the application owns stream + // scheduling from its first flight. With no application requested the + // native path is always ready. + return impl_->application_ != nullptr || + config().options.applicationName.empty(); +} + +bool Session::stream_fin_managed_by_application() const { + return impl_->application_ != nullptr && + impl_->application_->stream_fin_managed_by_application(); +} + +error_code Session::internal_error_code() const { + DCHECK(!is_destroyed()); + return impl_->state()->internal_error_code; } std::unique_ptr Session::SelectApplication() { @@ -2636,7 +2857,8 @@ std::unique_ptr Session::SelectApplication() { CHECK_NOT_NULL(factory); return factory(this, config().options.application_options); } - return CreateDefaultApplication(this, config().options.application_options); + // No application requested: run the native raw-stream path. + return nullptr; } void Session::SetApplication(std::unique_ptr app) { @@ -2831,7 +3053,7 @@ bool Session::ReadPacket(const uint8_t* data, STAT_INCREMENT_N(Stats, bytes_received, len); // Process deferred operations that couldn't run inside callback // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). - application().PostReceive(); + if (impl_->application_) application().PostReceive(); // Surface a server session to JS once its ClientHello has been // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). // Held first-flight events - including 0-RTT request streams - replay @@ -2995,7 +3217,7 @@ void Session::SendBatch(Packet::Ptr* packets, void Session::FlushPendingData() { DCHECK(!is_destroyed()); - if (impl_->application_) { + if (can_send_pending_data()) { // Prefer synchronous sends during the deferred flush to avoid the // one-tick latency of async uv_udp_send from the uv_check callback. flags_.prefer_try_send = true; @@ -3336,7 +3558,17 @@ void Session::RemoveStream(stream_id id) { void Session::ResumeStream(stream_id id) { DCHECK(!is_destroyed()); SendPendingDataScope send_scope(this); - application().ResumeStream(id); + if (impl_->application_) { + application().ResumeStream(id); + } else { + ScheduleStream(id); + } +} + +void Session::ScheduleStream(stream_id id) { + if (auto stream = FindStream(id)) [[likely]] { + stream->Schedule(&impl_->stream_queue_); + } } void Session::ShutdownStream(stream_id id, QuicError error) { @@ -3352,9 +3584,9 @@ void Session::ShutdownStream(stream_id id, QuicError error) { if (error.type() == QuicError::Type::APPLICATION) { code = error.code(); } else if (error.code() == NGTCP2_NO_ERROR) { - code = application().GetNoErrorCode(); + code = impl_->state()->no_error_code; } else { - code = application().GetInternalErrorCode(); + code = impl_->state()->internal_error_code; } ngtcp2_conn_shutdown_stream(*this, 0, id, code); } @@ -3368,9 +3600,9 @@ void Session::ShutdownStreamWrite(stream_id id, QuicError error) { if (error.type() == QuicError::Type::APPLICATION) { code = error.code(); } else if (error.code() == NGTCP2_NO_ERROR) { - code = application().GetNoErrorCode(); + code = impl_->state()->no_error_code; } else { - code = application().GetInternalErrorCode(); + code = impl_->state()->internal_error_code; } ngtcp2_conn_shutdown_stream_write(*this, 0, id, code); } @@ -3378,13 +3610,44 @@ void Session::ShutdownStreamWrite(stream_id id, QuicError error) { void Session::StreamDataBlocked(stream_id id) { DCHECK(!is_destroyed()); STAT_INCREMENT(Stats, block_count); - application().BlockStream(id); + if (impl_->application_) { + return application().BlockStream(id); + } + if (auto stream = FindStream(id)) [[likely]] { + // Native path: remove the stream from the send queue. It will be + // re-scheduled via the extend-max-stream-data callback when the peer + // grants more flow control. Without this, SendPendingData would + // repeatedly pop and retry the same blocked stream in an infinite + // loop. + stream->Unschedule(); + stream->EmitBlocked(); + } } void Session::CollectSessionTicketAppData( SessionTicket::AppData* app_data) const { DCHECK(!is_destroyed()); - application().CollectSessionTicketAppData(app_data); + if (impl_->application_) { + return application().CollectSessionTicketAppData(app_data); + } + // Native path: embed the configured opaque app_ticket_data behind the + // DEFAULT type byte. With no data configured write just the type byte. + static constexpr uint8_t kTypeByte = + static_cast(Application::Type::DEFAULT); + const auto& atd = config().options.app_ticket_data; + if (!atd.has_value() || atd->length() == 0) { + uint8_t buf[1] = {kTypeByte}; + app_data->Set(uv_buf_init(reinterpret_cast(buf), 1)); + return; + } + // Layout: [type byte][opaque app data]. + uv_buf_t bytes = *atd; + std::vector buf; + buf.reserve(1 + bytes.len); + buf.push_back(kTypeByte); + const auto* p = reinterpret_cast(bytes.base); + buf.insert(buf.end(), p, p + bytes.len); + app_data->Set(uv_buf_init(reinterpret_cast(buf.data()), buf.size())); } SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( @@ -3724,7 +3987,7 @@ void Session::SendConnectionClose() { void Session::OnTimeout() { if (is_destroyed()) return; - if (!impl_->application_) return; + if (!can_send_pending_data()) return; // Hold a strong reference to prevent the Session from being freed during // re-entrant calls. SendPendingData's scope guard calls UpdateTimer(), // which can synchronously re-enter OnTimeout() when the timer has already @@ -4346,6 +4609,8 @@ void Session::EmitStream(const BaseObjectWeakPtr& stream) { return; } + if (!stream) return; + if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -4400,7 +4665,7 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, } void Session::EmitOrigins(std::vector&& origins) { - DCHECK(!is_destroyed()); + if (is_destroyed()) return; if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::ORIGIN)) return; diff --git a/src/quic/session.h b/src/quic/session.h index 691461c23ca4d5..0cd8275c59b7a9 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -136,6 +136,37 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // (ALPN negotiated during handshake). Must be called before any // application data is received. void SetApplication(std::unique_ptr app); + + // The application data-plane dispatchers. Each routes to the installed + // Application when one exists; otherwise the session's native + // raw-stream implementation runs (the session schedules streams on its + // own send queue and pulls their data directly). + int GetStreamData(StreamData* data); + bool StreamCommit(StreamData* data, size_t datalen); + bool AcknowledgeStreamData(stream_id id, size_t datalen); + bool ReceiveStreamOpen(stream_id id); + bool ReceiveStreamData(stream_id id, + const uint8_t* data, + size_t datalen, + const Stream::ReceiveDataFlags& flags, + void* stream_user_data); + // Native-path scheduling: puts the stream on the session's send queue. + void ScheduleStream(stream_id id); + + // True when the send pump may run: a session that requested a protocol + // application must not send until that application has been installed; + // a session with no application requested is always ready. + bool can_send_pending_data() const; + + // True when the installed application manages stream FIN itself (e.g. + // HTTP/3 via nghttp3); false when no application is installed. + bool stream_fin_managed_by_application() const; + + // The application-level "internal error" wire code in effect for this + // session (the installed application's code, or the raw QUIC default + // when none is installed). + error_code internal_error_code() const; + // Controls which datagram to drop when the pending datagram queue is full. enum class DatagramDropPolicy : uint8_t { DROP_OLDEST = 0, // Drop the oldest queued datagram (default). @@ -752,7 +783,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { friend struct NgHttp3CallbackScope; friend class Application; friend class BindingData; - friend class DefaultApplication; friend class Http3ApplicationImpl; friend class Endpoint; friend class SessionManager; diff --git a/src/quic/streams.h b/src/quic/streams.h index 86cb36b2668985..f24cd29e6b19a8 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -477,7 +477,7 @@ class Stream final : public AsyncWrap, friend struct Impl; friend class PendingStream; friend class Http3ApplicationImpl; - friend class DefaultApplication; + friend class Session; public: // The Queue/Schedule here are part of the mechanism used to diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index daafb3df0ded44..531356afefabcb 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -357,24 +357,24 @@ int TLSContext::OnSelectAlpn(SSL* ssl, } // ALPN negotiated successfully. *out/*outlen point to the selected - // protocol name (without the length prefix). Select the Application - // implementation based on the negotiated ALPN. This must happen now - // because early data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt - // call and needs the Application to be ready. + // protocol name (without the length prefix). Install the session's + // Application now if its options request one (selection comes from the + // session's application option, not the negotiated ALPN value): early + // data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt call and + // needs the Application to be ready. Sessions that request no + // application run the native raw-stream path and install nothing. std::string_view negotiated(reinterpret_cast(*out), *outlen); Debug(&tls_session.session(), "ALPN negotiation succeeded: %s", std::string(negotiated).c_str()); auto& session = tls_session.session(); - auto app = session.SelectApplication(); - if (!app) { - Debug(&session, - "Failed to create Application for ALPN %s", - std::string(negotiated).c_str()); - return SSL_TLSEXT_ERR_NOACK; + if (auto app = session.SelectApplication()) { + // Note: SetApplication can still reject previously parsed session + // ticket app data, leaving the application uninstalled; the + // handshake then fails when the 1-RTT keys arrive. + session.SetApplication(std::move(app)); } - session.SetApplication(std::move(app)); session.set_hello_processed(); return SSL_TLSEXT_ERR_OK; diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs deleted file mode 100644 index 831ca11c99dc61..00000000000000 --- a/test/parallel/test-quic-alpn-h3.mjs +++ /dev/null @@ -1,67 +0,0 @@ -// Flags: --experimental-quic --no-warnings --expose-internals - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; - -const { strictEqual, notStrictEqual } = assert; -const { readKey } = fixtures; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); - -// Test that the h3 ALPN negotiates normally but doesn't auto-activate -// HTTP/3. ALPN is reported, not type-changing. HTTP/3 is used via -// the 'node:http3' module explicitly. -// Both server and client explicitly configure the h3 ALPN. - -const { createRequire } = await import('node:module'); -const require = createRequire(import.meta.url); -const { getQuicSessionState } = require('internal/quic/quic'); - -const serverOpened = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall(async (serverSession) => { - // ALPN negotiated h3, but with no application requested this is a RAW - // session: the h3 application is not installed (headersSupported === - // 2, i.e. unsupported). - strictEqual(serverSession.alpnProtocol, 'h3'); - strictEqual(getQuicSessionState(serverSession).headersSupported, 2); - const info = await serverSession.opened; - strictEqual(info.protocol, 'h3'); - serverOpened.resolve(); - serverSession.close(); -}), { - alpn: ['h3'], - sni: { '*': { keys: [key], certs: [cert] } }, -}); - -notStrictEqual(serverEndpoint.address, undefined); - -const clientSession = await connect(serverEndpoint.address, { - alpn: 'h3', - servername: 'localhost', - verifyPeer: 'manual', - // Application config is internal-only (kApplication) - user options are ignored - // Applications integrate natively with their consumers so custom options - // would reliably break things. - applicationName: 'http3', -}); - -async function checkClient() { - const info = await clientSession.opened; - strictEqual(info.protocol, 'h3'); - // Still a raw session: - strictEqual(getQuicSessionState(clientSession).headersSupported, 2); -} - -await Promise.all([serverOpened.promise, checkClient()]); -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-headers-support.mjs b/test/parallel/test-quic-h3-headers-support.mjs deleted file mode 100644 index 8807adde6ef276..00000000000000 --- a/test/parallel/test-quic-h3-headers-support.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -// Test: Headers support detection for non-H3 sessions. -// headersSupported is UNSUPPORTED for non-H3 sessions -// Sending headers on non-H3 session throws ERR_INVALID_STATE -// Setting header callbacks on non-H3 stream throws ERR_INVALID_STATE - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { throws } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); -const encoder = new TextEncoder(); - -const serverDone = Promise.withResolvers(); -const serverEndpoint = await listen(mustCall(async (serverSession) => { - serverSession.onstream = mustCall(async (stream) => { - // Sending headers on non-H3 stream throws. - throws(() => stream.sendHeaders({ ':status': '200' }), { code: 'ERR_INVALID_STATE' }); - - // Setting onheaders on non-H3 stream throws. - throws(() => stream.onheaders = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting ontrailers on non-H3 stream throws. - throws(() => stream.ontrailers = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting oninfo on non-H3 stream throws. - throws(() => stream.oninfo = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting onwanttrailers on non-H3 stream throws. - throws(() => stream.onwanttrailers = () => {}, { code: 'ERR_INVALID_STATE' }); - - // sendInformationalHeaders throws on non-H3. - throws(() => stream.sendInformationalHeaders({ ':status': '103' }), { - code: 'ERR_INVALID_STATE', - }); - - // sendTrailers throws on non-H3. - throws(() => stream.sendTrailers({ 'x-trailer': 'value' }), { code: 'ERR_INVALID_STATE' }); - - stream.writer.endSync(); - - serverSession.close(); - serverDone.resolve(); - }); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: 'quic-test', -}); - -const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - alpn: 'quic-test', -}); -await clientSession.opened; - -const stream = await clientSession.createBidirectionalStream({ - body: encoder.encode('ping'), -}); - -// Client side — sending headers on non-H3 stream throws. -throws(() => stream.sendHeaders({ ':method': 'GET' }), { code: 'ERR_INVALID_STATE' }); - -await serverDone.promise; -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs deleted file mode 100644 index 8629e0b1147757..00000000000000 --- a/test/parallel/test-quic-h3-settings.mjs +++ /dev/null @@ -1,197 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: HTTP/3 settings enforcement. -// maxHeaderPairs enforcement - reject headers exceeding pair count -// maxHeaderLength enforcement - reject headers exceeding byte length -// enableConnectProtocol setting (accepted without error) -// enableDatagrams setting (accepted without error) - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { strictEqual } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:http3'); -const { createPrivateKey } = await import('node:crypto'); -const { bytes } = await import('stream/iter'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -// maxHeaderPairs enforcement. -// Server limits to 5 header pairs. Client sends 4 pseudo-headers + -// 2 custom headers = 6 pairs. The 6th pair is silently dropped. -{ - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - stream.onheaders = mustCall((headers) => { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/limited'); - strictEqual(headers[':scheme'], 'https'); - strictEqual(headers[':authority'], 'localhost'); - // x-first is the 5th pair — accepted. - strictEqual(headers['x-first'], 'one'); - // x-second would be the 6th pair — dropped. - strictEqual(headers['x-second'], undefined); - - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync(encoder.encode('ok')); - stream.writer.endSync(); - }); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - // Allow 5 header pairs: 4 pseudo-headers + 1 custom. - application: { maxHeaderPairs: 5 }, - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - }); - await clientSession.opened; - - const stream = await clientSession.request({ - ':method': 'GET', - ':path': '/limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-first': 'one', - 'x-second': 'two', - }, { - onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); - }), - }); - - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'ok'); - await stream.closed; - await serverDone.promise; - await clientSession.close(); - await serverEndpoint.close(); -} - -// maxHeaderLength enforcement. -// Server limits total header byte length (name chars + value chars). -// The 4 pseudo-headers take ~45 bytes. A long custom header value -// pushes the total over the limit. -{ - const serverDone = Promise.withResolvers(); - const longValue = 'x'.repeat(200); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - stream.onheaders = mustCall((headers) => { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/length-limited'); - // x-long should be dropped — would push total over 100 bytes. - strictEqual(headers['x-long'], undefined); - - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync(encoder.encode('ok')); - stream.writer.endSync(); - }); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - // Limit total header bytes. The 4 pseudo-headers fit within 100 - // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. - application: { maxHeaderLength: 100 }, - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - }); - await clientSession.opened; - - const stream = await clientSession.request({ - ':method': 'GET', - ':path': '/length-limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-long': longValue, - }, { - onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); - }), - }); - - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'ok'); - await Promise.all([stream.closed, serverDone.promise]); - await clientSession.close(); - await serverEndpoint.close(); -} - -// enableConnectProtocol and enableDatagrams settings. -// Verify these options are accepted and H3 sessions work with them. -{ - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - stream.onheaders = mustCall((headers) => { - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync(encoder.encode('settings-ok')); - stream.writer.endSync(); - }); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - application: { enableConnectProtocol: true, enableDatagrams: true }, - onapplication: mustCall((appopt) => { - strictEqual(appopt.enableDatagrams, true); - strictEqual(appopt.enableConnectProtocol, false); - // Must be false, as this is only sent from server side - }) - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - application: { enableConnectProtocol: true, enableDatagrams: true }, - }); - clientSession.onapplication = mustCall((appopt) => { - strictEqual(appopt.enableConnectProtocol, true); - strictEqual(appopt.enableDatagrams, true); - }); - await clientSession.opened; - - const stream = await clientSession.request({ - ':method': 'GET', - ':path': '/settings', - ':scheme': 'https', - ':authority': 'localhost', - }, { - onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); - }), - }); - - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'settings-ok'); - await Promise.all([stream.closed, serverDone.promise]); - await clientSession.close(); - await serverEndpoint.close(); -} diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs deleted file mode 100644 index 1f5c9c0926808c..00000000000000 --- a/test/parallel/test-quic-session-application-options.mjs +++ /dev/null @@ -1,105 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: session.applicationOptions -// Verifies that applicationOptions is available after ALPN negotiation -// completes (i.e., once the application has been selected), returns a -// null-prototype object, and reflects the configured values. - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; - -const { ok, strictEqual } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('../common/quic.mjs'); - -const customAppOptions = { - maxHeaderPairs: 50n, - maxHeaderLength: 8192n, - maxFieldSectionSize: 16384n, - qpackMaxDTableCapacity: 2048n, - qpackEncoderMaxDTableCapacity: 2048n, - qpackBlockedStreams: 50n, - enableConnectProtocol: false, - enableDatagrams: false, -}; - -const serverDone = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall((serverSession) => { - serverSession.onstream = mustCall(async (stream) => { - // After the stream arrives, the handshake and ALPN negotiation are - // complete, so applicationOptions should be available. - const opts = serverSession.applicationOptions; - - ok(opts != null, 'server applicationOptions should be available after handshake'); - strictEqual(typeof opts, 'object'); - strictEqual(Object.getPrototypeOf(opts), null); - - // Verify configured values are reflected. - strictEqual(opts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); - strictEqual(opts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); - strictEqual(opts.maxFieldSectionSize, - BigInt(customAppOptions.maxFieldSectionSize)); - strictEqual(opts.qpackMaxDtableCapacity, - BigInt(customAppOptions.qpackMaxDTableCapacity)); - strictEqual(opts.qpackEncoderMaxDtableCapacity, - BigInt(customAppOptions.qpackEncoderMaxDTableCapacity)); - strictEqual(opts.qpackBlockedStreams, - BigInt(customAppOptions.qpackBlockedStreams)); - strictEqual(opts.enableConnectProtocol, - customAppOptions.enableConnectProtocol); - strictEqual(opts.enableDatagrams, customAppOptions.enableDatagrams); - - stream.writer.endSync(); - await stream.closed; - serverSession.close(); - serverDone.resolve(); - }); -}), { - application: customAppOptions, -}); - -const clientSession = await connect(serverEndpoint.address, { - application: customAppOptions, -}); -await clientSession.opened; - -// After opened, ALPN negotiation is complete and applicationOptions -// should be available on the client session. -const clientOpts = clientSession.applicationOptions; -ok(clientOpts != null, 'client applicationOptions should be available after handshake'); -strictEqual(typeof clientOpts, 'object'); -strictEqual(Object.getPrototypeOf(clientOpts), null); - -// Verify configured values on the client side. -strictEqual(clientOpts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); -strictEqual(clientOpts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); -strictEqual(clientOpts.maxFieldSectionSize, - customAppOptions.maxFieldSectionSize); -strictEqual(clientOpts.qpackMaxDtableCapacity, - customAppOptions.qpackMaxDTableCapacity); -strictEqual(clientOpts.qpackEncoderMaxDtableCapacity, - customAppOptions.qpackEncoderMaxDTableCapacity); -strictEqual(clientOpts.qpackBlockedStreams, - customAppOptions.qpackBlockedStreams); -strictEqual(clientOpts.enableConnectProtocol, - customAppOptions.enableConnectProtocol); -strictEqual(clientOpts.enableDatagrams, customAppOptions.enableDatagrams); - -// Exchange data to let the server side run its assertions. -const stream = await clientSession.createBidirectionalStream(); -stream.writer.endSync(); - -// eslint-disable-next-line no-unused-vars -for await (const _ of stream) { /* drain */ } -await Promise.all([stream.closed, serverDone.promise]); - -// After close, applicationOptions should return null. -await clientSession.close(); -strictEqual(clientSession.applicationOptions, null); - -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs deleted file mode 100644 index 59fb521183bda9..00000000000000 --- a/test/parallel/test-quic-session-emit-ordering.mjs +++ /dev/null @@ -1,48 +0,0 @@ -// Flags: --experimental-quic --no-warnings --expose-internals - -// Check that server `onsession` emit fires only after the session's -// ClientHello has been processed & validated. - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; - -const { ok, notStrictEqual, strictEqual } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { createRequire } = await import('node:module'); -const require = createRequire(import.meta.url); -const { getQuicSessionState } = require('internal/quic/quic'); -const { listen, connect } = await import('../common/quic.mjs'); - -const sessionSeen = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall((serverSession) => { - // All assertions run synchronously in the onsession emit frame. - - // The TLS details from the ClientHello are readable on the session. - strictEqual(serverSession.servername, 'localhost'); - strictEqual(serverSession.alpnProtocol, 'quic-test'); - - // The client's transport params arrived in the first flight and have - // been processed by the time the session is surfaced. - const params = serverSession.remoteTransportParams; - notStrictEqual(params, undefined); - notStrictEqual(params, null); - ok(params.initialMaxStreamsBidi >= 0n); - - // ALPN negotiation has completed: headers support is resolved (2 = - // unsupported, confirming non-h3 test ALPN) - strictEqual(getQuicSessionState(serverSession).headersSupported, 2); - - sessionSeen.resolve(); -})); - -const clientSession = await connect(serverEndpoint.address); -await clientSession.opened; -await sessionSeen.promise; - -await clientSession.close(); -await serverEndpoint.close(); From 03170587a1aeb470325e159d79e639a189e3f51c Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 16 Jun 2026 22:51:17 +0200 Subject: [PATCH 09/15] quic: move HTTP/3 protocol machinery into node:http3 Move nghttp3 integration, and header/priority/settings/datagram-framing logic out of node:quic core into node:http3, leaving node:quic as a pure transport-only layer. --- lib/internal/quic/http3.js | 560 ++++++++++++- lib/internal/quic/quic.js | 776 ++---------------- lib/internal/quic/state.js | 60 +- lib/internal/quic/symbols.js | 14 - src/quic/application.cc | 215 +---- src/quic/application.h | 147 ++-- src/quic/bindingdata.cc | 38 +- src/quic/bindingdata.h | 45 +- src/quic/data.cc | 4 +- src/quic/defs.h | 11 - src/quic/endpoint.cc | 4 +- src/quic/http3.cc | 623 +++++++++----- src/quic/http3.h | 20 +- src/quic/session.cc | 202 +++-- src/quic/session.h | 135 ++- src/quic/streams.cc | 123 +-- src/quic/streams.h | 48 +- test/parallel/test-quic-alpn-h3.mjs | 66 ++ test/parallel/test-quic-h3-goaway-non-h3.mjs | 66 -- .../test-quic-h3-header-validation.mjs | 73 ++ .../parallel/test-quic-h3-headers-support.mjs | 92 +++ test/parallel/test-quic-h3-http3session.mjs | 6 +- test/parallel/test-quic-h3-qpack-settings.mjs | 8 +- .../test-quic-h3-session-settings.mjs | 113 +++ test/parallel/test-quic-h3-settings.mjs | 196 +++++ ...est-quic-h3-zero-rtt-rejected-settings.mjs | 23 +- ...est-quic-internal-endpoint-stats-state.mjs | 3 +- .../test-quic-internal-setcallbacks.mjs | 28 +- .../test-quic-session-emit-ordering.mjs | 48 ++ test/parallel/test-quic-stream-bidi-basic.mjs | 2 +- .../test-quic-stream-destroy-emits-reset.mjs | 10 +- .../test-quic-stream-destroy-options-code.mjs | 4 +- ...est-quic-stream-writer-fail-error-code.mjs | 6 +- 33 files changed, 2019 insertions(+), 1750 deletions(-) create mode 100644 test/parallel/test-quic-alpn-h3.mjs delete mode 100644 test/parallel/test-quic-h3-goaway-non-h3.mjs create mode 100644 test/parallel/test-quic-h3-headers-support.mjs create mode 100644 test/parallel/test-quic-h3-session-settings.mjs create mode 100644 test/parallel/test-quic-h3-settings.mjs create mode 100644 test/parallel/test-quic-session-emit-ordering.mjs diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index 4b78ed4029810c..d42adc85fde3a0 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -1,22 +1,69 @@ 'use strict'; +const { + ArrayIsArray, + ArrayPrototypePush, + Symbol, + SymbolAsyncIterator, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +// HTTP/3 requires QUIC, which requires that Node.js be compiled with +// crypto support and that the --experimental-quic flag be enabled. +if (!process.features.quic || !getOptionValue('--experimental-quic')) { + return; +} + // Internal, experimental HTTP/3 consumer layer over node:quic. // // Http3Session wraps a QuicSession and surfaces incoming/outgoing request // streams as Http3Stream objects. -const { - SymbolAsyncIterator, -} = primordials; - const { connect: quicConnect, listen: quicListen, QuicSession, QuicStream, kApplication, + kApplicationSettings, + kStreamHandle, + kSessionHandle, + markSessionClosing, + getQuicStreamState, + getQuicSessionState, + safeCallbackInvoke: quicSafeCallbackInvoke, + kApplicationOwner, } = require('internal/quic/quic'); +const { + setHttp3Callbacks, + + QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, + QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, + QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, + QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, + QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, +} = internalBinding('quic'); + +const { + buildNgHeaderString, + assertValidPseudoHeader, + assertValidPseudoHeaderTrailer, +} = require('internal/http2/util'); + +const { + onSessionApplicationChannel, + onSessionClosingChannel, + onSessionGoawayChannel, + onSessionOriginChannel, + onStreamHeadersChannel, + onStreamTrailersChannel, + onStreamInfoChannel, +} = require('internal/quic/diagnostics'); + const { validateFunction, validateObject, @@ -25,12 +72,24 @@ const { const { codes: { ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_QUIC_OPEN_STREAM_FAILED, }, } = require('internal/errors'); +const assert = require('internal/assert'); + const kEmptyObject = { __proto__: null }; const kHttp3Alpn = 'h3'; +// Module-private: routing of the HTTP/3 application events (registered +// via setHttp3Callbacks below) into the wrappers. +const kOnHeaders = Symbol('kOnHeaders'); +const kOnWantTrailers = Symbol('kOnWantTrailers'); +const kOnGoaway = Symbol('kOnGoaway'); +const kOnOrigin = Symbol('kOnOrigin'); +const kOnSettings = Symbol('kOnSettings'); + function isQuicSession(value) { return value instanceof QuicSession; } @@ -39,9 +98,44 @@ function isQuicStream(value) { return value instanceof QuicStream; } +/** + * Parses an alternating [name, value, name, value, ...] array from the + * application into a plain header object. Multi-value headers become arrays. + * @param {string[]} pairs + * @returns {object} + */ +function parseHeaderPairs(pairs) { + assert(ArrayIsArray(pairs)); + assert(pairs.length % 2 === 0); + const block = { __proto__: null }; + for (let n = 0; n + 1 < pairs.length; n += 2) { + if (block[pairs[n]] !== undefined) { + if (ArrayIsArray(block[pairs[n]])) { + ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); + } else { + block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; + } + } else { + block[pairs[n]] = pairs[n + 1]; + } + } + return block; +} + +/** + * @typedef {object} SendHeadersOptions + * @property {boolean} [terminal] When true, indicates that no body data + * will be sent after these headers. + */ + class Http3Stream { #stream; #session; + #headers = undefined; + #onheaders = undefined; + #oninfo = undefined; + #ontrailers = undefined; + #onwanttrailers = undefined; /** * @param {QuicStream} stream the underlying QUIC stream @@ -54,6 +148,10 @@ class Http3Stream { } this.#stream = stream; this.#session = session; + const handle = stream[kStreamHandle]; + if (handle !== undefined) { + handle[kApplicationOwner] = this; + } const { onheaders, oninfo, @@ -70,6 +168,64 @@ class Http3Stream { if (onerror !== undefined) this.onerror = onerror; } + [kOnHeaders](pairs, kind) { + if (this.#stream.destroyed) return; + quicSafeCallbackInvoke(() => this.#onHeaderBlock(pairs, kind), this.#stream); + } + + [kOnWantTrailers]() { + if (this.#stream.destroyed) return; + if (typeof this.#onwanttrailers !== 'function') return; + quicSafeCallbackInvoke(() => this.#onwanttrailers(), this.#stream); + } + + #onHeaderBlock(pairs, kind) { + const block = parseHeaderPairs(pairs); + const stream = this.#stream; + switch (kind) { + case kHeadersKindInitial: + this.#headers ??= block; + if (onStreamHeadersChannel.hasSubscribers) { + onStreamHeadersChannel.publish({ + __proto__: null, + stream, + session: stream.session, + headers: block, + }); + } + if (typeof this.#onheaders === 'function') { + return this.#onheaders(block); + } + return undefined; + case kHeadersKindTrailing: + if (onStreamTrailersChannel.hasSubscribers) { + onStreamTrailersChannel.publish({ + __proto__: null, + stream, + session: stream.session, + trailers: block, + }); + } + if (typeof this.#ontrailers === 'function') { + return this.#ontrailers(block); + } + return undefined; + case kHeadersKindHints: + if (onStreamInfoChannel.hasSubscribers) { + onStreamInfoChannel.publish({ + __proto__: null, + stream, + session: stream.session, + headers: block, + }); + } + if (typeof this.#oninfo === 'function') { + return this.#oninfo(block); + } + return undefined; + } + } + /** @type {Http3Session} */ get session() { return this.#session; } @@ -80,7 +236,7 @@ class Http3Stream { get direction() { return this.#stream.direction; } // Received header block (after onheaders fires). - get headers() { return this.#stream.headers; } + get headers() { return this.#headers; } // True when the stream's data was received as 0-RTT early data. get early() { return this.#stream.early; } @@ -89,17 +245,49 @@ class Http3Stream { setPriority(options) { return this.#stream.setPriority(options); } - get onheaders() { return this.#stream.onheaders; } - set onheaders(fn) { this.#stream.onheaders = fn; } + #updateHeaderInterest() { + getQuicStreamState(this.#stream).wantsHeaders = + this.#onheaders !== undefined || + this.#oninfo !== undefined || + this.#ontrailers !== undefined; + } - get oninfo() { return this.#stream.oninfo; } - set oninfo(fn) { this.#stream.oninfo = fn; } + get onheaders() { return this.#onheaders; } + set onheaders(fn) { + if (fn !== undefined) validateFunction(fn, 'onheaders'); + this.#onheaders = fn; + this.#updateHeaderInterest(); + } - get ontrailers() { return this.#stream.ontrailers; } - set ontrailers(fn) { this.#stream.ontrailers = fn; } + get oninfo() { return this.#oninfo; } + set oninfo(fn) { + if (fn !== undefined) validateFunction(fn, 'oninfo'); + this.#oninfo = fn; + this.#updateHeaderInterest(); + } - get onwanttrailers() { return this.#stream.onwanttrailers; } - set onwanttrailers(fn) { this.#stream.onwanttrailers = fn; } + get ontrailers() { return this.#ontrailers; } + set ontrailers(fn) { + if (fn !== undefined) validateFunction(fn, 'ontrailers'); + this.#ontrailers = fn; + this.#updateHeaderInterest(); + } + + get onwanttrailers() { return this.#onwanttrailers; } + set onwanttrailers(fn) { + if (fn === undefined) { + this.#onwanttrailers = undefined; + // The application ends the stream after the body when no trailers + // are expected. + getQuicStreamState(this.#stream).wantsTrailers = false; + } else { + validateFunction(fn, 'onwanttrailers'); + this.#onwanttrailers = fn; + // Tell the application to keep the stream open after the body so + // trailers can be submitted. + getQuicStreamState(this.#stream).wantsTrailers = true; + } + } get onreset() { return this.#stream.onreset; } set onreset(fn) { this.#stream.onreset = fn; } @@ -107,16 +295,53 @@ class Http3Stream { get onerror() { return this.#stream.onerror; } set onerror(fn) { this.#stream.onerror = fn; } + /** + * Sends the initial request or response header block. + * @param {object} headers + * @param {SendHeadersOptions} [options] + * @returns {boolean} true if the headers were scheduled to be sent. + */ sendHeaders(headers, options = kEmptyObject) { - return this.#stream.sendHeaders(headers, options); + const stream = this.#stream; + if (stream.destroyed) return false; + validateObject(headers, 'headers'); + const { terminal = false } = options; + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true /* strictSingleValueFields */); + const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; + return stream[kStreamHandle].sendHeaders( + kHeadersKindInitial, headerString, flags); } + /** + * Sends informational (1xx) headers on this stream. Server only. + * @param {object} headers + * @returns {boolean} true if the headers were scheduled to be sent. + */ sendInformationalHeaders(headers) { - return this.#stream.sendInformationalHeaders(headers); + const stream = this.#stream; + if (stream.destroyed) return false; + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + return stream[kStreamHandle].sendHeaders( + kHeadersKindHints, headerString, kHeadersFlagsNone); } + /** + * Sends trailing headers on this stream. Must be called synchronously + * during the onwanttrailers callback. + * @param {object} headers + * @returns {boolean} true if the trailers were scheduled to be sent. + */ sendTrailers(headers) { - return this.#stream.sendTrailers(headers); + const stream = this.#stream; + if (stream.destroyed) return false; + validateObject(headers, 'headers'); + const headerString = + buildNgHeaderString(headers, assertValidPseudoHeaderTrailer); + return stream[kStreamHandle].sendHeaders( + kHeadersKindTrailing, headerString, kHeadersFlagsNone); } // Outbound body writer (stream/iter push writer). @@ -142,29 +367,136 @@ class Http3Stream { class Http3Session { #session; #onstream = undefined; + #ongoaway = undefined; + #onorigin = undefined; + #onsettings = undefined; /** * Wraps an existing (built-in) QuicSession. Must be constructed * synchronously in the frame the session is delivered, before any I/O - * tick, so that no incoming stream is missed. + * tick, so that no incoming stream or session event is missed. * @param {QuicSession} session the QUIC session to wrap + * @param {object} [callbacks] creation-time session callbacks */ - constructor(session) { + constructor(session, callbacks = kEmptyObject) { if (!isQuicSession(session)) { // Foreign QuicLike transports take the slow path; that adapter is not // wired up yet, so only built-in sessions are accepted for now. throw new ERR_INVALID_ARG_TYPE('session', 'QuicSession', session); } this.#session = session; + // Receive the raw HTTP/3 application events for this session (GOAWAY, + // ORIGIN): they are emitted on the session's binding handle and + // routed back here. + const handle = session[kSessionHandle]; + if (handle !== undefined) { + handle[kApplicationOwner] = this; + } + // Claim the session's incoming streams. This takes the single + // public onstream slot; a tap chains it explicitly (grab the + // previous handler and call both). session.onstream = (stream) => { - const wrapped = new Http3Stream(stream, this); - this.#onstream?.(wrapped); + if (typeof this.#onstream !== 'function') { + process.emitWarning( + 'A new HTTP/3 stream was received but no onstream callback ' + + 'was provided'); + stream.destroy(); + return; + } + + return this.#onstream(new Http3Stream(stream, this)); }; + const { ongoaway, onorigin, onsettings } = callbacks; + if (ongoaway !== undefined) this.ongoaway = ongoaway; + if (onorigin !== undefined) this.onorigin = onorigin; + if (onsettings !== undefined) this.onsettings = onsettings; + } + + /** + * The peer initiated a graceful shutdown of the session (HTTP/3 + * GOAWAY). The session stops allowing new streams. + * @param {bigint} lastStreamId The highest stream ID the peer may + * have processed - streams above it were not processed and may be + * retried. + */ + [kOnGoaway](lastStreamId) { + const session = this.#session; + if (session.destroyed) return; + markSessionClosing(session); + if (onSessionClosingChannel.hasSubscribers) { + onSessionClosingChannel.publish({ __proto__: null, session }); + } + if (onSessionGoawayChannel.hasSubscribers) { + onSessionGoawayChannel.publish({ + __proto__: null, + session, + lastStreamId, + }); + } + if (typeof this.#ongoaway === 'function') { + // A sync throw or async rejection from the user callback + // destroys the session with that error. + quicSafeCallbackInvoke(() => this.#ongoaway(lastStreamId), session); + } + } + + /** + * The peer announced the origins it claims authority for (HTTP/3 + * ORIGIN frame). + * @param {string[]} origins + */ + [kOnOrigin](origins) { + const session = this.#session; + if (session.destroyed) return; + if (onSessionOriginChannel.hasSubscribers) { + onSessionOriginChannel.publish({ + __proto__: null, + origins, + session, + }); + } + if (typeof this.#onorigin === 'function') { + quicSafeCallbackInvoke(() => this.#onorigin(origins), session); + } + } + + /** + * The peer's HTTP/3 SETTINGS were received (or updated). The negotiated + * values are now readable via `session.settings` and are passed to the + * callback for convenience. + */ + [kOnSettings]() { + const session = this.#session; + if (session.destroyed) return; + const settings = this.settings; + if (onSessionApplicationChannel.hasSubscribers) { + onSessionApplicationChannel.publish({ + __proto__: null, + settings, + session, + }); + } + if (typeof this.#onsettings === 'function') { + quicSafeCallbackInvoke(() => this.#onsettings(settings), session); + } } // The underlying QUIC session (transitional escape hatch). get session() { return this.#session; } + /** + * The effective HTTP/3 settings for this session. Reflects the + * configured values plus any updates received from the peer's + * SETTINGS frame (e.g. enableConnectProtocol). Returns null once the + * session is destroyed. + * @type {object|null} + */ + get settings() { + if (this.#session.destroyed) return null; + // Not cached: the values can be updated by the peer's SETTINGS. + return this.#session[kSessionHandle].applicationSettings(); + } + get servername() { return this.#session.servername; } get alpnProtocol() { return this.#session.alpnProtocol; } @@ -175,11 +507,38 @@ class Http3Session { this.#onstream = fn; } - get ongoaway() { return this.#session.ongoaway; } - set ongoaway(fn) { this.#session.ongoaway = fn; } + // Receives the last-processed stream id when the peer sends an + // HTTP/3 GOAWAY frame. + get ongoaway() { return this.#ongoaway; } + set ongoaway(fn) { + if (fn !== undefined) validateFunction(fn, 'ongoaway'); + this.#ongoaway = fn; + } + + // Receives the origin list when the peer sends an HTTP/3 ORIGIN + // frame (RFC 9412). + get onorigin() { return this.#onorigin; } + set onorigin(fn) { + if (fn === undefined) { + this.#onorigin = undefined; + // Tell the application to stop delivering origin announcements. + getQuicSessionState(this.#session).hasOriginListener = false; + } else { + validateFunction(fn, 'onorigin'); + this.#onorigin = fn; + // Tell the application that origin announcements have a consumer. + getQuicSessionState(this.#session).hasOriginListener = true; + } + } - get onorigin() { return this.#session.onorigin; } - set onorigin(fn) { this.#session.onorigin = fn; } + // Receives the negotiated HTTP/3 settings when the peer's SETTINGS + // frame arrives (which may be after the session opens). The current + // values are also available synchronously via `session.settings`. + get onsettings() { return this.#onsettings; } + set onsettings(fn) { + if (fn !== undefined) validateFunction(fn, 'onsettings'); + this.#onsettings = fn; + } get onerror() { return this.#session.onerror; } set onerror(fn) { this.#session.onerror = fn; } @@ -204,11 +563,56 @@ class Http3Session { * @returns {Promise} */ async request(headers, options = kEmptyObject) { + if (getQuicSessionState(this.#session).isServer) { + throw new ERR_INVALID_STATE( + 'Server sessions cannot open HTTP/3 request streams'); + } if (headers !== undefined) validateObject(headers, 'headers'); validateObject(options, 'options'); - const stream = await this.#session.createBidirectionalStream( - headers === undefined ? options : { ...options, headers }); - return new Http3Stream(stream, this, options); + // The HTTP/3-level callbacks stay at this layer; only the + // QUIC-level stream options (body, priority, highWaterMark, ...) + // are passed down. + const { + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + ...quicOptions + } = options; + + let headerString; + if (headers !== undefined) { + headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true /* strictSingleValueFields */); + } + const stream = await this.#session.createBidirectionalStream(quicOptions); + const wrapped = new Http3Stream(stream, this, { + __proto__: null, + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + }); + // Submit the request headers only after the callbacks above are + // attached. Nothing for this stream can hit the wire before the + // headers are submitted: the HTTP/3 application only learns about the + // stream (including any body configured at creation) from the + // header submission itself. When there is no body, the header + // block also terminates the stream. + if (headerString !== undefined) { + const flags = quicOptions.body === undefined ? + kHeadersFlagsTerminal : kHeadersFlagsNone; + if (!stream[kStreamHandle].sendHeaders( + kHeadersKindInitial, headerString, flags)) { + wrapped.destroy(); + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + } + return wrapped; } close(options) { return this.#session.close(options); } @@ -216,33 +620,121 @@ class Http3Session { destroy(error, options) { return this.#session.destroy(error, options); } } +// Register the HTTP/3 application-event callbacks with the binding. The +// C++ layer invokes each with `this` set to the binding handle (stream or +// session) the event belongs to; route it to the wrapper. Events for a +// handle with no live wrapper are dropped. This runs once per realm at +// module load: any HTTP/3 session requires this module, so the callbacks +// are always installed before an event can fire. +setHttp3Callbacks({ + /** + * A block of headers ([name, value, ...] pairs plus the + * application's kind constant) was received on a stream. + * @param {string[]} headers + * @param {number} kind + */ + onStreamHeaders(headers, kind) { + this[kApplicationOwner]?.[kOnHeaders](headers, kind); + }, + /** + * The application is ready for a stream's trailing headers. + */ + onStreamTrailers() { + this[kApplicationOwner]?.[kOnWantTrailers](); + }, + /** + * The peer initiated a graceful shutdown of a session. + * @param {bigint} lastStreamId + */ + onSessionGoaway(lastStreamId) { + this[kApplicationOwner]?.[kOnGoaway](lastStreamId); + }, + /** + * The peer announced the origins it claims authority for. + * @param {string[]} origins + */ + onSessionOrigin(origins) { + this[kApplicationOwner]?.[kOnOrigin](origins); + }, + /** + * The peer's HTTP/3 SETTINGS were received or updated. + */ + onSessionApplication() { + this[kApplicationOwner]?.[kOnSettings](); + }, +}); + +/** + * The HTTP/3 settings (RFC 9114 section 7.2 / RFC 9204 SETTINGS values + * advertised to the peer, plus local header-processing limits). + * Validated and applied by the HTTP/3 application in C++; the QUIC + * layer carries them opaquely. + * @typedef {object} Http3Settings + * @property {bigint|number} [maxHeaderPairs] Maximum number of header + * pairs accepted on a stream (local enforcement limit). + * @property {bigint|number} [maxHeaderLength] Maximum total header + * bytes accepted on a stream (local enforcement limit). + * @property {bigint|number} [maxFieldSectionSize] The maximum field + * section size advertised to the peer. + * @property {bigint|number} [qpackMaxDTableCapacity] The QPACK maximum + * dynamic table capacity. + * @property {bigint|number} [qpackEncoderMaxDTableCapacity] The QPACK + * encoder maximum dynamic table capacity. + * @property {bigint|number} [qpackBlockedStreams] The maximum number of + * QPACK blocked streams. + * @property {boolean} [enableConnectProtocol] Enable extended CONNECT + * (RFC 9220). + */ + /** - * Connects a built-in QUIC session with ALPN h3 and wraps it. + * Connects a built-in QUIC session with ALPN h3 and wraps it. The + * HTTP/3-level options (settings, ongoaway, onorigin) stay at this + * layer; the remaining options are passed down to node:quic. + * @param {SocketAddress|string} address the server address + * @param {object} [options] http3 + quic connect options + * @param {Http3Settings} [options.settings] the HTTP/3 settings * @returns {Promise} */ async function connect(address, options = kEmptyObject) { validateObject(options, 'options'); + const { ongoaway, onorigin, onsettings, settings, ...quicOptions } = options; const session = await quicConnect(address, { - ...options, + ...quicOptions, + // The onstream callback is owned by this layer. Datagrams are not (yet) + // exposed for HTTP/3, so suppress ondatagram too. + onstream: undefined, + ondatagram: undefined, alpn: kHttp3Alpn, [kApplication]: 'http3', + [kApplicationSettings]: settings, }); - return new Http3Session(session); + return new Http3Session(session, { ongoaway, onorigin, onsettings }); } /** * Listens with ALPN h3; onsession receives an Http3Session, wrapped - * synchronously in the delivery frame per the attach contract. + * synchronously in the delivery frame per the attach contract. The + * HTTP/3-level options (settings, ongoaway, onorigin) stay at this + * layer; the remaining options are passed down to node:quic. * @param {Function} onsession invoked with each new Http3Session - * @param {object} [options] quic listen options + * @param {object} [options] http3 + quic listen options + * @param {Http3Settings} [options.settings] the HTTP/3 settings * @returns {Promise} the listening QuicEndpoint */ async function listen(onsession, options = kEmptyObject) { validateFunction(onsession, 'onsession'); validateObject(options, 'options'); + const { ongoaway, onorigin, onsettings, settings, ...quicOptions } = options; return quicListen((session) => { - onsession(new Http3Session(session)); - }, { ...options, alpn: kHttp3Alpn, [kApplication]: 'http3' }); + return onsession(new Http3Session(session, { ongoaway, onorigin, onsettings })); + }, { + ...quicOptions, + onstream: undefined, + ondatagram: undefined, + alpn: kHttp3Alpn, + [kApplication]: 'http3', + [kApplicationSettings]: settings, + }); } module.exports = { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 1a90f96315129a..5d2561cc021f79 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -71,20 +71,8 @@ const { CLOSECONTEXT_RECEIVE_FAILURE: kCloseContextReceiveFailure, CLOSECONTEXT_SEND_FAILURE: kCloseContextSendFailure, CLOSECONTEXT_START_FAILURE: kCloseContextStartFailure, - QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, - QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, - QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, - QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, - QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, } = internalBinding('quic'); -// Maps the numeric HeadersKind constants from C++ to user-facing strings. -// Indexed by the enum value (HINTS=0, INITIAL=1, TRAILING=2). -const kHeadersKindName = []; -kHeadersKindName[kHeadersKindHints] = 'hints'; -kHeadersKindName[kHeadersKindInitial] = 'initial'; -kHeadersKindName[kHeadersKindTrailing] = 'trailing'; - const { markPromiseAsHandled, } = internalBinding('util'); @@ -168,11 +156,6 @@ const { validateString, } = require('internal/validators'); -const { - buildNgHeaderString, - assertValidPseudoHeader, -} = require('internal/http2/util'); - const kEmptyObject = { __proto__: null }; const { @@ -184,11 +167,9 @@ const { kDrain, kEarlyDataRejected, kFinishClose, - kGoaway, kHandshake, kHandshakeCompleted, kVerifyPeer, - kHeaders, kOwner, kRemoveSession, kKeylog, @@ -198,15 +179,10 @@ const { kRemoveStream, kNewStream, kNewToken, - kOrigin, - kStreamCallbacks, kPathValidation, kPrivateConstructor, kReset, - kSendHeaders, - kSessionApplication, kSessionTicket, - kTrailers, kVersionNegotiation, kInspect, } = require('internal/quic/symbols'); @@ -253,17 +229,11 @@ const { onSessionReceiveDatagramStatusChannel, onSessionPathValidationChannel, onSessionNewTokenChannel, - onSessionApplicationChannel, onSessionTicketChannel, onSessionVersionNegotiationChannel, - onSessionOriginChannel, onSessionHandshakeChannel, - onSessionGoawayChannel, onSessionEarlyRejectedChannel, onStreamClosedChannel, - onStreamHeadersChannel, - onStreamTrailersChannel, - onStreamInfoChannel, onStreamResetChannel, onStreamBlockedChannel, onSessionErrorChannel, @@ -272,6 +242,29 @@ const { const kNilDatagramId = 0n; +// Constructor option symbol for installing a protocol application on a +// session. This is a private API for configuring native integrations, not a +// public option: installing an application without its consumer layer on top +// breaks the session, as applications integrate natively with their matching +// consumer (e.g. node:http3). +const kApplication = Symbol('kApplication'); + +// Constructor option symbol carrying the application-specific settings for +// the application named by kApplication. The value is opaque to the QUIC +// layer: it is parsed by the registered application's own settings parser +// in C++ and carried through the session options untouched. +const kApplicationSettings = Symbol('kApplicationSettings'); + +// Internal symbols to allow a protocol application's consumer layer (e.g. +// node:http3) to reach the binding handles of a stream or session: +const kStreamHandle = Symbol('kStreamHandle'); +const kSessionHandle = Symbol('kSessionHandle'); +// Brands a C++ handle object with its application-layer owner (e.g. the +// Http3Stream/Http3Session wrapper). Lives here rather than in the +// application module: any application module would share it, and the +// handle objects it brands are this layer's objects. +const kApplicationOwner = Symbol('kApplicationOwner'); + // Module-level registry of all live QuicEndpoint instances. Used by // connect() and listen() to find existing endpoints for reuse instead // of creating a new one per session. @@ -288,18 +281,10 @@ const endpointRegistry = new SafeSet(); * FileHandle|AsyncIterable|Iterable|Promise|null} [body] The outbound * body source. See the public docs for `stream.setBody()` for details * on supported types. When omitted, the stream is closed immediately. - * @property {object} [headers] Initial request or response headers to - * send. Only used when the negotiated application supports headers - * (e.g. HTTP/3). * @property {'high'|'default'|'low'} [priority] The priority level of the stream. * @property {boolean} [incremental] Whether to interleave data with same-priority streams. * @property {number} [highWaterMark] The high water mark for write * backpressure, in bytes. **Default:** `65536`. - * @property {OnHeadersCallback} [onheaders] Callback for incoming initial headers - * @property {OnTrailersCallback} [ontrailers] Callback for incoming trailing headers - * @property {OnInfoCallback} [oninfo] Callback for informational (1xx) headers - * @property {OnWantTrailersCallback} [onwanttrailers] Callback fired when the - * transport is ready to send trailers for this stream. */ /** @@ -353,18 +338,8 @@ const endpointRegistry = new SafeSet(); * @property {bigint|number} [ackDelayExponent] The acknowledgment delay exponent * @property {bigint|number} [maxAckDelay] The maximum acknowledgment delay * @property {bigint|number} [maxDatagramFrameSize] The maximum datagram frame size - */ - -/** - * @typedef {object} ApplicationOptions - * @property {bigint|number} [maxHeaderPairs] The maximum header pairs - * @property {bigint|number} [maxHeaderLength] The maximum header length - * @property {bigint|number} [maxFieldSectionSize] The maximum field section size - * @property {bigint|number} [qpackMaxDTableCapacity] The qpack maximum dynamic table capacity - * @property {bigint|number} [qpackEncoderMaxDTableCapacity] The qpack encoder maximum dynamic table capacity - * @property {bigint|number} [qpackBlockedStreams] The qpack blocked streams - * @property {boolean} [enableConnectProtocol] Enable the connect protocol - * @property {boolean} [enableDatagrams] Enable datagrams + * (RFC 9221). Setting `false` advertises a maximum datagram frame size + * of zero, disabling datagrams entirely. **Default:** `true`. */ /** @@ -375,11 +350,13 @@ const endpointRegistry = new SafeSet(); * @property {ArrayBuffer|ArrayBufferView|Array} certs The TLS certificates. * @property {boolean} [verifyPrivateKey] Verify the private key. * **Default:** `false`. - * @property {number} [port] The port to advertise in HTTP/3 ORIGIN frames - * for this host name. **Default:** `443`. + * @property {number} [port] The port to advertise for this host name + * when the session's protocol application announces server authority + * (origin advertisement). **Default:** `443`. * @property {boolean} [authoritative] Whether to include this host name - * in HTTP/3 ORIGIN frames. **Default:** `true`. Wildcard (`'*'`) - * entries are always excluded regardless of this setting. + * in the protocol application's origin advertisement. **Default:** `true`. + * Wildcard (`'*'`) entries are always excluded regardless of this + * setting. */ /** @@ -392,7 +369,6 @@ const endpointRegistry = new SafeSet(); * @property {number} [minVersion] The minimum acceptable QUIC version * @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy * @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only) - * @property {ApplicationOptions} [application] The application options * @property {TransportParams} [transportParams] The transport parameters * @property {string} [servername] The server name identifier (client only) * @property {string|string[]} [alpn] The ALPN protocol identifier(s). @@ -455,16 +431,8 @@ const endpointRegistry = new SafeSet(); * @property {OnVersionNegotiationCallback} [onversionnegotiation] Version negotiation callback. * @property {OnHandshakeCallback} [onhandshake] Handshake-completed callback. * @property {OnNewTokenCallback} [onnewtoken] NEW_TOKEN frame callback (client only). - * @property {OnOriginCallback} [onorigin] ORIGIN frame callback (client only). - * @property {OnGoawayCallback} [ongoaway] GOAWAY frame callback. * @property {OnKeylogCallback} [onkeylog] TLS key-log callback. * @property {OnQlogCallback} [onqlog] qlog data callback. - * @property {OnApplicationCallback} [onapplication] application options callback. - * @property {OnHeadersCallback} [onheaders] Default per-stream initial-headers callback. - * @property {OnTrailersCallback} [ontrailers] Default per-stream trailing-headers callback. - * @property {OnInfoCallback} [oninfo] Default per-stream informational-headers callback. - * @property {OnWantTrailersCallback} [onwanttrailers] Default per-stream - * want-trailers callback. */ /** @@ -504,12 +472,6 @@ const endpointRegistry = new SafeSet(); * frames do not themselves carry a reason field over the wire. */ -/** - * @typedef {object} SendHeadersOptions - * @property {boolean} [terminal] When true, indicates that no body data will be - * sent after these headers. - */ - /** * @typedef {object} StreamPriority * @property {'default' | 'low' | 'high'} level The priority level of the stream. @@ -590,13 +552,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * @callback OnApplicationCallback - * @this {QuicSession} - * @param {ApplicationOptions} applicationoptions - * @returns {void} - */ - /** * @callback OnSessionTicketCallback * @this {QuicSession} @@ -639,22 +594,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when the server sends an ORIGIN frame. - * @callback OnOriginCallback - * @this {QuicSession} - * @param {string[]} origins The list of origins the server claims authority for - * @returns {void} - */ - -/** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). - * @callback OnGoawayCallback - * @this {QuicSession} - * @param {bigint} lastStreamId The highest stream ID the peer may have processed - * @returns {void} - */ - /** * Called when TLS key-log material is available. Only fires when * `sessionOptions.keylog` is `true`. @@ -674,14 +613,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when `ApplicationOptions` are changed, e.g. HTTP/3 settings. - * @callback OnApplicationCallback - * @this {QuicSession} - * @param {ApplicationOptions} applicationoptions ApplicationOptions object - * @returns {void} - */ - /** * @callback OnBlockedCallback * @this {QuicStream} @@ -695,41 +626,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when initial request or response headers are received. - * @callback OnHeadersCallback - * @this {QuicStream} - * @param {object} headers Header object with lowercase string keys and - * string or string-array values. - * @returns {void} - */ - -/** - * Called when trailing headers are received from the peer. - * @callback OnTrailersCallback - * @this {QuicStream} - * @param {object} trailers Trailing header object. - * @returns {void} - */ - -/** - * Called when informational (1xx) headers are received from the server - * (e.g. 103 Early Hints). - * @callback OnInfoCallback - * @this {QuicStream} - * @param {object} headers Informational header object. - * @returns {void} - */ - -/** - * Called when the transport is ready to send trailers for this stream. - * The handler should call `stream.sendTrailers(...)` (or - * `stream.sendTrailers()` with previously-set trailers) to provide them. - * @callback OnWantTrailersCallback - * @this {QuicStream} - * @returns {void} - */ - setCallbacks({ // QuicEndpoint callbacks @@ -743,15 +639,11 @@ setCallbacks({ debug('endpoint close callback', status); this[kOwner][kFinishClose](context, status); }, - /** - * Called when the QuicEndpoint C++ handle receives a new server-side session - * @param {object} session The QuicSession C++ handle - */ /** * Called when a new server session is surfaced. The emit happens after * the session's first datagram (the ClientHello) has been processed, * so the session's servername/protocol getters are already readable. - * @param {object} session The session handle + * @param {object} session The QuicSession C++ handle */ onSessionNew(session) { debug('new server session callback', this[kOwner], session); @@ -774,16 +666,6 @@ setCallbacks({ this[kOwner][kFinishClose](errorType, code, reason, errorName); }, - /** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). - * @param {bigint} lastStreamId The highest stream ID the peer may have - * processed. Streams above this ID were not processed and can be retried. - */ - onSessionGoaway(lastStreamId) { - debug('session goaway callback', lastStreamId); - this[kOwner][kGoaway](lastStreamId); - }, - /** * Called when a datagram is received on this session. * @param {Uint8Array} uint8Array @@ -845,16 +727,6 @@ setCallbacks({ preferredAddress); }, - /** - * Called when the session's application object is updated - * E.g. http/3 session arrived. - * @param {ApplicationOptions} applicationoptions An application object - */ - onSessionApplication(applicationoptions) { - debug('session application callback', this[kOwner]); - this[kOwner][kSessionApplication](applicationoptions); - }, - /** * Called when the session generates a new TLS session ticket * @param {object} ticket An opaque session ticket @@ -886,15 +758,6 @@ setCallbacks({ this[kOwner][kEarlyDataRejected](); }, - /** - * Called when the session receives an ORIGIN frame from the peer (RFC 9412). - * @param {string[]} origins The list of origins the peer claims authority for - */ - onSessionOrigin(origins) { - debug('session origin callback', this[kOwner]); - this[kOwner][kOrigin](origins); - }, - /** * Called when the session receives a session version negotiation request * @param {number} version @@ -991,18 +854,6 @@ setCallbacks({ debug('stream reset callback', this[kOwner], error); this[kOwner][kReset](error); }, - - onStreamHeaders(headers, kind) { - // Called when the stream C++ handle has received a full block of headers. - debug(`stream ${this[kOwner].id} headers callback`, headers, kind); - this[kOwner][kHeaders](headers, kind); - }, - - onStreamTrailers() { - // Called when the stream C++ handle is ready to receive trailing headers. - debug('stream want trailers callback', this[kOwner]); - this[kOwner][kTrailers](); - }, }); function assertPrivateSymbol(privateSymbol) { @@ -1266,31 +1117,7 @@ function validateBody(body) { } /** - * Parses an alternating [name, value, name, value, ...] array from C++ - * into a plain header object. Multi-value headers become arrays. - * @param {string[]} pairs - * @returns {object} - */ -function parseHeaderPairs(pairs) { - assert(ArrayIsArray(pairs)); - assert(pairs.length % 2 === 0); - const block = { __proto__: null }; - for (let n = 0; n + 1 < pairs.length; n += 2) { - if (block[pairs[n]] !== undefined) { - if (ArrayIsArray(block[pairs[n]])) { - ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); - } else { - block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; - } - } else { - block[pairs[n]] = pairs[n + 1]; - } - } - return block; -} - -/** - * Applies session and stream callbacks from an options object to a session. + * Applies session callbacks from an options object to a session. * @param {QuicSession} session * @param {object} cbs */ @@ -1305,20 +1132,8 @@ function applyCallbacks(session, cbs) { if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; if (cbs.onearlyrejected) session.onearlyrejected = cbs.onearlyrejected; - if (cbs.onorigin) session.onorigin = cbs.onorigin; - if (cbs.ongoaway) session.ongoaway = cbs.ongoaway; if (cbs.onkeylog) session.onkeylog = cbs.onkeylog; if (cbs.onqlog) session.onqlog = cbs.onqlog; - if (cbs.onapplication) session.onapplication = cbs.onapplication; - if (cbs.onheaders || cbs.ontrailers || cbs.oninfo || cbs.onwanttrailers) { - session[kStreamCallbacks] = { - __proto__: null, - onheaders: cbs.onheaders, - ontrailers: cbs.ontrailers, - oninfo: cbs.oninfo, - onwanttrailers: cbs.onwanttrailers, - }; - } } /** @@ -1522,10 +1337,10 @@ function isSyncIterable(obj) { let getQuicStreamState; let getQuicSessionState; let getQuicEndpointState; +let markSessionClosing; let assertIsQuicEndpoint; let assertIsQuicStream; let assertIsQuicSession; -let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; let isQuicStream; @@ -1572,15 +1387,9 @@ class QuicStream { outboundSet: false, writer: undefined, fileHandle: undefined, - headers: undefined, - pendingTrailers: undefined, onerror: undefined, onblocked: undefined, onreset: undefined, - onheaders: undefined, - ontrailers: undefined, - oninfo: undefined, - onwanttrailers: undefined, }; static { @@ -1594,13 +1403,6 @@ class QuicStream { } }; - assertHeadersSupported = function(session) { - if (getQuicSessionState(session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - }; - getQuicStreamState = function(stream) { assertIsQuicStream(stream); return stream.#inner.state; @@ -1782,112 +1584,14 @@ class QuicStream { } } - /** @type {OnHeadersCallback} */ - get onheaders() { - assertIsQuicStream(this); - return this.#inner.onheaders; - } - - set onheaders(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onheaders = undefined; - inner.state.wantsHeaders = false; - } else { - validateFunction(fn, 'onheaders'); - assertHeadersSupported(inner.session); - inner.onheaders = FunctionPrototypeBind(fn, this); - inner.state.wantsHeaders = true; - } - } - - /** @type {Function|undefined} */ - get oninfo() { - assertIsQuicStream(this); - return this.#inner.oninfo; - } - - set oninfo(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.oninfo = undefined; - } else { - validateFunction(fn, 'oninfo'); - assertHeadersSupported(inner.session); - inner.oninfo = FunctionPrototypeBind(fn, this); - } - } - - /** @type {Function|undefined} */ - get ontrailers() { - assertIsQuicStream(this); - return this.#inner.ontrailers; - } - - set ontrailers(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ontrailers = undefined; - } else { - validateFunction(fn, 'ontrailers'); - assertHeadersSupported(inner.session); - inner.ontrailers = FunctionPrototypeBind(fn, this); - } - } - - /** @type {Function|undefined} */ - get onwanttrailers() { - assertIsQuicStream(this); - return this.#inner.onwanttrailers; - } - - set onwanttrailers(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onwanttrailers = undefined; - inner.state.wantsTrailers = false; - } else { - validateFunction(fn, 'onwanttrailers'); - assertHeadersSupported(inner.session); - inner.onwanttrailers = FunctionPrototypeBind(fn, this); - inner.state.wantsTrailers = true; - } - } - /** - * The buffered initial headers received on this stream, or undefined - * if the application does not support headers or no headers have - * been received yet. + * Accessor to allow applications (e.g. http3) to access the stream handle + * directly for internal operations. Returns undefined once destroyed. * @type {object|undefined} */ - get headers() { - assertIsQuicStream(this); - return this.#inner.headers; - } - - /** - * Set trailing headers to be sent when nghttp3 asks for them. - * @type {object|undefined} - */ - get pendingTrailers() { - assertIsQuicStream(this); - return this.#inner.pendingTrailers; - } - - set pendingTrailers(headers) { - const inner = this.#inner; + get [kStreamHandle]() { assertIsQuicStream(this); - assertHeadersSupported(inner.session); - if (headers === undefined) { - inner.pendingTrailers = undefined; - return; - } - validateObject(headers, 'headers'); - inner.pendingTrailers = headers; + return this.#handle; } /** @@ -1960,7 +1664,7 @@ class QuicStream { * are emitted to the peer for any still-open writable / readable * side of the stream. The wire code is resolved as: * `options.code` -> `error.errorCode` (when `error` is a - * `QuicError`) -> the negotiated application's "internal error" + * `QuicError`) -> the installed application's "internal error" * code from `QuicSessionState.internalErrorCode`. * @param {any} error * @param {QuicStreamDestroyOptions} [options] @@ -2053,66 +1757,6 @@ class QuicStream { this.#handle.attachSource(validateBody(outbound)); } - /** - * @param {object} headers - * @param {SendHeadersOptions} [options] - * @returns {boolean} - */ - sendHeaders(headers, options = kEmptyObject) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const { terminal = false } = options; - const headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true /* strictSingleValueFields */); - const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; - return this.#handle.sendHeaders(kHeadersKindInitial, headerString, flags); - } - - /** - * Send informational (1xx) headers on this stream. Server only. - * Throws if the application does not support headers. - * @param {object} headers - * @returns {boolean} - */ - sendInformationalHeaders(headers) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true); - return this.#handle.sendHeaders( - kHeadersKindHints, headerString, kHeadersFlagsNone); - } - - /** - * Send trailing headers on this stream. Must be called synchronously - * during the onwanttrailers callback, or set via pendingTrailers before - * the body completes. Throws if the application does not support headers. - * @param {object} headers - * @returns {boolean} - */ - sendTrailers(headers) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const headerString = buildNgHeaderString(headers); - return this.#handle.sendHeaders( - kHeadersKindTrailing, headerString, kHeadersFlagsNone); - } - /** * Returns a Writer for pushing data to this stream incrementally. * Only available when no body source was provided at creation time @@ -2308,7 +1952,7 @@ class QuicStream { // order: // 1. If `reason` is a `QuicError`, use its explicit // `errorCode`. - // 2. Otherwise fall back to the negotiated application's + // 2. Otherwise fall back to the installed application's // "internal error" code, surfaced via // `QuicSessionState.internalErrorCode`. For HTTP/3 this is // `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications @@ -2474,38 +2118,6 @@ class QuicStream { this.#handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); } - /** - * Send a block of headers. The headers are formatted as an array - * of key, value pairs. The reason we don't use a Headers object - * here is because this needs to be able to represent headers like - * :method which the high-level Headers API does not allow. - * - * Note that QUIC in general does not support headers. This method - * is in place to support HTTP3 and is therefore not generally - * exposed except via a private symbol. - * @param {object} headers - * @returns {boolean} true if the headers were scheduled to be sent. - */ - [kSendHeaders](headers, kind = kHeadersKindInitial, - flags = kHeadersFlagsTerminal) { - validateObject(headers, 'headers'); - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - if (this.pending) { - debug('pending stream enqueuing headers', headers); - } else { - debug(`stream ${this.id} sending headers`, headers); - } - const headerString = buildNgHeaderString( - headers, - assertValidPseudoHeader, - true, // This could become an option in future - ); - return this.#handle.sendHeaders(kind, headerString, flags); - } - [kFinishClose](error) { const inner = this.#inner; inner.pendingClose ??= PromiseWithResolvers(); @@ -2544,13 +2156,7 @@ class QuicStream { inner.pendingClose.resolve = undefined; inner.onblocked = undefined; inner.onreset = undefined; - inner.onheaders = undefined; inner.onerror = undefined; - inner.ontrailers = undefined; - inner.oninfo = undefined; - inner.onwanttrailers = undefined; - inner.headers = undefined; - inner.pendingTrailers = undefined; this.#handle = undefined; if (inner.fileHandle !== undefined) { // Close the FileHandle that was used as a body source. The close @@ -2597,66 +2203,6 @@ class QuicStream { safeCallbackInvoke(inner.onreset, this, error); } - [kHeaders](headers, kind) { - const block = parseHeaderPairs(headers); - const kindName = kHeadersKindName[kind] ?? kind; - const inner = this.#inner; - - switch (kindName) { - case 'initial': - assert(inner.onheaders, 'Unexpected stream headers event'); - inner.headers ??= block; - if (onStreamHeadersChannel.hasSubscribers) { - onStreamHeadersChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - headers: block, - }); - } - safeCallbackInvoke(inner.onheaders, this, block); - break; - case 'trailing': - if (onStreamTrailersChannel.hasSubscribers) { - onStreamTrailersChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - trailers: block, - }); - } - if (inner.ontrailers) - safeCallbackInvoke(inner.ontrailers, this, block); - break; - case 'hints': - if (onStreamInfoChannel.hasSubscribers) { - onStreamInfoChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - headers: block, - }); - } - if (typeof inner.oninfo === 'function') - safeCallbackInvoke(inner.oninfo, this, block); - break; - } - } - - [kTrailers]() { - if (this.destroyed) return; - const inner = this.#inner; - - // nghttp3 is asking us to provide trailers to send. - // Check for pre-set pendingTrailers first, then the callback. - if (inner.pendingTrailers) { - this.sendTrailers(inner.pendingTrailers); - inner.pendingTrailers = undefined; - } else if (typeof inner.onwanttrailers === 'function') { - safeCallbackInvoke(inner.onwanttrailers, this); - } - } - [kInspect](depth, options) { if (depth < 0) { return 'QuicStream { }'; @@ -2717,8 +2263,6 @@ class QuicSession { onhandshake: undefined, onnewtoken: undefined, onearlyrejected: undefined, - onorigin: undefined, - ongoaway: undefined, onkeylog: undefined, onqlog: undefined, pendingQlog: undefined, @@ -2752,6 +2296,14 @@ class QuicSession { assertIsQuicSession(session); return session.#inner.state; }; + + // Marks the session as having a close pending: no new streams or + // datagrams may be initiated locally. Used by protocol applications to + // report that the peer initiated a graceful shutdown of the session. + markSessionClosing = function(session) { + assertIsQuicSession(session); + session.#inner.isPendingClose = true; + }; } /** @@ -2785,14 +2337,6 @@ class QuicSession { debug('session created'); } - get applicationOptions() { - // We don't cache application options because they may be updated by the - // C++ layer after session creation depending on the behavior of the - // application. - if (this.destroyed) return null; - return this.#handle.applicationOptions(); - } - get localTransportParams() { if (this.#inner.localTransportParams !== undefined) { return this.#inner.localTransportParams; @@ -2837,6 +2381,16 @@ class QuicSession { return this.#handle === undefined || this.#inner.isPendingClose; } + /** + * Accessor to allow applications (e.g. http3) to access the session handle + * directly for internal operations. Returns undefined once destroyed. + * @type {object|undefined} + */ + get [kSessionHandle]() { + assertIsQuicSession(this); + return this.#handle; + } + /** @type {Function|undefined} */ get onerror() { assertIsQuicSession(this); @@ -3023,25 +2577,6 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onapplication() { - assertIsQuicSession(this); - return this.#inner.onapplication; - } - - set onapplication(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onapplication = undefined; - inner.state.hasApplicationListener = false; - } else { - validateFunction(fn, 'onapplication'); - inner.onapplication = FunctionPrototypeBind(fn, this); - inner.state.hasApplicationListener = true; - } - } - /** @type {Function|undefined} */ get onversionnegotiation() { assertIsQuicSession(this); @@ -3112,42 +2647,6 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onorigin() { - assertIsQuicSession(this); - return this.#inner.onorigin; - } - - set onorigin(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onorigin = undefined; - inner.state.hasOriginListener = false; - } else { - validateFunction(fn, 'onorigin'); - inner.onorigin = FunctionPrototypeBind(fn, this); - inner.state.hasOriginListener = true; - } - } - - /** @type {Function|undefined} */ - get ongoaway() { - assertIsQuicSession(this); - return this.#inner.ongoaway; - } - - set ongoaway(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ongoaway = undefined; - } else { - validateFunction(fn, 'ongoaway'); - inner.ongoaway = FunctionPrototypeBind(fn, this); - } - } - /** * The maximum datagram size the peer will accept, or 0 if datagrams * are not supported or the handshake has not yet completed. @@ -3265,11 +2764,6 @@ class QuicSession { priority = 'default', incremental = false, highWaterMark = kDefaultHighWaterMark, - headers, - onheaders, - ontrailers, - oninfo, - onwanttrailers, } = options; validateOneOf(priority, 'options.priority', ['default', 'low', 'high']); @@ -3303,16 +2797,6 @@ class QuicSession { // Set the high water mark for backpressure. stream.highWaterMark = highWaterMark; - // Set stream callbacks before sending headers to avoid missing events. - if (onheaders) stream.onheaders = onheaders; - if (ontrailers) stream.ontrailers = ontrailers; - if (oninfo) stream.oninfo = oninfo; - if (onwanttrailers) stream.onwanttrailers = onwanttrailers; - - if (headers !== undefined) { - stream.sendHeaders(headers, { terminal: validatedBody === undefined }); - } - if (onSessionOpenStreamChannel.hasSubscribers) { onSessionOpenStreamChannel.publish({ __proto__: null, @@ -3629,13 +3113,10 @@ class QuicSession { inner.ondatagramstatus = undefined; inner.onpathvalidation = undefined; inner.onsessionticket = undefined; - inner.onapplication = undefined; inner.onkeylog = undefined; inner.onversionnegotiation = undefined; inner.onhandshake = undefined; inner.onnewtoken = undefined; - inner.onorigin = undefined; - inner.ongoaway = undefined; inner.path = undefined; inner.certificate = undefined; inner.peerCertificate = undefined; @@ -3660,30 +3141,6 @@ class QuicSession { } } - /** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). The - * lastStreamId indicates the highest stream ID the peer may have - * processed - streams above it were not processed and may be retried. - * @param {bigint} lastStreamId - */ - [kGoaway](lastStreamId) { - const inner = this.#inner; - inner.isPendingClose = true; - if (onSessionClosingChannel.hasSubscribers) { - onSessionClosingChannel.publish({ __proto__: null, session: this }); - } - if (onSessionGoawayChannel.hasSubscribers) { - onSessionGoawayChannel.publish({ - __proto__: null, - session: this, - lastStreamId, - }); - } - if (typeof inner.ongoaway === 'function') { - safeCallbackInvoke(inner.ongoaway, this, lastStreamId); - } - } - /** * @param {number} errorType * @param {number} code @@ -3766,10 +3223,7 @@ class QuicSession { * @param {boolean} early A boolean indicating whether this datagram was received before the handshake completed */ [kDatagram](u8, early) { - // The datagram event should only be called if the session has - // an ondatagram callback. The callback should always exist here. const inner = this.#inner; - assert(typeof inner.ondatagram === 'function', 'Unexpected datagram event'); if (this.destroyed) return; const length = TypedArrayPrototypeGetByteLength(u8); if (onSessionReceiveDatagramChannel.hasSubscribers) { @@ -3780,6 +3234,7 @@ class QuicSession { session: this, }); } + assert(typeof inner.ondatagram === 'function', 'Unexpected datagram event'); safeCallbackInvoke(inner.ondatagram, this, u8, early); } @@ -3858,23 +3313,6 @@ class QuicSession { safeCallbackInvoke(inner.onsessionticket, this, ticket); } - /** - * @param {ApplicationOptions} applicationoptions - */ - [kSessionApplication](applicationoptions) { - if (this.destroyed) return; - if (onSessionApplicationChannel.hasSubscribers) { - onSessionApplicationChannel.publish({ - __proto__: null, - applicationoptions, - session: this, - }); - } - const inner = this.#inner; - if (typeof inner.onapplication === 'function') - safeCallbackInvoke(inner.onapplication, this, applicationoptions); - } - /** * @param {Buffer} token * @param {SocketAddress} address @@ -3936,24 +3374,6 @@ class QuicSession { this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); } - /** - * Called when the session receives an ORIGIN frame (RFC 9412). - * @param {string[]} origins - */ - [kOrigin](origins) { - if (this.destroyed) return; - const inner = this.#inner; - assert(typeof inner.onorigin === 'function', 'Unexpected origin event'); - if (onSessionOriginChannel.hasSubscribers) { - onSessionOriginChannel.publish({ - __proto__: null, - origins, - session: this, - }); - } - safeCallbackInvoke(inner.onorigin, this, origins); - } - /** * @param {string} servername * @param {string} protocol @@ -4058,8 +3478,8 @@ class QuicSession { // Set the default high water mark for received streams. stream.highWaterMark = kDefaultHighWaterMark; - // A new stream was received. If we don't have an onstream callback, then - // there's nothing we can do about it. Destroy the stream in this case. + // A new stream was received. If no onstream callback can claim it, + // drop it. if (typeof inner.onstream !== 'function') { process.emitWarning('A new stream was received but no onstream callback was provided'); stream.destroy(); @@ -4073,16 +3493,6 @@ class QuicSession { markPromiseAsHandled(stream.closed); } - // Apply default stream callbacks set at listen time before - // notifying onstream, so the user sees them already set. - const scbs = this[kStreamCallbacks]; - if (scbs) { - if (scbs.onheaders) stream.onheaders = scbs.onheaders; - if (scbs.ontrailers) stream.ontrailers = scbs.ontrailers; - if (scbs.oninfo) stream.oninfo = scbs.oninfo; - if (scbs.onwanttrailers) stream.onwanttrailers = scbs.onwanttrailers; - } - if (onSessionReceivedStreamChannel.hasSubscribers) { onSessionReceivedStreamChannel.publish({ __proto__: null, @@ -4448,20 +3858,12 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - // Stream-level callbacks applied to each incoming stream. - onheaders, - ontrailers, - oninfo, - onwanttrailers, ...rest } = options; - // Store session and stream callbacks to apply to each new incoming session. + // Store session callbacks to apply to each new incoming session. inner.sessionCallbacks = { __proto__: null, onerror, @@ -4474,15 +3876,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - onheaders, - ontrailers, - oninfo, - onwanttrailers, }; this.#handle.listen(rest); @@ -5084,12 +4479,14 @@ function processTlsOptions(tls, forServer) { if (identity.certs === undefined) { throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].certs`); } - // Extract ORIGIN frame options from the SNI entry. + // Extract the origin-advertisement options from the SNI entry; + // they are passed through to the protocol application. const { port, authoritative, } = sni[hostname]; - // Build a full TLS options object: shared + identity + origin options. + // Build a full TLS options object: shared + identity + + // origin-advertisement options. sniEntries[hostname] = { __proto__: null, ...shared, @@ -5192,9 +4589,6 @@ function processSessionOptions(options, config = kEmptyObject) { maxDatagramSendAttempts = 5, streamIdleTimeout, verifyPeer = 'auto', - // HTTP/3 application-specific options. Nested under `application` - // to separate protocol-specific settings from transport-level ones. - application = kEmptyObject, // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately @@ -5209,17 +4603,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - // Application level options changed, e.g. HTTP/3 settings related - // Stream-level callbacks. - onheaders, - ontrailers, - oninfo, - onwanttrailers, } = options; const { @@ -5227,8 +4612,11 @@ function processSessionOptions(options, config = kEmptyObject) { targetAddress, } = config; - const applicationName = options[kApplication]; - assert(applicationName === undefined || typeof applicationName === 'string', + // The protocol application to install (internal-only; set by the + // application's consumer layer, e.g. node:http3). Bad values are core + // bugs, not user errors, so this is an internal assert. + const application = options[kApplication]; + assert(application === undefined || typeof application === 'string', 'options[kApplication] must be a registered application name'); if (token !== undefined) { @@ -5334,7 +4722,7 @@ function processSessionOptions(options, config = kEmptyObject) { maxDatagramSendAttempts, streamIdleTimeout, application, - applicationName, + applicationSettings: options[kApplicationSettings], onerror, onstream, ondatagram, @@ -5345,15 +4733,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - onheaders, - ontrailers, - oninfo, - onwanttrailers, }; } @@ -5471,11 +4852,20 @@ module.exports = { CC_ALGO_BBR, DEFAULT_CIPHERS, DEFAULT_GROUPS, - // Internal only for http3+quic integration + // Internal-only, protocol-neutral hooks for a protocol application's + // consumer layer (e.g. node:http3). Never exported from node:quic. kApplication, - // These are exported only for internal testing purposes. + kApplicationSettings, + kStreamHandle, + kSessionHandle, + kApplicationOwner, + markSessionClosing, + safeCallbackInvoke, + // State accessors, used by the consumer layer (consumer-interest + // signaling) and by tests. getQuicStreamState, getQuicSessionState, + // These are exported only for internal testing purposes. getQuicEndpointState, listEndpoints, }; diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 22763c1df31b68..b50210d73234e5 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -71,9 +71,9 @@ const { IDX_STATE_SESSION_HANDSHAKE_CONFIRMED, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, IDX_STATE_SESSION_PRIORITY_SUPPORTED, - IDX_STATE_SESSION_HEADERS_SUPPORTED, IDX_STATE_SESSION_WRAPPED, - IDX_STATE_SESSION_APPLICATION_TYPE, + IDX_STATE_SESSION_IS_SERVER, + IDX_STATE_SESSION_HAS_APPLICATION, IDX_STATE_SESSION_NO_ERROR_CODE, IDX_STATE_SESSION_INTERNAL_ERROR_CODE, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, @@ -117,9 +117,9 @@ assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined); assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined); assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); -assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined); +assert(IDX_STATE_SESSION_HAS_APPLICATION !== undefined); +assert(IDX_STATE_SESSION_IS_SERVER !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); -assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined); @@ -349,7 +349,6 @@ class QuicSessionState { static #LISTENER_SESSION_TICKET = 1 << 3; static #LISTENER_NEW_TOKEN = 1 << 4; static #LISTENER_ORIGIN = 1 << 5; - static #LISTENER_APPLICATION = 1 << 6; #getListenerFlag(flag) { const handle = this.#handle; @@ -368,13 +367,6 @@ class QuicSessionState { val ? (current | flag) : (current & ~flag), kIsLittleEndian); } - /** @type {boolean} */ - get hasApplicationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_APPLICATION); - } - set hasApplicationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_APPLICATION, val); - } /** @type {boolean} */ get hasPathValidationListener() { @@ -481,14 +473,14 @@ class QuicSessionState { } /** - * Whether the negotiated application protocol supports headers. - * Returns 0 (unknown), 1 (supported), or 2 (not supported). - * @type {number} + * Whether a protocol application (vs the native raw-stream path) is + * installed on the session. + * @type {boolean} */ - get headersSupported() { + get hasApplication() { const handle = this.#handle; if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HEADERS_SUPPORTED); + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HAS_APPLICATION) !== 0; } /** @type {boolean} */ @@ -498,17 +490,21 @@ class QuicSessionState { return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_WRAPPED) !== 0; } - /** @type {number} */ - get applicationType() { + /** + * True for server (accepted) sessions, false for client (initiated) + * sessions. Fixed for the session's lifetime. + * @type {boolean} + */ + get isServer() { const handle = this.#handle; if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_APPLICATION_TYPE); + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_IS_SERVER) !== 0; } /** - * The negotiated application protocol's "no error" code, populated - * by the C++ layer when the application is selected during ALPN - * negotiation. For raw QUIC this is `0n`; for HTTP/3 this is + * The installed application's "no error" code, populated + * by the C++ layer when the application is installed. + * For raw QUIC this is `0n`; for HTTP/3 this is * `0x100n` (`H3_NO_ERROR`). * @type {bigint} */ @@ -520,7 +516,7 @@ class QuicSessionState { } /** - * The negotiated application protocol's "internal error" code, + * The installed application's "internal error" code, * populated by the C++ layer when the application is selected * during ALPN negotiation. Used as the wire code for `RESET_STREAM` * frames when a stream is aborted without a more specific code. @@ -589,9 +585,9 @@ class QuicSessionState { isHandshakeConfirmed, isStreamOpenAllowed, isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, @@ -614,9 +610,9 @@ class QuicSessionState { isHandshakeConfirmed, isStreamOpenAllowed, isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode: `${noErrorCode}`, internalErrorCode: `${internalErrorCode}`, maxDatagramSize: `${maxDatagramSize}`, @@ -655,9 +651,9 @@ class QuicSessionState { isHandshakeConfirmed, isStreamOpenAllowed, isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, @@ -680,9 +676,9 @@ class QuicSessionState { isHandshakeConfirmed, isStreamOpenAllowed, isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 665ab7ca1911c2..e41f1273ce4b7f 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -35,29 +35,22 @@ const kDatagram = Symbol('kDatagram'); const kDatagramStatus = Symbol('kDatagramStatus'); const kEarlyDataRejected = Symbol('kEarlyDataRejected'); const kFinishClose = Symbol('kFinishClose'); -const kGoaway = Symbol('kGoaway'); const kHandshake = Symbol('kHandshake'); const kHandshakeCompleted = Symbol('kHandshakeCompleted'); const kVerifyPeer = Symbol('kVerifyPeer'); -const kHeaders = Symbol('kHeaders'); const kKeylog = Symbol('kKeylog'); const kListen = Symbol('kListen'); const kQlog = Symbol('kQlog'); const kNewSession = Symbol('kNewSession'); const kNewStream = Symbol('kNewStream'); const kNewToken = Symbol('kNewToken'); -const kStreamCallbacks = Symbol('kStreamCallbacks'); -const kOrigin = Symbol('kOrigin'); const kOwner = Symbol('kOwner'); const kPathValidation = Symbol('kPathValidation'); const kPrivateConstructor = Symbol('kPrivateConstructor'); const kRemoveSession = Symbol('kRemoveSession'); const kRemoveStream = Symbol('kRemoveStream'); const kReset = Symbol('kReset'); -const kSendHeaders = Symbol('kSendHeaders'); -const kSessionApplication = Symbol('kSessionApplication'); const kSessionTicket = Symbol('kSessionTicket'); -const kTrailers = Symbol('kTrailers'); const kVersionNegotiation = Symbol('kVersionNegotiation'); module.exports = { @@ -69,11 +62,9 @@ module.exports = { kDrain, kEarlyDataRejected, kFinishClose, - kGoaway, kHandshake, kHandshakeCompleted, kVerifyPeer, - kHeaders, kInspect, kKeylog, kKeyObjectHandle, @@ -81,8 +72,6 @@ module.exports = { kNewSession, kNewStream, kNewToken, - kStreamCallbacks, - kOrigin, kOwner, kQlog, kPathValidation, @@ -90,10 +79,7 @@ module.exports = { kRemoveSession, kRemoveStream, kReset, - kSendHeaders, - kSessionApplication, kSessionTicket, - kTrailers, kVersionNegotiation, }; diff --git a/src/quic/application.cc b/src/quic/application.cc index 5b2ddeed074ba6..2d8a3f3ec7f811 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -4,169 +4,19 @@ #ifndef OPENSSL_NO_QUIC #include #include -#include -#include -#include -#include #include -#include #include #include #include #include "application.h" #include "defs.h" -#include "endpoint.h" -#include "http3.h" -#include "packet.h" #include "session.h" namespace node { - -using v8::BigInt; -using v8::Boolean; -using v8::DictionaryTemplate; -using v8::Just; -using v8::Local; -using v8::Maybe; -using v8::MaybeLocal; -using v8::Nothing; -using v8::Object; -using v8::Value; - namespace quic { // ============================================================================ -// Session::Application_Options -const Session::Application_Options Session::Application_Options::kDefault = {}; - -Session::Application_Options::operator const nghttp3_settings() const { - // In theory, Application::Options might contain options for more than just - // HTTP/3. Here we extract only the properties that are relevant to HTTP/3. - // Later if we add more application types we can add more properties or - // divide this up into multiple option structs. - return nghttp3_settings{ - .max_field_section_size = max_field_section_size, - .qpack_max_dtable_capacity = - static_cast(qpack_max_dtable_capacity), - .qpack_encoder_max_dtable_capacity = - static_cast(qpack_encoder_max_dtable_capacity), - .qpack_blocked_streams = static_cast(qpack_blocked_streams), - .enable_connect_protocol = enable_connect_protocol, - .h3_datagram = enable_datagrams, - // origin_list is nullptr here because it is set directly on the - // nghttp3_settings in Http3ApplicationImpl::InitializeConnection() - // from the SNI configuration. - .origin_list = nullptr, - .glitch_ratelim_burst = 1000, - .glitch_ratelim_rate = 33, - .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER, - }; -} - -std::string Session::Application_Options::ToString() const { - DebugIndentScope indent; - auto prefix = indent.Prefix(); - std::string res("{"); - res += prefix + "max header pairs: " + std::to_string(max_header_pairs); - res += prefix + "max header length: " + std::to_string(max_header_length); - res += prefix + - "max field section size: " + std::to_string(max_field_section_size); - res += prefix + "qpack max dtable capacity: " + - std::to_string(qpack_max_dtable_capacity); - res += prefix + "qpack encoder max dtable capacity: " + - std::to_string(qpack_encoder_max_dtable_capacity); - res += prefix + - "qpack blocked streams: " + std::to_string(qpack_blocked_streams); - res += prefix + "enable connect protocol: " + - (enable_connect_protocol ? std::string("yes") : std::string("no")); - res += prefix + "enable datagrams: " + - (enable_datagrams ? std::string("yes") : std::string("no")); - res += indent.Close(); - return res; -} - -Maybe Session::Application_Options::From( - Environment* env, Local value) { - if (value.IsEmpty()) [[unlikely]] { - THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object"); - return Nothing(); - } - - Application_Options options; - auto& state = BindingData::Get(env); - -#define SET(name) \ - SetOption( \ - env, &options, params, state.name##_string()) - - if (!value->IsUndefined()) { - if (!value->IsObject()) { - THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object"); - return Nothing(); - } - auto params = value.As(); - if (!SET(max_header_pairs) || !SET(max_header_length) || - !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) || - !SET(qpack_encoder_max_dtable_capacity) || - !SET(qpack_blocked_streams) || !SET(enable_connect_protocol) || - !SET(enable_datagrams)) { - // The call to SetOption should have scheduled an exception to be thrown. - return Nothing(); - } - } - -#undef SET - - // Ensure the advertised max_field_section_size in SETTINGS is at least - // as large as max_header_length. Otherwise the peer would be told to - // restrict headers to a smaller size than what CanAddHeader accepts. - if (options.max_field_section_size < options.max_header_length) { - options.max_field_section_size = options.max_header_length; - } - - return Just(options); -} - -MaybeLocal Session::Application_Options::ToObject( - Environment* env) const { - auto& binding_data = BindingData::Get(env); - auto tmpl = binding_data.application_options_template(); - static constexpr std::string_view names[] = { - "maxHeaderPairs", - "maxHeaderLength", - "maxFieldSectionSize", - "qpackMaxDtableCapacity", - "qpackEncoderMaxDtableCapacity", - "qpackBlockedStreams", - "enableConnectProtocol", - "enableDatagrams", - }; - if (tmpl.IsEmpty()) { - tmpl = DictionaryTemplate::New(env->isolate(), names); - binding_data.set_application_options_template(tmpl); - } - MaybeLocal values[] = { - BigInt::NewFromUnsigned(env->isolate(), max_header_pairs), - BigInt::NewFromUnsigned(env->isolate(), max_header_length), - BigInt::NewFromUnsigned(env->isolate(), max_field_section_size), - BigInt::NewFromUnsigned(env->isolate(), qpack_max_dtable_capacity), - BigInt::NewFromUnsigned(env->isolate(), - qpack_encoder_max_dtable_capacity), - BigInt::NewFromUnsigned(env->isolate(), qpack_blocked_streams), - Boolean::New(env->isolate(), enable_connect_protocol), - Boolean::New(env->isolate(), enable_datagrams), - }; - static_assert(std::size(values) == std::size(names)); - - auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { - return {}; - } - return obj; -} - -// ============================================================================ +// The application factory registry. namespace { struct ApplicationFactoryEntry { @@ -181,28 +31,30 @@ std::vector* application_factories = nullptr; } // namespace void RegisterApplicationFactory(std::string_view name, - ApplicationFactory factory) { + const ApplicationFactory& factory) { + DCHECK_NOT_NULL(factory.create); std::lock_guard lock(application_factories_mutex); if (application_factories == nullptr) { application_factories = new std::vector(); } for (const auto& entry : *application_factories) { - if (entry.factory == factory) return; + if (entry.factory.create == factory.create) return; } application_factories->push_back({std::string(name), factory}); } -ApplicationFactory FindApplicationFactory(std::string_view name) { +const ApplicationFactory* FindApplicationFactory(std::string_view name) { std::lock_guard lock(application_factories_mutex); if (application_factories == nullptr) return nullptr; for (const auto& entry : *application_factories) { - if (entry.name == name) return entry.factory; + if (entry.name == name) return &entry.factory; } return nullptr; } -Session::Application::Application(Session* session, const Options& options) - : session_(session) {} +// ============================================================================ + +Session::Application::Application(Session* session) : session_(session) {} bool Session::Application::Start() { // By default there is nothing to do. Specific implementations may @@ -228,55 +80,6 @@ void Session::Application::CollectSessionTicketAppData( app_data->Set(uv_buf_init(reinterpret_cast(buf), 1)); } -SessionTicket::AppData::Status -Session::Application::ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, Flag flag) { - // By default we do not have any application data to retrieve. - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; -} - -std::optional Session::Application::ParseTicketData( - const uv_buf_t& data) { - if (data.len == 0 || data.base == nullptr) return std::nullopt; - auto app_type = - static_cast(reinterpret_cast(data.base)[0]); - switch (app_type) { - case Type::DEFAULT: { - // Everything after the leading type byte is opaque application data. - DefaultTicketData dtd; - const auto* p = reinterpret_cast(data.base); - if (data.len > 1) dtd.data.assign(p + 1, p + data.len); - return dtd; - } - case Type::HTTP3: - return ParseHttp3TicketData(data); - default: - return std::nullopt; - } -} - -bool Session::Application::ValidateTicketData( - const PendingTicketAppData& data, const Application_Options& options) { - if (std::holds_alternative(data)) { - // TODO(@jasnell): This validation probably belongs in http3.cc but keeping - // it here for now. - const auto& ticket = std::get(data); - return options.max_field_section_size >= ticket.max_field_section_size && - options.qpack_max_dtable_capacity >= - ticket.qpack_max_dtable_capacity && - options.qpack_encoder_max_dtable_capacity >= - ticket.qpack_encoder_max_dtable_capacity && - options.qpack_blocked_streams >= ticket.qpack_blocked_streams && - (!ticket.enable_connect_protocol || - options.enable_connect_protocol) && - (!ticket.enable_datagrams || options.enable_datagrams); - } - // DefaultTicketData always validates. - return true; -} - void Session::Application::ReceiveStreamClose(Stream* stream, QuicError&& error) { DCHECK_NOT_NULL(stream); diff --git a/src/quic/application.h b/src/quic/application.h index c9f1a900ece3d2..d659a5e48b48c5 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -2,10 +2,8 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include +#include #include -#include -#include #include "base_object.h" #include "bindingdata.h" @@ -16,45 +14,29 @@ namespace node::quic { -// Parsed session ticket application data, produced by -// Application::ParseTicketData() before ALPN negotiation and consumed -// by Application::ApplySessionTicketData() after. -struct DefaultTicketData { - // The opaque application data carried in the ticket (after the type byte), - // byte-matched against the server's current `app_ticket_data` to gate 0-RTT. - std::vector data; -}; -struct Http3TicketData { - uint64_t max_field_section_size; - uint64_t qpack_max_dtable_capacity; - uint64_t qpack_encoder_max_dtable_capacity; - uint64_t qpack_blocked_streams; - bool enable_connect_protocol; - bool enable_datagrams; -}; -using PendingTicketAppData = - std::variant; +// Opaque application-typed session-ticket data, produced by a registered +// application factory's ticket hook at decrypt time and consumed by +// Application::ApplySessionTicketData() once the application is +// installed. The QUIC core never inspects the contents; only the owning +// application knows the concrete type. +using PendingTicketAppData = std::shared_ptr; -// An Application implements the ALPN-protocol specific semantics on behalf +// An Application implements the protocol-specific semantics on behalf // of a QUIC Session. class Session::Application : public MemoryRetainer { public: - using Options = Session::Application_Options; - - Application(Session* session, const Options& options); + explicit Application(Session* session); DISALLOW_COPY_AND_MOVE(Application) - // Get the active options for this application. These may differ from the - // options passed at construction time since some options can be negotiated. - virtual const Options& options() const = 0; - - // The type of Application, exposed via the session state so JS - // can observe which Application was selected after ALPN negotiation. - // This is used primarily for testing/debugging. + // The session-ticket app-data type byte: the leading byte of the + // application data embedded in session tickets, identifying how the + // remainder of the data is interpreted. DEFAULT tags the native opaque + // byte-match data used when no application is installed (the + // `appTicketData` option); application-typed values (e.g. HTTP3) tag + // data owned by the matching application's ticket hooks. enum class Type : uint8_t { - NONE = 0, // Not yet selected (server pre-negotiation) - DEFAULT = 1, // DefaultApplication (non-h3 ALPN) - HTTP3 = 2, // Http3ApplicationImpl (h3 / h3-XX ALPN) + DEFAULT = 1, // Native opaque byte-match data (no application) + HTTP3 = 2, // Http3Conn typed settings data }; virtual Type type() const = 0; @@ -109,14 +91,6 @@ class Session::Application : public MemoryRetainer { // Application. virtual bool AcknowledgeStreamData(stream_id id, size_t datalen); - // Called to determine if a Header can be added to this application. - // Applications that do not support headers will always return false. - virtual bool CanAddHeader(size_t current_count, - size_t current_headers_length, - size_t this_header_length) { - return false; - } - // Called when ngtcp2 reports NGTCP2_ERR_STREAM_SHUT_WR for a stream. // Applications that manage their own framing (e.g., HTTP/3) must inform // their protocol layer that the stream's write side is shut so it stops @@ -132,14 +106,6 @@ class Session::Application : public MemoryRetainer { // to send for the given stream. virtual void ResumeStream(stream_id id) {} - // Called when the Session determines that the maximum number of - // remotely-initiated unidirectional streams has been extended. Not all - // Application types will require this notification so the default is to do - // nothing. - virtual void ExtendMaxStreams(EndpointLabel label, - Direction direction, - uint64_t max_streams) {} - // Returns true if the application manages stream FIN internally (e.g., // HTTP/3 uses nghttp3 which sends FIN via the fin flag in writev_stream). // When true, the stream infrastructure must NOT call @@ -162,32 +128,20 @@ class Session::Application : public MemoryRetainer { virtual void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const; - // Different Applications may set some application data in the session - // ticket (e.g. http/3 would set server settings in the application data). - // By default, there's nothing to get. - virtual SessionTicket::AppData::Status ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, - SessionTicket::AppData::Source::Flag flag); - - // Validates parsed ticket data against current application options. - // Returns false if the stored settings are more permissive than the - // current config (e.g., a feature was enabled when the ticket was - // issued but is now disabled). - static bool ValidateTicketData(const PendingTicketAppData& data, - const Application_Options& options); - - // Parse session ticket app data before ALPN negotiation. Reads the - // type byte and dispatches to the appropriate application-specific - // parser. Returns std::nullopt if parsing fails. - static std::optional ParseTicketData( - const uv_buf_t& data); - - // Called after ALPN negotiation to validate and apply previously - // parsed session ticket app data. Returns false if the data is - // incompatible (e.g., type mismatch or settings downgrade), which + // Called at install time to apply session ticket app data previously + // parsed (and validated) by this application's registered ticket hook + // at decrypt time. Returns false if the data is incompatible, which // causes the handshake to fail. virtual bool ApplySessionTicketData(const PendingTicketAppData& data) = 0; + // Returns a JS object describing the application's effective protocol + // settings (some values may have been updated by negotiation with the + // peer), or an empty result when the application exposes no settings. + // The shape is application-defined; the QUIC core never interprets it. + virtual v8::MaybeLocal GetSettingsObject(Environment* env) { + return {}; + } + // Notifies the Application that the identified stream has been closed. virtual void ReceiveStreamClose(Stream* stream, QuicError&& error = QuicError()); @@ -263,20 +217,41 @@ class Session::Application : public MemoryRetainer { Session* session_ = nullptr; }; -// Create a DefaultApplication for the given session. -std::unique_ptr CreateDefaultApplication( - Session* session, const Session::Application_Options& options); - -// A factory for protocol-specific Session::Application implementations. -// Protocols register themselves under a name at binding initialization -// (e.g. "http3"); a session installs one only when its options request -// that name explicitly. -using ApplicationFactory = std::unique_ptr (*)( - Session* session, const Session::Application_Options& options); +// The registration record for a protocol-specific Session::Application +// implementation. Protocols register themselves under a name at binding +// initialization (e.g. "http3"); a session installs one only when its +// options request that name explicitly. The settings produced and +// consumed by these hooks are opaque to the QUIC core: only the +// registering protocol knows their shape or field names. +struct ApplicationFactory { + // Creates the Application for the given session. Any parsed settings + // holder produced by parse_settings is carried on the session's + // options (application_settings); implementations resolve it from + // there (using their defaults when it is nullptr). + std::unique_ptr (*create)(Session* session) = nullptr; + + // Parses the application-specific settings value, supplied through an + // internal symbol by the application's consumer layer (e.g. + // node:http3), into an opaque holder carried on Session::Options. + // Called while session options are processed; invalid user-supplied + // values should throw and return Nothing. + v8::Maybe> (*parse_settings)( + Environment* env, v8::Local value) = nullptr; + + // Parses and validates application-typed session-ticket data (the + // full payload, including the leading type byte) against the session + // options at ticket-decrypt time, before the Application instance + // exists. Returns the parsed data for the ApplySessionTicketData() + // call at install time, or nullptr to reject the ticket (0-RTT is + // abandoned and the handshake falls back to a full 1-RTT exchange). + PendingTicketAppData (*parse_ticket)(const uv_buf_t& data, + const Session::Options& options) = + nullptr; +}; void RegisterApplicationFactory(std::string_view name, - ApplicationFactory factory); + const ApplicationFactory& factory); // Returns the factory registered under name, or nullptr. -ApplicationFactory FindApplicationFactory(std::string_view name); +const ApplicationFactory* FindApplicationFactory(std::string_view name); } // namespace node::quic diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index 6ad52b8c14af55..532a4836ea4799 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -289,6 +289,7 @@ void nghttp3_debug_log(const char* fmt, va_list args) { void BindingData::InitPerContext(Realm* realm, Local target) { nghttp3_set_debug_vprintf_callback(nghttp3_debug_log); SetMethod(realm->context(), target, "setCallbacks", SetCallbacks); + SetMethod(realm->context(), target, "setHttp3Callbacks", SetHttp3Callbacks); Realm::GetCurrent(realm->context())->AddBindingData(target); } @@ -296,6 +297,7 @@ void BindingData::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(IllegalConstructor); registry->Register(SetCallbacks); + registry->Register(SetHttp3Callbacks); } BindingData::BindingData(Realm* realm, Local object) @@ -355,7 +357,7 @@ void BindingData::OnFlushCheck() { void BindingData::MemoryInfo(MemoryTracker* tracker) const { #define V(name, _) tracker->TrackField(#name, name##_callback()); - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -390,14 +392,14 @@ Local BindingData::transport_params_template() const { transport_params_template_); } -void BindingData::set_application_options_template( +void BindingData::set_http3_settings_template( Local tmpl) { - application_options_template_.Reset(env()->isolate(), tmpl); + http3_settings_template_.Reset(env()->isolate(), tmpl); } -Local BindingData::application_options_template() const { +Local BindingData::http3_settings_template() const { return PersistentToLocal::Default(env()->isolate(), - application_options_template_); + http3_settings_template_); } #define V(name, _) \ @@ -408,7 +410,7 @@ Local BindingData::application_options_template() const { return PersistentToLocal::Default(env()->isolate(), name##_callback_); \ } -QUIC_JS_CALLBACKS(V) +QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -433,7 +435,7 @@ QUIC_STRINGS(V) return on_##name##_string_.Get(env()->isolate()); \ } -QUIC_JS_CALLBACKS(V) +QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -467,6 +469,28 @@ JS_METHOD_IMPL(BindingData::SetCallbacks) { #undef V } +JS_METHOD_IMPL(BindingData::SetHttp3Callbacks) { + auto env = Environment::GetCurrent(args); + auto isolate = env->isolate(); + auto& state = Get(env); + CHECK(args[0]->IsObject()); + Local obj = args[0].As(); + +#define V(name, key) \ + do { \ + Local val; \ + if (!obj->Get(env->context(), state.on_##name##_string()).ToLocal(&val) || \ + !val->IsFunction()) { \ + return THROW_ERR_MISSING_ARGS(isolate, "Missing Callback: on" #key); \ + } \ + state.set_##name##_callback(val.As()); \ + } while (0); + + QUIC_HTTP3_JS_CALLBACKS(V) + +#undef V +} + NgTcp2CallbackScope::NgTcp2CallbackScope(Session* session) : session(session) { CHECK(!session->flags_.in_ngtcp2_callback_scope); session->flags_.in_ngtcp2_callback_scope = true; diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 94c381262a0ce7..38ea00c1e77227 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -36,14 +36,14 @@ class SessionManager; // The callbacks are persistent v8::Function references that are set in the // quic::BindingState used to communicate data and events back out to the JS -// environment. They are set once from the JavaScript side when the -// internalBinding('quic') is first loaded. +// environment. The protocol-neutral set is registered once from the +// JavaScript side (via setCallbacks) when the internalBinding('quic') is +// first loaded; the HTTP/3 application-event set is registered separately +// (via setHttp3Callbacks) when the HTTP/3 consumer layer is first loaded. #define QUIC_JS_CALLBACKS(V) \ V(endpoint_close, EndpointClose) \ V(session_close, SessionClose) \ - V(session_application, SessionApplication) \ V(session_early_data_rejected, SessionEarlyDataRejected) \ - V(session_goaway, SessionGoaway) \ V(session_datagram, SessionDatagram) \ V(session_datagram_status, SessionDatagramStatus) \ V(session_handshake, SessionHandshake) \ @@ -51,7 +51,6 @@ class SessionManager; V(session_qlog, SessionQlog) \ V(session_new, SessionNew) \ V(session_new_token, SessionNewToken) \ - V(session_origin, SessionOrigin) \ V(session_path_validation, SessionPathValidation) \ V(session_ticket, SessionTicket) \ V(session_version_negotiation, SessionVersionNegotiation) \ @@ -59,10 +58,22 @@ class SessionManager; V(stream_close, StreamClose) \ V(stream_created, StreamCreated) \ V(stream_drain, StreamDrain) \ + V(stream_reset, StreamReset) + +// The HTTP/3 application-event callbacks, registered separately by the +// HTTP/3 consumer layer (lib/internal/quic/http3.js) via setHttp3Callbacks. +#define QUIC_HTTP3_JS_CALLBACKS(V) \ + V(session_application, SessionApplication) \ + V(session_goaway, SessionGoaway) \ + V(session_origin, SessionOrigin) \ V(stream_headers, StreamHeaders) \ - V(stream_reset, StreamReset) \ V(stream_trailers, StreamTrailers) +// All of the callback slots, regardless of which JS module registers them. +#define QUIC_ALL_JS_CALLBACKS(V) \ + QUIC_JS_CALLBACKS(V) \ + QUIC_HTTP3_JS_CALLBACKS(V) + // The various JS strings the implementation uses. #define QUIC_STRINGS(V) \ V(abandoned, "abandoned") \ @@ -74,6 +85,7 @@ class SessionManager; V(allow, "allow") \ V(app_ticket_data, "appTicketData") \ V(application, "application") \ + V(application_settings, "applicationSettings") \ V(authoritative, "authoritative") \ V(bbr, "bbr") \ V(ca, "ca") \ @@ -88,7 +100,6 @@ class SessionManager; V(disable_stateless_reset, "disableStatelessReset") \ V(draining_period_multiplier, "drainingPeriodMultiplier") \ V(enable_connect_protocol, "enableConnectProtocol") \ - V(applicationName, "applicationName") \ V(enable_early_data, "enableEarlyData") \ V(enable_datagrams, "enableDatagrams") \ V(enable_tls_trace, "tlsTrace") \ @@ -97,7 +108,6 @@ class SessionManager; V(failure, "failure") \ V(groups, "groups") \ V(handshake_timeout, "handshakeTimeout") \ - V(http3_alpn, &NGHTTP3_ALPN_H3[1]) \ V(initial_rtt, "initialRtt") \ V(keep_alive_timeout, "keepAlive") \ V(initial_max_data, "initialMaxData") \ @@ -295,6 +305,11 @@ class BindingData final // bridge out to the JS API. JS_METHOD(SetCallbacks); + // Installs the HTTP/3 application-event callback functions. Registered + // separately from SetCallbacks by the HTTP/3 consumer layer when it + // is first loaded in a realm. + JS_METHOD(SetHttp3Callbacks); + // Lazily-created per-Realm SessionManager. Centralizes CID -> Session // routing so that any endpoint can route packets to any session. SessionManager& session_manager(); @@ -326,13 +341,13 @@ class BindingData final void set_transport_params_template(v8::Local tmpl); v8::Local transport_params_template() const; - void set_application_options_template(v8::Local tmpl); - v8::Local application_options_template() const; + void set_http3_settings_template(v8::Local tmpl); + v8::Local http3_settings_template() const; #define V(name, _) \ void set_##name##_callback(v8::Local fn); \ v8::Local name##_callback() const; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name, _) v8::Local name##_string() const; @@ -340,7 +355,7 @@ class BindingData final #undef V #define V(name, _) v8::Local on_##name##_string() const; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name) v8::Global name##_constructor_template_; @@ -348,10 +363,10 @@ class BindingData final #undef V v8::Global transport_params_template_; - v8::Global application_options_template_; + v8::Global http3_settings_template_; #define V(name, _) v8::Global name##_callback_; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name, _) mutable v8::Eternal name##_string_; @@ -359,7 +374,7 @@ class BindingData final #undef V #define V(name, _) mutable v8::Eternal on_##name##_string_; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V // Lazy cache backing error_name_string() diff --git a/src/quic/data.cc b/src/quic/data.cc index 9599adec62f805..26c68f2d8c4b56 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -426,8 +426,8 @@ MaybeLocal QuicError::ToV8Value(Environment* env) const { (type() == Type::APPLICATION && (code() == 0 || code() == NGHTTP3_H3_NO_ERROR)) || type() == Type::IDLE_CLOSE) { - // Application code 0 is the default no-error code for raw QUIC - // applications (DefaultApplication::GetNoErrorCode() returns 0). + // Application code 0 is the native no-error code for raw QUIC + // sessions (no application installed). // NGHTTP3_H3_NO_ERROR (0x100) is the HTTP/3 no-error code. // Idle close is always clean — the session timed out normally. return Undefined(env->isolate()); diff --git a/src/quic/defs.h b/src/quic/defs.h index 75ae915335be93..17a25017160002 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -290,11 +290,6 @@ enum class Side : uint8_t { SERVER, }; -enum class EndpointLabel : uint8_t { - LOCAL, - REMOTE, -}; - enum class Direction : uint8_t { BIDIRECTIONAL, UNIDIRECTIONAL, @@ -322,12 +317,6 @@ enum class StreamPriorityFlags : uint8_t { INCREMENTAL, }; -enum class HeadersSupportState : uint8_t { - UNKNOWN, - SUPPORTED, - UNSUPPORTED, -}; - enum class PathValidationResult : uint8_t { SUCCESS = NGTCP2_PATH_VALIDATION_RESULT_SUCCESS, FAILURE = NGTCP2_PATH_VALIDATION_RESULT_FAILURE, diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index dfce2fbb20ec06..621a2c129780bf 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -442,8 +442,10 @@ class Endpoint::UDP::Impl final : public HandleWrap { // UV_UDP_MMSG_FREE signals the end of a recvmmsg batch — the // buffer can be reused. Since our buffer is pre-allocated and - // persistent, there is nothing to free. + // persistent, there is nothing to free. The cached batch timestamp + // is reset so the next batch takes a fresh one. if (flags & UV_UDP_MMSG_FREE) { + impl->recv_batch_ts_ = 0; return; } diff --git a/src/quic/http3.cc b/src/quic/http3.cc index b28cc0271d091a..186be7c85a717d 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -8,10 +8,13 @@ #include #include #include +#include #include #include #include #include +#include +#include #include "application.h" #include "bindingdata.h" #include "defs.h" @@ -22,7 +25,18 @@ namespace node { using v8::Array; +using v8::BigInt; +using v8::Boolean; +using v8::DictionaryTemplate; +using v8::Integer; +using v8::Just; using v8::Local; +using v8::LocalVector; +using v8::Maybe; +using v8::MaybeLocal; +using v8::Nothing; +using v8::Object; +using v8::Value; namespace quic { @@ -83,6 +97,175 @@ inline size_t FormatPriority(char* buf, size_t buflen, const nghttp3_pri& pri) { } } // namespace +// ============================================================================ +// The HTTP/3 application settings: the RFC 9114/9204 SETTINGS values +// advertised to the peer plus the local header-processing limits. The +// values are supplied by node:http3 (the `settings` option of +// http3.connect()/listen()), carried opaquely through Session::Options, +// and parsed here by the registered parse hook. The QUIC core never +// knows these field names. +struct Http3Settings final { + // The maximum number of header pairs permitted for a Stream + // (local enforcement limit, not a wire setting). + uint64_t max_header_pairs = DEFAULT_MAX_HEADER_LIST_PAIRS; + + // The maximum total number of header bytes (including header + // name and value) permitted for a Stream (local enforcement limit, + // not a wire setting). + uint64_t max_header_length = DEFAULT_MAX_HEADER_LENGTH; + + // The maximum header section size advertised to the peer in SETTINGS. + // Defaults to match max_header_length so the SETTINGS frame accurately + // reflects the enforcement limit. A value of 0 would incorrectly tell + // the peer not to send any headers at all. + uint64_t max_field_section_size = DEFAULT_MAX_HEADER_LENGTH; + uint64_t qpack_max_dtable_capacity = 4096; + uint64_t qpack_encoder_max_dtable_capacity = 4096; + uint64_t qpack_blocked_streams = 100; + + bool enable_connect_protocol = true; + + // SETTINGS_H3_DATAGRAM (RFC 9297). HTTP/3 datagrams are not yet + // supported, so this is always false and the setting is never + // advertised. We reserve the setting logic to enable later. + bool enable_datagrams = false; + + operator const nghttp3_settings() const; + + static v8::Maybe From(Environment* env, + v8::Local value); + + std::string ToString() const; + + v8::MaybeLocal ToObject(Environment* env) const; +}; + +Http3Settings::operator const nghttp3_settings() const { + return nghttp3_settings{ + .max_field_section_size = max_field_section_size, + .qpack_max_dtable_capacity = + static_cast(qpack_max_dtable_capacity), + .qpack_encoder_max_dtable_capacity = + static_cast(qpack_encoder_max_dtable_capacity), + .qpack_blocked_streams = static_cast(qpack_blocked_streams), + .enable_connect_protocol = enable_connect_protocol, + .h3_datagram = enable_datagrams, + // origin_list is nullptr here because it is set directly on the + // nghttp3_settings in Http3ApplicationImpl::InitializeConnection() + // from the SNI configuration. + .origin_list = nullptr, + .glitch_ratelim_burst = 1000, + .glitch_ratelim_rate = 33, + .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER, + }; +} + +std::string Http3Settings::ToString() const { + DebugIndentScope indent; + auto prefix = indent.Prefix(); + std::string res("{"); + res += prefix + "max header pairs: " + std::to_string(max_header_pairs); + res += prefix + "max header length: " + std::to_string(max_header_length); + res += prefix + + "max field section size: " + std::to_string(max_field_section_size); + res += prefix + "qpack max dtable capacity: " + + std::to_string(qpack_max_dtable_capacity); + res += prefix + "qpack encoder max dtable capacity: " + + std::to_string(qpack_encoder_max_dtable_capacity); + res += prefix + + "qpack blocked streams: " + std::to_string(qpack_blocked_streams); + res += prefix + "enable connect protocol: " + + (enable_connect_protocol ? std::string("yes") : std::string("no")); + res += prefix + "enable datagrams: " + + (enable_datagrams ? std::string("yes") : std::string("no")); + res += indent.Close(); + return res; +} + +Maybe Http3Settings::From(Environment* env, + Local value) { + if (value.IsEmpty() || !value->IsObject()) [[unlikely]] { + THROW_ERR_INVALID_ARG_TYPE(env, "settings must be an object"); + return Nothing(); + } + + Http3Settings settings; + auto& state = BindingData::Get(env); + auto params = value.As(); + +#define SET(name) \ + SetOption( \ + env, &settings, params, state.name##_string()) + + if (!SET(max_header_pairs) || !SET(max_header_length) || + !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) || + !SET(qpack_encoder_max_dtable_capacity) || !SET(qpack_blocked_streams) || + !SET(enable_connect_protocol)) { + // The call to SetOption should have scheduled an exception to be thrown. + return Nothing(); + } + +#undef SET + + // Ensure the advertised max_field_section_size in SETTINGS is at least + // as large as max_header_length. Otherwise the peer would be told to + // restrict headers to a smaller size than the inbound header limits + // (enforced in AddHeader) accept. + if (settings.max_field_section_size < settings.max_header_length) { + settings.max_field_section_size = settings.max_header_length; + } + + return Just(settings); +} + +MaybeLocal Http3Settings::ToObject(Environment* env) const { + auto& binding_data = BindingData::Get(env); + auto tmpl = binding_data.http3_settings_template(); + static constexpr std::string_view names[] = { + "maxHeaderPairs", + "maxHeaderLength", + "maxFieldSectionSize", + "qpackMaxDtableCapacity", + "qpackEncoderMaxDtableCapacity", + "qpackBlockedStreams", + "enableConnectProtocol" + }; + if (tmpl.IsEmpty()) { + tmpl = DictionaryTemplate::New(env->isolate(), names); + binding_data.set_http3_settings_template(tmpl); + } + MaybeLocal values[] = { + BigInt::NewFromUnsigned(env->isolate(), max_header_pairs), + BigInt::NewFromUnsigned(env->isolate(), max_header_length), + BigInt::NewFromUnsigned(env->isolate(), max_field_section_size), + BigInt::NewFromUnsigned(env->isolate(), qpack_max_dtable_capacity), + BigInt::NewFromUnsigned(env->isolate(), + qpack_encoder_max_dtable_capacity), + BigInt::NewFromUnsigned(env->isolate(), qpack_blocked_streams), + Boolean::New(env->isolate(), enable_connect_protocol), + }; + static_assert(std::size(values) == std::size(names)); + + auto obj = tmpl->NewInstance(env->context(), values); + if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + return {}; + } + return obj; +} + +// The typed HTTP/3 session-ticket app data: the settings in effect when +// the ticket was issued, validated relax-tolerantly against the current +// settings on resume (0-RTT is blocked only when settings were +// tightened, not relaxed). +struct Http3TicketData final { + uint64_t max_field_section_size; + uint64_t qpack_max_dtable_capacity; + uint64_t qpack_encoder_max_dtable_capacity; + uint64_t qpack_blocked_streams; + bool enable_connect_protocol; + bool enable_datagrams; +}; + struct Http3HeadersTraits { using nv_t = nghttp3_nv; }; @@ -138,13 +321,14 @@ struct Http3HeaderTraits { using Http3Header = NgHeader; -// Implements the low-level HTTP/3 Application semantics. +// The Session::Application implementation for HTTP/3, which owns the nghttp3 +// connection and all HTTP/3 protocol logic and state. class Http3ApplicationImpl final : public Session::Application { public: - Http3ApplicationImpl(Session* session, const Options& options) - : Application(session, options), - allocator_(BindingData::Get(env()).nghttp3_allocator()), - options_(options), + Http3ApplicationImpl(Session* session, const Http3Settings& settings) + : Application(session), + allocator_(BindingData::Get(session->env()).nghttp3_allocator()), + options_(settings), conn_(nullptr) { // Build the ORIGIN frame payload from the SNI configuration before // creating the nghttp3 connection, since InitializeConnection needs @@ -156,11 +340,10 @@ class Http3ApplicationImpl final : public Session::Application { session->set_priority_supported(); } - const Options& options() const override { return options_; } + // ========================================================================== + // Session::Application - Session::Application::Type type() const override { - return Session::Application::Type::HTTP3; - } + Type type() const override { return Type::HTTP3; } error_code GetNoErrorCode() const override { return NGHTTP3_H3_NO_ERROR; } @@ -181,6 +364,7 @@ class Http3ApplicationImpl final : public Session::Application { // condition (code 0 would be treated as a clean close). conn_.reset(); started_ = false; + header_state_.clear(); session().DestroyAllStreams( QuicError::ForApplication(GetInternalErrorCode())); if (!session().is_destroyed()) { @@ -206,7 +390,6 @@ class Http3ApplicationImpl final : public Session::Application { bool Start() override { if (started_) return true; - started_ = true; Debug(&session(), "Starting HTTP/3 application."); const auto params = session().remote_transport_params(); @@ -260,6 +443,7 @@ class Http3ApplicationImpl final : public Session::Application { qpack_dec_stream_id_); } + started_ = ret; return ret; } @@ -277,11 +461,51 @@ class Http3ApplicationImpl final : public Session::Application { if (conn_ && started_) nghttp3_conn_shutdown(*this); } + void PostReceive() override { + if (pending_goaway_id_ < 0) return; + stream_id goaway_id = pending_goaway_id_; + pending_goaway_id_ = -1; + + bool is_notice = + static_cast(goaway_id) >= NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID; + + // For the shutdown notice, replace the sentinel stream ID with -1 + // so JS sees a clean marker instead of a huge implementation detail. + stream_id emit_id = is_notice ? -1 : goaway_id; + + if (!is_notice) { + // Final GOAWAY: destroy client-initiated bidi streams with + // IDs > goaway_id. These were not processed by the peer and + // can be retried. Copy the map because Destroy modifies it. + auto streams = session().streams(); + for (auto& [id, stream] : streams) { + if (session().is_destroyed()) return; + if (ngtcp2_is_bidi_stream(id) && id > goaway_id) { + stream->Destroy( + QuicError::ForApplication(NGHTTP3_H3_REQUEST_REJECTED)); + } + } + if (session().is_destroyed()) return; + } + + // Notify JS for both notice and final GOAWAY. The notice uses + // -1 to signal "server is shutting down, stop new requests" without + // implying any specific stream boundary. The final GOAWAY (if it + // arrives separately) provides the exact stream ID for retry decisions. + // + // We do NOT call Close(GRACEFUL) here. The JS ongoaway handler sets + // isPendingClose (preventing new streams). The session closes naturally + // when the peer sends CONNECTION_CLOSE after all streams finish. + // Calling Close(GRACEFUL) would send a GOAWAY back and trigger + // BeginShutdown, which can interfere with in-progress streams. + session().EmitGoaway(emit_id); + } + bool ReceiveStreamData(stream_id id, const uint8_t* data, size_t datalen, const Stream::ReceiveDataFlags& flags, - void* unused) override { + void* stream_user_data) override { Debug(&session(), "HTTP/3 application received %zu bytes of data " "on stream %" PRIi64 ". Is final? %d. Is early? %d", @@ -328,17 +552,6 @@ class Http3ApplicationImpl final : public Session::Application { return nghttp3_conn_add_ack_offset(*this, id, datalen) == 0; } - bool CanAddHeader(size_t current_count, - size_t current_headers_length, - size_t this_header_length) override { - // We cannot add the header if we've either reached - // * the max number of header pairs or - // * the max number of header bytes (name + value combined) - return (current_count < options_.max_header_pairs) && - (current_headers_length + this_header_length) <= - options_.max_header_length; - } - bool stream_fin_managed_by_application() const override { return true; } void StreamWriteShut(stream_id id) override { @@ -347,28 +560,18 @@ class Http3ApplicationImpl final : public Session::Application { void BlockStream(stream_id id) override { nghttp3_conn_block_stream(*this, id); - Application::BlockStream(id); } void ResumeStream(stream_id id) override { nghttp3_conn_resume_stream(*this, id); - Application::ResumeStream(id); } - void ExtendMaxStreams(EndpointLabel label, - Direction direction, - uint64_t max_streams) override { - switch (label) { - case EndpointLabel::LOCAL: - return; - case EndpointLabel::REMOTE: { - Debug(&session(), - "HTTP/3 application extending max %s streams by %" PRIu64, - direction == Direction::BIDIRECTIONAL ? "bidi" : "uni", - max_streams); - session().ExtendMaxStreams(direction, max_streams); - } - } + void ExtendMaxStreams(Direction direction, uint64_t max_streams) { + Debug(&session(), + "HTTP/3 application extending max %s streams by %" PRIu64, + direction == Direction::BIDIRECTIONAL ? "bidi" : "uni", + max_streams); + session().ExtendMaxStreams(direction, max_streams); } void ExtendMaxStreamData(Stream* stream, uint64_t max_data) override { @@ -400,89 +603,13 @@ class Http3ApplicationImpl final : public Session::Application { uv_buf_init(reinterpret_cast(buf), kSessionTicketAppDataSize)); } - SessionTicket::AppData::Status ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, - SessionTicket::AppData::Source::Flag flag) override { - auto data = app_data.Get(); - if (!data || data->len != kSessionTicketAppDataSize) { - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - const uint8_t* buf = reinterpret_cast(data->base); - - // buf[0] is the application type byte, buf[1] is the version. - if (buf[0] != static_cast(Type::HTTP3) || - buf[1] != kSessionTicketAppDataVersion) { - Debug(&session(), - "Ticket app data rejected: type=%d version=%d " - "(expected type=%d version=%d)", - buf[0], - buf[1], - static_cast(Type::HTTP3), - kSessionTicketAppDataVersion); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; - uint32_t stored_crc = ReadBE32(buf + 2); - uLong computed_crc = crc32(0L, Z_NULL, 0); - computed_crc = - crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); - if (stored_crc != static_cast(computed_crc)) { - Debug(&session(), - "Ticket app data rejected: CRC mismatch " - "(stored=%u computed=%u)", - stored_crc, - static_cast(computed_crc)); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - uint64_t stored_max_field_section_size = ReadBE64(payload); - uint64_t stored_qpack_max_dtable_capacity = ReadBE64(payload + 8); - uint64_t stored_qpack_encoder_max_dtable_capacity = ReadBE64(payload + 16); - uint64_t stored_qpack_blocked_streams = ReadBE64(payload + 24); - bool stored_enable_connect_protocol = payload[32] != 0; - bool stored_enable_datagrams = payload[33] != 0; - - Debug(&session(), - "Ticket app data: stored mfss=%" PRIu64 " qmdc=%" PRIu64 - " qemdc=%" PRIu64 " qbs=%" PRIu64 " ecp=%d ed=%d", - stored_max_field_section_size, - stored_qpack_max_dtable_capacity, - stored_qpack_encoder_max_dtable_capacity, - stored_qpack_blocked_streams, - stored_enable_connect_protocol, - stored_enable_datagrams); - Debug(&session(), - "Current opts: mfss=%" PRIu64 " qmdc=%" PRIu64 " qemdc=%" PRIu64 - " qbs=%" PRIu64 " ecp=%d ed=%d", - options_.max_field_section_size, - options_.qpack_max_dtable_capacity, - options_.qpack_encoder_max_dtable_capacity, - options_.qpack_blocked_streams, - options_.enable_connect_protocol, - options_.enable_datagrams); - if (options_.max_field_section_size < stored_max_field_section_size || - options_.qpack_max_dtable_capacity < stored_qpack_max_dtable_capacity || - options_.qpack_encoder_max_dtable_capacity < - stored_qpack_encoder_max_dtable_capacity || - options_.qpack_blocked_streams < stored_qpack_blocked_streams || - (stored_enable_connect_protocol && !options_.enable_connect_protocol) || - (stored_enable_datagrams && !options_.enable_datagrams)) { - Debug(&session(), "Ticket app data REJECTED"); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - Debug(&session(), "Ticket app data ACCEPTED"); - - return flag == SessionTicket::AppData::Source::Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; - } - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - if (!std::holds_alternative(data)) return false; - const auto& ticket = std::get(data); - // Validate that current settings are >= stored settings. + // The pending data was produced by this application's own ticket + // hook (ParseHttp3Ticket below), which already validated it against + // the configured settings at decrypt time. + CHECK(data); + const auto& ticket = *std::static_pointer_cast(data); + // Re-validate that current settings are >= stored settings. return options_.max_field_section_size >= ticket.max_field_section_size && options_.qpack_max_dtable_capacity >= ticket.qpack_max_dtable_capacity && @@ -494,8 +621,11 @@ class Http3ApplicationImpl final : public Session::Application { (!ticket.enable_datagrams || options_.enable_datagrams); } - void ReceiveStreamClose(Stream* stream, - QuicError&& error = QuicError()) override { + MaybeLocal GetSettingsObject(Environment* env) override { + return options_.ToObject(env); + } + + void ReceiveStreamClose(Stream* stream, QuicError&& error) override { Debug( &session(), "HTTP/3 application closing stream %" PRIi64, stream->id()); error_code code = NGHTTP3_H3_NO_ERROR; @@ -504,13 +634,13 @@ class Http3ApplicationImpl final : public Session::Application { } int rv = nghttp3_conn_close_stream(*this, stream->id(), code); - // If the call is successful, Http3Application::OnStreamClose callback will - // be invoked when the stream is ready to be closed. We'll handle destroying - // the actual Stream object there. + // If the call is successful, the Http3ApplicationImpl::OnStreamClose callback will + // be invoked when the stream is ready to be closed. We'll handle + // destroying the actual Stream object there. if (rv == 0) return; if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) { - ExtendMaxStreams(EndpointLabel::REMOTE, stream->direction(), 1); + ExtendMaxStreams(stream->direction(), 1); return; } @@ -520,7 +650,7 @@ class Http3ApplicationImpl final : public Session::Application { void ReceiveStreamReset(Stream* stream, uint64_t final_size, - QuicError&& error = QuicError()) override { + QuicError&& error) override { // We are shutting down the readable side of the local stream here. Debug(&session(), "HTTP/3 application resetting stream %" PRIi64, @@ -535,15 +665,10 @@ class Http3ApplicationImpl final : public Session::Application { session().Close(); } - void ReceiveStreamStopSending(Stream* stream, - QuicError&& error = QuicError()) override { - Application::ReceiveStreamStopSending(stream, std::move(error)); - } - bool SendHeaders(const Stream& stream, HeadersKind kind, const Local& headers, - HeadersFlags flags = HeadersFlags::NONE) override { + HeadersFlags flags) override { Session::SendPendingDataScope send_scope(&session()); Http3Headers nva(env(), headers); @@ -555,6 +680,7 @@ class Http3ApplicationImpl final : public Session::Application { } Debug(&session(), "Submitting %" PRIu64 " early hints for stream %" PRIu64, + nva.length(), stream.id()); return nghttp3_conn_submit_info( *this, stream.id(), nva.data(), nva.length()) == 0; @@ -697,6 +823,15 @@ class Http3ApplicationImpl final : public Session::Application { data->id != qpack_enc_stream_id_) { data->stream = session().FindStream(data->id); } + } else { + // Nothing can be pulled right now (connection flow control is + // exhausted). Present an empty result: the caller reuses a single + // StreamData across send-loop iterations, so the fields must not + // be left holding the previous iteration's stream. + data->id = -1; + data->count = 0; + data->fin = 0; + data->stream.reset(); } return 0; @@ -730,7 +865,7 @@ class Http3ApplicationImpl final : public Session::Application { // for the next writev_stream in the send loop. if (pending_trailers_stream_ == data->id) { pending_trailers_stream_ = -1; - if (data->stream) data->stream->EmitWantTrailers(); + if (data->stream) EmitWantTrailers(data->stream.get()); } return true; } @@ -795,6 +930,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnStreamClose(Stream* stream, error_code app_error_code) { + header_state_.erase(stream->id()); if (app_error_code != NGHTTP3_H3_NO_ERROR) { Debug(&session(), "HTTP/3 application received stream close for stream %" PRIi64 @@ -804,7 +940,7 @@ class Http3ApplicationImpl final : public Session::Application { } auto direction = stream->direction(); stream->Destroy(QuicError::ForApplication(app_error_code)); - ExtendMaxStreams(EndpointLabel::REMOTE, direction, 1); + ExtendMaxStreams(direction, 1); } void OnBeginHeaders(stream_id id) { @@ -815,7 +951,10 @@ class Http3ApplicationImpl final : public Session::Application { "HTTP/3 application beginning initial block of headers for stream " "%" PRIi64, id); - stream->BeginHeaders(HeadersKind::INITIAL); + auto& hs = header_state_[id]; + hs.headers.clear(); + hs.headers_length = 0; + hs.kind = HeadersKind::INITIAL; } void OnReceiveHeader(stream_id id, std::unique_ptr header) { @@ -827,7 +966,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application switching to hints headers for stream %" PRIi64, stream->id()); - stream->set_headers_kind(HeadersKind::HINTS); + header_state_[id].kind = HeadersKind::HINTS; } IF_QUIC_DEBUG(env()) { Debug(&session(), @@ -835,7 +974,7 @@ class Http3ApplicationImpl final : public Session::Application { header->name(), header->value()); } - stream->AddHeader(std::move(header)); + AddHeader(id, std::move(header)); } void OnEndHeaders(stream_id id, int fin) { @@ -845,7 +984,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application received end of headers for stream %" PRIi64, id); - stream->EmitHeaders(); + EmitHeaders(stream.get()); if (fin) { // The stream is done. There's no more data to receive! Debug(&session(), "Headers are final for stream %" PRIi64, id); @@ -864,7 +1003,10 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application beginning block of trailers for stream %" PRIi64, id); - stream->BeginHeaders(HeadersKind::TRAILING); + auto& hs = header_state_[id]; + hs.headers.clear(); + hs.headers_length = 0; + hs.kind = HeadersKind::TRAILING; } void OnReceiveTrailer(stream_id id, std::unique_ptr header) { @@ -877,7 +1019,7 @@ class Http3ApplicationImpl final : public Session::Application { header->name(), header->value()); } - stream->AddHeader(std::move(header)); + AddHeader(id, std::move(header)); } void OnEndTrailers(stream_id id, int fin) { @@ -887,7 +1029,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application received end of trailers for stream %" PRIi64, id); - stream->EmitHeaders(); + EmitHeaders(stream.get()); if (fin) { Debug(&session(), "Trailers are final for stream %" PRIi64, id); Stream::ReceiveDataFlags flags{ @@ -898,6 +1040,67 @@ class Http3ApplicationImpl final : public Session::Application { } } + void AddHeader(stream_id id, std::unique_ptr header) { + auto& hs = header_state_[id]; + size_t len = header->length(); + if (hs.headers.size() >= options_.max_header_pairs || + hs.headers_length + len > options_.max_header_length) { + return; + } + hs.headers_length += len; + hs.headers.push_back(std::move(header)); + } + + void EmitHeaders(Stream* stream) { + auto it = header_state_.find(stream->id()); + if (it == header_state_.end()) return; + auto& hs = it->second; + if (!env()->can_call_into_js() || !stream->wants_headers()) { + hs.headers.clear(); + hs.headers_length = 0; + return; + } + CallbackScope cb_scope(stream); + + auto& binding = BindingData::Get(env()); + size_t count = hs.headers.size() * 2; + LocalVector values(env()->isolate(), count); + + for (size_t i = 0; i < hs.headers.size(); i++) { + Local name; + Local value; + if (!hs.headers[i]->GetName(&binding).ToLocal(&name) || + !hs.headers[i]->GetValue(&binding).ToLocal(&value)) [[unlikely]] { + hs.headers.clear(); + hs.headers_length = 0; + return; + } + values[i * 2] = name; + values[i * 2 + 1] = value; + } + + auto kind = hs.kind; + hs.headers.clear(); + hs.headers_length = 0; + + Local argv[] = { + Array::New(env()->isolate(), values.data(), count), + Integer::NewFromUnsigned(env()->isolate(), + static_cast(kind))}; + + stream->MakeCallback( + binding.stream_headers_callback(), arraysize(argv), argv); + } + + void EmitWantTrailers(Stream* stream) { + if (!env()->can_call_into_js() || !stream->wants_trailers()) { + return; + } + CallbackScope cb_scope(stream); + stream->MakeCallback( + BindingData::Get(env()).stream_trailers_callback(), 0, nullptr); + } + void OnEndStream(stream_id id) { auto stream = session().FindStream(id); if (!stream) [[unlikely]] @@ -947,71 +1150,31 @@ class Http3ApplicationImpl final : public Session::Application { pending_goaway_id_ = id; } - void PostReceive() override { - if (pending_goaway_id_ < 0) return; - stream_id goaway_id = pending_goaway_id_; - pending_goaway_id_ = -1; - - bool is_notice = - static_cast(goaway_id) >= NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID; - - // For the shutdown notice, replace the sentinel stream ID with -1 - // so JS sees a clean marker instead of a huge implementation detail. - stream_id emit_id = is_notice ? -1 : goaway_id; - - if (!is_notice) { - // Final GOAWAY: destroy client-initiated bidi streams with - // IDs > goaway_id. These were not processed by the peer and - // can be retried. Copy the map because Destroy modifies it. - auto streams = session().streams(); - for (auto& [id, stream] : streams) { - if (session().is_destroyed()) return; - if (ngtcp2_is_bidi_stream(id) && id > goaway_id) { - stream->Destroy( - QuicError::ForApplication(NGHTTP3_H3_REQUEST_REJECTED)); - } - } - if (session().is_destroyed()) return; - } - - // Notify JS for both notice and final GOAWAY. The notice uses - // -1 to signal "server is shutting down, stop new requests" without - // implying any specific stream boundary. The final GOAWAY (if it - // arrives separately) provides the exact stream ID for retry decisions. - // - // We do NOT call Close(GRACEFUL) here. The JS ongoaway handler sets - // isPendingClose (preventing new streams). The session closes naturally - // when the peer sends CONNECTION_CLOSE after all streams finish. - // Calling Close(GRACEFUL) would send a GOAWAY back and trigger - // BeginShutdown, which can interfere with in-progress streams. - session().EmitGoaway(emit_id); - } - void OnReceiveSettings(const nghttp3_proto_settings* settings) { options_.enable_connect_protocol = settings->enable_connect_protocol; - options_.enable_datagrams = settings->h3_datagram; options_.max_field_section_size = settings->max_field_section_size; options_.qpack_blocked_streams = settings->qpack_blocked_streams; options_.qpack_max_dtable_capacity = settings->qpack_max_dtable_capacity; - // Per RFC 9297 §3, an H3 endpoint MUST NOT send HTTP Datagrams - // unless the peer indicated support via SETTINGS_H3_DATAGRAM=1. - // If the peer disabled it, set the session's max datagram size to 0 - // which blocks sends at the existing JS/C++ check. - if (!settings->h3_datagram) { - session().set_max_datagram_size(0); - } - Debug(&session(), "HTTP/3 application received updated settings: %s", options_); - // The settings are part of the application + // Report the negotiated settings up to the JS application layer. session().EmitApplication(); } + // Inbound header-block accumulation, keyed by stream id. Entries are + // created by the header callbacks and erased on stream close (or + // wholesale on 0-RTT rejection). + struct StreamHeaderState { + std::vector> headers; + size_t headers_length = 0; + HeadersKind kind = HeadersKind::INITIAL; + }; + std::unordered_map header_state_; bool started_ = false; nghttp3_mem* allocator_; - Options options_; + Http3Settings options_; Http3ConnectionPointer conn_; stream_id control_stream_id_ = -1; stream_id qpack_dec_stream_id_ = -1; @@ -1396,21 +1559,57 @@ class Http3ApplicationImpl final : public Session::Application { on_receive_settings}; }; -std::optional ParseHttp3TicketData(const uv_buf_t& data) { - if (data.len != kSessionTicketAppDataSize) return std::nullopt; +namespace { +// Resolves the effective HTTP/3 settings for a session's options: the +// consumer-supplied settings (or defaults). SETTINGS_H3_DATAGRAM stays +// disabled (HTTP/3 datagrams not yet supported). +Http3Settings ResolveHttp3Settings(const Session::Options& options) { + return options.application_settings + ? *std::static_pointer_cast( + options.application_settings) + : Http3Settings(); +} + +std::unique_ptr CreateHttp3Application(Session* session) { + Debug(session, "Installing HTTP/3 application"); + return std::make_unique( + session, + ResolveHttp3Settings(std::as_const(*session).config().options)); +} + +Maybe> ParseHttp3Settings(Environment* env, + Local value) { + Http3Settings settings; + if (!Http3Settings::From(env, value).To(&settings)) { + return Nothing>(); + } + return Just(std::static_pointer_cast( + std::make_shared(settings))); +} + +// Parses and validates the typed HTTP/3 session-ticket app data against +// the session's configured settings at decrypt time (relax-tolerant: +// 0-RTT is rejected only when settings were tightened relative to +// ticket issuance). Returns nullptr to reject the ticket. +PendingTicketAppData ParseHttp3Ticket(const uv_buf_t& data, + const Session::Options& options) { + if (data.len != kSessionTicketAppDataSize) return nullptr; const uint8_t* buf = reinterpret_cast(data.base); - // buf[0] is the type byte (already checked by caller), buf[1] is version. - if (buf[1] != kSessionTicketAppDataVersion) return std::nullopt; + // buf[0] is the type byte, buf[1] is the version. + if (buf[0] != static_cast(Session::Application::Type::HTTP3) || + buf[1] != kSessionTicketAppDataVersion) { + return nullptr; + } const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; uint32_t stored_crc = ReadBE32(buf + 2); uLong computed_crc = crc32(0L, Z_NULL, 0); computed_crc = crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); - if (stored_crc != static_cast(computed_crc)) return std::nullopt; + if (stored_crc != static_cast(computed_crc)) return nullptr; - return Http3TicketData{ + Http3TicketData ticket{ ReadBE64(payload), ReadBE64(payload + 8), ReadBE64(payload + 16), @@ -1418,16 +1617,30 @@ std::optional ParseHttp3TicketData(const uv_buf_t& data) { payload[32] != 0, payload[33] != 0, }; -} -std::unique_ptr CreateHttp3Application( - Session* session, const Session::Application_Options& options) { - Debug(session, "Selecting HTTP/3 application"); - return std::make_unique(session, options); + Http3Settings current = ResolveHttp3Settings(options); + if (current.max_field_section_size < ticket.max_field_section_size || + current.qpack_max_dtable_capacity < ticket.qpack_max_dtable_capacity || + current.qpack_encoder_max_dtable_capacity < + ticket.qpack_encoder_max_dtable_capacity || + current.qpack_blocked_streams < ticket.qpack_blocked_streams || + (ticket.enable_connect_protocol && !current.enable_connect_protocol) || + (ticket.enable_datagrams && !current.enable_datagrams)) { + return nullptr; + } + + return std::static_pointer_cast( + std::make_shared(ticket)); } +} // namespace void RegisterHttp3Application() { - RegisterApplicationFactory("http3", CreateHttp3Application); + RegisterApplicationFactory("http3", + { + .create = CreateHttp3Application, + .parse_settings = ParseHttp3Settings, + .parse_ticket = ParseHttp3Ticket, + }); } } // namespace quic diff --git a/src/quic/http3.h b/src/quic/http3.h index ee3232cdebbba2..1476167e5a8680 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -2,26 +2,14 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include -#include -#include "application.h" -#include "session.h" - namespace node::quic { -// Create an HTTP/3 Application implementation for the given session. -// Uses the Application_Options from the session's config for HTTP/3 -// specific settings (qpack, max header length, etc.). -std::unique_ptr CreateHttp3Application( - Session* session, const Session::Application_Options& options); - +// Registers the HTTP/3 application factory (creation, settings parsing, +// and session-ticket hooks) under the name "http3". Called once at +// binding initialization; a session installs the application only when +// its options request that name explicitly (set by node:http3). void RegisterHttp3Application(); -// Parse HTTP/3 specific session ticket app data. Called from -// Application::ParseTicketData() when the type byte is HTTP3. -// The data includes the type byte prefix. -std::optional ParseHttp3TicketData(const uv_buf_t& data); - } // namespace node::quic #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/session.cc b/src/quic/session.cc index 61998dc582df19..5aee2b853abf45 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -16,7 +16,6 @@ #include #include #include -#include #include #include "application.h" #include "bindingdata.h" @@ -71,8 +70,7 @@ enum class SessionListenerFlags : uint32_t { DATAGRAM_STATUS = 1 << 2, SESSION_TICKET = 1 << 3, NEW_TOKEN = 1 << 4, - ORIGIN = 1 << 5, - APPLICATION = 1 << 6 + ORIGIN = 1 << 5 }; inline SessionListenerFlags operator|(SessionListenerFlags a, @@ -135,9 +133,9 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(HANDSHAKE_CONFIRMED, handshake_confirmed, uint8_t) \ V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t) \ V(PRIORITY_SUPPORTED, priority_supported, uint8_t) \ - V(HEADERS_SUPPORTED, headers_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ - V(APPLICATION_TYPE, application_type, uint8_t) \ + V(IS_SERVER, is_server, uint8_t) \ + V(HAS_APPLICATION, has_application, uint8_t) \ V(NO_ERROR_CODE, no_error_code, error_code) \ V(INTERNAL_ERROR_CODE, internal_error_code, error_code) \ V(MAX_DATAGRAM_SIZE, max_datagram_size, uint16_t) \ @@ -197,7 +195,7 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(SendDatagram, sendDatagram, SIDE_EFFECT) \ V(LocalTransportParams, localTransportParams, NO_SIDE_EFFECT) \ V(RemoteTransportParams, remoteTransportParams, NO_SIDE_EFFECT) \ - V(ApplicationOptions, applicationOptions, NO_SIDE_EFFECT) + V(ApplicationSettings, applicationSettings, NO_SIDE_EFFECT) struct Session::State final { #define V(_, name, type) type name; @@ -615,7 +613,7 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || - !SET(applicationName) || + !SET(application) || !SET(handshake_timeout) || !SET(initial_rtt) || !SET(keep_alive_timeout) || !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || @@ -649,15 +647,24 @@ Maybe Session::Options::From(Environment* env, } } - // Parse the application-specific options (HTTP/3 qpack settings, etc.). - // These are used if the negotiated ALPN selects Http3ApplicationImpl. - { - Local app_val; - if (params->Get(env->context(), state.application_string()) - .ToLocal(&app_val) && - !app_val->IsUndefined()) { - if (!Application_Options::From(env, app_val) - .To(&options.application_options)) { + // When an application is requested, parse its settings (supplied by + // the application's consumer layer alongside the name) through the + // registered factory's parse hook. The result is carried opaquely on + // the options; the QUIC core never interprets it. + if (!options.application.empty()) { + // The application option is internal-only: a missing registration + // is a bug in the consumer layer, not a user error. + const auto* factory = FindApplicationFactory(options.application); + CHECK_NOT_NULL(factory); + CHECK_NOT_NULL(factory->parse_settings); + Local settings_val; + if (!params->Get(env->context(), state.application_settings_string()) + .ToLocal(&settings_val)) { + return Nothing(); + } + if (!settings_val->IsUndefined()) { + if (!factory->parse_settings(env, settings_val) + .To(&options.application_settings)) { return Nothing(); } } @@ -810,7 +817,7 @@ struct Session::Impl final : public MemoryRetainer { // the registered application factory's ticket hook early in the // handshake (at ticket decryption). Applied in SetApplication() once // the application is installed. Opaque to the session. - std::optional pending_ticket_data_; + PendingTicketAppData pending_ticket_data_; // The native send queue used when no application is installed: streams // with pending outbound data are scheduled here and the send pump @@ -1250,20 +1257,19 @@ struct Session::Impl final : public MemoryRetainer { } } - JS_METHOD(ApplicationOptions) { + JS_METHOD(ApplicationSettings) { auto env = Environment::GetCurrent(args); Session* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); Local obj; if (!session->has_application()) { - // The application has not yet been selected (ALPN negotiation is not - // yet complete on the server) or the session has been destroyed. In - // either case, the application options are not available. + // No application is installed (none was requested, the server has + // not completed ALPN negotiation yet, or the session has been + // destroyed). In all of these cases there are no settings. return args.GetReturnValue().SetUndefined(); } - auto& options = session->application().options(); - if (options.ToObject(env).ToLocal(&obj)) { + if (session->application().GetSettingsObject(env).ToLocal(&obj)) { args.GetReturnValue().Set(obj); } } @@ -1557,7 +1563,7 @@ struct Session::Impl final : public MemoryRetainer { // mismatch, or the session ticket data was rejected) and the // handshake must fail. if (!session->impl_->application_) { - return session->config().options.applicationName.empty() + return session->config().options.application.empty() ? NGTCP2_SUCCESS : NGTCP2_ERR_CALLBACK_FAILURE; } @@ -2480,6 +2486,7 @@ Session::Session(Endpoint* endpoint, impl_->state()->no_error_code = NGTCP2_NO_ERROR; impl_->state()->internal_error_code = NGTCP2_INTERNAL_ERROR; + impl_->state()->is_server = config.side == Side::SERVER ? 1 : 0; // For clients, install the requested Application immediately - it is // known upfront from the options. For servers, application_ stays @@ -2657,7 +2664,7 @@ void Session::Close(CloseMethod method) { // yet (a server before the TLS handshake completes ALPN) cannot do // an application-level graceful shutdown (GOAWAY, CONNECTION_CLOSE // etc.), so fall through to a silent close. - if (!impl_->application_ && !config().options.applicationName.empty()) { + if (!impl_->application_ && !config().options.application.empty()) { impl_->state()->silent_close = 1; return FinishClose(); } @@ -2837,7 +2844,7 @@ bool Session::can_send_pending_data() const { // scheduling from its first flight. With no application requested the // native path is always ready. return impl_->application_ != nullptr || - config().options.applicationName.empty(); + config().options.application.empty(); } bool Session::stream_fin_managed_by_application() const { @@ -2851,38 +2858,32 @@ error_code Session::internal_error_code() const { } std::unique_ptr Session::SelectApplication() { - const auto& name = config().options.applicationName; - if (!name.empty()) { - auto factory = FindApplicationFactory(name); - CHECK_NOT_NULL(factory); - return factory(this, config().options.application_options); - } - // No application requested: run the native raw-stream path. - return nullptr; + const auto& name = config().options.application; + if (name.empty()) return nullptr; + const auto* factory = FindApplicationFactory(name); + CHECK_NOT_NULL(factory); + return factory->create(this); } void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); + DCHECK(app); // If we have pending ticket data from a session ticket that was - // parsed before ALPN negotiation, validate it against the selected - // application now. If the type doesn't match or the application + // parsed (and validated) by the application's ticket hook before ALPN + // negotiation, apply it to the application now. If the application // rejects the data, the handshake will fail (application_ stays null // and the caller returns an error). - if (impl_->pending_ticket_data_.has_value()) { - auto data = std::move(*impl_->pending_ticket_data_); - impl_->pending_ticket_data_.reset(); + if (impl_->pending_ticket_data_) { + auto data = std::move(impl_->pending_ticket_data_); if (!app->ApplySessionTicketData(data)) { Debug(this, "Session ticket app data rejected by application"); return; } } - impl_->state()->application_type = static_cast(app->type()); - impl_->state()->headers_supported = static_cast( - app->SupportsHeaders() ? HeadersSupportState::SUPPORTED - : HeadersSupportState::UNSUPPORTED); + impl_->state()->has_application = 1; // Surface the application's "no error" and "internal error" codes via // session state so that JS-side code (e.g. the stream writer's fail() - // path) can resolve the right wire code for the negotiated ALPN + // path) can resolve the right wire code for the installed application // without duplicating the per-application table. impl_->state()->no_error_code = app->GetNoErrorCode(); impl_->state()->internal_error_code = app->GetInternalErrorCode(); @@ -3653,53 +3654,62 @@ void Session::CollectSessionTicketAppData( SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { DCHECK(!is_destroyed()); - // If the application is already selected (client side, or server after - // ALPN), delegate directly. if (impl_->application_) { - return application().ExtractSessionTicketAppData(app_data, flag); + // I think this is unreacahable. If it ever happens, ignoring + // the ticket to fall back to 1RTT is fine. + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - // The application is not yet selected (server during ClientHello - // processing, before ALPN). Parse the ticket data now while the - // SSL_SESSION is still valid, and stash the result for validation - // after ALPN negotiation in SetApplication(). - auto data = app_data.Get(); - if (!data.has_value() || data->len == 0) { - // No app data in the ticket. Accept optimistically. + const auto accept = [&] { return flag == Flag::STATUS_RENEW ? SessionTicket::AppData::Status::TICKET_USE_RENEW : SessionTicket::AppData::Status::TICKET_USE; - } - auto parsed = Application::ParseTicketData(*data); - if (!parsed.has_value()) { - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - // Pre-validate the ticket data against the current configuration. If it - // does not match, reject the ticket so 0-RTT is not used. This must happen - // here (during TLS ticket processing) rather than in SetApplication, - // because by SetApplication time the TLS layer has already accepted - // the ticket and told the client 0-RTT is ok. - if (std::holds_alternative(*parsed)) { - // Generic opaque app-data (raw QUIC / non-h3): byte-match the stored - // bytes against the server's currently-configured `app_ticket_data`. - const auto& dtd = std::get(*parsed); - const auto& atd = config().options.app_ticket_data; - uv_buf_t cur = - atd.has_value() ? static_cast(*atd) : uv_buf_init(nullptr, 0); - if (dtd.data.size() != cur.len || - (cur.len > 0 && memcmp(dtd.data.data(), cur.base, cur.len) != 0)) { - Debug(this, "Session ticket app data does not match configured value"); + }; + auto data = app_data.Get(); + if (!data.has_value() || data->len == 0) { + // No app data in the ticket. Accept optimistically. + return accept(); + } + // Validation must happen here (during TLS ticket processing) rather + // than later at install time, because by then the TLS layer has + // already accepted the ticket and told the client 0-RTT is ok. + const auto& application_name = config().options.application; + if (!application_name.empty()) { + // An application is configured but not yet installed (server during + // ClientHello processing, before ALPN completes). Route the data to + // the registered application's ticket hook: it parses and validates + // against the configured settings now - while the SSL_SESSION is + // still valid - and the parsed result is applied once the + // application installs in SetApplication(). + const auto* factory = FindApplicationFactory(application_name); + CHECK_NOT_NULL(factory); + CHECK_NOT_NULL(factory->parse_ticket); + auto parsed = factory->parse_ticket(*data, config().options); + if (!parsed) { + Debug(this, "Session ticket app data rejected for application"); return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - } else if (!Application::ValidateTicketData( - *parsed, config().options.application_options)) { - // Typed app-data (HTTP/3): the application's own asymmetric validation. - Debug(this, "Session ticket app data incompatible with current settings"); + impl_->pending_ticket_data_ = std::move(parsed); + return accept(); + } + // Native path (no application): only DEFAULT-typed opaque app data is + // usable - byte-match the stored bytes against the server's currently + // configured `app_ticket_data`. Application-typed tickets cannot be + // validated here and are rejected, falling back cleanly to a full + // 1-RTT handshake. + const auto* p = reinterpret_cast(data->base); + if (p[0] != static_cast(Application::Type::DEFAULT)) { + Debug(this, "Session ticket app data has an unusable type byte"); + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + const auto& atd = config().options.app_ticket_data; + uv_buf_t cur = + atd.has_value() ? static_cast(*atd) : uv_buf_init(nullptr, 0); + if (data->len - 1 != cur.len || + (cur.len > 0 && memcmp(p + 1, cur.base, cur.len) != 0)) { + Debug(this, "Session ticket app data does not match configured value"); return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - impl_->pending_ticket_data_ = std::move(parsed); - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; + return accept(); } void Session::MemoryInfo(MemoryTracker* tracker) const { @@ -4535,27 +4545,13 @@ void Session::EmitApplication() { if (is_destroyed()) return; if (!env()->can_call_into_js()) return; - if (!has_application()) { - // The application has not yet been selected (ALPN negotiation is not - // yet complete on the server) or the session has been destroyed. In - // either case, the application options are not available. - // Should not happen, but we bail out - return; - } - - if (!HasListenerFlag(impl_->state()->listener_flags, - SessionListenerFlags::APPLICATION)) [[likely]] { - return; - } - + // A bare notification that the installed application's negotiated options + // have been updated (e.g. an HTTP/3 SETTINGS frame arrived). The consumer + // reads the current values back through the application settings binding; + // the transport layer carries no protocol-specific data here. CallbackScope cb_scope(this); - - Local argv; - auto& options = application().options(); - if (options.ToObject(env()).ToLocal(&argv)) { - MakeCallback( - BindingData::Get(env()).session_application_callback(), 1, &argv); - } + MakeCallback( + BindingData::Get(env()).session_application_callback(), 0, nullptr); } void Session::DestroyAllStreams(const QuicError& error) { @@ -4758,8 +4754,6 @@ void Session::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_BIDIRECTIONAL); NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_UNIDIRECTIONAL); - NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LIST_PAIRS); - NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LENGTH); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MIN); diff --git a/src/quic/session.h b/src/quic/session.h index 0cd8275c59b7a9..5c9115b8392e98 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -7,10 +7,10 @@ #include #include #include -#include #include #include #include +#include #include #include "bindingdata.h" #include "cid.h" @@ -77,96 +77,22 @@ struct StreamData final { class Session final : public AsyncWrap, private SessionTicket::AppData::Source { public: SessionTicket::AppData::Source& ticket_app_data_source() { return *this; } - // For simplicity, we use the same Application::Options struct for all - // Application types. This may change in the future. Not all of the options - // are going to be relevant for all Application types. - struct Application_Options final : public MemoryRetainer { - // The maximum number of header pairs permitted for a Stream. - // Only relevant if the selected application supports headers. - uint64_t max_header_pairs = DEFAULT_MAX_HEADER_LIST_PAIRS; - - // The maximum total number of header bytes (including header - // name and value) permitted for a Stream. - // Only relevant if the selected application supports headers. - uint64_t max_header_length = DEFAULT_MAX_HEADER_LENGTH; - - // HTTP/3 specific options. - // The maximum header section size advertised to the peer in SETTINGS. - // Defaults to match max_header_length so the SETTINGS frame accurately - // reflects the enforcement limit. A value of 0 would incorrectly tell - // the peer not to send any headers at all. - uint64_t max_field_section_size = DEFAULT_MAX_HEADER_LENGTH; - uint64_t qpack_max_dtable_capacity = 4096; - uint64_t qpack_encoder_max_dtable_capacity = 4096; - uint64_t qpack_blocked_streams = 100; - - bool enable_connect_protocol = true; - bool enable_datagrams = true; - - operator const nghttp3_settings() const; - - SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(Application::Options) - SET_SELF_SIZE(Options) - - static v8::Maybe From(Environment* env, - v8::Local value); - - std::string ToString() const; - v8::MaybeLocal ToObject(Environment* env) const; - - static const Application_Options kDefault; - }; - - // An Application implements the ALPN-protocol specific semantics on behalf + // An Application implements the protocol-specific semantics on behalf // of a QUIC Session. class Application; - // Decode the first ALPN protocol name from wire format (length-prefixed). - static std::string_view DecodeAlpn(std::string_view wire); - // Select the Application implementation: the factory registered under - // options.applicationName when set (see application.h), otherwise the default - // raw-stream application. + // options.application when set (see application.h), otherwise nullptr. + // With no application requested the session runs the native raw-stream + // path and no Application is ever installed. std::unique_ptr SelectApplication(); // Install the Application on the session. Called at construction for - // clients (ALPN known upfront) or from OnSelectAlpn for servers - // (ALPN negotiated during handshake). Must be called before any + // clients or from OnSelectAlpn for servers (later in the handshake, + // after session-ticket decryption). Must be called before any // application data is received. void SetApplication(std::unique_ptr app); - - // The application data-plane dispatchers. Each routes to the installed - // Application when one exists; otherwise the session's native - // raw-stream implementation runs (the session schedules streams on its - // own send queue and pulls their data directly). - int GetStreamData(StreamData* data); - bool StreamCommit(StreamData* data, size_t datalen); - bool AcknowledgeStreamData(stream_id id, size_t datalen); - bool ReceiveStreamOpen(stream_id id); - bool ReceiveStreamData(stream_id id, - const uint8_t* data, - size_t datalen, - const Stream::ReceiveDataFlags& flags, - void* stream_user_data); - // Native-path scheduling: puts the stream on the session's send queue. - void ScheduleStream(stream_id id); - - // True when the send pump may run: a session that requested a protocol - // application must not send until that application has been installed; - // a session with no application requested is always ready. - bool can_send_pending_data() const; - - // True when the installed application manages stream FIN itself (e.g. - // HTTP/3 via nghttp3); false when no application is installed. - bool stream_fin_managed_by_application() const; - - // The application-level "internal error" wire code in effect for this - // session (the installed application's code, or the raw QUIC default - // when none is installed). - error_code internal_error_code() const; - // Controls which datagram to drop when the pending datagram queue is full. enum class DatagramDropPolicy : uint8_t { DROP_OLDEST = 0, // Drop the oldest queued datagram (default). @@ -201,11 +127,17 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // so that it cannot be garbage collected. BaseObjectPtr cid_factory_ref; - // Application-specific options (used for HTTP/3 if the negotiated - // ALPN selects Http3ApplicationImpl). - Application_Options application_options = Application_Options::kDefault; + // The name of the protocol application to install on the session + // (see application.h). Set only by internal consumer layers (e.g. + // node:http3); empty means the session runs the native raw-stream + // path with no application installed. + std::string application; - std::string applicationName; + // The application-specific settings supplied by the consumer layer + // alongside the application name, pre-parsed by the registered + // application factory's parse hook at option-processing time. + // Opaque to the QUIC core; only the application's own hooks read it. + std::shared_ptr application_settings; // When true, QLog output will be enabled for the session. bool qlog = false; @@ -556,6 +488,37 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void ShutdownStream(stream_id id, QuicError error = QuicError()); void ShutdownStreamWrite(stream_id id, QuicError code = QuicError()); + // The application data-plane dispatchers. Each routes to the installed + // Application when one exists; otherwise the session's native + // raw-stream implementation runs (the session schedules streams on its + // own send queue and pulls their data directly). + int GetStreamData(StreamData* data); + bool StreamCommit(StreamData* data, size_t datalen); + bool AcknowledgeStreamData(stream_id id, size_t datalen); + bool ReceiveStreamOpen(stream_id id); + bool ReceiveStreamData(stream_id id, + const uint8_t* data, + size_t datalen, + const Stream::ReceiveDataFlags& flags, + void* stream_user_data); + // Native-path scheduling: puts the stream on the session's send queue. + void ScheduleStream(stream_id id); + + // True when the send pump may run: a session that requested a protocol + // application must not send until that application has been installed + // (for servers this happens during ALPN processing of the first + // flight); a session with no application requested is always ready. + bool can_send_pending_data() const; + + // True when the installed application manages stream FIN itself (e.g. + // HTTP/3 via nghttp3); always false when no application is installed. + bool stream_fin_managed_by_application() const; + + // The application-level "internal error" wire code in effect for this + // session (application-supplied once installed, the raw QUIC default + // otherwise). Mirrors the session state slot read by the JS side. + error_code internal_error_code() const; + // Use the configured CID::Factory to generate a new CID. CID new_cid(size_t len = CID::kMaxLength) const; @@ -706,7 +669,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void EmitGoaway(stream_id last_stream_id); // Sets the max datagram payload size in the shared state. Used by - // Http3ApplicationImpl to block datagram sends when the peer's + // Http3Conn to block datagram sends when the peer's // SETTINGS_H3_DATAGRAM=0 (RFC 9297 §3). void set_max_datagram_size(uint16_t size); void EmitDatagram(Store&& datagram, DatagramReceivedFlags flag); diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 7186aed89a78e9..00cec23614a29c 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -31,14 +31,12 @@ using v8::HandleScope; using v8::Integer; using v8::Just; using v8::Local; -using v8::LocalVector; using v8::Maybe; using v8::Nothing; using v8::Object; using v8::ObjectTemplate; using v8::SharedArrayBuffer; using v8::String; -using v8::Uint32; using v8::Uint8Array; using v8::Value; @@ -457,15 +455,17 @@ struct Stream::Impl { Local headers = args[1].As(); HeadersFlags flags = FromV8Value(args[2]); + // Headers require an installed application that supports them + // (e.g. HTTP/3); with no application there is nowhere to send them. + if (!stream->session().has_application() || + !stream->session().application().SupportsHeaders()) { + return args.GetReturnValue().Set(false); + } + // If the stream is pending, the headers will be queued until the // stream is opened, at which time the queued header block will be - // immediately sent when the stream is opened. If we already know - // that the application does not support headers, return false - // immediately so the JS side can throw an appropriate error. + // immediately sent when the stream is opened. if (stream->is_pending()) { - if (!stream->session().application().SupportsHeaders()) { - return args.GetReturnValue().Set(false); - } stream->EnqueuePendingHeaders(kind, headers, flags); return args.GetReturnValue().Set(true); } @@ -547,7 +547,9 @@ struct Stream::Impl { .pending = stream->is_pending(), }; - if (!stream->is_pending()) { + // Priority signaling is application-defined (e.g. HTTP/3 RFC 9218); + // with no application installed only the stored value is updated. + if (!stream->is_pending() && stream->session().has_application()) { stream->session().application().SetStreamPriority( *stream, priority, flags); } @@ -558,11 +560,13 @@ struct Stream::Impl { ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); // On the client side, priority is always read from the stream's - // stored value since the client is the one setting it. On the - // server side, we delegate to the application which can read - // the peer's requested priority (e.g., from PRIORITY_UPDATE - // frames in HTTP/3). - if (!stream->session().is_server()) { + // stored value since the client is the one setting it. The same + // applies when no application is installed (nothing tracks peer + // priority signals). On the server side with an application, we + // delegate to the application which can read the peer's requested + // priority (e.g., from PRIORITY_UPDATE frames in HTTP/3). + if (!stream->session().is_server() || + !stream->session().has_application()) { auto& pri = stream->priority_; uint32_t packed = (static_cast(pri.priority) << 1) | (pri.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); @@ -1261,17 +1265,18 @@ void Stream::NotifyStreamOpened(stream_id id) { maybe_pending_stream_.reset(); if (priority_.pending) { - session().application().SetStreamPriority( - *this, priority_.priority, priority_.flags); + if (session().has_application()) { + session().application().SetStreamPriority( + *this, priority_.priority, priority_.flags); + } priority_.pending = false; } if (!pending_headers_queue_.empty()) { - if (!session().application().SupportsHeaders()) { - // Headers were enqueued while the application was not yet known - // (headers_supported == 0), and the negotiated application does - // not support headers. This is a fatal mismatch. - Destroy(QuicError::ForApplication( - session().application().GetInternalErrorCode())); + if (!session().has_application() || + !session().application().SupportsHeaders()) { + // Headers were enqueued but the session has no application that + // supports them. This is a fatal mismatch. + Destroy(QuicError::ForApplication(session().internal_error_code())); return; } decltype(pending_headers_queue_) queue; @@ -1290,7 +1295,7 @@ void Stream::NotifyStreamOpened(stream_id id) { NotifyReadableEnded(pending_close_read_code_); } if (!is_remote_unidirectional() && !is_writable() && - !session_->application().stream_fin_managed_by_application()) { + !session_->stream_fin_managed_by_application()) { NotifyWritableEnded(pending_close_write_code_); } @@ -1367,6 +1372,10 @@ bool Stream::is_eos() const { return state()->fin_sent; } +bool Stream::wants_headers() const { + return state()->wants_headers == 1; +} + bool Stream::wants_trailers() const { return state()->wants_trailers; } @@ -1562,28 +1571,6 @@ int Stream::DoPull(bob::Next next, return outbound_->Pull(std::move(next), options, data, count, max_count_hint); } -void Stream::BeginHeaders(HeadersKind kind) { - headers_length_ = 0; - headers_.clear(); - set_headers_kind(kind); -} - -void Stream::set_headers_kind(HeadersKind kind) { - headers_kind_ = kind; -} - -bool Stream::AddHeader(std::unique_ptr
header) { - size_t len = header->length(); - if (!session_->application().CanAddHeader( - headers_.size(), headers_length_, len)) { - return false; - } - - headers_length_ += len; - headers_.push_back(std::move(header)); - return true; -} - void Stream::Acknowledge(size_t datalen) { if (outbound_ == nullptr) return; @@ -1890,42 +1877,6 @@ void Stream::EmitClose(const QuicError& error) { MakeCallback(BindingData::Get(env()).stream_close_callback(), 1, &err); } -void Stream::EmitHeaders() { - STAT_RECORD_TIMESTAMP(Stats, received_at); - // state()->wants_headers will be set from the javascript side if the - // stream object has a handler for the headers event. - if (!env()->can_call_into_js() || !state()->wants_headers) { - headers_.clear(); - return; - } - CallbackScope cb_scope(this); - - auto& binding = BindingData::Get(env()); - size_t count = headers_.size() * 2; - LocalVector values(env()->isolate(), count); - - for (size_t i = 0; i < headers_.size(); i++) { - Local name; - Local value; - if (!headers_[i]->GetName(&binding).ToLocal(&name) || - !headers_[i]->GetValue(&binding).ToLocal(&value)) [[unlikely]] { - headers_.clear(); - return; - } - values[i * 2] = name; - values[i * 2 + 1] = value; - } - - headers_.clear(); - - Local argv[] = { - Array::New(env()->isolate(), values.data(), count), - Integer::NewFromUnsigned(env()->isolate(), - static_cast(headers_kind_))}; - - MakeCallback(binding.stream_headers_callback(), arraysize(argv), argv); -} - void Stream::EmitReset(const QuicError& error) { // state()->wants_reset will be set from the javascript side if the // stream object has a handler for the reset event. @@ -1939,16 +1890,6 @@ void Stream::EmitReset(const QuicError& error) { MakeCallback(BindingData::Get(env()).stream_reset_callback(), 1, &err); } -void Stream::EmitWantTrailers() { - // state()->wants_trailers will be set from the javascript side if the - // stream object has a handler for the trailers event. - if (!env()->can_call_into_js() || !state()->wants_trailers) { - return; - } - CallbackScope cb_scope(this); - MakeCallback(BindingData::Get(env()).stream_trailers_callback(), 0, nullptr); -} - // ============================================================================ void Stream::Schedule(Queue* queue) { diff --git a/src/quic/streams.h b/src/quic/streams.h index f24cd29e6b19a8..239d02d628f070 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -10,7 +10,6 @@ #include #include #include -#include #include #include "bindingdata.h" #include "data.h" @@ -202,8 +201,6 @@ class Stream final : public AsyncWrap, public Ngtcp2Source, public DataQueue::BackpressureListener { public: - using Header = NgHeaderBase; - // Acquire a DataQueue from the given value if it is valid. The return // follows the typical V8 rules for Maybe types. If an error occurs, // the Maybe will be empty and an exception will be set on the isolate. @@ -275,6 +272,7 @@ class Stream final : public AsyncWrap, // True if the stream wants to send trailing headers after the body. bool wants_trailers() const; + bool wants_headers() const; // Marks this stream as having received 0-RTT early data. void set_early(); @@ -344,15 +342,8 @@ class Stream final : public AsyncWrap, // Currently, only HTTP/3 streams support headers. These methods are here // to support that. They are not used when using any other QUIC application. - void BeginHeaders(HeadersKind kind); - void set_headers_kind(HeadersKind kind); - // Returns false if the header cannot be added. This will typically happen - // if the application does not support headers, a maximum number of headers - // have already been added, or the maximum total header length is reached. - bool AddHeader(std::unique_ptr
header); - // TODO(@jasnell): Implement MemoryInfo to track outbound_, inbound_, - // reader_, headers_, and pending_headers_queue_. + // reader_, and pending_headers_queue_. SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Stream) SET_SELF_SIZE(Stream) @@ -403,11 +394,6 @@ class Stream final : public AsyncWrap, // Notifies the JavaScript side that the stream has been reset. void EmitReset(const QuicError& error); - // Notifies the JavaScript side that the application is ready to receive - // trailing headers. Any trailing headers must be sent immediately, and - // synchronously when this callback is triggered. - void EmitWantTrailers(); - // Notifies the JavaScript side that sending data on the stream has been // blocked because of flow control restriction. void EmitBlocked(); @@ -420,9 +406,6 @@ class Stream final : public AsyncWrap, // and outbound buffer state. Emits drain if transitioning from 0 to > 0. void UpdateWriteDesiredSize(); - // Delivers the set of inbound headers that have been collected. - void EmitHeaders(); - void NotifyReadableEnded(error_code code); void NotifyWritableEnded(error_code code); @@ -459,21 +442,6 @@ class Stream final : public AsyncWrap, const StoredPriority& stored_priority() const { return priority_; } - // The headers_ field holds a block of headers that have been received and - // are being buffered for delivery to the JavaScript side. Headers are - // stored as C++ objects during collection (AddHeader) and converted to - // V8 strings only when emitted (EmitHeaders), avoiding StrongRootAllocator - // mutex contention on the per-header hot path. - std::vector> headers_; - - // The headers_kind_ field indicates the kind of headers that are being - // buffered. - HeadersKind headers_kind_ = HeadersKind::INITIAL; - - // The headers_length_ field holds the total length of the headers that have - // been buffered. - size_t headers_length_ = 0; - friend struct Impl; friend class PendingStream; friend class Http3ApplicationImpl; @@ -483,12 +451,12 @@ class Stream final : public AsyncWrap, // The Queue/Schedule here are part of the mechanism used to // determine which streams have data to send on the session. When a stream // potentially has data available, it will be scheduled in the Queue. Then, - // when the Session::Application starts sending pending data, it will check - // the queue to see if there are streams waiting. If there are, it will grab - // one and check to see if there is data to send. When a stream does not have - // data to send (such as when it is initially created or is using an async - // source that is still waiting for data to be pushed) it will not appear in - // the queue. + // when the session (or its installed application) starts sending pending + // data, it will check the queue to see if there are streams waiting. If + // there are, it will grab one and check to see if there is data to send. + // When a stream does not have data to send (such as when it is initially + // created or is using an async source that is still waiting for data to be + // pushed) it will not appear in the queue. ListNode stream_queue_; using Queue = ListHead; diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs new file mode 100644 index 00000000000000..85d2ee81d006e5 --- /dev/null +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -0,0 +1,66 @@ +// Flags: --experimental-quic --no-warnings --expose-internals + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, notStrictEqual } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +// Test that the h3 ALPN negotiates normally but doesn't auto-activate +// HTTP/3. ALPN is reported, not type-changing. HTTP/3 is used via +// the 'node:http3' module explicitly. +// Both server and client explicitly configure the h3 ALPN. + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); + +const serverOpened = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + // ALPN negotiated h3, but with no application requested this is a RAW + // session: no protocol application is installed. + strictEqual(serverSession.alpnProtocol, 'h3'); + strictEqual(getQuicSessionState(serverSession).hasApplication, false); + const info = await serverSession.opened; + strictEqual(info.protocol, 'h3'); + serverOpened.resolve(); + serverSession.close(); +}), { + alpn: ['h3'], + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +notStrictEqual(serverEndpoint.address, undefined); + +const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', + servername: 'localhost', + verifyPeer: 'manual', + // Application config is internal-only (kApplication) - user options + // are ignored. Applications integrate natively with their consumers + // so custom options would reliably break things. + application: 'http3', +}); + +async function checkClient() { + const info = await clientSession.opened; + strictEqual(info.protocol, 'h3'); + // Still a raw session: + strictEqual(getQuicSessionState(clientSession).hasApplication, false); +} + +await Promise.all([serverOpened.promise, checkClient()]); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-goaway-non-h3.mjs b/test/parallel/test-quic-h3-goaway-non-h3.mjs deleted file mode 100644 index e0dd89d20279a0..00000000000000 --- a/test/parallel/test-quic-h3-goaway-non-h3.mjs +++ /dev/null @@ -1,66 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: Non-H3 session close does not fire ongoaway. -// GOAWAY is an HTTP/3 concept. When a non-H3 session closes, the -// ongoaway callback must not fire. - -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import { setImmediate } from 'node:timers/promises'; -import * as fixtures from '../common/fixtures.mjs'; - -const { strictEqual } = assert; -const { readKey } = fixtures; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); -const { bytes } = await import('stream/iter'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -const serverDone = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - // Read client data, send response, close stream. - const data = await bytes(stream); - strictEqual(decoder.decode(data), 'ping'); - stream.writer.writeSync('pong'); - stream.writer.endSync(); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: 'quic-test', -}); - -const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - alpn: 'quic-test', - // Ongoaway must NOT fire for non-H3 sessions. - ongoaway: mustNotCall(), -}); -await clientSession.opened; - -const stream = await clientSession.createBidirectionalStream({ - body: encoder.encode('ping'), -}); - -const response = await bytes(stream); -strictEqual(decoder.decode(response), 'pong'); -await Promise.all([stream.closed, serverDone.promise]); - -// Wait a tick for any deferred callbacks to fire. -await setImmediate(); -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 3db72af5cbafe1..2de3366dc10ee7 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -157,3 +157,76 @@ const decoder = new TextDecoder(); await clientSession.close(); await serverEndpoint.close(); } + +// Send-side validation. Invalid request headers reject before a stream +// is opened and pseudo-headers in trailers are rejected. +{ + const serverDone = Promise.withResolvers(); + const encoder = new TextEncoder(); + + const serverEndpoint = await listen(mustCall(async (ss) => { + // Exactly one stream must arrive: the invalid requests below must + // never hit the wire. + ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }); + const w = stream.writer; + w.writeSync(encoder.encode('payload')); + w.endSync(); + }); + stream.onwanttrailers = mustCall(() => { + assert.throws(() => stream.sendTrailers({ ':status': '200' }), + { code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' }); + stream.sendTrailers({ 'x-checksum': 'abc' }); + }); + await stream.closed; + ss.close(); + serverDone.resolve(); + }); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + }); + await clientSession.opened; + + // Connection-specific headers are forbidden in HTTP/3. The request + // rejects during validation, before any stream is opened. + await assert.rejects(clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + 'connection': 'keep-alive', + }), { code: 'ERR_HTTP2_INVALID_CONNECTION_HEADERS' }); + + // Unknown pseudo-headers are forbidden. + await assert.rejects(clientSession.request({ + ':bogus': 'nope', + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }), { code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' }); + + // The failed requests left nothing behind: the session is fully + // usable and the next stream gets the first stream id (0). + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + ontrailers: mustCall((trailers) => { + strictEqual(trailers['x-checksum'], 'abc'); + }), + }); + strictEqual(stream.id, 0n); + + await Promise.all([bytes(stream), stream.closed, serverDone.promise]); + await clientSession.close(); + await serverEndpoint.close(); +} diff --git a/test/parallel/test-quic-h3-headers-support.mjs b/test/parallel/test-quic-h3-headers-support.mjs new file mode 100644 index 00000000000000..9b8ea14a13c9a4 --- /dev/null +++ b/test/parallel/test-quic-h3-headers-support.mjs @@ -0,0 +1,92 @@ +// Flags: --experimental-quic --no-warnings + +// Test: QuicStream/QuicSession expose no HTTP/3 surface. +// The HTTP/3 members (sendHeaders, sendInformationalHeaders, sendTrailers, +// headers, onheaders, oninfo, ontrailers, onwanttrailers) were stripped from +// the public node:quic API; node:http3 reaches them via internal symbols. +// Regardless of the negotiated ALPN, plain QUIC streams must expose none of +// them: the methods are undefined and the callback names are plain inert +// expando properties with no prototype accessors behind them. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; +const { readKey } = fixtures; + +const { strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect, QuicStream, QuicSession } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); +const encoder = new TextEncoder(); + +// No h3 accessors or methods exist on the prototypes at all. +for (const name of ['sendHeaders', 'sendInformationalHeaders', 'sendTrailers', + 'headers', 'pendingTrailers', 'onheaders', 'oninfo', + 'ontrailers', 'onwanttrailers']) { + strictEqual(Object.getOwnPropertyDescriptor(QuicStream.prototype, name), + undefined); +} +for (const name of ['ongoaway', 'onorigin']) { + strictEqual(Object.getOwnPropertyDescriptor(QuicSession.prototype, name), + undefined); +} + +function assertNoH3Surface(stream) { + strictEqual(typeof stream.sendHeaders, 'undefined'); + strictEqual(typeof stream.sendInformationalHeaders, 'undefined'); + strictEqual(typeof stream.sendTrailers, 'undefined'); + strictEqual(stream.headers, undefined); + strictEqual(stream.pendingTrailers, undefined); + + // Assigning the old callback names just creates inert expandos; no + // prototype setter intercepts them. + const inert = () => {}; + stream.onheaders = inert; + strictEqual(stream.onheaders, inert); + delete stream.onheaders; +} + +const serverDone = Promise.withResolvers(); +const serverEndpoint = await listen(mustCall(async (serverSession) => { + strictEqual(typeof serverSession.ongoaway, 'undefined'); + strictEqual(typeof serverSession.onorigin, 'undefined'); + serverSession.onstream = mustCall(async (stream) => { + assertNoH3Surface(stream); + + stream.writer.endSync(); + + serverSession.close(); + serverDone.resolve(); + }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: 'quic-test', +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + alpn: 'quic-test', +}); +await clientSession.opened; + +strictEqual(typeof clientSession.ongoaway, 'undefined'); +strictEqual(typeof clientSession.onorigin, 'undefined'); + +const stream = await clientSession.createBidirectionalStream({ + body: encoder.encode('ping'), +}); + +// Client side: no h3 surface either. +assertNoH3Surface(stream); + +await serverDone.promise; +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-http3session.mjs b/test/parallel/test-quic-h3-http3session.mjs index be59b4c05a0e9a..20bd3ce1b8427f 100644 --- a/test/parallel/test-quic-h3-http3session.mjs +++ b/test/parallel/test-quic-h3-http3session.mjs @@ -8,7 +8,7 @@ import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { strictEqual, ok } = assert; +const { strictEqual, ok, rejects } = assert; const { readKey } = fixtures; if (!hasQuic) { @@ -29,6 +29,10 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall((session) => { ok(session instanceof Http3Session); + // HTTP/3 requests are client-initiated only: a server session cannot + // open a request stream. + rejects(session.request(), { code: 'ERR_INVALID_STATE' }) + .then(mustCall()); session.onstream = mustCall((stream) => { ok(stream instanceof Http3Stream); stream.onheaders = mustCall((headers) => { diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 60331e48fd1e77..a7eba3c622428e 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -66,14 +66,14 @@ async function makeRequest(clientSession, path) { }), { sni: { '*': { keys: [key], certs: [cert] } }, // Server disables QPACK dynamic table. - application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, + settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', // Client also disables QPACK dynamic table. - application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, + settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); await clientSession.opened; @@ -105,13 +105,13 @@ async function makeRequest(clientSession, path) { }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, - application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, + settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', - application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, + settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); await clientSession.opened; diff --git a/test/parallel/test-quic-h3-session-settings.mjs b/test/parallel/test-quic-h3-session-settings.mjs new file mode 100644 index 00000000000000..76e96ad7678da6 --- /dev/null +++ b/test/parallel/test-quic-h3-session-settings.mjs @@ -0,0 +1,113 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings --expose-internals + +// Test: http3Session.settings +// Verifies that the effective HTTP/3 settings are exposed on the +// Http3Session wrapper, are a null-prototype object, reflect the values +// configured through the `settings` option of http3.connect()/listen() +// (some values are subsequently confirmed/updated by the peer's SETTINGS +// frame; both sides are configured identically here so the values are +// stable), and return null after close. Also pins that h3 sessions +// (unlike raw QUIC sessions) report an installed application. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); + +const { listen, connect } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const customSettings = { + maxHeaderPairs: 50n, + maxHeaderLength: 8192n, + maxFieldSectionSize: 16384n, + qpackMaxDTableCapacity: 2048n, + qpackEncoderMaxDTableCapacity: 2048n, + qpackBlockedStreams: 50n, + enableConnectProtocol: false, +}; + +function checkSettings(settings, what) { + ok(settings != null, `${what} settings should be available`); + strictEqual(typeof settings, 'object'); + strictEqual(Object.getPrototypeOf(settings), null); + strictEqual(settings.maxHeaderPairs, customSettings.maxHeaderPairs); + strictEqual(settings.maxHeaderLength, customSettings.maxHeaderLength); + strictEqual(settings.maxFieldSectionSize, + customSettings.maxFieldSectionSize); + strictEqual(settings.qpackMaxDtableCapacity, + customSettings.qpackMaxDTableCapacity); + strictEqual(settings.qpackEncoderMaxDtableCapacity, + customSettings.qpackEncoderMaxDTableCapacity); + strictEqual(settings.qpackBlockedStreams, + customSettings.qpackBlockedStreams); + strictEqual(settings.enableConnectProtocol, + customSettings.enableConnectProtocol); +} + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.onstream = mustCall(async (stream) => { + // The application is installed from the first flight, so the + // settings are available as soon as the session is surfaced. + checkSettings(serverSession.settings, 'server'); + strictEqual( + getQuicSessionState(serverSession.session).hasApplication, true); + strictEqual(getQuicSessionState(serverSession.session).isServer, true); + + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); + await stream.closed; + serverSession.close(); + serverDone.resolve(); + }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + settings: customSettings, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + settings: customSettings, +}); +await clientSession.opened; + +// The client installs its application at session creation, so the +// settings are available immediately after the session opens. +checkSettings(clientSession.settings, 'client'); +strictEqual(getQuicSessionState(clientSession.session).hasApplication, true); +strictEqual(getQuicSessionState(clientSession.session).isServer, false); + +// Exchange a request to let the server side run its assertions. +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', +}); + +// eslint-disable-next-line no-unused-vars +for await (const _ of stream) { /* drain */ } +await Promise.all([stream.closed, serverDone.promise]); + +// After close, settings should return null. +await clientSession.close(); +strictEqual(clientSession.settings, null); + +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs new file mode 100644 index 00000000000000..cde0d6c4d87b85 --- /dev/null +++ b/test/parallel/test-quic-h3-settings.mjs @@ -0,0 +1,196 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: HTTP/3 settings enforcement. +// maxHeaderPairs enforcement - reject headers exceeding pair count +// maxHeaderLength enforcement - reject headers exceeding byte length +// enableConnectProtocol setting (accepted without error) + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; +const { readKey } = fixtures; + +const { strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +// maxHeaderPairs enforcement. +// Server limits to 5 header pairs. Client sends 4 pseudo-headers + +// 2 custom headers = 6 pairs. The 6th pair is silently dropped. +{ + const serverDone = Promise.withResolvers(); + + const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/limited'); + strictEqual(headers[':scheme'], 'https'); + strictEqual(headers[':authority'], 'localhost'); + // x-first is the 5th pair — accepted. + strictEqual(headers['x-first'], 'one'); + // x-second would be the 6th pair — dropped. + strictEqual(headers['x-second'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); + await stream.closed; + ss.close(); + serverDone.resolve(); + }); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, + // Allow 5 header pairs: 4 pseudo-headers + 1 custom. + settings: { maxHeaderPairs: 5 }, + }); + + const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + }); + await clientSession.opened; + + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-first': 'one', + 'x-second': 'two', + }, { + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + }), + }); + + const body = await bytes(stream); + strictEqual(decoder.decode(body), 'ok'); + await stream.closed; + await serverDone.promise; + await clientSession.close(); + await serverEndpoint.close(); +} + +// maxHeaderLength enforcement. +// Server limits total header byte length (name chars + value chars). +// The 4 pseudo-headers take ~45 bytes. A long custom header value +// pushes the total over the limit. +{ + const serverDone = Promise.withResolvers(); + const longValue = 'x'.repeat(200); + + const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/length-limited'); + // x-long should be dropped — would push total over 100 bytes. + strictEqual(headers['x-long'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); + await stream.closed; + ss.close(); + serverDone.resolve(); + }); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, + // Limit total header bytes. The 4 pseudo-headers fit within 100 + // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. + settings: { maxHeaderLength: 100 }, + }); + + const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + }); + await clientSession.opened; + + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/length-limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-long': longValue, + }, { + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + }), + }); + + const body = await bytes(stream); + strictEqual(decoder.decode(body), 'ok'); + await Promise.all([stream.closed, serverDone.promise]); + await clientSession.close(); + await serverEndpoint.close(); +} + +// enableConnectProtocol setting plus transport-level datagrams. +// Verify these options are accepted and H3 sessions work with them. +// (Datagram support is a QUIC transport parameter; HTTP/3 datagrams are +// not yet supported, so SETTINGS_H3_DATAGRAM is never advertised.) +{ + const serverDone = Promise.withResolvers(); + + const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('settings-ok')); + stream.writer.endSync(); + }); + await stream.closed; + ss.close(); + serverDone.resolve(); + }); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, + settings: { enableConnectProtocol: true }, + onsettings: mustCall((settings) => { + strictEqual(settings.enableConnectProtocol, false); + // Must be false, as this is only sent from the server side. + }), + }); + + const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + settings: { enableConnectProtocol: true }, + }); + clientSession.onsettings = mustCall((settings) => { + strictEqual(settings.enableConnectProtocol, true); + }); + await clientSession.opened; + + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/settings', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + }), + }); + + const body = await bytes(stream); + strictEqual(decoder.decode(body), 'settings-ok'); + await Promise.all([stream.closed, serverDone.promise]); + await clientSession.close(); + await serverEndpoint.close(); +} diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index 3875b14c1e3435..3e57d5d6890f4a 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -3,7 +3,6 @@ // Test: H3 0-RTT rejected when server reduces application settings. // 0-RTT rejected when max_field_section_size decreased // 0-RTT rejected when enable_connect_protocol disabled -// 0-RTT rejected when enable_datagrams disabled // Each test creates two endpoints with the same key/cert/tokenSecret. // The first endpoint issues a ticket with generous H3 settings. The // second endpoint has reduced settings, causing the H3 session ticket @@ -137,27 +136,13 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - application: { enableConnectProtocol: true }, + settings: { enableConnectProtocol: true }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // EnableConnectProtocol reduced from true to false. - application: { enableConnectProtocol: false }, - }, ticket, token); -} - -// enable_datagrams disabled. -{ - const { ticket, token } = await getTicket({ - endpoint: { tokenSecret }, - application: { enableDatagrams: true }, - }); - - await attemptRejected0RTT({ - endpoint: { tokenSecret }, - // EnableDatagrams reduced from true to false. - application: { enableDatagrams: false }, + settings: { enableConnectProtocol: false }, }, ticket, token); } @@ -165,12 +150,12 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - application: { maxFieldSectionSize: 10000 }, + settings: { maxFieldSectionSize: 10000 }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // MaxFieldSectionSize reduced from 10000 to 100. - application: { maxFieldSectionSize: 100 }, + settings: { maxFieldSectionSize: 100 }, }, ticket, token); } diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 2af27724eb4e18..ce33537edf74c9 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -173,8 +173,9 @@ strictEqual(sessionState.isHandshakeCompleted, false); strictEqual(sessionState.isHandshakeConfirmed, false); strictEqual(sessionState.isStreamOpenAllowed, false); strictEqual(sessionState.isPrioritySupported, false); -strictEqual(sessionState.headersSupported, 0); +strictEqual(sessionState.hasApplication, false); strictEqual(sessionState.isWrapped, false); +strictEqual(sessionState.isServer, false); strictEqual(sessionState.maxDatagramSize, 0); strictEqual(sessionState.lastDatagramId, 0n); diff --git a/test/parallel/test-quic-internal-setcallbacks.mjs b/test/parallel/test-quic-internal-setcallbacks.mjs index b485b5e9b43457..848586aac275a3 100644 --- a/test/parallel/test-quic-internal-setcallbacks.mjs +++ b/test/parallel/test-quic-internal-setcallbacks.mjs @@ -15,7 +15,6 @@ const callbacks = { onEndpointClose() {}, onSessionNew() {}, onSessionClose() {}, - onSessionApplication() {}, onSessionDatagram() {}, onSessionDatagramStatus() {}, onSessionHandshake() {}, @@ -25,16 +24,12 @@ const callbacks = { onSessionKeyLog() {}, onSessionQlog() {}, onSessionEarlyDataRejected() {}, - onSessionOrigin() {}, - onSessionGoaway() {}, onSessionVersionNegotiation() {}, onStreamCreated() {}, onStreamBlocked() {}, onStreamClose() {}, onStreamDrain() {}, onStreamReset() {}, - onStreamHeaders() {}, - onStreamTrailers() {}, }; // Fail if any callback is missing for (const fn of Object.keys(callbacks)) { @@ -49,3 +44,26 @@ quic.setCallbacks(callbacks); // Multiple calls should just be ignored. quic.setCallbacks(callbacks); + +// The HTTP/3 application-event callbacks are registered separately (by the +// HTTP/3 consumer layer) and validate their own set. +const http3Callbacks = { + onSessionApplication() {}, + onSessionGoaway() {}, + onSessionOrigin() {}, + onStreamHeaders() {}, + onStreamTrailers() {}, +}; +// Fail if any callback is missing +for (const fn of Object.keys(http3Callbacks)) { + // eslint-disable-next-line no-unused-vars + const { [fn]: _, ...rest } = http3Callbacks; + throws(() => quic.setHttp3Callbacks(rest), { + code: 'ERR_MISSING_ARGS', + }); +} +// If all callbacks are present it should work +quic.setHttp3Callbacks(http3Callbacks); + +// Multiple calls should just be ignored. +quic.setHttp3Callbacks(http3Callbacks); diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs new file mode 100644 index 00000000000000..56bfeb4daefe67 --- /dev/null +++ b/test/parallel/test-quic-session-emit-ordering.mjs @@ -0,0 +1,48 @@ +// Flags: --experimental-quic --no-warnings --expose-internals + +// Check that server `onsession` emit fires only after the session's +// ClientHello has been processed & validated. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, notStrictEqual, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); +const { listen, connect } = await import('../common/quic.mjs'); + +const sessionSeen = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + // All assertions run synchronously in the onsession emit frame. + + // The TLS details from the ClientHello are readable on the session. + strictEqual(serverSession.servername, 'localhost'); + strictEqual(serverSession.alpnProtocol, 'quic-test'); + + // The client's transport params arrived in the first flight and have + // been processed by the time the session is surfaced. + const params = serverSession.remoteTransportParams; + notStrictEqual(params, undefined); + notStrictEqual(params, null); + ok(params.initialMaxStreamsBidi >= 0n); + + // ALPN negotiation has completed without installing an application + // (none requested, so this is a raw session). + strictEqual(getQuicSessionState(serverSession).hasApplication, false); + + sessionSeen.resolve(); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; +await sessionSeen.promise; + +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-stream-bidi-basic.mjs b/test/parallel/test-quic-stream-bidi-basic.mjs index de71890e888ac9..0cb86c2c07a061 100644 --- a/test/parallel/test-quic-stream-bidi-basic.mjs +++ b/test/parallel/test-quic-stream-bidi-basic.mjs @@ -50,7 +50,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Create a bidi stream with the message as the body. -// For DefaultApplication, the server's onstream fires when data arrives. +// For raw QUIC sessions, the server's onstream fires when data arrives. const stream = await clientSession.createBidirectionalStream({ body: body, }); diff --git a/test/parallel/test-quic-stream-destroy-emits-reset.mjs b/test/parallel/test-quic-stream-destroy-emits-reset.mjs index e565f5e9ff7b8f..192a885ea6a431 100644 --- a/test/parallel/test-quic-stream-destroy-emits-reset.mjs +++ b/test/parallel/test-quic-stream-destroy-emits-reset.mjs @@ -9,10 +9,10 @@ // idle timer fired. // // Verified by observing the server-side `onreset` callback. The wire -// code is the negotiated application's "internal error" code: for -// the test fixture's non-h3 ALPN (`quic-test`) the C++ -// DefaultApplication reports `1n`, which propagates to the server -// as `ERR_QUIC_APPLICATION_ERROR` exposing `errorCode === 1n`. +// code is the session's "internal error" code: with no application +// installed (the test fixture's raw `quic-test` ALPN) the native +// default is `1n`, which propagates to the server as +// `ERR_QUIC_APPLICATION_ERROR` exposing `errorCode === 1n`. import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -31,7 +31,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { stream.onreset = mustCall((err) => { strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR'); - // The DefaultApplication's internal error code is 0x1n. + // The native (no application) internal error code is 0x1n. strictEqual(err.errorCode, 1n); serverResetSeen.resolve(); }); diff --git a/test/parallel/test-quic-stream-destroy-options-code.mjs b/test/parallel/test-quic-stream-destroy-options-code.mjs index b1fd70018d456b..0ec53c10910f10 100644 --- a/test/parallel/test-quic-stream-destroy-options-code.mjs +++ b/test/parallel/test-quic-stream-destroy-options-code.mjs @@ -5,8 +5,8 @@ // caller-supplied code is sent on RESET_STREAM (and STOP_SENDING for // the readable side) so the peer observes exactly that code. // -// For the test fixture's non-h3 ALPN (`quic-test`), the -// DefaultApplication's `internalErrorCode` is `0x1n`. Without +// For the test fixture's non-h3 ALPN (`quic-test`, a raw session +// with no application), the native `internalErrorCode` is `0x1n`. Without // `options.code`, a plain `Error` would result in the peer seeing // `0x1n`. With `options.code = 0x42n`, the peer must see `0x42n`. diff --git a/test/parallel/test-quic-stream-writer-fail-error-code.mjs b/test/parallel/test-quic-stream-writer-fail-error-code.mjs index ff3c18f69c5d03..23f1ad588dec8b 100644 --- a/test/parallel/test-quic-stream-writer-fail-error-code.mjs +++ b/test/parallel/test-quic-stream-writer-fail-error-code.mjs @@ -4,9 +4,9 @@ // application error code instead of the previously hard-coded 0n. // // Two cases are exercised against the test fixture's non-h3 ALPN -// (`quic-test`), which selects the C++ DefaultApplication and exposes -// `internalErrorCode === 0x1n` (NGTCP2_INTERNAL_ERROR) via session -// state: +// (`quic-test`), a raw session with no application installed, which +// exposes the native `internalErrorCode === 0x1n` +// (NGTCP2_INTERNAL_ERROR) via session state: // // 1. `writer.fail(plainError)` — peer receives RESET_STREAM with the // session's `internalErrorCode` (`0x1`), proving the hard-coded From 3f2fd0dbbc9e885ba24ddb140fb6d0e26a677ed1 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 12 Jun 2026 11:37:39 +0200 Subject: [PATCH 10/15] quic: bring in a related performance optimizations en route --- src/quic/application.h | 3 +++ src/quic/endpoint.cc | 17 ++++++++++--- src/quic/endpoint.h | 5 +++- src/quic/http3.cc | 58 ++++++++++++++++++++++++++++++------------ src/quic/session.cc | 4 +++ src/quic/session.h | 4 +++ 6 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/quic/application.h b/src/quic/application.h index d659a5e48b48c5..23e69724ca6b35 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -146,6 +146,9 @@ class Session::Application : public MemoryRetainer { virtual void ReceiveStreamClose(Stream* stream, QuicError&& error = QuicError()); + // Notify the Application that this stream is about to be removed + virtual void StreamRemoved(stream_id id) {} + // Notifies the Application that the identified stream has been reset. virtual void ReceiveStreamReset(Stream* stream, uint64_t final_size, diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 621a2c129780bf..3bc3575b977acc 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -466,14 +466,24 @@ class Endpoint::UDP::Impl final : public HandleWrap { // UV_UDP_MMSG_CHUNK is set for each packet in a recvmmsg batch. // Processing is the same as for a single-message receive — ngtcp2 // copies what it needs synchronously from the buf slice. + uint64_t now; + if (flags & UV_UDP_MMSG_CHUNK) { + if (impl->recv_batch_ts_ == 0) impl->recv_batch_ts_ = uv_hrtime(); + now = impl->recv_batch_ts_; + } else { + now = uv_hrtime(); + } impl->endpoint_->Receive(reinterpret_cast(buf->base), static_cast(nread), - SocketAddress(addr)); + SocketAddress(addr), + now); } uv_udp_t handle_; Endpoint* endpoint_; + uint64_t recv_batch_ts_ = 0; + friend class UDP; }; @@ -1353,9 +1363,8 @@ void Endpoint::CloseGracefully() { void Endpoint::Receive(const uint8_t* data, size_t len, - const SocketAddress& remote_address) { - const uint64_t now = uv_hrtime(); - + const SocketAddress& remote_address, + uint64_t now) { // Block list filtering — applied before any packet processing to // minimize resource expenditure on blocked sources. if (options_.block_list) { diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index f54e028ead4554..cac12c0956343a 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -435,7 +435,10 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // Ref() causes a listening Endpoint to keep the event loop active. JS_METHOD(Ref); - void Receive(const uint8_t* data, size_t len, const SocketAddress& from); + void Receive(const uint8_t* data, + size_t len, + const SocketAddress& from, + uint64_t now); AliasedStruct stats_; AliasedStruct state_; diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 186be7c85a717d..2905ec6fe9326e 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -514,8 +514,12 @@ class Http3ApplicationImpl final : public Session::Application { flags.fin, flags.early); + uint64_t ts = session().rx_packet_ts(); + if (ts == 0) [[unlikely]] { + ts = uv_hrtime(); + } auto nread = nghttp3_conn_read_stream2( - *this, id, data, datalen, flags.fin ? 1 : 0, uv_hrtime()); + *this, id, data, datalen, flags.fin ? 1 : 0, ts); if (nread < 0) { Debug(&session(), @@ -665,6 +669,11 @@ class Http3ApplicationImpl final : public Session::Application { session().Close(); } + void StreamRemoved(stream_id id) override { + if (conn_) nghttp3_conn_set_stream_user_data(*this, id, nullptr); + header_state_.erase(id); + } + bool SendHeaders(const Stream& stream, HeadersKind kind, const Local& headers, @@ -944,7 +953,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginHeaders(stream_id id) { - auto stream = FindOrCreateStream(conn_.get(), &session(), id); + auto stream = FindOrCreateStream(id); if (!stream) [[unlikely]] return; Debug(&session(), @@ -997,7 +1006,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginTrailers(stream_id id) { - auto stream = FindOrCreateStream(conn_.get(), &session(), id); + auto stream = FindOrCreateStream(id); if (!stream) [[unlikely]] return; Debug(&session(), @@ -1208,13 +1217,19 @@ class Http3ApplicationImpl final : public Session::Application { return app; } - static BaseObjectWeakPtr FindOrCreateStream(nghttp3_conn* conn, - Session* session, - stream_id id) { - if (auto stream = session->FindStream(id)) { + // Persist the Stream* in the stream user data, so we can look it + // up directly without a FindStream map lookup every time. + void BindStreamUserData(stream_id id, Stream* stream) { + if (conn_) nghttp3_conn_set_stream_user_data(*this, id, stream); + } + + BaseObjectWeakPtr FindOrCreateStream(stream_id id) { + if (auto stream = session().FindStream(id)) { + BindStreamUserData(id, stream.get()); return stream; } - if (auto stream = session->CreateStream(id)) { + if (auto stream = session().CreateStream(id)) { + BindStreamUserData(id, stream.get()); return stream; } return {}; @@ -1238,8 +1253,11 @@ class Http3ApplicationImpl final : public Session::Application { auto& app = *ptr; NgHttp3CallbackScope scope(&app.session()); - auto stream = app.session().FindStream(id); - if (!stream) return NGHTTP3_ERR_CALLBACK_FAILURE; + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + stream = app.session().FindStream(id); + if (!stream) return NGHTTP3_ERR_CALLBACK_FAILURE; + } if (stream->is_eos()) { *pflags |= NGHTTP3_DATA_FLAG_EOF; @@ -1320,7 +1338,11 @@ class Http3ApplicationImpl final : public Session::Application { auto ptr = From(conn, conn_user_data); CHECK_NOT_NULL(ptr); auto& app = *ptr; - if (auto stream = app.session().FindStream(id)) { + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + stream = app.session().FindStream(id); + } + if (stream) { stream->Acknowledge(static_cast(datalen)); } return NGTCP2_SUCCESS; @@ -1351,12 +1373,16 @@ class Http3ApplicationImpl final : public Session::Application { if (app.is_control_stream(id)) [[unlikely]] { return NGHTTP3_ERR_CALLBACK_FAILURE; } - auto& session = app.session(); - if (auto stream = FindOrCreateStream(conn, &session, id)) [[likely]] { - stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); - return NGTCP2_SUCCESS; + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + if (auto created = app.FindOrCreateStream(id)) { + created->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); + return NGTCP2_SUCCESS; + } + return NGHTTP3_ERR_CALLBACK_FAILURE; } - return NGHTTP3_ERR_CALLBACK_FAILURE; + stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); + return NGTCP2_SUCCESS; } static int on_deferred_consume(nghttp3_conn* conn, diff --git a/src/quic/session.cc b/src/quic/session.cc index 5aee2b853abf45..16a49e8f27b505 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -3041,7 +3041,9 @@ bool Session::ReadPacket(const uint8_t* data, // receive path caches a timestamp and passes it to all ReadPacket() // calls in the same I/O burst. if (ts == 0) ts = uv_hrtime(); + rx_packet_ts_ = ts; err = ngtcp2_conn_read_pkt(*this, &path, pkt_info, data, len, ts); + rx_packet_ts_ = 0; } if (is_destroyed()) return false; @@ -3538,6 +3540,8 @@ void Session::RemoveStream(stream_id id) { ngtcp2_conn_set_stream_user_data(*this, id, nullptr); + if (impl_->application_) application().StreamRemoved(id); + // Note that removing the stream from the streams map likely releases // the last BaseObjectPtr holding onto the Stream instance, at which // point it will be freed. If there are other BaseObjectPtr instances diff --git a/src/quic/session.h b/src/quic/session.h index 5c9115b8392e98..c9eccd8520387e 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -407,6 +407,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { const PacketInfo& pkt_info = PacketInfo(), uint64_t ts = 0); + uint64_t rx_packet_ts() const { return rx_packet_ts_; } + // Called by BindingData's flush callback to trigger SendPendingData // on this session. Encapsulates the application() access so that // bindingdata.cc doesn't need the full Application type definition. @@ -736,6 +738,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { }; Flags flags_; + uint64_t rx_packet_ts_ = 0; + bool hello_processed_ = false; bool active_ = false; From 6a233949d213f0524811e6bb98111e8a5ca48b47 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 15 Jun 2026 21:45:54 +0200 Subject: [PATCH 11/15] quic: move HTTP/3 event emit logic into Http3Application --- src/quic/application.h | 2 +- src/quic/http3.cc | 91 +++++++++++++++++++++++++++++++++--------- src/quic/session.cc | 60 +++++----------------------- src/quic/session.h | 24 ++++++++--- src/quic/streams.h | 2 +- 5 files changed, 102 insertions(+), 77 deletions(-) diff --git a/src/quic/application.h b/src/quic/application.h index 23e69724ca6b35..a7f586ca7567e1 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -36,7 +36,7 @@ class Session::Application : public MemoryRetainer { // data owned by the matching application's ticket hooks. enum class Type : uint8_t { DEFAULT = 1, // Native opaque byte-match data (no application) - HTTP3 = 2, // Http3Conn typed settings data + HTTP3 = 2, // Http3Application typed settings data }; virtual Type type() const = 0; diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 2905ec6fe9326e..4bd4ff24d4fc0b 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -151,7 +151,7 @@ Http3Settings::operator const nghttp3_settings() const { .enable_connect_protocol = enable_connect_protocol, .h3_datagram = enable_datagrams, // origin_list is nullptr here because it is set directly on the - // nghttp3_settings in Http3ApplicationImpl::InitializeConnection() + // nghttp3_settings in Http3Application::InitializeConnection() // from the SNI configuration. .origin_list = nullptr, .glitch_ratelim_burst = 1000, @@ -323,9 +323,9 @@ using Http3Header = NgHeader; // The Session::Application implementation for HTTP/3, which owns the nghttp3 // connection and all HTTP/3 protocol logic and state. -class Http3ApplicationImpl final : public Session::Application { +class Http3Application final : public Session::Application { public: - Http3ApplicationImpl(Session* session, const Http3Settings& settings) + Http3Application(Session* session, const Http3Settings& settings) : Application(session), allocator_(BindingData::Get(session->env()).nghttp3_allocator()), options_(settings), @@ -340,6 +340,59 @@ class Http3ApplicationImpl final : public Session::Application { session->set_priority_supported(); } + // ========================================================================== + // HTTP/3 events reported up to the session/JS layer. Each routes through + // Session::DeferOrRun, so an event raised before the session is surfaced to + // JS (in 0-RTT first-flight frames) is held and replayed after attach. + + void EmitGoaway(stream_id last_stream_id) { + session().DeferOrRun([this, last_stream_id]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + CallbackScope cb_scope(&s); + Local argv[] = {BigInt::New(s.env()->isolate(), last_stream_id)}; + s.MakeCallback(BindingData::Get(s.env()).session_goaway_callback(), + arraysize(argv), + argv); + }); + } + + void EmitOrigins(std::vector&& origins) { + auto held = std::make_shared>(std::move(origins)); + session().DeferOrRun([this, held]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + if (!s.has_origin_listener()) return; + CallbackScope cb_scope(&s); + auto* isolate = s.env()->isolate(); + LocalVector elements(isolate, held->size()); + for (size_t i = 0; i < held->size(); i++) { + Local str; + if (!ToV8Value(s.env()->context(), (*held)[i]).ToLocal(&str)) + [[unlikely]] { + return; + } + elements[i] = str; + } + Local argv[] = { + Array::New(isolate, elements.data(), elements.size())}; + s.MakeCallback(BindingData::Get(s.env()).session_origin_callback(), + arraysize(argv), + argv); + }); + } + + void EmitApplicationSettings() { + session().DeferOrRun([this]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + CallbackScope cb_scope(&s); + s.MakeCallback(BindingData::Get(s.env()).session_application_callback(), + 0, + nullptr); + }); + } + // ========================================================================== // Session::Application @@ -474,9 +527,9 @@ class Http3ApplicationImpl final : public Session::Application { stream_id emit_id = is_notice ? -1 : goaway_id; if (!is_notice) { - // Final GOAWAY: destroy client-initiated bidi streams with - // IDs > goaway_id. These were not processed by the peer and - // can be retried. Copy the map because Destroy modifies it. + // Final GOAWAY: destroy client-initiated bidi streams with IDs > + // goaway_id. These were not processed by the peer and can be retried. + // Copy the map because Destroy() modifies it. auto streams = session().streams(); for (auto& [id, stream] : streams) { if (session().is_destroyed()) return; @@ -498,7 +551,7 @@ class Http3ApplicationImpl final : public Session::Application { // when the peer sends CONNECTION_CLOSE after all streams finish. // Calling Close(GRACEFUL) would send a GOAWAY back and trigger // BeginShutdown, which can interfere with in-progress streams. - session().EmitGoaway(emit_id); + EmitGoaway(emit_id); } bool ReceiveStreamData(stream_id id, @@ -638,9 +691,9 @@ class Http3ApplicationImpl final : public Session::Application { } int rv = nghttp3_conn_close_stream(*this, stream->id(), code); - // If the call is successful, the Http3ApplicationImpl::OnStreamClose callback will - // be invoked when the stream is ready to be closed. We'll handle - // destroying the actual Stream object there. + // If the call is successful, the Http3Application::OnStreamClose + // callback will be invoked when the stream is ready to be closed. We'll + // handle destroying the actual Stream object there. if (rv == 0) return; if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) { @@ -880,8 +933,8 @@ class Http3ApplicationImpl final : public Session::Application { } SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(Http3ApplicationImpl) - SET_SELF_SIZE(Http3ApplicationImpl) + SET_MEMORY_INFO_NAME(Http3Application) + SET_SELF_SIZE(Http3Application) private: inline operator nghttp3_conn*() const { @@ -1168,8 +1221,8 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application received updated settings: %s", options_); - // Report the negotiated settings up to the JS application layer. - session().EmitApplication(); + // Report the negotiated settings up to the session/JS layer. + EmitApplicationSettings(); } // Inbound header-block accumulation, keyed by stream id. Entries are @@ -1210,9 +1263,9 @@ class Http3ApplicationImpl final : public Session::Application { // ========================================================================== // Static callbacks - static Http3ApplicationImpl* From(nghttp3_conn* conn, void* user_data) { + static Http3Application* From(nghttp3_conn* conn, void* user_data) { DCHECK_NOT_NULL(user_data); - auto app = static_cast(user_data); + auto app = static_cast(user_data); DCHECK_EQ(conn, app->conn_.get()); return app; } @@ -1329,7 +1382,7 @@ class Http3ApplicationImpl final : public Session::Application { void* conn_user_data, void* stream_user_data) { // This callback is invoked by nghttp3_conn_add_ack_offset() (called - // from Http3ApplicationImpl::AcknowledgeStreamData). We must NOT call + // from Http3Application::AcknowledgeStreamData). We must NOT call // AcknowledgeStreamData here — that would re-enter nghttp3 via // nghttp3_conn_add_ack_offset, triggering the NgHttp3CallbackScope // re-entrancy assertion. Instead, directly notify the stream that data @@ -1553,7 +1606,7 @@ class Http3ApplicationImpl final : public Session::Application { static int on_end_origin(nghttp3_conn* conn, void* conn_user_data) { NGHTTP3_CALLBACK_SCOPE(app); if (!app.received_origins_.empty()) { - app.session().EmitOrigins(std::move(app.received_origins_)); + app.EmitOrigins(std::move(app.received_origins_)); app.received_origins_.clear(); } return NGTCP2_SUCCESS; @@ -1598,7 +1651,7 @@ Http3Settings ResolveHttp3Settings(const Session::Options& options) { std::unique_ptr CreateHttp3Application(Session* session) { Debug(session, "Installing HTTP/3 application"); - return std::make_unique( + return std::make_unique( session, ResolveHttp3Settings(std::as_const(*session).config().options)); } diff --git a/src/quic/session.cc b/src/quic/session.cc index 16a49e8f27b505..9629e4afe04ce7 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -3839,6 +3839,15 @@ void Session::ReplayDeferredEmits() { } } +void Session::QueueDeferredEmit(std::function fn) { + impl_->deferred_emits_.emplace_back(std::move(fn)); +} + +bool Session::has_origin_listener() const { + return HasListenerFlag(impl_->state()->listener_flags, + SessionListenerFlags::ORIGIN); +} + void Session::set_priority_supported(bool on) { DCHECK(!is_destroyed()); impl_->state()->priority_supported = on ? 1 : 0; @@ -4347,20 +4356,6 @@ void Session::set_max_datagram_size(uint16_t size) { } } -void Session::EmitGoaway(stream_id last_stream_id) { - if (is_destroyed()) return; - if (!env()->can_call_into_js()) return; - - CallbackScope cb_scope(this); - - Local argv[] = { - BigInt::New(env()->isolate(), last_stream_id), - }; - - MakeCallback( - BindingData::Get(env()).session_goaway_callback(), arraysize(argv), argv); -} - void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { DCHECK(!is_destroyed()); if (!env()->can_call_into_js()) return; @@ -4545,18 +4540,6 @@ void Session::EmitSessionTicket(Store&& ticket) { } } -void Session::EmitApplication() { - if (is_destroyed()) return; - if (!env()->can_call_into_js()) return; - - // A bare notification that the installed application's negotiated options - // have been updated (e.g. an HTTP/3 SETTINGS frame arrived). The consumer - // reads the current values back through the application settings binding; - // the transport layer carries no protocol-specific data here. - CallbackScope cb_scope(this); - MakeCallback( - BindingData::Get(env()).session_application_callback(), 0, nullptr); -} void Session::DestroyAllStreams(const QuicError& error) { DCHECK(!is_destroyed()); @@ -4664,31 +4647,6 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, argv); } -void Session::EmitOrigins(std::vector&& origins) { - if (is_destroyed()) return; - if (!HasListenerFlag(impl_->state()->listener_flags, - SessionListenerFlags::ORIGIN)) - return; - if (!env()->can_call_into_js()) return; - - CallbackScope cb_scope(this); - - auto isolate = env()->isolate(); - - LocalVector elements(env()->isolate(), origins.size()); - for (size_t i = 0; i < origins.size(); i++) { - Local str; - if (!ToV8Value(env()->context(), origins[i]).ToLocal(&str)) [[unlikely]] { - return; - } - elements[i] = str; - } - - Local argv[] = {Array::New(isolate, elements.data(), elements.size())}; - MakeCallback( - BindingData::Get(env()).session_origin_callback(), arraysize(argv), argv); -} - void Session::EmitKeylog(const char* line) { DCHECK(!is_destroyed()); if (!env()->can_call_into_js()) return; diff --git a/src/quic/session.h b/src/quic/session.h index c9eccd8520387e..31efffa7d3177c 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #include "bindingdata.h" #include "cid.h" #include "data.h" @@ -623,6 +625,21 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // Called synchronously right after the new-session emit. void ReplayDeferredEmits(); + // Runs fn now, or queues it for replay if emits are currently being + // deferred (must_defer_emits()) before the session is ready. + template + void DeferOrRun(F&& fn) { + if (must_defer_emits()) { + QueueDeferredEmit(std::forward(fn)); + } else { + fn(); + } + } + + void QueueDeferredEmit(std::function fn); + + bool has_origin_listener() const; + enum class CloseMethod : uint8_t { // Immediate close with a roundtrip through JavaScript, causing all // currently opened streams to be closed. An attempt will be made to @@ -668,17 +685,15 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // JavaScript callouts void EmitClose(const QuicError& error = QuicError()); - void EmitGoaway(stream_id last_stream_id); // Sets the max datagram payload size in the shared state. Used by - // Http3Conn to block datagram sends when the peer's + // Http3Application to block datagram sends when the peer's // SETTINGS_H3_DATAGRAM=0 (RFC 9297 §3). void set_max_datagram_size(uint16_t size); void EmitDatagram(Store&& datagram, DatagramReceivedFlags flag); void EmitDatagramStatus(datagram_id id, DatagramStatus status); void EmitHandshakeComplete(); void EmitKeylog(const char* line); - void EmitOrigins(std::vector&& origins); struct ValidatedPath { std::shared_ptr local; @@ -697,7 +712,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, const uint32_t* sv, size_t nsv); - void EmitApplication(); void DatagramStatus(datagram_id datagramId, DatagramStatus status); void DatagramReceived(const uint8_t* data, size_t datalen, @@ -750,7 +764,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { friend struct NgHttp3CallbackScope; friend class Application; friend class BindingData; - friend class Http3ApplicationImpl; + friend class Http3Application; friend class Endpoint; friend class SessionManager; friend class Stream; diff --git a/src/quic/streams.h b/src/quic/streams.h index 239d02d628f070..31ffc9f9bdf6fc 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -444,7 +444,7 @@ class Stream final : public AsyncWrap, friend struct Impl; friend class PendingStream; - friend class Http3ApplicationImpl; + friend class Http3Application; friend class Session; public: From f4b3a0050a1a7b6dd3ef7efb4fe5fb62587fe444 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 16 Jun 2026 11:21:08 +0200 Subject: [PATCH 12/15] quic: use http3Binding to drop header & priority stream logic from QUIC --- lib/internal/quic/http3.js | 132 +++++- lib/internal/quic/quic.js | 57 --- lib/internal/quic/state.js | 13 - src/quic/application.cc | 7 - src/quic/application.h | 56 +-- src/quic/bindingdata.cc | 2 + src/quic/bindingdata.h | 2 + src/quic/defs.h | 23 - src/quic/http3.cc | 404 ++++++++++++++---- src/quic/http3.h | 15 +- src/quic/quic.cc | 2 + src/quic/session.cc | 41 +- src/quic/session.h | 1 - src/quic/streams.cc | 160 +------ src/quic/streams.h | 26 +- test/parallel/test-quic-h3-no-application.mjs | 107 +++++ test/parallel/test-quic-h3-pending-stream.mjs | 8 +- .../parallel/test-quic-h3-priority-header.mjs | 88 ++++ test/parallel/test-quic-h3-priority.mjs | 2 + ...est-quic-internal-endpoint-stats-state.mjs | 1 - test/parallel/test-quic-stream-priority.mjs | 96 ----- 21 files changed, 692 insertions(+), 551 deletions(-) create mode 100644 test/parallel/test-quic-h3-no-application.mjs create mode 100644 test/parallel/test-quic-h3-priority-header.mjs delete mode 100644 test/parallel/test-quic-stream-priority.mjs diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js index d42adc85fde3a0..1f5e8f37210910 100644 --- a/lib/internal/quic/http3.js +++ b/lib/internal/quic/http3.js @@ -3,6 +3,7 @@ const { ArrayIsArray, ArrayPrototypePush, + ObjectHasOwn, Symbol, SymbolAsyncIterator, } = primordials; @@ -40,6 +41,7 @@ const { const { setHttp3Callbacks, + createHttp3Handle, QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, @@ -65,8 +67,10 @@ const { } = require('internal/quic/diagnostics'); const { + validateBoolean, validateFunction, validateObject, + validateOneOf, } = require('internal/validators'); const { @@ -128,14 +132,29 @@ function parseHeaderPairs(pairs) { * will be sent after these headers. */ +const kGetHttp3Handle = Symbol('kGetHttp3Handle'); +const kSubmitInitialHeaders = Symbol('kSubmitInitialHeaders'); + +function priorityFieldValue(level, incremental) { + const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; + if (urgency === 3 && !incremental) return undefined; + return incremental ? `u=${urgency}, i` : `u=${urgency}`; +} + class Http3Stream { #stream; #session; + #h3handle; #headers = undefined; #onheaders = undefined; #oninfo = undefined; #ontrailers = undefined; #onwanttrailers = undefined; + // Client-side stored priority (the value this side requested). The server + // side reads the peer's requested priority from nghttp3 instead. + #priority = { __proto__: null, level: 'default', incremental: false }; + // Have we submitted an initial header block (request/response) to nghttp3? + #headersSubmitted = false; /** * @param {QuicStream} stream the underlying QUIC stream @@ -148,6 +167,7 @@ class Http3Stream { } this.#stream = stream; this.#session = session; + this.#h3handle = session[kGetHttp3Handle](); const handle = stream[kStreamHandle]; if (handle !== undefined) { handle[kApplicationOwner] = this; @@ -241,9 +261,57 @@ class Http3Stream { // True when the stream's data was received as 0-RTT early data. get early() { return this.#stream.early; } - get priority() { return this.#stream.priority; } + get #isServer() { + return getQuicSessionState(this.#session.session).isServer; + } - setPriority(options) { return this.#stream.setPriority(options); } + get #knownToNgHttp3() { + return this.#isServer || this.#headersSubmitted; + } + + // The stream's priority. On a client this is the value we requested; on a + // server it is the peer's requested priority read from nghttp3. + get priority() { + const stream = this.#stream; + if (stream.destroyed || this.#h3handle === undefined) return null; + if (!this.#isServer) { + return { level: this.#priority.level, + incremental: this.#priority.incremental }; + } + const packed = this.#h3handle.getPriority(stream[kStreamHandle]); + if (packed === undefined) return null; + const urgency = packed >> 1; + const incremental = !!(packed & 1); + const level = urgency < 3 ? 'high' : urgency > 3 ? 'low' : 'default'; + return { level, incremental }; + } + + setPriority(options = kEmptyObject) { + const stream = this.#stream; + if (stream.destroyed) return; + validateObject(options, 'options'); + const { level = 'default', incremental = false } = options; + validateOneOf(level, 'options.level', ['default', 'low', 'high']); + validateBoolean(incremental, 'options.incremental'); + this.#priority = { __proto__: null, level, incremental }; + + // Before the stream is known to nghttp3 (client, pre-submit) the requested + // priority is carried in the initial header block by sendHeaders; + // afterwards a change is signaled with a PRIORITY_UPDATE frame. + if (this.#knownToNgHttp3 && this.#h3handle !== undefined) { + const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; + this.#h3handle.setPriority( + stream[kStreamHandle], (urgency << 1) | (incremental ? 1 : 0)); + } + } + + [kSubmitInitialHeaders](headerString, flags) { + const stream = this.#stream; + if (stream.destroyed || this.#h3handle === undefined) return false; + this.#headersSubmitted = true; + return this.#h3handle.sendHeaders( + stream[kStreamHandle], headerString, flags); + } #updateHeaderInterest() { getQuicStreamState(this.#stream).wantsHeaders = @@ -303,14 +371,29 @@ class Http3Stream { */ sendHeaders(headers, options = kEmptyObject) { const stream = this.#stream; - if (stream.destroyed) return false; + if (stream.destroyed || this.#h3handle === undefined) return false; validateObject(headers, 'headers'); const { terminal = false } = options; + + // A client request carries its requested priority as a priority header + // when set - server responses signal via setPriority instead. + let toSend = headers; + if (!this.#isServer) { + const pri = priorityFieldValue( + this.#priority.level, this.#priority.incremental); + if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) { + toSend = { __proto__: null, ...headers, priority: pri }; + } + } + const headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true /* strictSingleValueFields */); + toSend, assertValidPseudoHeader, true /* strictSingleValueFields */); const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; - return stream[kStreamHandle].sendHeaders( - kHeadersKindInitial, headerString, flags); + // The stream now exists in nghttp3; later priority changes use + // PRIORITY_UPDATE rather than the header block. + this.#headersSubmitted = true; + return this.#h3handle.sendHeaders( + stream[kStreamHandle], headerString, flags); } /** @@ -321,11 +404,12 @@ class Http3Stream { sendInformationalHeaders(headers) { const stream = this.#stream; if (stream.destroyed) return false; + if (this.#h3handle === undefined) return false; validateObject(headers, 'headers'); const headerString = buildNgHeaderString( headers, assertValidPseudoHeader, true); - return stream[kStreamHandle].sendHeaders( - kHeadersKindHints, headerString, kHeadersFlagsNone); + return this.#h3handle.sendInformationalHeaders( + stream[kStreamHandle], headerString); } /** @@ -337,11 +421,12 @@ class Http3Stream { sendTrailers(headers) { const stream = this.#stream; if (stream.destroyed) return false; + if (this.#h3handle === undefined) return false; validateObject(headers, 'headers'); const headerString = buildNgHeaderString(headers, assertValidPseudoHeaderTrailer); - return stream[kStreamHandle].sendHeaders( - kHeadersKindTrailing, headerString, kHeadersFlagsNone); + return this.#h3handle.sendTrailers( + stream[kStreamHandle], headerString); } // Outbound body writer (stream/iter push writer). @@ -366,6 +451,7 @@ class Http3Stream { class Http3Session { #session; + #h3handle; #onstream = undefined; #ongoaway = undefined; #onorigin = undefined; @@ -390,6 +476,11 @@ class Http3Session { // routed back here. const handle = session[kSessionHandle]; if (handle !== undefined) { + if (handle[kApplicationOwner] !== undefined) { + throw new ERR_INVALID_STATE( + 'The QUIC session already has an application attached'); + } + this.#h3handle = createHttp3Handle(handle); handle[kApplicationOwner] = this; } // Claim the session's incoming streams. This takes the single @@ -412,6 +503,9 @@ class Http3Session { if (onsettings !== undefined) this.onsettings = onsettings; } + // Internal: hands the per-session HTTP/3 handle to this session's streams. + [kGetHttp3Handle]() { return this.#h3handle; } + /** * The peer initiated a graceful shutdown of the session (HTTP/3 * GOAWAY). The session stops allowing new streams. @@ -579,14 +673,22 @@ class Http3Session { onwanttrailers, onreset, onerror, + priority, + incremental, ...quicOptions } = options; let headerString; if (headers !== undefined) { + let toSend = headers; + const pri = priorityFieldValue(priority ?? 'default', incremental ?? false); + if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) { + toSend = { __proto__: null, ...headers, priority: pri }; + } headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true /* strictSingleValueFields */); + toSend, assertValidPseudoHeader, true /* strictSingleValueFields */); } + const stream = await this.#session.createBidirectionalStream(quicOptions); const wrapped = new Http3Stream(stream, this, { __proto__: null, @@ -597,6 +699,11 @@ class Http3Session { onreset, onerror, }); + + if (priority !== undefined || incremental !== undefined) { + wrapped.setPriority({ __proto__: null, level: priority, incremental }); + } + // Submit the request headers only after the callbacks above are // attached. Nothing for this stream can hit the wire before the // headers are submitted: the HTTP/3 application only learns about the @@ -606,8 +713,7 @@ class Http3Session { if (headerString !== undefined) { const flags = quicOptions.body === undefined ? kHeadersFlagsTerminal : kHeadersFlagsNone; - if (!stream[kStreamHandle].sendHeaders( - kHeadersKindInitial, headerString, flags)) { + if (!wrapped[kSubmitInitialHeaders](headerString, flags)) { wrapped.destroy(); throw new ERR_QUIC_OPEN_STREAM_FAILED(); } diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 5d2561cc021f79..d3d2654395cbc8 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -281,8 +281,6 @@ const endpointRegistry = new SafeSet(); * FileHandle|AsyncIterable|Iterable|Promise|null} [body] The outbound * body source. See the public docs for `stream.setBody()` for details * on supported types. When omitted, the stream is closed immediately. - * @property {'high'|'default'|'low'} [priority] The priority level of the stream. - * @property {boolean} [incremental] Whether to interleave data with same-priority streams. * @property {number} [highWaterMark] The high water mark for write * backpressure, in bytes. **Default:** `65536`. */ @@ -472,12 +470,6 @@ const endpointRegistry = new SafeSet(); * frames do not themselves carry a reason field over the wire. */ -/** - * @typedef {object} StreamPriority - * @property {'default' | 'low' | 'high'} level The priority level of the stream. - * @property {boolean} incremental Whether to interleave data with same-priority streams. - */ - /** * @typedef {object} QuicSessionPath * @property {SocketAddress} local The local address for this path @@ -2079,45 +2071,6 @@ class QuicStream { this.#handle.resetStream(BigInt(code)); } - /** - * The priority of the stream. If the stream is destroyed or if - * the session does not support priority, `null` will be - * returned. - * @type {StreamPriority | null} - */ - get priority() { - assertIsQuicStream(this); - if (this.destroyed || - !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; - const packed = this.#handle.getPriority(); - const urgency = packed >> 1; - const incremental = !!(packed & 1); - const level = urgency < 3 ? 'high' : urgency > 3 ? 'low' : 'default'; - return { level, incremental }; - } - - /** - * Sets the priority of the stream. - * @param {StreamPriority} [options] - */ - setPriority(options = kEmptyObject) { - assertIsQuicStream(this); - if (this.destroyed) return; - if (!getQuicSessionState(this.#inner.session).isPrioritySupported) { - throw new ERR_INVALID_STATE( - 'The session does not support stream priority'); - } - validateObject(options, 'options'); - const { - level = 'default', - incremental = false, - } = options; - validateOneOf(level, 'options.level', ['default', 'low', 'high']); - validateBoolean(incremental, 'options.incremental'); - const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; - this.#handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); - } - [kFinishClose](error) { const inner = this.#inner; inner.pendingClose ??= PromiseWithResolvers(); @@ -2761,14 +2714,9 @@ class QuicSession { validateObject(options, 'options'); const { body, - priority = 'default', - incremental = false, highWaterMark = kDefaultHighWaterMark, } = options; - validateOneOf(priority, 'options.priority', ['default', 'low', 'high']); - validateBoolean(incremental, 'options.incremental'); - const validatedBody = validateBody(body); const handle = this.#handle.openStream(direction, validatedBody); @@ -2776,11 +2724,6 @@ class QuicSession { throw new ERR_QUIC_OPEN_STREAM_FAILED(); } - if (inner.state.isPrioritySupported) { - const urgency = priority === 'high' ? 0 : priority === 'low' ? 7 : 3; - handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); - } - const stream = new QuicStream( kPrivateConstructor, handle, this, direction, true /* isLocal */); inner.streams.add(stream); diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index b50210d73234e5..11931526eb9e53 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -70,7 +70,6 @@ const { IDX_STATE_SESSION_HANDSHAKE_COMPLETED, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, - IDX_STATE_SESSION_PRIORITY_SUPPORTED, IDX_STATE_SESSION_WRAPPED, IDX_STATE_SESSION_IS_SERVER, IDX_STATE_SESSION_HAS_APPLICATION, @@ -116,7 +115,6 @@ assert(IDX_STATE_SESSION_STATELESS_RESET !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined); assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined); -assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); assert(IDX_STATE_SESSION_HAS_APPLICATION !== undefined); assert(IDX_STATE_SESSION_IS_SERVER !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); @@ -465,13 +463,6 @@ class QuicSessionState { return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_STREAM_OPEN_ALLOWED) !== 0; } - /** @type {boolean} */ - get isPrioritySupported() { - const handle = this.#handle; - if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_PRIORITY_SUPPORTED) !== 0; - } - /** * Whether a protocol application (vs the native raw-stream path) is * installed on the session. @@ -584,7 +575,6 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, hasApplication, isWrapped, isServer, @@ -609,7 +599,6 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, hasApplication, isWrapped, isServer, @@ -650,7 +639,6 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, hasApplication, isWrapped, isServer, @@ -675,7 +663,6 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, hasApplication, isWrapped, isServer, diff --git a/src/quic/application.cc b/src/quic/application.cc index 2d8a3f3ec7f811..a41d41f58d2c70 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -73,13 +73,6 @@ bool Session::Application::AcknowledgeStreamData(stream_id id, size_t datalen) { return true; } -void Session::Application::CollectSessionTicketAppData( - SessionTicket::AppData* app_data) const { - // By default, write just the application type byte. - uint8_t buf[1] = {static_cast(type())}; - app_data->Set(uv_buf_init(reinterpret_cast(buf), 1)); -} - void Session::Application::ReceiveStreamClose(Stream* stream, QuicError&& error) { DCHECK_NOT_NULL(stream); diff --git a/src/quic/application.h b/src/quic/application.h index a7f586ca7567e1..4a6e8215f20103 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -28,18 +28,6 @@ class Session::Application : public MemoryRetainer { explicit Application(Session* session); DISALLOW_COPY_AND_MOVE(Application) - // The session-ticket app-data type byte: the leading byte of the - // application data embedded in session tickets, identifying how the - // remainder of the data is interpreted. DEFAULT tags the native opaque - // byte-match data used when no application is installed (the - // `appTicketData` option); application-typed values (e.g. HTTP3) tag - // data owned by the matching application's ticket hooks. - enum class Type : uint8_t { - DEFAULT = 1, // Native opaque byte-match data (no application) - HTTP3 = 2, // Http3Application typed settings data - }; - virtual Type type() const = 0; - virtual bool Start(); // Returns true if Start() has been called successfully. @@ -121,12 +109,11 @@ class Session::Application : public MemoryRetainer { // By default do nothing. } - // Different Applications may wish to set some application data in the - // session ticket (e.g. http/3 would set server settings in the application - // data). The first byte written MUST be the Application::Type enum value. - // By default, writes just the type byte. + // Sets application data for the session ticket (e.g. http/3 settings). + // The format is private to the application: we route the data back to it by + // name at resumption via its registered ticket hook. virtual void CollectSessionTicketAppData( - SessionTicket::AppData* app_data) const; + SessionTicket::AppData* app_data) const = 0; // Called at install time to apply session ticket app data previously // parsed (and validated) by this application's registered ticket hook @@ -158,21 +145,6 @@ class Session::Application : public MemoryRetainer { virtual void ReceiveStreamStopSending(Stream* stream, QuicError&& error = QuicError()); - // Submits an outbound block of headers for the given stream. Not all - // Application types will support headers, in which case this function - // should return false. - virtual bool SendHeaders(const Stream& stream, - HeadersKind kind, - const v8::Local& headers, - HeadersFlags flags = HeadersFlags::NONE) { - return false; - } - - // Returns true if the application protocol supports sending and - // receiving headers on streams (e.g. HTTP/3). Applications that - // do not support headers should return false (the default). - virtual bool SupportsHeaders() const { return false; } - // Initiates application-level graceful shutdown signaling (e.g., // HTTP/3 GOAWAY). Called when Session::Close(GRACEFUL) is invoked. virtual void BeginShutdown() {} @@ -182,26 +154,6 @@ class Session::Application : public MemoryRetainer { // sends the final GOAWAY with the actual last accepted stream ID. virtual void CompleteShutdown() {} - // Set the priority level of the stream if supported by the application. Not - // all applications support priorities, in which case this function is a - // non-op. - virtual void SetStreamPriority( - const Stream& stream, - StreamPriority priority = StreamPriority::DEFAULT, - StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL) {} - - struct StreamPriorityResult { - StreamPriority priority; - StreamPriorityFlags flags; - }; - - // Get the priority level of the stream if supported by the application. Not - // all applications support priorities, in which case this function returns - // the default stream priority. - virtual StreamPriorityResult GetStreamPriority(const Stream& stream) { - return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; - } - virtual int GetStreamData(StreamData* data) = 0; virtual bool StreamCommit(StreamData* data, size_t datalen) = 0; diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index 532a4836ea4799..d411e1eb7587b9 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -290,6 +290,7 @@ void BindingData::InitPerContext(Realm* realm, Local target) { nghttp3_set_debug_vprintf_callback(nghttp3_debug_log); SetMethod(realm->context(), target, "setCallbacks", SetCallbacks); SetMethod(realm->context(), target, "setHttp3Callbacks", SetHttp3Callbacks); + SetMethod(realm->context(), target, "createHttp3Handle", CreateHttp3Handle); Realm::GetCurrent(realm->context())->AddBindingData(target); } @@ -298,6 +299,7 @@ void BindingData::RegisterExternalReferences( registry->Register(IllegalConstructor); registry->Register(SetCallbacks); registry->Register(SetHttp3Callbacks); + RegisterHttp3ExternalReferences(registry); } BindingData::BindingData(Realm* realm, Local object) diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 38ea00c1e77227..fb70e602cff013 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -30,6 +30,7 @@ class SessionManager; // The FunctionTemplates the BindingData will store for us. #define QUIC_CONSTRUCTORS(V) \ V(endpoint) \ + V(http3binding) \ V(session) \ V(stream) \ V(udp) @@ -108,6 +109,7 @@ class SessionManager; V(failure, "failure") \ V(groups, "groups") \ V(handshake_timeout, "handshakeTimeout") \ + V(http3binding, "Http3Binding") \ V(initial_rtt, "initialRtt") \ V(keep_alive_timeout, "keepAlive") \ V(initial_max_data, "initialMaxData") \ diff --git a/src/quic/defs.h b/src/quic/defs.h index 17a25017160002..9a76c34ce45c07 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -295,28 +294,6 @@ enum class Direction : uint8_t { UNIDIRECTIONAL, }; -enum class HeadersKind : uint8_t { - HINTS, - INITIAL, - TRAILING, -}; - -enum class HeadersFlags : uint8_t { - NONE, - TERMINAL, -}; - -enum class StreamPriority : uint8_t { - DEFAULT = NGHTTP3_DEFAULT_URGENCY, - LOW = NGHTTP3_URGENCY_LOW, - HIGH = NGHTTP3_URGENCY_HIGH, -}; - -enum class StreamPriorityFlags : uint8_t { - NON_INCREMENTAL, - INCREMENTAL, -}; - enum class PathValidationResult : uint8_t { SUCCESS = NGTCP2_PATH_VALIDATION_RESULT_SUCCESS, FAILURE = NGTCP2_PATH_VALIDATION_RESULT_FAILURE, diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 4bd4ff24d4fc0b..188f6844da1ad4 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -9,10 +9,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include "application.h" @@ -28,6 +30,9 @@ using v8::Array; using v8::BigInt; using v8::Boolean; using v8::DictionaryTemplate; +using v8::FunctionCallbackInfo; +using v8::Global; +using v8::HandleScope; using v8::Integer; using v8::Just; using v8::Local; @@ -36,15 +41,38 @@ using v8::Maybe; using v8::MaybeLocal; using v8::Nothing; using v8::Object; +using v8::Uint32; using v8::Value; namespace quic { +enum class HeadersKind : uint8_t { + HINTS, + INITIAL, + TRAILING, +}; + +enum class HeadersFlags : uint8_t { + NONE, + TERMINAL, +}; + +enum class StreamPriority : uint8_t { + DEFAULT = NGHTTP3_DEFAULT_URGENCY, + LOW = NGHTTP3_URGENCY_LOW, + HIGH = NGHTTP3_URGENCY_HIGH, +}; + +enum class StreamPriorityFlags : uint8_t { + NON_INCREMENTAL, + INCREMENTAL, +}; + namespace { constexpr uint8_t kSessionTicketAppDataVersion = 1; -// Layout: [type(1)][version(1)][crc(4)][payload(34)] = 40 bytes -constexpr size_t kSessionTicketAppDataSize = 40; -constexpr size_t kSessionTicketAppDataHeaderSize = 6; // type + version + crc +// Layout: [version(1)][crc(4)][payload(34)] = 39 bytes. +constexpr size_t kSessionTicketAppDataSize = 39; +constexpr size_t kSessionTicketAppDataHeaderSize = 5; // version + crc constexpr size_t kSessionTicketAppDataPayloadSize = kSessionTicketAppDataSize - kSessionTicketAppDataHeaderSize; @@ -337,7 +365,6 @@ class Http3Application final : public Session::Application { BuildOriginPayload(); } conn_ = InitializeConnection(); - session->set_priority_supported(); } // ========================================================================== @@ -396,8 +423,6 @@ class Http3Application final : public Session::Application { // ========================================================================== // Session::Application - Type type() const override { return Type::HTTP3; } - error_code GetNoErrorCode() const override { return NGHTTP3_H3_NO_ERROR; } // HTTP/3 defines H3_INTERNAL_ERROR (0x102) for non-specific failures @@ -437,8 +462,6 @@ class Http3Application final : public Session::Application { return true; } - bool SupportsHeaders() const override { return true; } - bool is_started() const override { return started_; } bool Start() override { @@ -641,8 +664,7 @@ class Http3Application final : public Session::Application { void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const override { uint8_t buf[kSessionTicketAppDataSize]; - buf[0] = static_cast(Type::HTTP3); - buf[1] = kSessionTicketAppDataVersion; + buf[0] = kSessionTicketAppDataVersion; uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; WriteBE64(payload, options_.max_field_section_size); @@ -654,7 +676,7 @@ class Http3Application final : public Session::Application { uLong crc = crc32(0L, Z_NULL, 0); crc = crc32(crc, payload, kSessionTicketAppDataPayloadSize); - WriteBE32(buf + 2, static_cast(crc)); + WriteBE32(buf + 1, static_cast(crc)); app_data->Set( uv_buf_init(reinterpret_cast(buf), kSessionTicketAppDataSize)); @@ -727,81 +749,66 @@ class Http3Application final : public Session::Application { header_state_.erase(id); } - bool SendHeaders(const Stream& stream, - HeadersKind kind, - const Local& headers, - HeadersFlags flags) override { + struct StreamPriorityResult { + StreamPriority priority; + StreamPriorityFlags flags; + }; + + bool SubmitHeaders(const Stream& stream, + const Local& headers, + HeadersFlags flags) { Session::SendPendingDataScope send_scope(&session()); Http3Headers nva(env(), headers); + static constexpr nghttp3_data_reader reader = {on_read_data_callback}; + const nghttp3_data_reader* reader_ptr = + flags != HeadersFlags::TERMINAL ? &reader : nullptr; - switch (kind) { - case HeadersKind::HINTS: { - if (!session().is_server()) { - // Client side cannot send hints - return false; - } - Debug(&session(), - "Submitting %" PRIu64 " early hints for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_info( - *this, stream.id(), nva.data(), nva.length()) == 0; - break; - } - case HeadersKind::INITIAL: { - static constexpr nghttp3_data_reader reader = {on_read_data_callback}; - const nghttp3_data_reader* reader_ptr = nullptr; - - // If the terminal flag is set, that means that we know we're only - // sending headers and no body and the stream writable side should be - // closed immediately because there is no nghttp3_data_reader provided. - if (flags != HeadersFlags::TERMINAL) { - reader_ptr = &reader; - } - - if (session().is_server()) { - // If this is a server, we're submitting a response... - Debug(&session(), - "Submitting %" PRIu64 " response headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_response(*this, - stream.id(), - nva.data(), - nva.length(), - reader_ptr) == 0; - } else { - // Otherwise we're submitting a request... - Debug(&session(), - "Submitting %" PRIu64 " request headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_request(*this, - stream.id(), - nva.data(), - nva.length(), - reader_ptr, - const_cast(&stream)) == 0; - } - break; - } - case HeadersKind::TRAILING: { - Debug(&session(), - "Submitting %" PRIu64 " trailing headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_trailers( - *this, stream.id(), nva.data(), nva.length()) == 0; - break; - } + if (session().is_server()) { + Debug(&session(), + "Submitting %" PRIu64 " response headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_response( + *this, stream.id(), nva.data(), nva.length(), reader_ptr) == 0; } + Debug(&session(), + "Submitting %" PRIu64 " request headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_request(*this, + stream.id(), + nva.data(), + nva.length(), + reader_ptr, + const_cast(&stream)) == 0; + } + + bool SubmitInfo(const Stream& stream, const Local& headers) { + if (!session().is_server()) return false; + Session::SendPendingDataScope send_scope(&session()); + Http3Headers nva(env(), headers); + Debug(&session(), + "Submitting %" PRIu64 " early hints for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_info( + *this, stream.id(), nva.data(), nva.length()) == 0; + } - return false; + bool SubmitTrailers(const Stream& stream, const Local& headers) { + Session::SendPendingDataScope send_scope(&session()); + Http3Headers nva(env(), headers); + Debug(&session(), + "Submitting %" PRIu64 " trailing headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_trailers( + *this, stream.id(), nva.data(), nva.length()) == 0; } void SetStreamPriority(const Stream& stream, StreamPriority priority, - StreamPriorityFlags flags) override { + StreamPriorityFlags flags) { nghttp3_pri pri; pri.inc = (flags == StreamPriorityFlags::INCREMENTAL) ? 1 : 0; switch (priority) { @@ -827,14 +834,13 @@ class Http3Application final : public Session::Application { } } - StreamPriorityResult GetStreamPriority(const Stream& stream) override { + StreamPriorityResult GetStreamPriority(const Stream& stream) { // nghttp3_conn_get_stream_priority is only available on the server // side, where it reflects the peer's requested priority (e.g., from - // PRIORITY_UPDATE frames). Client-side priority is tracked by the - // Stream itself and returned directly from GetPriority in streams.cc. + // PRIORITY_UPDATE frames). The client tracks its own requested priority + // in node:http3, and doesn't use this. if (!session().is_server()) { - auto& stored = stream.stored_priority(); - return {stored.priority, stored.flags}; + return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; } nghttp3_pri pri; if (nghttp3_conn_get_stream_priority(*this, &pri, stream.id()) == 0) { @@ -1638,6 +1644,234 @@ class Http3Application final : public Session::Application { on_receive_settings}; }; +// The per-session JS-facing handle for HTTP/3 stream operations +// (kHttp3Handle). One per session, with a weak reference to the Session, +// operating on the Application reached from the passed stream handle. +class Http3Binding final : public BaseObject { + public: + static BaseObjectPtr Create(Session* session); + + Http3Binding(Environment* env, Local object) + : BaseObject(env, object) { + MakeWeak(); + } + + JS_CONSTRUCTOR(Http3Binding); + + JS_METHOD(SendHeaders); + JS_METHOD(SendInformationalHeaders); + JS_METHOD(SendTrailers); + JS_METHOD(SetPriority); + JS_METHOD(GetPriority); + + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(Http3Binding) + SET_SELF_SIZE(Http3Binding) +}; + +JS_CONSTRUCTOR_IMPL(Http3Binding, http3binding_constructor_template, { + JS_ILLEGAL_CONSTRUCTOR(); + JS_CLASS(http3binding); + SetProtoMethod(env->isolate(), tmpl, "sendHeaders", SendHeaders); + SetProtoMethod(env->isolate(), + tmpl, + "sendInformationalHeaders", + SendInformationalHeaders); + SetProtoMethod(env->isolate(), tmpl, "sendTrailers", SendTrailers); + SetProtoMethod(env->isolate(), tmpl, "setPriority", SetPriority); + SetProtoMethodNoSideEffect(env->isolate(), tmpl, "getPriority", GetPriority); +}) + +BaseObjectPtr Http3Binding::Create(Session* session) { + JS_NEW_INSTANCE_OR_RETURN(session->env(), obj, BaseObjectPtr()); + return MakeBaseObject(session->env(), obj); +} + +void Http3Binding::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(SendHeaders); + registry->Register(SendInformationalHeaders); + registry->Register(SendTrailers); + registry->Register(SetPriority); + registry->Register(GetPriority); +} + +// The binding is only ever attached to an HTTP/3 session, so the session's +// installed application is always an Http3Application. +inline Http3Application& Http3App(Session& session) { + return static_cast(session.application()); +} + +JS_METHOD_IMPL(Http3Binding::SendHeaders) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + CHECK(args[2]->IsUint32()); // flags + + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return args.GetReturnValue().Set(false); + + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + + Local headers = args[1].As(); + auto flags = static_cast(args[2].As()->Value()); + + // A pending stream has no id yet; defer the submission until the transport + // opens it (the priority header, if any, rides along in this header block). + if (stream->is_pending()) { + auto held = std::make_shared>(binding->env()->isolate(), + headers); + stream->RunWhenOpen([stream, flags, held]() { + Session& session = stream->session(); + if (!session.has_application()) return; + Http3Application& app = Http3App(session); + Environment* env = stream->env(); + HandleScope scope(env->isolate()); + if (!app.SubmitHeaders(*stream, held->Get(env->isolate()), flags)) { + stream->Destroy(QuicError::ForApplication(app.GetInternalErrorCode())); + } + }); + return args.GetReturnValue().Set(true); + } + + args.GetReturnValue().Set( + Http3App(session).SubmitHeaders(*stream, headers, flags)); +} + +// Informational/trailing headers are only sent on open streams so they need +// no pending-stream deferral. +JS_METHOD_IMPL(Http3Binding::SendInformationalHeaders) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr || stream->is_pending()) + return args.GetReturnValue().Set(false); + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + args.GetReturnValue().Set( + Http3App(session).SubmitInfo(*stream, args[1].As())); +} + +JS_METHOD_IMPL(Http3Binding::SendTrailers) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr || stream->is_pending()) + return args.GetReturnValue().Set(false); + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + args.GetReturnValue().Set( + Http3App(session).SubmitTrailers(*stream, args[1].As())); +} + +JS_METHOD_IMPL(Http3Binding::SetPriority) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsUint32()); // packed: (urgency << 1) | incremental + + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return; + Session& session = stream->session(); + if (!session.has_application()) return; + + uint32_t packed = args[1].As()->Value(); + auto priority = static_cast(packed >> 1); + StreamPriorityFlags flags = (packed & 1) + ? StreamPriorityFlags::INCREMENTAL + : StreamPriorityFlags::NON_INCREMENTAL; + + // A PRIORITY_UPDATE needs the stream to exist in nghttp3, which only happens + // once the deferred header submission runs; defer until the stream opens. + if (stream->is_pending()) { + stream->RunWhenOpen([stream, priority, flags]() { + if (stream->session().has_application()) { + Http3App(stream->session()).SetStreamPriority(*stream, priority, flags); + } + }); + return; + } + Http3App(session).SetStreamPriority(*stream, priority, flags); +} + +JS_METHOD_IMPL(Http3Binding::GetPriority) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return; + Session& session = stream->session(); + if (!session.has_application() || stream->is_pending()) return; + + auto result = Http3App(session).GetStreamPriority(*stream); + uint32_t packed = + (static_cast(result.priority) << 1) | + (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); + args.GetReturnValue().Set(packed); +} + +namespace { +std::unique_ptr CreateHttp3Application(Session* session); +} // namespace + +void CreateHttp3Handle(const FunctionCallbackInfo& args) { + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args[0]); + + if (!session->has_application()) { + if (session->is_active()) { + THROW_ERR_INVALID_STATE( + session->env(), + "An application can only be attached to a QUIC session before it " + "becomes active (begins emitting events)"); + return; + } + session->SetApplication(CreateHttp3Application(session)); + + if (session->is_server() && !session->application().Start()) { + // Start() failed (e.g. the peer's initial_max_streams_uni is < 3), so + // the application cannot run HTTP/3. + THROW_ERR_INVALID_STATE( + session->env(), + "The HTTP/3 application could not be started"); + return; + } + } + + BaseObjectPtr handle = Http3Binding::Create(session); + if (handle) args.GetReturnValue().Set(handle->object()); +} + +void RegisterHttp3ExternalReferences(ExternalReferenceRegistry* registry) { + registry->Register(CreateHttp3Handle); + Http3Binding::RegisterExternalReferences(registry); +} + +void InitHttp3PerContext(Local target) { + // The HTTP/3 header kind/flags values consumed by node:http3 when calling + // the kHttp3Handle methods. These are http3-owned constants exposed on the + // quic binding object. + constexpr int QUIC_STREAM_HEADERS_KIND_HINTS = + static_cast(HeadersKind::HINTS); + constexpr int QUIC_STREAM_HEADERS_KIND_INITIAL = + static_cast(HeadersKind::INITIAL); + constexpr int QUIC_STREAM_HEADERS_KIND_TRAILING = + static_cast(HeadersKind::TRAILING); + constexpr int QUIC_STREAM_HEADERS_FLAGS_NONE = + static_cast(HeadersFlags::NONE); + constexpr int QUIC_STREAM_HEADERS_FLAGS_TERMINAL = + static_cast(HeadersFlags::TERMINAL); + + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_HINTS); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_INITIAL); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_TRAILING); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_NONE); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_TERMINAL); +} + namespace { // Resolves the effective HTTP/3 settings for a session's options: the // consumer-supplied settings (or defaults). SETTINGS_H3_DATAGRAM stays @@ -1676,14 +1910,12 @@ PendingTicketAppData ParseHttp3Ticket(const uv_buf_t& data, const uint8_t* buf = reinterpret_cast(data.base); - // buf[0] is the type byte, buf[1] is the version. - if (buf[0] != static_cast(Session::Application::Type::HTTP3) || - buf[1] != kSessionTicketAppDataVersion) { + if (buf[0] != kSessionTicketAppDataVersion) { return nullptr; } const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; - uint32_t stored_crc = ReadBE32(buf + 2); + uint32_t stored_crc = ReadBE32(buf + 1); uLong computed_crc = crc32(0L, Z_NULL, 0); computed_crc = crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); if (stored_crc != static_cast(computed_crc)) return nullptr; diff --git a/src/quic/http3.h b/src/quic/http3.h index 1476167e5a8680..94e0f50c46b29c 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -2,7 +2,11 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -namespace node::quic { +#include + +namespace node { +class ExternalReferenceRegistry; +namespace quic { // Registers the HTTP/3 application factory (creation, settings parsing, // and session-ticket hooks) under the name "http3". Called once at @@ -10,6 +14,13 @@ namespace node::quic { // its options request that name explicitly (set by node:http3). void RegisterHttp3Application(); -} // namespace node::quic +void CreateHttp3Handle(const v8::FunctionCallbackInfo& args); + +void RegisterHttp3ExternalReferences(ExternalReferenceRegistry* registry); + +void InitHttp3PerContext(v8::Local target); + +} // namespace quic +} // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/quic.cc b/src/quic/quic.cc index 36e781f1d700b7..f8e814ca006b02 100644 --- a/src/quic/quic.cc +++ b/src/quic/quic.cc @@ -10,6 +10,7 @@ #include #include "bindingdata.h" #include "endpoint.h" +#include "http3.h" #include "node_external_reference.h" #include @@ -49,6 +50,7 @@ void CreatePerContextProperties(Local target, Endpoint::InitPerContext(realm, target); Session::InitPerContext(realm, target); Stream::InitPerContext(realm, target); + InitHttp3PerContext(target); } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { diff --git a/src/quic/session.cc b/src/quic/session.cc index 9629e4afe04ce7..317a642e90fbb9 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -132,7 +132,6 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(HANDSHAKE_COMPLETED, handshake_completed, uint8_t) \ V(HANDSHAKE_CONFIRMED, handshake_confirmed, uint8_t) \ V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t) \ - V(PRIORITY_SUPPORTED, priority_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ V(IS_SERVER, is_server, uint8_t) \ V(HAS_APPLICATION, has_application, uint8_t) \ @@ -3635,24 +3634,12 @@ void Session::CollectSessionTicketAppData( if (impl_->application_) { return application().CollectSessionTicketAppData(app_data); } - // Native path: embed the configured opaque app_ticket_data behind the - // DEFAULT type byte. With no data configured write just the type byte. - static constexpr uint8_t kTypeByte = - static_cast(Application::Type::DEFAULT); + // Native path: embed the configured opaque app_ticket_data directly so it + // can be checked at resumption. const auto& atd = config().options.app_ticket_data; - if (!atd.has_value() || atd->length() == 0) { - uint8_t buf[1] = {kTypeByte}; - app_data->Set(uv_buf_init(reinterpret_cast(buf), 1)); - return; + if (atd.has_value() && atd->length() != 0) { + app_data->Set(*atd); } - // Layout: [type byte][opaque app data]. - uv_buf_t bytes = *atd; - std::vector buf; - buf.reserve(1 + bytes.len); - buf.push_back(kTypeByte); - const auto* p = reinterpret_cast(bytes.base); - buf.insert(buf.end(), p, p + bytes.len); - app_data->Set(uv_buf_init(reinterpret_cast(buf.data()), buf.size())); } SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( @@ -3695,21 +3682,14 @@ SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( impl_->pending_ticket_data_ = std::move(parsed); return accept(); } - // Native path (no application): only DEFAULT-typed opaque app data is - // usable - byte-match the stored bytes against the server's currently - // configured `app_ticket_data`. Application-typed tickets cannot be - // validated here and are rejected, falling back cleanly to a full - // 1-RTT handshake. + // Native path (no application): byte-match the opaque app data against the + // server's currently configured `app_ticket_data`, or reject back to 1RTT. const auto* p = reinterpret_cast(data->base); - if (p[0] != static_cast(Application::Type::DEFAULT)) { - Debug(this, "Session ticket app data has an unusable type byte"); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } const auto& atd = config().options.app_ticket_data; uv_buf_t cur = atd.has_value() ? static_cast(*atd) : uv_buf_init(nullptr, 0); - if (data->len - 1 != cur.len || - (cur.len > 0 && memcmp(p + 1, cur.base, cur.len) != 0)) { + if (data->len != cur.len || + (cur.len > 0 && memcmp(p, cur.base, cur.len) != 0)) { Debug(this, "Session ticket app data does not match configured value"); return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } @@ -3848,11 +3828,6 @@ bool Session::has_origin_listener() const { SessionListenerFlags::ORIGIN); } -void Session::set_priority_supported(bool on) { - DCHECK(!is_destroyed()); - impl_->state()->priority_supported = on ? 1 : 0; -} - void Session::ExtendStreamOffset(stream_id id, size_t amount) { DCHECK(!is_destroyed()); Debug(this, "Extending stream %" PRIi64 " offset by %zu bytes", id, amount); diff --git a/src/quic/session.h b/src/quic/session.h index 31efffa7d3177c..dd320b5b76e69c 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -531,7 +531,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool is_destroyed_or_closing() const; size_t max_packet_size() const; - void set_priority_supported(bool on = true); // Open a new locally-initialized stream with the specified directionality. // If the session is not yet in a state where the stream can be openen -- diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 00cec23614a29c..85d8dfa0681d13 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -26,7 +26,6 @@ using v8::BackingStore; using v8::BackingStoreInitializationMode; using v8::BigInt; using v8::FunctionCallbackInfo; -using v8::Global; using v8::HandleScope; using v8::Integer; using v8::Just; @@ -94,11 +93,8 @@ namespace quic { #define STREAM_JS_METHODS(V) \ V(AttachSource, attachSource, false) \ V(Destroy, destroy, false) \ - V(SendHeaders, sendHeaders, false) \ V(StopSending, stopSending, false) \ V(ResetStream, resetStream, false) \ - V(SetPriority, setPriority, false) \ - V(GetPriority, getPriority, true) \ V(GetReader, getReader, false) \ V(InitStreamingSource, initStreamingSource, false) \ V(Write, write, false) \ @@ -223,17 +219,6 @@ void PendingStream::reject(QuicError error) { stream_->Destroy(error); } -struct Stream::PendingHeaders { - HeadersKind kind; - Global headers; - HeadersFlags flags; - PendingHeaders(HeadersKind kind_, Global headers_, HeadersFlags flags_) - : kind(kind_), headers(std::move(headers_)), flags(flags_) {} - DISALLOW_COPY_AND_MOVE(PendingHeaders) -}; - -// ============================================================================ - struct Stream::State { #define V(_, name, type) type name; STREAM_STATE(V) @@ -441,39 +426,6 @@ struct Stream::Impl { } } - // Sends a block of headers to the peer. If the stream is not yet open, - // the headers will be queued and sent immediately when the stream is - // opened. Returns false if the application does not support headers. - JS_METHOD(SendHeaders) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - CHECK(args[0]->IsUint32()); // Kind - CHECK(args[1]->IsArray()); // Headers - CHECK(args[2]->IsUint32()); // Flags - - HeadersKind kind = FromV8Value(args[0]); - Local headers = args[1].As(); - HeadersFlags flags = FromV8Value(args[2]); - - // Headers require an installed application that supports them - // (e.g. HTTP/3); with no application there is nowhere to send them. - if (!stream->session().has_application() || - !stream->session().application().SupportsHeaders()) { - return args.GetReturnValue().Set(false); - } - - // If the stream is pending, the headers will be queued until the - // stream is opened, at which time the queued header block will be - // immediately sent when the stream is opened. - if (stream->is_pending()) { - stream->EnqueuePendingHeaders(kind, headers, flags); - return args.GetReturnValue().Set(true); - } - - args.GetReturnValue().Set(stream->session().application().SendHeaders( - *stream, kind, headers, flags)); - } - // Tells the peer to stop sending data for this stream. This has the effect // of shutting down the readable side of the stream for this peer. Any data // that has already been received is still readable. @@ -529,57 +481,6 @@ struct Stream::Impl { } } - JS_METHOD(SetPriority) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - CHECK(args[0]->IsUint32()); // Packed: (urgency << 1) | incremental - - uint32_t packed = args[0].As()->Value(); - StreamPriority priority = static_cast(packed >> 1); - StreamPriorityFlags flags = (packed & 1) - ? StreamPriorityFlags::INCREMENTAL - : StreamPriorityFlags::NON_INCREMENTAL; - - // Always update the stored priority on the stream. - stream->priority_ = StoredPriority{ - .priority = priority, - .flags = flags, - .pending = stream->is_pending(), - }; - - // Priority signaling is application-defined (e.g. HTTP/3 RFC 9218); - // with no application installed only the stored value is updated. - if (!stream->is_pending() && stream->session().has_application()) { - stream->session().application().SetStreamPriority( - *stream, priority, flags); - } - } - - JS_METHOD(GetPriority) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - - // On the client side, priority is always read from the stream's - // stored value since the client is the one setting it. The same - // applies when no application is installed (nothing tracks peer - // priority signals). On the server side with an application, we - // delegate to the application which can read the peer's requested - // priority (e.g., from PRIORITY_UPDATE frames in HTTP/3). - if (!stream->session().is_server() || - !stream->session().has_application()) { - auto& pri = stream->priority_; - uint32_t packed = (static_cast(pri.priority) << 1) | - (pri.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); - return args.GetReturnValue().Set(packed); - } - - auto result = stream->session().application().GetStreamPriority(*stream); - uint32_t packed = - (static_cast(result.priority) << 1) | - (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); - args.GetReturnValue().Set(packed); - } - // Returns a Blob::Reader that can be used to read data that has been // received on the stream. JS_METHOD(GetReader) { @@ -1081,25 +982,6 @@ void Stream::InitPerContext(Realm* realm, Local target) { #undef V NODE_DEFINE_CONSTANT(target, IDX_STATS_STREAM_COUNT); - - constexpr int QUIC_STREAM_HEADERS_KIND_HINTS = - static_cast(HeadersKind::HINTS); - constexpr int QUIC_STREAM_HEADERS_KIND_INITIAL = - static_cast(HeadersKind::INITIAL); - constexpr int QUIC_STREAM_HEADERS_KIND_TRAILING = - static_cast(HeadersKind::TRAILING); - - constexpr int QUIC_STREAM_HEADERS_FLAGS_NONE = - static_cast(HeadersFlags::NONE); - constexpr int QUIC_STREAM_HEADERS_FLAGS_TERMINAL = - static_cast(HeadersFlags::TERMINAL); - - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_HINTS); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_INITIAL); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_TRAILING); - - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_NONE); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_TERMINAL); } Stream* Stream::From(void* stream_user_data) { @@ -1264,29 +1146,13 @@ void Stream::NotifyStreamOpened(stream_id id) { CHECK_EQ(ngtcp2_conn_set_stream_user_data(this->session(), id, this), 0); maybe_pending_stream_.reset(); - if (priority_.pending) { - if (session().has_application()) { - session().application().SetStreamPriority( - *this, priority_.priority, priority_.flags); - } - priority_.pending = false; - } - if (!pending_headers_queue_.empty()) { - if (!session().has_application() || - !session().application().SupportsHeaders()) { - // Headers were enqueued but the session has no application that - // supports them. This is a fatal mismatch. - Destroy(QuicError::ForApplication(session().internal_error_code())); - return; - } - decltype(pending_headers_queue_) queue; - pending_headers_queue_.swap(queue); - for (auto& headers : queue) { - session().application().SendHeaders( - *this, - headers->kind, - headers->headers.Get(env()->isolate()), - headers->flags); + if (!pending_open_callbacks_.empty()) { + BaseObjectPtr self(this); + decltype(pending_open_callbacks_) callbacks; + pending_open_callbacks_.swap(callbacks); + for (auto& fn : callbacks) { + fn(); + if (stats()->destroyed_at != 0) return; } } // If the stream is not a local undirectional stream and is_readable is @@ -1318,12 +1184,12 @@ void Stream::NotifyWritableEnded(error_code code) { ngtcp2_conn_shutdown_stream_write(session(), 0, id(), code); } -void Stream::EnqueuePendingHeaders(HeadersKind kind, - Local headers, - HeadersFlags flags) { - Debug(this, "Enqueuing headers for pending stream"); - pending_headers_queue_.push_back(std::make_unique( - kind, Global(env()->isolate(), headers), flags)); +void Stream::RunWhenOpen(std::function fn) { + if (is_pending()) { + pending_open_callbacks_.push_back(std::move(fn)); + } else { + fn(); + } } bool Stream::is_pending() const { diff --git a/src/quic/streams.h b/src/quic/streams.h index 31ffc9f9bdf6fc..1e8ac4c43f48c4 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -14,6 +14,7 @@ #include "bindingdata.h" #include "data.h" +#include #include namespace node::quic { @@ -339,11 +340,8 @@ class Stream final : public AsyncWrap, void ReceiveStopSending(QuicError error); void ReceiveStreamReset(uint64_t final_size, QuicError error); - // Currently, only HTTP/3 streams support headers. These methods are here - // to support that. They are not used when using any other QUIC application. - // TODO(@jasnell): Implement MemoryInfo to track outbound_, inbound_, - // reader_, and pending_headers_queue_. + // and reader_. SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Stream) SET_SELF_SIZE(Stream) @@ -364,7 +362,6 @@ class Stream final : public AsyncWrap, private: struct Impl; - struct PendingHeaders; class Outbound; @@ -412,9 +409,10 @@ class Stream final : public AsyncWrap, // When a pending stream is finally opened, the NotifyStreamOpened method // will be called and the id will be assigned. void NotifyStreamOpened(stream_id id); - void EnqueuePendingHeaders(HeadersKind kind, - v8::Local headers, - HeadersFlags flags); + + // Runs fn once the stream has a transport id: immediately if the stream is + // already open, otherwise queued and run in NotifyStreamOpened. + void RunWhenOpen(std::function fn); ArenaSlotBase stats_slot_; ArenaSlotBase state_slot_; @@ -429,22 +427,14 @@ class Stream final : public AsyncWrap, // and the stream id will be assigned. std::optional> maybe_pending_stream_ = std::nullopt; - std::vector> pending_headers_queue_; + std::vector> pending_open_callbacks_; error_code pending_close_read_code_ = 0; error_code pending_close_write_code_ = 0; - struct StoredPriority { - StreamPriority priority = StreamPriority::DEFAULT; - StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL; - bool pending = false; - }; - StoredPriority priority_; - - const StoredPriority& stored_priority() const { return priority_; } - friend struct Impl; friend class PendingStream; friend class Http3Application; + friend class Http3Binding; friend class Session; public: diff --git a/test/parallel/test-quic-h3-no-application.mjs b/test/parallel/test-quic-h3-no-application.mjs new file mode 100644 index 00000000000000..67f7633c192486 --- /dev/null +++ b/test/parallel/test-quic-h3-no-application.mjs @@ -0,0 +1,107 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: HTTP/3 over a QUIC session with NO kApplication preconfigured. +// Wrapping a raw node:quic session with new Http3Session(session) installs +// and starts the HTTP/3 application itself. Covers a working request/response +// with both peers raw, and the attach guards: only before the session becomes +// active (a server in its delivery frame, a client before its handshake +// completes), and no double-wrap. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, throws } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen: quicListen, connect: quicConnect } = await import('node:quic'); +const { Http3Session } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const serverOpts = { sni: { '*': { keys: [key], certs: [cert] } }, alpn: 'h3' }; +const clientOpts = { servername: 'localhost', verifyPeer: 'manual', alpn: 'h3' }; +const reqHeaders = { + ':method': 'GET', ':path': '/', ':scheme': 'https', ':authority': 'localhost', +}; +const body = 'Hello from a no-preconfig H3 server'; +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +// Working request/response with BOTH peers raw (no kApplication): the server +// installs + starts at its delivery frame, the client before its handshake. +{ + const serverDone = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + const session = new Http3Session(quicSession); + // The session is now owned; a second wrap of it is rejected. + throws(() => new Http3Session(quicSession), { code: 'ERR_INVALID_STATE' }); + session.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(enc.encode(body)); + stream.writer.endSync(); + }); + await stream.closed; + session.close(); + serverDone.resolve(); + }); + }), serverOpts); + + const client = new Http3Session(await quicConnect(endpoint.address, clientOpts)); + const gotHeaders = Promise.withResolvers(); + const stream = await client.request(reqHeaders, { + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + gotHeaders.resolve(); + }), + }); + await gotHeaders.promise; + strictEqual(dec.decode(await bytes(stream)), body); + await Promise.all([stream.closed, serverDone.promise]); + await client.close(); + await endpoint.close(); +} + +// An application may only be attached before the session becomes active. +const tooLate = { code: 'ERR_INVALID_STATE', message: /before it becomes active/ }; + +// Server: a wrap deferred past the delivery frame is rejected. +{ + const done = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + setImmediate(mustCall(() => { + throws(() => new Http3Session(quicSession), tooLate); + done.resolve(); + })); + }), serverOpts); + const client = await quicConnect(endpoint.address, clientOpts); + await client.opened; + await done.promise; + await client.close(); + await endpoint.close(); +} + +// Client: a wrap after the handshake completes is rejected. A rejected wrap +// must not poison the session - a retry reports the same reason, not a +// spurious 'already has an application attached'. +{ + const wrapped = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + new Http3Session(quicSession); + wrapped.resolve(); + }), serverOpts); + const client = await quicConnect(endpoint.address, clientOpts); + await client.opened; + await wrapped.promise; + throws(() => new Http3Session(client), tooLate); + throws(() => new Http3Session(client), tooLate); + await client.close(); + await endpoint.close(); +} diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index f187ea97f8732b..53ea9c152f0e8a 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -73,11 +73,15 @@ const decoder = new TextDecoder(); // Priority should reflect what was set even while pending. deepStrictEqual(stream.priority, { level: 'high', incremental: true }); + // Reprioritize while still pending - sends deferred PRIORITY_UPDATE + stream.setPriority({ level: 'low', incremental: false }); + deepStrictEqual(stream.priority, { level: 'low', incremental: false }); + // Now wait for the handshake. await clientSession.opened; - // Priority persists after stream opens. - deepStrictEqual(stream.priority, { level: 'high', incremental: true }); + // The reprioritized value persists after the stream opens. + deepStrictEqual(stream.priority, { level: 'low', incremental: false }); // Headers were sent and server responded. const body = await bytes(stream); diff --git a/test/parallel/test-quic-h3-priority-header.mjs b/test/parallel/test-quic-h3-priority-header.mjs new file mode 100644 index 00000000000000..3ce3c0e1529c1f --- /dev/null +++ b/test/parallel/test-quic-h3-priority-header.mjs @@ -0,0 +1,88 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: a client's initial request priority (set via request({ priority })) +// reaches the server as an RFC 9218 `priority` request header, and the +// server's stream.priority getter reflects it. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; +const { readKey } = fixtures; + +const { deepStrictEqual, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); +const encoder = new TextEncoder(); + +const expectedByPath = { + '/high': { level: 'high', incremental: false }, + '/low-inc': { level: 'low', incremental: true }, + '/default': { level: 'default', incremental: false }, + '/default-inc': { level: 'default', incremental: true }, +}; + +const serverDone = Promise.withResolvers(); +let seen = 0; + +const serverEndpoint = await listen(mustCall((ss) => { + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + deepStrictEqual(stream.priority, expectedByPath[headers[':path']]); + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + if (++seen === 4) serverDone.resolve(); + }); + }, 4); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +await clientSession.opened; + +const get = (path) => ({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', +}); + +const stream1 = await clientSession.request(get('/high'), { + priority: 'high', + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream2 = await clientSession.request(get('/low-inc'), { + priority: 'low', + incremental: true, + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream3 = await clientSession.request(get('/default'), { + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream4 = await clientSession.request(get('/default-inc'), { + incremental: true, + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); + +await Promise.all([ + bytes(stream1), + bytes(stream2), + bytes(stream3), + bytes(stream4), + serverDone.promise, +]); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index b4c5314f10e29f..ce4482def9124a 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -44,6 +44,8 @@ const decoder = new TextDecoder(); // Attach onheaders synchronously in the onstream frame, before // any await. stream.onheaders = mustCall((headers) => { + stream.setPriority({ level: 'high', incremental: true }); + deepStrictEqual(stream.priority, { level: 'high', incremental: true }); stream.sendHeaders({ ':status': '200' }); stream.writer.writeSync(encoder.encode(headers[':path'])); stream.writer.endSync(); diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index ce33537edf74c9..f81561ea84b5a7 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -172,7 +172,6 @@ strictEqual(sessionState.isStatelessReset, false); strictEqual(sessionState.isHandshakeCompleted, false); strictEqual(sessionState.isHandshakeConfirmed, false); strictEqual(sessionState.isStreamOpenAllowed, false); -strictEqual(sessionState.isPrioritySupported, false); strictEqual(sessionState.hasApplication, false); strictEqual(sessionState.isWrapped, false); strictEqual(sessionState.isServer, false); diff --git a/test/parallel/test-quic-stream-priority.mjs b/test/parallel/test-quic-stream-priority.mjs deleted file mode 100644 index bed1fd065fdfc9..00000000000000 --- a/test/parallel/test-quic-stream-priority.mjs +++ /dev/null @@ -1,96 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { rejects, strictEqual, throws } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); - -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await serverSession.opened; - await serverSession.close(); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: ['quic-test'], -}); - -const clientSession = await connect(serverEndpoint.address, { - alpn: 'quic-test', - verifyPeer: 'manual', -}); -await clientSession.opened; - -// Collect stream.closed promises so we can await them all at the end. -// We must not await them inline because the server's CONNECTION_CLOSE -// arrives asynchronously and would put the session into a closing state, -// preventing subsequent createBidirectionalStream calls. -const streamClosedPromises = []; - -// Test 1: Priority getter returns null for non-HTTP/3 sessions. -// setPriority throws because the session doesn't support priority. -{ - const stream = await clientSession.createBidirectionalStream(); - streamClosedPromises.push(stream.closed); - strictEqual(stream.priority, null); - - throws( - () => stream.setPriority({ level: 'high', incremental: true }), - { code: 'ERR_INVALID_STATE' }, - ); -} - -// Test 2: Validation of createStream priority/incremental options -{ - await rejects( - clientSession.createBidirectionalStream({ priority: 'urgent' }), - { code: 'ERR_INVALID_ARG_VALUE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ priority: 42 }), - { code: 'ERR_INVALID_ARG_VALUE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ incremental: 'yes' }), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ incremental: 1 }), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); -} - -// Test 3: setPriority throws on non-H3 sessions regardless of arguments -{ - const stream = await clientSession.createBidirectionalStream(); - streamClosedPromises.push(stream.closed); - - throws( - () => stream.setPriority({ level: 'high' }), - { code: 'ERR_INVALID_STATE' }, - ); - throws( - () => stream.setPriority({ level: 'low', incremental: true }), - { code: 'ERR_INVALID_STATE' }, - ); - throws( - () => stream.setPriority(), - { code: 'ERR_INVALID_STATE' }, - ); -} - -// Wait for all streams to close (they close when the session closes -// in response to the server's CONNECTION_CLOSE). -await Promise.all(streamClosedPromises); -await clientSession.close(); -await serverEndpoint.close(); From 3c4a4527352e50babd7fa42b2f204648ac40ff9e Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 18 Jun 2026 16:01:43 +0200 Subject: [PATCH 13/15] quic: don't allow combining appTicketData with Http3Session --- lib/internal/quic/quic.js | 5 +++++ src/quic/http3.cc | 4 +++- src/quic/session.cc | 11 +++++++++++ src/quic/session.h | 11 +++++++---- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index d3d2654395cbc8..98f49eb7bb36b7 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -4574,6 +4574,11 @@ function processSessionOptions(options, config = kEmptyObject) { throw new ERR_INVALID_ARG_TYPE('options.appTicketData', ['ArrayBufferView'], appTicketData); } + if (application !== undefined) { + throw new ERR_INVALID_ARG_VALUE( + 'options.appTicketData', appTicketData, + 'cannot be combined with a session application'); + } } if (cc !== undefined) { diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 188f6844da1ad4..5f444cb49debfa 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -1829,7 +1829,9 @@ void CreateHttp3Handle(const FunctionCallbackInfo& args) { "becomes active (begins emitting events)"); return; } - session->SetApplication(CreateHttp3Application(session)); + if (!session->AttachApplication(CreateHttp3Application(session))) { + return; + } if (session->is_server() && !session->application().Start()) { // Start() failed (e.g. the peer's initial_max_streams_uni is < 3), so diff --git a/src/quic/session.cc b/src/quic/session.cc index 317a642e90fbb9..b178891c6c9f4f 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2864,6 +2864,17 @@ std::unique_ptr Session::SelectApplication() { return factory->create(this); } +bool Session::AttachApplication(std::unique_ptr app) { + if (config().options.app_ticket_data.has_value()) { + THROW_ERR_INVALID_STATE( + env(), + "A QUIC application cannot be combined with appTicketData"); + return false; + } + SetApplication(std::move(app)); + return true; +} + void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); DCHECK(app); diff --git a/src/quic/session.h b/src/quic/session.h index dd320b5b76e69c..67d8277d8d5916 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -90,11 +90,14 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // path and no Application is ever installed. std::unique_ptr SelectApplication(); - // Install the Application on the session. Called at construction for - // clients or from OnSelectAlpn for servers (later in the handshake, - // after session-ticket decryption). Must be called before any - // application data is received. + // Installs the Application on the session WITHOUT validation. Called at + // construction for clients or from OnSelectAlpn for servers (later in the + // handshake, after session-ticket decryption). void SetApplication(std::unique_ptr app); + + // Validating wrapper around SetApplication() for dynamic attachment of an + // application to a live session. + bool AttachApplication(std::unique_ptr app); // Controls which datagram to drop when the pending datagram queue is full. enum class DatagramDropPolicy : uint8_t { DROP_OLDEST = 0, // Drop the oldest queued datagram (default). From 4d58a376697aff8b5e042a1c76555c22b862ef92 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 18 Jun 2026 20:44:05 +0200 Subject: [PATCH 14/15] quic: add docs for http3 module, update quic docs & internal README --- doc/api/http3.md | 729 +++++++++++++++++++++++++++++++++ doc/api/quic.md | 677 ++---------------------------- doc/type-map.json | 16 +- src/quic/README.md | 86 ++-- test/doctool/test-make-doc.mjs | 3 +- 5 files changed, 842 insertions(+), 669 deletions(-) create mode 100644 doc/api/http3.md diff --git a/doc/api/http3.md b/doc/api/http3.md new file mode 100644 index 00000000000000..a856ffb7cf1103 --- /dev/null +++ b/doc/api/http3.md @@ -0,0 +1,729 @@ +# HTTP/3 + + + + + +> Stability: 1.0 - Early development + + + +The `node:http3` module provides an implementation of the HTTP/3 protocol on +top of the QUIC transport in [`node:quic`][]. To access it, start Node.js with +the `--experimental-quic` option and: + +```mjs +import http3 from 'node:http3'; +``` + +```cjs +const http3 = require('node:http3'); +``` + +The module is only available under the `node:` scheme. + +## Overview + +`node:http3` allows you to send & receive HTTP/3 requests and responses. It +builds on top of the QUIC transport from [`node:quic`][]. + +Each connection is an {Http3Session}, wrapping a [`QuicSession`][], and +each request/response exchange is an {Http3Stream}, wrapping a +[`QuicStream`][]. + +Servers call [`listen()`][] to create an endpoint, from which sessions will +be emitted for incoming connections. Clients call [`connect()`][] to create +a session with a remote server. + +Transport-level configuration (TLS certificates, SNI, address validation, +flow control, and so on) is identical to `node:quic`. QUIC options not +managed by `node:http3` can be passed straight through to the underlying +endpoint, session, or stream. + +HTTP/3 requests are always client-initiated. A server session receives request +streams via its `onstream` callback and cannot itself call `request()`. + +### Example + +Server: + +```mjs +import { listen } from 'node:http3'; + +const endpoint = await listen((session) => { + session.onstream = (stream) => { + stream.onheaders = (headers) => { + // headers[':method'], headers[':path'], headers[':authority'] ... + stream.sendHeaders({ ':status': '200', 'content-type': 'text/plain' }); + const w = stream.writer; + w.writeSync('hello h3'); + w.endSync(); + }; + }; +}, { + sni: { '*': { keys: [key], certs: [cert] } }, +}); +``` + +Client: + +```mjs +import { connect } from 'node:http3'; +import { bytes } from 'stream/iter'; + +const session = await connect(endpoint.address, { servername: 'localhost' }); +await session.opened; + +const stream = await session.request({ + ':method': 'GET', + ':path': '/hello', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: (headers) => { /* headers[':status'] === '200' */ }, +}); + +const body = new TextDecoder().decode(await bytes(stream)); +// body === 'hello h3' + +await session.close(); +``` + +## `http3.connect(address[, options])` + + + +* `address` {string|net.SocketAddress} The server address. See [`quic.connect()`][]. +* `options` {Object} HTTP/3 options. Other [`quic.connect()`][] session options + may also be provided and will be passed through to the underlying QUIC session. + * `settings` {Http3Settings} Local HTTP/3 SETTINGS to advertise to the peer. + * `ongoaway` {http3.OnGoawayCallback} The session's initial `ongoaway` callback. + * `onorigin` {http3.OnOriginCallback} The session's initial `onorigin` callback. + * `onsettings` {http3.OnSettingsCallback} The session's initial `onsettings` callback. +* Returns: {Promise} Fulfilled with an {Http3Session}. + +Opens a client connection to an HTTP/3 server. The ALPN identifier is fixed to +`h3`. Await [`session.opened`][] before sending requests: the TLS handshake — +including certificate validation — completes asynchronously, and any handshake +or connection failure is reported through `opened` rather than by `connect()`. + +## `http3.listen(onsession[, options])` + + + +* `onsession` {http3.OnSessionCallback} Invoked with each new {Http3Session}. +* `options` {Object} The same HTTP/3 options as [`connect()`][], plus any + [`quic.listen()`][] option (for example `sni`), passed through to the + endpoint. +* Returns: {Promise} Fulfilled with the listening {quic.QuicEndpoint}. + +Creates a server endpoint that negotiates the `h3` ALPN identifier and invokes +`onsession` for each incoming connection. + +## Class: `Http3Session` + + + +An `Http3Session` wraps a {quic.QuicSession} with HTTP/3 semantics. Servers receive +one through the [`listen()`][] callback; clients create one with [`connect()`][]. +Alternatively, you can call the constructor directly on a QuicSession if it's not +already active. + +### `new Http3Session(session[, callbacks])` + + + +* `session` {quic.QuicSession} The raw transport session to wrap. +* `callbacks` {Object} + * `ongoaway` {http3.OnGoawayCallback} The initial `ongoaway` callback. + * `onorigin` {http3.OnOriginCallback} The initial `onorigin` callback. + * `onsettings` {http3.OnSettingsCallback} The initial `onsettings` callback. + +Attaches HTTP/3 to an existing `node:quic` session. + +The HTTP/3 session must be attached before the QUIC session becomes active and +begins emitting events: for servers that means synchronously inside a server's +`onsession` callback, or for clients before the handshake completes. Throws +`ERR_INVALID_STATE` if the QUIC session is already attached or active. + +### `session.request(headers[, options])` + + + +* `headers` {Object} The request pseudo-headers (`:method`, `:path`, + `:scheme`, `:authority`) and regular headers. When omitted, headers can be + sent later with `sendHeaders()`. +* `options` {Object} Any {quic.QuicStream} option (for example `body`) may also be + given and is passed through. + * `priority` {string} Initial priority level: `'high'`, `'default'`, or + `'low'`. **Default:** `'default'`. + * `incremental` {boolean} Whether the stream may be served incrementally. + **Default:** `false`. + * `onheaders` {http3.OnHeadersCallback} The new stream's `onheaders` callback. + * `oninfo` {http3.OnInfoCallback} The new stream's `oninfo` callback. + * `ontrailers` {http3.OnTrailersCallback} The new stream's `ontrailers` callback. + * `onwanttrailers` {http3.OnWantTrailersCallback} The new stream's `onwanttrailers` + callback. + * `onreset` {http3.OnResetCallback} The new stream's `onreset` callback. + * `onerror` {http3.OnErrorCallback} The new stream's `onerror` callback. +* Returns: {Promise} Fulfilled with an {Http3Stream}. + +Opens a request stream. Client only — throws `ERR_INVALID_STATE` on a server +session. The per-stream callbacks are passed here so they are attached before +any event can be delivered on the new stream. + +### `session.close([options])` + + + +* `options` {Object} +* Returns: {Promise} + +Gracefully closes the session, delegating to the underlying QUIC session. + +### `session.destroy([error[, options]])` + + + +* `error` {Error} +* `options` {Object} + +Immediately destroys the session and all of its streams. + +### `session.settings` + + + +* Type: {Http3Settings|null} + +The effective HTTP/3 settings for the session: the locally configured values +plus any updates received in the peer's SETTINGS frame. Returns `null` once the +session is destroyed. Read only. + +### `session.opened` + + + +* Type: {Promise} + +Resolves with the handshake details once the TLS handshake completes, or +rejects if the session fails to open. Read only. + +### `session.closed` + + + +* Type: {Promise} + +Resolves when the session has fully closed, or rejects if it ends with an +error. Read only. + +### `session.servername` + + + +* Type: {string|undefined} + +The SNI servername of the session, or `undefined` if none was negotiated. +Read only. + +### `session.alpnProtocol` + + + +* Type: {string|undefined} + +The negotiated ALPN protocol (`'h3'`), or `undefined` before negotiation +completes. Read only. + +### `session.session` + + + +* Type: {quic.QuicSession} + +The underlying transport session. Read only. + +### `session.onstream` + + + +* Type: {http3.OnStreamCallback} + +Server callback invoked with each incoming request stream. Read/write. + +### `session.ongoaway` + + + +* Type: {http3.OnGoawayCallback} + +Invoked when the peer sends an HTTP/3 GOAWAY frame. Read/write. + +### `session.onorigin` + + + +* Type: {http3.OnOriginCallback} + +Invoked when the peer sends an HTTP/3 ORIGIN frame (RFC 9412). Read/write. + +### `session.onsettings` + + + +* Type: {http3.OnSettingsCallback} + +Invoked when the peer's SETTINGS frame is received, which may be after the +session opens. The current values are also available synchronously via +`session.settings`. Read/write. + +### `session.onerror` + + + +* Type: {http3.OnErrorCallback} + +Invoked when the session encounters an error. Read/write. + +## Class: `Http3Stream` + + + +An `Http3Stream` represents a single HTTP/3 request/response exchange, wrapping +a {quic.QuicStream}. It is async-iterable: `for await (const chunk of stream)` (or +the `stream/iter` `bytes()` helper) reads the inbound body. + +### `stream.sendHeaders(headers[, options])` + + + +* `headers` {Object} The header block to send — a server's response headers or + a client's request headers. +* `options` {Object} + * `terminal` {boolean} When `true`, the header block is the final frame and + no body follows. **Default:** `false`. +* Returns: {boolean} `true` if the headers were scheduled to be sent. + +Sends the initial request or response header block. + +### `stream.sendInformationalHeaders(headers)` + + + +* `headers` {Object} +* Returns: {boolean} `true` if the headers were scheduled to be sent. + +Sends an informational (1xx) response header block. Server only. + +### `stream.sendTrailers(headers)` + + + +* `headers` {Object} +* Returns: {boolean} `true` if the trailers were scheduled to be sent. + +Sends a trailing header block. Must be called synchronously from the +`onwanttrailers` callback. + +### `stream.setPriority([options])` + + + +* `options` {Object} + * `level` {string} `'high'`, `'default'`, or `'low'`. **Default:** + `'default'`. + * `incremental` {boolean} Whether the stream may be served incrementally. + **Default:** `false`. + +Updates the stream priority. Before the request is submitted the priority is +carried in the initial header block; afterwards it is signaled with a +PRIORITY\_UPDATE frame. + +### `stream.stopSending([code])` + + + +* `code` {bigint|number} The application error code sent to the peer. + **Default:** `0n`. + +Asks the peer to stop sending data on this stream, delegating to the underlying +QUIC stream. + +### `stream.resetStream([code])` + + + +* `code` {bigint|number} The application error code sent to the peer. + **Default:** `0n`. + +Tells the peer this end will send no more data on this stream, delegating to +the underlying QUIC stream. + +### `stream.destroy([error[, options]])` + + + +* `error` {Error} +* `options` {Object} + +Immediately destroys the stream. + +### `stream[Symbol.asyncIterator]()` + + + +Iterates the inbound body bytes, ending at the stream FIN. Equivalent to +iterating the underlying [`QuicStream`][]. + +### `stream.writer` + + + +* Type: {QuicStreamWriter} + +The outbound body writer (`write`, `writeSync`, `end`, `endSync`). Read only. + +### `stream.headers` + + + +* Type: {Object} + +The most recently received header block, or `undefined` before any headers +arrive. Read only. + +### `stream.priority` + + + +* Type: {Object|null} + +The stream's priority as `{ level, incremental }` — on a client the requested +value, on a server the peer's requested priority. `null` once the stream is +destroyed. Read only. + +### `stream.id` + + + +* Type: {bigint} + +The QUIC stream id. Read only. + +### `stream.direction` + + + +* Type: {string} + +`'bidi'` or `'uni'`. Read only. + +### `stream.early` + + + +* Type: {boolean} + +`true` if the stream's data arrived as 0-RTT early data. Read only. + +### `stream.closed` + + + +* Type: {Promise} + +Resolves when the stream has closed. Read only. + +### `stream.destroyed` + + + +* Type: {boolean} + +`true` once the stream has been destroyed. Read only. + +### `stream.session` + + + +* Type: {Http3Session} + +The session that owns this stream. Read only. + +### `stream.onheaders` + + + +* Type: {http3.OnHeadersCallback} + +Invoked when the initial response/request header block is received. Read/write. + +### `stream.oninfo` + + + +* Type: {http3.OnInfoCallback} + +Invoked when an informational (1xx) header block is received. Read/write. + +### `stream.ontrailers` + + + +* Type: {http3.OnTrailersCallback} + +Invoked when a trailing header block is received. Read/write. + +### `stream.onwanttrailers` + + + +* Type: {http3.OnWantTrailersCallback} + +Invoked when trailers may be sent. Setting it keeps the stream open after the +body; send trailers synchronously with `sendTrailers()`. Read/write. + +### `stream.onreset` + + + +* Type: {http3.OnResetCallback} + +Invoked when the peer aborts the stream with a `RESET_STREAM` frame. Read/write. + +### `stream.onerror` + + + +* Type: {http3.OnErrorCallback} + +Invoked when the stream encounters an error. Read/write. + +## Types + +### Type: `Http3Settings` + + + +* `maxFieldSectionSize` {number} Maximum size, in bytes, of a header field + section the endpoint will accept. +* `qpackMaxDtableCapacity` {number} Maximum QPACK dynamic table capacity. + **Default:** `4096`. +* `qpackEncoderMaxDtableCapacity` {number} Maximum QPACK encoder dynamic table + capacity. **Default:** `4096`. +* `qpackBlockedStreams` {number} Maximum number of streams that may be blocked + waiting for QPACK state. **Default:** `100`. +* `enableConnectProtocol` {boolean} Whether the Extended CONNECT protocol + (RFC 9220) is enabled. **Default:** `true`. + +The HTTP/3 SETTINGS for a session (RFC 9114). Advertised to the peer via the +`settings` option, and read back — merged with the peer's values — through +`session.settings`. + +## Callbacks + +### Callback: `OnSessionCallback` + + + +* `session` {Http3Session} + +Invoked by [`listen()`][] for each new server session. + +### Callback: `OnStreamCallback` + + + +* `stream` {Http3Stream} + +Invoked with each incoming request stream (server side). + +### Callback: `OnGoawayCallback` + + + +* `lastStreamId` {bigint} + +Invoked when the peer sends an HTTP/3 GOAWAY frame. + +### Callback: `OnOriginCallback` + + + +* `origins` {string\[]} + +Invoked when the peer sends an HTTP/3 ORIGIN frame (RFC 9412). + +### Callback: `OnSettingsCallback` + + + +* `settings` {Http3Settings} + +Invoked when the peer's SETTINGS frame is received. + +### Callback: `OnHeadersCallback` + + + +* `headers` {Object} + +Invoked when a response or request header block is received. + +### Callback: `OnInfoCallback` + + + +* `headers` {Object} + +Invoked when an informational (1xx) header block is received. + +### Callback: `OnTrailersCallback` + + + +* `headers` {Object} + +Invoked when a trailing header block is received. + +### Callback: `OnWantTrailersCallback` + + + +Invoked with no arguments when trailers may be sent. + +### Callback: `OnResetCallback` + + + +* `error` {Error} + +Invoked when the peer aborts the stream with a `RESET_STREAM` frame. + +### Callback: `OnErrorCallback` + + + +* `error` {Error} + +Invoked when the session or stream encounters an error. + +[`QuicSession`]: quic.md#class-quicsession +[`QuicStream`]: quic.md#class-quicstream +[`connect()`]: #http3connectaddress-options +[`listen()`]: #http3listenonsession-options +[`node:quic`]: quic.md +[`quic.connect()`]: quic.md#quicconnectaddress-options +[`quic.listen()`]: quic.md#quiclistenonsession-options +[`session.opened`]: #sessionopened diff --git a/doc/api/quic.md b/doc/api/quic.md index ce0307514508af..2dbc3f2ca73760 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -235,24 +235,26 @@ counter tracks how many packets have been dropped by the filter. ### Applications -Every `QuicSession` is associated with a single application protocol, negotiated -via ALPN during the TLS handshake. The `quic` module is designed to be -application-agnostic in general but includes built-in support for HTTP/3 as a -specific application protocol. When using HTTP/3, the `quic` module provides -additional APIs for handling HTTP/3-specific features such as headers, trailers, -and prioritization. For other application protocols, users can implement their -own message framing and multiplexing on top of the core QUIC transport features. - -When initiating a TLS handshake, the client will include a list of supported -ALPN protocols in the `ClientHello`. The server selects one of these protocols -(if any) and includes it in the `ServerHello`. The negotiated protocol determines -how the `QuicSession` and `QuicStream` APIs behave. For example, when the `h3` -protocol is negotiated for HTTP/3, the `QuicSession` and `QuicStream` will support -HTTP/3-specific features. - -Currently, the `quic` module only supports HTTP/3 as a built-in application protocol. -All other protocols must be implemented by the user on top of the provided JavaScript -API. +All QUIC sessions are associated with a single application protocol, negotiated +via ALPN during the TLS handshake. The `quic` module is an application-agnostic +transport on which application protocols can be implemented. + +HTTP/3 is provided separately by the `node:http3` module, which provides HTTP/3 +features on top of a `QuicSession`. Other application protocols can be +implemented with their own message framing and multiplexing on top of the core +QUIC transport features. + +To select a protocol, when connecting via QUIC, the client will send a list of +supported protocols via ALPN. The server selects one of these protocols, if it +accepts one, and confirms that to the client to complete connection setup. +The negotiated protocol is exposed as `session.alpnProtocol`, which can be used +to select the right application protocol to use: either your own implementation, +or activating the built-in HTTP/3 protocol on the session with +`new Http3Session(quicSession)`. + +Alternatively, if you use the `node:http3` module directly, the server or client +will be preconfigured for HTTP/3 application protocol, and will automatically +handle this for you. ### Configuration @@ -340,11 +342,9 @@ compatible with `node:stream/iter` utilities such as `Stream.bytes()`, In addition to streams, QUIC supports unreliable datagrams ([RFC 9221][]) for use cases that require low-latency, best-effort messaging. -Datagram support is enabled at two levels. At the QUIC transport level, both -peers must advertise a non-zero [`maxDatagramFrameSize`][] transport parameter -during the handshake. For HTTP/3 sessions, both peers must additionally set -[`application.enableDatagrams`][] to `true`, which exchanges the -`SETTINGS_H3_DATAGRAM` setting on the HTTP/3 control stream. +Datagram support is enabled at the QUIC transport level: both peers must +advertise a non-zero [`maxDatagramFrameSize`][] transport parameter during the +handshake. A datagram is sent with a single call to [`session.sendDatagram()`][]. Each datagram must fit within a single QUIC packet — datagrams cannot be @@ -404,8 +404,9 @@ A typical client session progresses through these stages: (also available as `session.closed`) resolves when teardown is complete. On the server side, call [`quic.listen()`][] with a callback. The callback -fires for each incoming session after the TLS handshake begins. Incoming -streams arrive via the [`session.onstream`][] callback. +fires for each incoming session, once the TLS handshake client hello has +been processed. Incoming streams arrive via the [`session.onstream`][] callback. +`session.opened` can be used to wait for the TLS handshake to fully complete. [`session.destroy()`][] is available for immediate teardown — all open streams are destroyed and the session is closed without waiting for them to finish. @@ -923,20 +924,6 @@ added: v23.8.0 A `QuicSession` represents the local side of a QUIC connection. -### `session.applicationOptions` - - - -* Type: {quic.ApplicationOptions} - -The current application-level options for this session. These include settings -that are specific to the negotiated application protocol (e.g. HTTP/3) and may -be negotiated separately from the transport parameters. Read only. -You can use the callback [`session.onapplication`][] to be informed, when settings -from the remote arrive. - ### `session.close([options])` - -* Type: {quic.OnApplicationCallback} - -The callback to invoke when new application options, e.g. HTTP/3 settings arrived. - ### `session.onerror` - -* Type: {quic.OnOriginCallback} - -The callback to invoke when an ORIGIN frame (RFC 9412) is received from -the server, indicating which origins the server is authoritative for. -Read/write. - -### `session.ongoaway` - - - -* Type: {Function} - -The callback to invoke when the peer sends an HTTP/3 GOAWAY frame, -indicating it is initiating a graceful shutdown. The callback receives -`(lastStreamId)` where `lastStreamId` is a `{bigint}`: - -* When `lastStreamId` is `-1n`, the peer sent a shutdown notice (intent - to close) without specifying a stream boundary. All existing streams - may still be processed. -* When `lastStreamId` is `>= 0n`, it is the highest stream ID the peer - may have processed. Streams with IDs above this value were NOT - processed and can be safely retried on a new connection. - -After GOAWAY is received, `session.createBidirectionalStream()` will -throw `ERR_INVALID_STATE`. Existing streams continue until they -complete or the session closes. - -This callback is only relevant for HTTP/3 sessions. Read/write. - ### `session.onkeylog` - -* Type: {Object|undefined} - -The buffered initial headers received on this stream, or `undefined` if the -application does not support headers or no headers have been received yet. -For server-side streams, this contains the request headers (e.g., `:method`, -`:path`, `:scheme`). For client-side streams, this contains the response -headers (e.g., `:status`). - -Header names are lowercase strings. Multi-value headers are represented as -arrays. The object has `__proto__: null`. - -### `stream.onheaders` - - - -* Type: {Function} - -The callback to invoke when initial headers are received on the stream. The -callback receives `(headers)` where `headers` is an object (same format as -`stream.headers`). For HTTP/3, this delivers request pseudo-headers on the -server side and response headers on the client side. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.ontrailers` - - - -* Type: {Function} - -The callback to invoke when trailing headers are received from the peer. -The callback receives `(trailers)` where `trailers` is an object in the -same format as `stream.headers`. Throws `ERR_INVALID_STATE` if set on a -session that does not support headers. Read/write. - -### `stream.oninfo` - - - -* Type: {Function} - -The callback to invoke when informational (1xx) headers are received from -the server. The callback receives `(headers)` where `headers` is an object -in the same format as `stream.headers`. Informational headers are sent -before the final response (e.g., 103 Early Hints). Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.onwanttrailers` - - - -* Type: {Function} - -The callback to invoke when the application is ready for trailing headers -to be sent. This is called synchronously — the user must call -[`stream.sendTrailers()`][] within this callback. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.pendingTrailers` - - - -* Type: {Object|undefined} - -Set trailing headers to be sent automatically when the application requests -them. This is an alternative to the [`stream.onwanttrailers`][] callback -for cases where the trailers are known before the body completes. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.sendHeaders(headers[, options])` - - - -* `headers` {Object} Header object with string keys and string or - string-array values. Pseudo-headers (`:method`, `:path`, etc.) must - appear before regular headers. -* `options` {Object} - * `terminal` {boolean} If `true`, the stream is closed for sending - after the headers (no body will follow). **Default:** `false`. -* Returns: {boolean} - -Sends initial or response headers on the stream. For client-side streams, -this sends request headers. For server-side streams, this sends response -headers. Throws `ERR_INVALID_STATE` if the session does not support headers. - -### `stream.sendInformationalHeaders(headers)` - - - -* `headers` {Object} Header object. Must include `:status` with a 1xx - value (e.g., `{ ':status': '103', 'link': '; rel=preload' }`). -* Returns: {boolean} - -Sends informational (1xx) response headers. Server only. Throws -`ERR_INVALID_STATE` if the session does not support headers. - -### `stream.sendTrailers(headers)` - - - -* `headers` {Object} Trailing header object. Pseudo-headers must not be - included in trailers. -* Returns: {boolean} - -Sends trailing headers on the stream. Must be called synchronously during -the [`stream.onwanttrailers`][] callback, or set ahead of time via -[`stream.pendingTrailers`][]. Throws `ERR_INVALID_STATE` if the session -does not support headers. - -### `stream.priority` - - - -* Type: {Object|null} - * `level` {string} One of `'high'`, `'default'`, or `'low'`. - * `incremental` {boolean} Whether the stream data should be interleaved - with other streams of the same priority level. - -The current priority of the stream. Returns `null` if the session does not -support priority (e.g. non-HTTP/3) or if the stream has been destroyed. -Read only. Use [`stream.setPriority()`][] to change the priority. - -On client-side HTTP/3 sessions, the value reflects what was set via -[`stream.setPriority()`][]. On server-side HTTP/3 sessions, the value -reflects the peer's requested priority (e.g., from `PRIORITY_UPDATE` frames). - -### `stream.setPriority([options])` - - - -* `options` {Object} - * `level` {string} The priority level. One of `'high'`, `'default'`, or - `'low'`. **Default:** `'default'`. - * `incremental` {boolean} When `true`, data from this stream may be - interleaved with data from other streams of the same priority level. - **Default:** `false`. - -Sets the priority of the stream. Throws `ERR_INVALID_STATE` if the session -does not support priority (e.g. non-HTTP/3). Has no effect if the stream -has been destroyed. - ### `stream[Symbol.asyncIterator]()` - -* Type: {Object} - -The application specific options. - -#### `applicationOptions.maxHeaderPairs` - -* Type: {bigint|number} - -Maximum number of header name-value pairs accepted per header block. -Headers beyond this limit are silently dropped. **Default:** `128` - -#### `applicationOptions.maxHeaderLength` - -* Type: {bigint|number} - -Maximum total byte length of all header names and values combined per header -block. Headers that would push the total over this limit are silently -dropped. **Default:** `8192` - -#### `applicationOptions.maxFieldSectionSize` - -* Type: {bigint|number} - -Maximum size of a compressed header field section (QPACK). `0` means -unlimited. **Default:** `0` - -#### `applicationOptions.qpackMaxDTableCapacity` - -* Type: {bigint|number} - -QPACK dynamic table capacity in bytes. Set to `0` to disable the dynamic -table. **Default:** `4096` - -#### `applicationOptions.qpackEncoderMaxDTableCapacity` - -* Type: {bigint|number} - -QPACK encoder maximum dynamic table capacity. **Default:** `4096` - -#### `applicationOptions.qpackBlockedStreams` - -* Type: {bigint|number} - -Maximum number of streams that can e blocked waiting for QPACK dynamic table -updates. **Default:** `100` - -#### `applicationOptions.enableConnectProtocol` - -* Type: {boolean} - -Enable the extended CONNECT protocol (RFC 9220). **Default:** `false` - -#### `applicationOptions.enableDatagrams` - -* Type: {boolean} - -Enable HTTP/3 datagrams (RFC 9297). **Default:** `false` - ### Type: `EndpointOptions` - -* Type: {quic.ApplicationOptions} - -Application-specific options. - -```mjs -const { listen } = await import('node:quic'); - -await listen((session) => { /* ... */ }, { - application: { - maxHeaderPairs: 64, - qpackMaxDTableCapacity: 8192, - enableDatagrams: true, - }, - // ... other session options -}); -``` +The negotiated identifier is exposed as `session.alpnProtocol`. This can be +read to confirm which application protocol to use in the session. See the +Applications section for more information about enabling HTTP/3 when the +`h3` ALPN protocol is configured. #### `sessionOptions.ca` (client only) @@ -3535,13 +3178,11 @@ functions. If a callback throws synchronously or returns a promise that rejects, the error is caught and the owning session or stream is destroyed with that error: -* Stream callbacks (`onblocked`, `onreset`, `onheaders`, `ontrailers`, - `oninfo`, `onwanttrailers`): the stream is destroyed. -* Session callbacks (`onapplication`, `onstream`, `ondatagram`, - `ondatagramstatus`, `onpathvalidation`, `onsessionticket`, - `onnewtoken`, `onversionnegotiation`, `onorigin`, `ongoaway`, - `onhandshake`, `onkeylog`, `onqlog`): the session is destroyed along - with all of its streams. +* Stream callbacks (`onblocked`, `onreset`): the stream is destroyed. +* Session callbacks (`onstream`, `ondatagram`, `ondatagramstatus`, + `onpathvalidation`, `onsessionticket`, `onnewtoken`, + `onversionnegotiation`, `onhandshake`, `onkeylog`, `onqlog`): the session + is destroyed along with all of its streams. Before destruction, the optional [`session.onerror`][] or [`stream.onerror`][] callback is invoked (if set), giving the application a @@ -3595,19 +3236,6 @@ added: v23.8.0 datagram was never sent on the wire (dropped due to queue overflow, send attempt limit exceeded, or frame size rejection). -### Callback: `OnApplicationCallback` - - - -* `this` {quic.QuicSession} -* `applicationoption` {quic.QuicSession} - -The callback function that is invoked when application options change. -E.g. for http/3 settings are included in applications options and -may arrive after the connection is established. - ### Callback: `OnPathValidationCallback` - -* `this` {quic.QuicSession} -* `origins` {string\[]} The list of origins the server is authoritative for. - ### Callback: `OnKeylogCallback` - -* `this` {quic.QuicStream} -* `headers` {Object} Header object with lowercase string keys and - string or string-array values. - -Called when initial request or response headers are received. For HTTP/3, -this delivers request pseudo-headers on the server and response headers -on the client. - -### Callback: `OnTrailersCallback` - - - -* `this` {quic.QuicStream} -* `trailers` {Object} Trailing header object. - -Called when trailing headers are received from the peer. - -### Callback: `OnInfoCallback` - - - -* `this` {quic.QuicStream} -* `headers` {Object} Informational header object. - -Called when informational (1xx) headers are received from the server -(e.g., 103 Early Hints). - -## HTTP/3 support - - - -When the negotiated ALPN identifier is `'h3'` (or one of the `'h3-*'` -draft variants), the QUIC session runs the HTTP/3 application backed -by `nghttp3`. `'h3'` is the default ALPN for `quic.connect()` and -`quic.listen()`, so HTTP/3 is what you get unless you select a -different ALPN explicitly. - -Selecting the HTTP/3 application enables a number of stream- and -session-level capabilities that are not available to non-HTTP/3 -applications: - -* **Headers and trailers** — request and response header blocks - (including pseudo-headers such as `:method`, `:path`, `:scheme`, - `:authority`, and `:status`), trailing headers, and informational - (`1xx`) responses. See [`stream.sendHeaders()`][], - [`stream.sendTrailers()`][], and - [`stream.sendInformationalHeaders()`][]. -* **Stream priority (RFC 9218)** — per-stream urgency and - incremental flags. See [`stream.priority`][] and - [`stream.setPriority()`][]. -* **HTTP/3 datagrams (RFC 9297)** — unreliable application-layer - datagrams. The peer must advertise `SETTINGS_H3_DATAGRAM=1`, which - is enabled by setting [`application.enableDatagrams`][] to `true` - on both peers. See [`session.sendDatagram()`][] and - [`session.ondatagram`][]. -* **ORIGIN frame (RFC 9412)** — servers automatically advertise the - hostnames in their [`sessionOptions.sni`][] map (entries with - `authoritative: true`); clients receive the list via - [`session.onorigin`][]. -* **GOAWAY** — graceful shutdown. The server emits `GOAWAY` as part - of [`session.close()`][]; the client observes it via - [`session.ongoaway`][] and stops opening new bidirectional streams. -* **Extended CONNECT settings (RFC 9220)** — the - `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be enabled via - [`application.enableConnectProtocol`][]. The setting is exchanged - but the application is responsible for handling the `:protocol` - pseudo-header and any payload framing on top. -* **QPACK tuning** — dynamic-table size and blocked-streams limits - via [`application.qpackMaxDTableCapacity`][] and friends. - -### Minimal HTTP/3 client - -```mjs -import { connect } from 'node:quic'; -import process from 'node:process'; - -const session = await connect('example.com:443', { - // ALPN defaults to 'h3'. - servername: 'example.com', -}); -await session.opened; - -const stream = await session.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, - onheaders(headers) { - console.log('status:', headers[':status']); - }, -}); - -const decoder = new TextDecoder(); -for await (const chunks of stream) { - for (const chunk of chunks) { - process.stdout.write(decoder.decode(chunk, { stream: true })); - } -} - -await session.close(); -``` - -A few things to note: - -* `session.createBidirectionalStream({ headers })` automatically - marks the HEADERS frame as terminal when no `body` is provided — - the request is `HEADERS` followed by `END_STREAM`. -* The `onheaders` callback receives the response pseudo-headers and - regular headers in a single object with lowercase string keys. - After the callback returns, the same object is also accessible - via [`stream.headers`][]. -* Reading `for await (const chunks of stream)` consumes the response - body. Each iteration yields a `Uint8Array[]` batch of chunks. -* HTTP semantic helpers (URL parsing, method/status validation, - redirects, content negotiation, and so on) are intentionally not - built in. The caller is responsible for any HTTP-level handling - beyond the wire framing. - -### Minimal HTTP/3 server - -```mjs -import { listen } from 'node:quic'; - -const encoder = new TextEncoder(); - -const endpoint = await listen((session) => { - // The session.onstream callback fires for each new client-initiated stream. -}, { - sni: { '*': { keys: [defaultKey], certs: [defaultCert] } }, - // ALPN defaults to 'h3'. - onheaders(headers) { - // `this` is the QuicStream. Pseudo-headers are available on the - // request header block (`:method`, `:path`, `:scheme`, - // `:authority`). - if (headers[':path'] === '/health') { - this.sendHeaders({ ':status': '200', 'content-type': 'text/plain' }); - const w = this.writer; - w.writeSync(encoder.encode('ok\n')); - w.endSync(); - } else { - this.sendHeaders({ ':status': '404' }, { terminal: true }); - } - }, -}); - -console.log('listening on', endpoint.address); -``` - -Server-side notes: - -* Setting `onheaders` at the [`listen()`][`quic.listen()`] level - applies it to every incoming stream (it is wired up before - `onstream` fires). Setting it inside `onstream` is too late for - HTTP/3, where the request HEADERS frame is the first thing that - arrives on the stream. -* `this.sendHeaders(headers, { terminal: true })` marks the - response HEADERS frame as terminal (no body follows). -* For body responses, send headers first, then write to - `this.writer` and call `endSync()` to send the body and close the - stream cleanly. - -### What is not implemented - -* **Server push** — `PUSH_PROMISE` and the related push-stream - machinery are not implemented and are not on the near-term - roadmap. Server push has limited deployment in practice, and most - use cases are better served by Early Hints (`103`) or by direct - fetches from the client. -* **WebTransport / extended-CONNECT helpers** — the - `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be negotiated but - there is no built-in support for the `:protocol` pseudo-header, - WebTransport datagram demultiplexing, or capsule framing. -* **Higher-level HTTP semantics** — there is no built-in - request/response router, URL parsing, content-encoding - negotiation, body-type coercion, redirect following, or - cookie handling. These are deliberately left to higher-level - libraries built on top of `node:quic`. - ## Performance measurement -* `applicationoptions` {quic.ApplicationOptions} Current application options. +* `applicationoptions` {Object} Current application options. * `session` {quic.QuicSession} -Published when a locally-initiated stream is opened. +Published when the peer's application settings are received (for HTTP/3, its +SETTINGS frame). ### Channel: `quic.session.created.client` @@ -4447,9 +3875,6 @@ throughput issues caused by flow control. [`PerformanceObserver`]: perf_hooks.md#class-performanceobserver [`QuicEndpoint`]: #class-quicendpoint [`QuicError`]: #class-quicerror -[`application.enableConnectProtocol`]: #sessionoptionsapplication -[`application.enableDatagrams`]: #sessionoptionsapplication -[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication [`endpoint.busy`]: #endpointbusy [`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost [`endpoint.maxConnectionsTotal`]: #endpointmaxconnectionstotal @@ -4476,15 +3901,11 @@ throughput issues caused by flow control. [`session.createUnidirectionalStream()`]: #sessioncreateunidirectionalstreamoptions [`session.destroy()`]: #sessiondestroyerror-options [`session.maxPendingDatagrams`]: #sessionmaxpendingdatagrams -[`session.onapplication`]: #sessiononapplication -[`session.ondatagram`]: #sessionondatagram [`session.ondatagramstatus`]: #sessionondatagramstatus [`session.onearlyrejected`]: #sessiononearlyrejected [`session.onerror`]: #sessiononerror -[`session.ongoaway`]: #sessionongoaway [`session.onkeylog`]: #sessiononkeylog [`session.onnewtoken`]: #sessiononnewtoken -[`session.onorigin`]: #sessiononorigin [`session.onqlog`]: #sessiononqlog [`session.onsessionticket`]: #sessiononsessionticket [`session.onstream`]: #sessiononstream @@ -4499,16 +3920,8 @@ throughput issues caused by flow control. [`sessionOptions.sni`]: #sessionoptionssni-server-only [`sessionOptions.token`]: #sessionoptionstoken-client-only [`stream.destroy()`]: #streamdestroyerror-options -[`stream.headers`]: #streamheaders [`stream.onerror`]: #streamonerror -[`stream.onwanttrailers`]: #streamonwanttrailers -[`stream.pendingTrailers`]: #streampendingtrailers -[`stream.priority`]: #streampriority -[`stream.sendHeaders()`]: #streamsendheadersheaders-options -[`stream.sendInformationalHeaders()`]: #streamsendinformationalheadersheaders -[`stream.sendTrailers()`]: #streamsendtrailersheaders [`stream.setBody()`]: #streamsetbodybody -[`stream.setPriority()`]: #streamsetpriorityoptions [`stream.writer`]: #streamwriter [`writer.fail()`]: #streamwriter [`writer.fail(reason)`]: #streamwriter diff --git a/doc/type-map.json b/doc/type-map.json index 4741264f60e46f..438bc0fb617721 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -123,5 +123,19 @@ "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions", "HTTP/2 Headers Object": "http2.html#headers-object", - "HTTP/2 Settings Object": "http2.html#settings-object" + "HTTP/2 Settings Object": "http2.html#settings-object", + "Http3Session": "http3.html#class-http3session", + "Http3Stream": "http3.html#class-http3stream", + "Http3Settings": "http3.html#type-http3settings", + "http3.OnSessionCallback": "http3.html#callback-onsessioncallback", + "http3.OnStreamCallback": "http3.html#callback-onstreamcallback", + "http3.OnGoawayCallback": "http3.html#callback-ongoawaycallback", + "http3.OnOriginCallback": "http3.html#callback-onorigincallback", + "http3.OnSettingsCallback": "http3.html#callback-onsettingscallback", + "http3.OnHeadersCallback": "http3.html#callback-onheaderscallback", + "http3.OnInfoCallback": "http3.html#callback-oninfocallback", + "http3.OnTrailersCallback": "http3.html#callback-ontrailerscallback", + "http3.OnWantTrailersCallback": "http3.html#callback-onwanttrailerscallback", + "http3.OnResetCallback": "http3.html#callback-onresetcallback", + "http3.OnErrorCallback": "http3.html#callback-onerrorcallback" } diff --git a/src/quic/README.md b/src/quic/README.md index 9583acbcd76417..c7c8d20c44ebaf 100644 --- a/src/quic/README.md +++ b/src/quic/README.md @@ -15,7 +15,7 @@ The stack is layered as: ├─────────────────────────────────────────────┤ │ Endpoint — UDP socket, packet I/O │ │ Session — QUIC connection (ngtcp2) │ -│ Application — ALPN protocol logic │ +│ Application — protocol logic (optional) │ │ Stream — Bidirectional data flow │ ├─────────────────────────────────────────────┤ │ ngtcp2 / nghttp3 / OpenSSL │ @@ -25,10 +25,11 @@ The stack is layered as: ``` An **Endpoint** binds a UDP socket and dispatches incoming packets to -**Sessions**. Each Session wraps an `ngtcp2_conn` and delegates -protocol-specific behavior to an **Application** (selected by ALPN -negotiation). Sessions contain **Streams** — bidirectional or unidirectional -data channels that carry application data. +**Sessions**. Each Session wraps an `ngtcp2_conn` and may delegate +protocol-specific behavior to an optional **Application**. With no +Application, the Session handles raw streams itself. Sessions contain +**Streams** — bidirectional or unidirectional data channels that +carry application data. ## File Map @@ -52,13 +53,13 @@ data channels that carry application data. ### Core -| File | Purpose | -| ------------------ | ------------------------------------------------------------ | -| `endpoint.h/cc` | `Endpoint` — UDP binding, packet dispatch, retry/validation | -| `session.h/cc` | `Session` — QUIC connection state machine (\~3,500 lines) | -| `streams.h/cc` | `Stream`, `Outbound`, `PendingStream` — data flow | -| `application.h/cc` | `Session::Application` base + `DefaultApplication` | -| `http3.h/cc` | `Http3ApplicationImpl` — nghttp3 integration (\~1,400 lines) | +| File | Purpose | +| ------------------ | ----------------------------------------------------------- | +| `endpoint.h/cc` | `Endpoint` — UDP binding, packet dispatch, retry/validation | +| `session.h/cc` | `Session` — QUIC connection state machine (\~4,700 lines) | +| `streams.h/cc` | `Stream`, `Outbound`, `PendingStream` — data flow | +| `application.h/cc` | `Session::Application` base + name-keyed factory registry | +| `http3.h/cc` | `Http3Application` — nghttp3 integration (\~1,950 lines) | ### Infrastructure @@ -134,21 +135,35 @@ re-reading from the source. ### Application Abstraction -`Session::Application` is a virtual interface that the Session delegates -ALPN-specific behavior to. Two implementations exist: +`Session::Application` is a protocol-agnostic virtual interface that the +Session delegates protocol-specific behavior to. Implementations register +under a name via `RegisterApplicationFactory`; a Session installs one from +its `options.application` option, if the application is known in advance, or +a consumer attaches one later to wrap the session (see timing below). +When no application is requested or attached, the Session runs a native +raw-stream path itself. -* **`DefaultApplication`** (`application.cc`): Used for non-HTTP/3 ALPN - protocols. Maintains its own stream scheduling queue. Streams are scheduled - via an intrusive linked list. +In effect, an Application acts as an optimization layer for built-in protocols +like HTTP/3: instead of working entirely through the JS interface, it +integrates at the C++ layer for performance and low-level control. -* **`Http3ApplicationImpl`** (`http3.cc`): Used when ALPN negotiates `h3`. - Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK), - server push, and stream prioritization. Manages unidirectional control - streams internally. +One implementation currently ships in core: -The Application is selected during ALPN negotiation — immediately for -clients (ALPN known upfront), during the `OnSelectAlpn` TLS callback for -servers. +* **`Http3Application`** (`http3.cc`): Registered under the name `http3`. + Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK), and + stream prioritization. Manages unidirectional control streams internally. + +Install timing has two paths. Using the `options.application` option configured +up front, the Application is installed early, so it exists before any 0-RTT +early data: immediately at construction for clients, and during the TLS +callback for servers. Alternatively, the consumer can install it later to +wrap an existing session - this must occur either synchronously in the +server's 'session' emit frame, or for a client before its handshake completes. +Effectively the application must be attached before any events are emitted. + +Late attach only supports 1-RTT data for now, but can be expanded to support +0-RTT use cases in future. Attaching once the session is active (`is_active()`, +i.e. delivering events to JS) is rejected. ### Thread-Local Allocator @@ -179,12 +194,14 @@ succeed but memory tracking is silently skipped. **Client**: `Endpoint::Connect()` builds a `Session::Config` with `Side::CLIENT`, creates a `TLSContext`, and calls `Session::Create()` → -`ngtcp2_conn_client_new()`. The Application is selected immediately. +`ngtcp2_conn_client_new()`. An Application configured statically is +installed immediately, or alternatively consumers can install one later. **Server**: `Endpoint::Receive()` processes an Initial packet through address validation (retry tokens, LRU cache), then calls `Session::Create()` -→ `ngtcp2_conn_server_new()`. The Application is selected later, during ALPN -negotiation in the TLS handshake. +→ `ngtcp2_conn_server_new()`. An Application configured statically is +installed during the initial handshake; otherwise a consumer may install +one in the session-delivery tick. ### The Receive Path @@ -341,7 +358,7 @@ connections from the same address to skip validation entirely. ## HTTP/3 Application (`http3.cc`) -The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles: +The `Http3Application` wraps `nghttp3_conn` and handles: * **Header compression**: QPACK encoding/decoding via nghttp3's internal encoder/decoder streams (unidirectional). @@ -360,13 +377,12 @@ The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles: deferred to `PostReceive()` (outside callback scopes) so it can safely invoke JavaScript. * **Settings**: HTTP/3 SETTINGS (max field section size, QPACK capacities, - CONNECT protocol, datagrams) are negotiated and enforced. Datagram - support follows RFC 9297 — when the peer's SETTINGS disable datagrams, - `sendDatagram()` is blocked. -* **0-RTT**: Early data settings are validated during ticket extraction - (`ValidateTicketData` in `ExtractSessionTicketAppData`). If the server's - settings changed incompatibly, the ticket is rejected before TLS accepts - it. + CONNECT protocol) are negotiated and enforced. `SETTINGS_H3_DATAGRAM` + (RFC 9297) is reserved but not advertised (not yet supported). +* **0-RTT**: Early data settings are validated when the session ticket is + parsed (the `parse_ticket` factory hook, `ParseHttp3Ticket`). If the + server's settings changed incompatibly, the ticket is rejected before + TLS accepts it. ## Error Handling diff --git a/test/doctool/test-make-doc.mjs b/test/doctool/test-make-doc.mjs index 59e681707dd473..a7d92baa585077 100644 --- a/test/doctool/test-make-doc.mjs +++ b/test/doctool/test-make-doc.mjs @@ -46,7 +46,8 @@ const expectedJsons = linkedHtmls .map((name) => name.replace('.html', '.json')); const expectedDocs = linkedHtmls.concat(expectedJsons); const renamedDocs = ['policy.json', 'policy.html']; -const skipedDocs = ['dtls.json', 'dtls.html', 'quic.json', 'quic.html']; +const skipedDocs = ['dtls.json', 'dtls.html', 'quic.json', 'quic.html', + 'http3.json', 'http3.html']; // Test that all the relative links in the TOC match to the actual documents. for (const expectedDoc of expectedDocs) { From 0f764fbf8ac4d5c375f1ca229e90496be4dc13ea Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 19 Jun 2026 01:16:28 +0200 Subject: [PATCH 15/15] Fix missed autoformatting --- src/quic/application.h | 5 ++-- src/quic/bindingdata.cc | 8 +++--- src/quic/http3.cc | 60 ++++++++++++++++++----------------------- src/quic/session.cc | 22 ++++++--------- 4 files changed, 39 insertions(+), 56 deletions(-) diff --git a/src/quic/application.h b/src/quic/application.h index 4a6e8215f20103..c6f3a62ded7823 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -199,9 +199,8 @@ struct ApplicationFactory { // exists. Returns the parsed data for the ApplySessionTicketData() // call at install time, or nullptr to reject the ticket (0-RTT is // abandoned and the handshake falls back to a full 1-RTT exchange). - PendingTicketAppData (*parse_ticket)(const uv_buf_t& data, - const Session::Options& options) = - nullptr; + PendingTicketAppData (*parse_ticket)( + const uv_buf_t& data, const Session::Options& options) = nullptr; }; void RegisterApplicationFactory(std::string_view name, const ApplicationFactory& factory); diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index d411e1eb7587b9..ef373540aa3fbd 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -7,7 +7,6 @@ #include #include #include -#include "http3.h" #include #include #include @@ -15,6 +14,7 @@ #include #include #include "bindingdata.h" +#include "http3.h" #include "session.h" #include "session_manager.h" @@ -394,14 +394,12 @@ Local BindingData::transport_params_template() const { transport_params_template_); } -void BindingData::set_http3_settings_template( - Local tmpl) { +void BindingData::set_http3_settings_template(Local tmpl) { http3_settings_template_.Reset(env()->isolate(), tmpl); } Local BindingData::http3_settings_template() const { - return PersistentToLocal::Default(env()->isolate(), - http3_settings_template_); + return PersistentToLocal::Default(env()->isolate(), http3_settings_template_); } #define V(name, _) \ diff --git a/src/quic/http3.cc b/src/quic/http3.cc index 5f444cb49debfa..89ac7f2a3fae48 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -210,8 +210,7 @@ std::string Http3Settings::ToString() const { return res; } -Maybe Http3Settings::From(Environment* env, - Local value) { +Maybe Http3Settings::From(Environment* env, Local value) { if (value.IsEmpty() || !value->IsObject()) [[unlikely]] { THROW_ERR_INVALID_ARG_TYPE(env, "settings must be an object"); return Nothing(); @@ -249,15 +248,13 @@ Maybe Http3Settings::From(Environment* env, MaybeLocal Http3Settings::ToObject(Environment* env) const { auto& binding_data = BindingData::Get(env); auto tmpl = binding_data.http3_settings_template(); - static constexpr std::string_view names[] = { - "maxHeaderPairs", - "maxHeaderLength", - "maxFieldSectionSize", - "qpackMaxDtableCapacity", - "qpackEncoderMaxDtableCapacity", - "qpackBlockedStreams", - "enableConnectProtocol" - }; + static constexpr std::string_view names[] = {"maxHeaderPairs", + "maxHeaderLength", + "maxFieldSectionSize", + "qpackMaxDtableCapacity", + "qpackEncoderMaxDtableCapacity", + "qpackBlockedStreams", + "enableConnectProtocol"}; if (tmpl.IsEmpty()) { tmpl = DictionaryTemplate::New(env->isolate(), names); binding_data.set_http3_settings_template(tmpl); @@ -414,9 +411,8 @@ class Http3Application final : public Session::Application { Session& s = session(); if (s.is_destroyed() || !s.env()->can_call_into_js()) return; CallbackScope cb_scope(&s); - s.MakeCallback(BindingData::Get(s.env()).session_application_callback(), - 0, - nullptr); + s.MakeCallback( + BindingData::Get(s.env()).session_application_callback(), 0, nullptr); }); } @@ -874,12 +870,12 @@ class Http3Application final : public Session::Application { ssize_t ret = 0; Debug(&session(), "HTTP/3 application getting stream data"); if (conn_ && session().max_data_left()) { - ret = nghttp3_conn_writev_stream( - *this, - &data->id, - &data->fin, - reinterpret_cast(data->data), - data->count); + ret = + nghttp3_conn_writev_stream(*this, + &data->id, + &data->fin, + reinterpret_cast(data->data), + data->count); // A negative return value indicates an error. if (ret < 0) { return static_cast(ret); @@ -1151,10 +1147,9 @@ class Http3Application final : public Session::Application { hs.headers.clear(); hs.headers_length = 0; - Local argv[] = { - Array::New(env()->isolate(), values.data(), count), - Integer::NewFromUnsigned(env()->isolate(), - static_cast(kind))}; + Local argv[] = {Array::New(env()->isolate(), values.data(), count), + Integer::NewFromUnsigned( + env()->isolate(), static_cast(kind))}; stream->MakeCallback( binding.stream_headers_callback(), arraysize(argv), argv); @@ -1722,8 +1717,8 @@ JS_METHOD_IMPL(Http3Binding::SendHeaders) { // A pending stream has no id yet; defer the submission until the transport // opens it (the priority header, if any, rides along in this header block). if (stream->is_pending()) { - auto held = std::make_shared>(binding->env()->isolate(), - headers); + auto held = + std::make_shared>(binding->env()->isolate(), headers); stream->RunWhenOpen([stream, flags, held]() { Session& session = stream->session(); if (!session.has_application()) return; @@ -1807,9 +1802,8 @@ JS_METHOD_IMPL(Http3Binding::GetPriority) { if (!session.has_application() || stream->is_pending()) return; auto result = Http3App(session).GetStreamPriority(*stream); - uint32_t packed = - (static_cast(result.priority) << 1) | - (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); + uint32_t packed = (static_cast(result.priority) << 1) | + (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); args.GetReturnValue().Set(packed); } @@ -1836,9 +1830,8 @@ void CreateHttp3Handle(const FunctionCallbackInfo& args) { if (session->is_server() && !session->application().Start()) { // Start() failed (e.g. the peer's initial_max_streams_uni is < 3), so // the application cannot run HTTP/3. - THROW_ERR_INVALID_STATE( - session->env(), - "The HTTP/3 application could not be started"); + THROW_ERR_INVALID_STATE(session->env(), + "The HTTP/3 application could not be started"); return; } } @@ -1888,8 +1881,7 @@ Http3Settings ResolveHttp3Settings(const Session::Options& options) { std::unique_ptr CreateHttp3Application(Session* session) { Debug(session, "Installing HTTP/3 application"); return std::make_unique( - session, - ResolveHttp3Settings(std::as_const(*session).config().options)); + session, ResolveHttp3Settings(std::as_const(*session).config().options)); } Maybe> ParseHttp3Settings(Environment* env, diff --git a/src/quic/session.cc b/src/quic/session.cc index b178891c6c9f4f..05b2f05abd3ac1 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -612,8 +612,7 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || - !SET(application) || - !SET(handshake_timeout) || !SET(initial_rtt) || + !SET(application) || !SET(handshake_timeout) || !SET(initial_rtt) || !SET(keep_alive_timeout) || !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm) || !SET(draining_period_multiplier) || @@ -1612,10 +1611,9 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - QuicError error = - (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) - ? QuicError::ForApplication(app_error_code) - : QuicError(); + QuicError error = (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) + ? QuicError::ForApplication(app_error_code) + : QuicError(); if (session->impl_->application_) { session->application().ReceiveStreamClose(stream, std::move(error)); } else { @@ -2842,8 +2840,7 @@ bool Session::can_send_pending_data() const { // before the application is installed: the application owns stream // scheduling from its first flight. With no application requested the // native path is always ready. - return impl_->application_ != nullptr || - config().options.application.empty(); + return impl_->application_ != nullptr || config().options.application.empty(); } bool Session::stream_fin_managed_by_application() const { @@ -2867,8 +2864,7 @@ std::unique_ptr Session::SelectApplication() { bool Session::AttachApplication(std::unique_ptr app) { if (config().options.app_ticket_data.has_value()) { THROW_ERR_INVALID_STATE( - env(), - "A QUIC application cannot be combined with appTicketData"); + env(), "A QUIC application cannot be combined with appTicketData"); return false; } SetApplication(std::move(app)); @@ -4348,9 +4344,8 @@ void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { if (must_defer_emits()) { auto held = std::make_shared(std::move(datagram)); - impl_->deferred_emits_.emplace_back([this, held, flag]() { - EmitDatagram(std::move(*held), flag); - }); + impl_->deferred_emits_.emplace_back( + [this, held, flag]() { EmitDatagram(std::move(*held), flag); }); return; } @@ -4526,7 +4521,6 @@ void Session::EmitSessionTicket(Store&& ticket) { } } - void Session::DestroyAllStreams(const QuicError& error) { DCHECK(!is_destroyed()); // Copy the streams map since streams remove themselves during