Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,32 @@ will be silently dropped and `0n` returned. The local
`maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls
what this endpoint advertises to the peer as its own maximum.

### `session.servername`

<!-- YAML
added: REPLACEME
-->

* Type: {string|boolean|null}

The SNI (Server Name Indication) host name associated with the session. This is
`null` before the client hello is processed. Once the hello has been
processed, this is either the host name string or `false` if the handshake
had no SNI.

### `session.alpnProtocol`

<!-- YAML
added: REPLACEME
-->

* Type: {string|null}

The negotiated ALPN protocol. This is `null` before the client hello is
processed. Once ALPN has been negotiated, this is the protocol string. ALPN
is mandatory in QUIC so this is never `false` on successful connections,
unlike `node:tls` where this is optional.

### `session.certificate`

<!-- YAML
Expand Down Expand Up @@ -3563,7 +3589,11 @@ added: v23.8.0
* `this` {quic.QuicEndpoint}
* `session` {quic.QuicSession}

The callback function that is invoked when a new session is initiated by a remote peer.
The callback function that is invoked when a new server session is initiated by
a remote peer. It is called once the peer's TLS `ClientHello` has been
processed, so the negotiated TLS parameters are immediately available when
the callback runs. Sessions whose handshake is rejected before this point are
never surfaced.

### Callback: `OnStreamCallback`

Expand Down
38 changes: 37 additions & 1 deletion lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,9 @@ setCallbacks({
this[kOwner][kFinishClose](context, status);
},
/**
* Called when the QuicEndpoint C++ handle receives a new server-side session
* Called when a new server session is surfaced. The emit happens once the
* session's ClientHello has been processed, so its servername/protocol
* getters are already readable.
* @param {object} session The QuicSession C++ handle
*/
onSessionNew(session) {
Expand Down Expand Up @@ -2727,6 +2729,8 @@ class QuicSession {
certificate: undefined,
peerCertificate: undefined,
ephemeralKeyInfo: undefined,
servername: undefined,
alpnProtocol: undefined,
localTransportParams: undefined,
remoteTransportParams: undefined,
};
Expand Down Expand Up @@ -2877,6 +2881,38 @@ class QuicSession {
}
}

/**
* The SNI servername: `null` until known, then the host name string, or
* `false` if the handshake produced no SNI.
* @type {string|boolean|null}
*/
get servername() {
assertIsQuicSession(this);
const inner = this.#inner;
if (inner.servername !== undefined) return inner.servername;
if (this.destroyed) return null;
// The handle returns null until the value is final; cache it only once it
// settles (to a string or `false`) so earlier reads re-query.
const value = this.#handle.getServername();
if (value !== null) inner.servername = value;
return value;
}

/**
* The negotiated ALPN protocol: `null` until known, then the protocol
* string. ALPN is mandatory for QUIC, so there is no "no ALPN" case.
* @type {string|null}
*/
get alpnProtocol() {
assertIsQuicSession(this);
const inner = this.#inner;
if (inner.alpnProtocol !== undefined) return inner.alpnProtocol;
if (this.destroyed) return null;
const value = this.#handle.getAlpnProtocol();
if (value !== null) inner.alpnProtocol = value;
return value;
}

/** @type {OnDatagramCallback} */
get ondatagram() {
assertIsQuicSession(this);
Expand Down
30 changes: 18 additions & 12 deletions src/quic/endpoint.cc
Original file line number Diff line number Diff line change
Expand Up @@ -814,8 +814,8 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> 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);
}
Expand All @@ -825,22 +825,22 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> 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
// ClientHello has been processed (see Session::ReadPacket), so the
// session is exposed to JS only once its SNI/ALPN are known and invalid
// handshakes never surface.
} else {
STAT_INCREMENT(Stats, client_sessions);
}
Expand Down Expand Up @@ -1980,6 +1980,12 @@ void Endpoint::EmitNewSession(const BaseObjectPtr<Session>& 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) {
Expand Down
123 changes: 121 additions & 2 deletions src/quic/session.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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) \
Expand Down Expand Up @@ -781,6 +783,8 @@ struct Session::Impl final : public MemoryRetainer {
SocketAddress remote_address_;
std::unique_ptr<Application> application_;
StreamsMap streams_;
// Emits deferred until after session setup is completed
std::vector<std::function<void()>> deferred_emits_;
TimerWrapHandle timer_;
size_t send_scope_depth_ = 0;
QuicError last_error_;
Expand Down Expand Up @@ -1001,6 +1005,41 @@ struct Session::Impl final : public MemoryRetainer {
session->Destroy();
}

// The SNI servername: null until the TLS parameters are final, then the
// host name string, or false if the handshake produced no SNI.
JS_METHOD(GetServername) {
auto env = Environment::GetCurrent(args);
Session* session;
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
if (session->is_destroyed() || !session->tls_info_ready()) {
return args.GetReturnValue().SetNull();
}
auto sn = session->tls_session().servername();
if (sn.empty()) return args.GetReturnValue().Set(false);
Local<Value> ret;
if (ToV8Value(env->context(), sn).ToLocal(&ret)) {
args.GetReturnValue().Set(ret);
}
}

// The negotiated ALPN protocol: null until the TLS parameters are final,
// then the protocol string.
JS_METHOD(GetAlpnProtocol) {
auto env = Environment::GetCurrent(args);
Session* session;
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
if (session->is_destroyed() || !session->tls_info_ready()) {
return args.GetReturnValue().SetNull();
}
auto proto = session->tls_session().protocol();
// QUIC requires ALPN
DCHECK(!proto.empty());
Local<Value> ret;
if (ToV8Value(env->context(), proto).ToLocal(&ret)) {
args.GetReturnValue().Set(ret);
}
}

JS_METHOD(GetRemoteAddress) {
auto env = Environment::GetCurrent(args);
Session* session;
Expand Down Expand Up @@ -2190,6 +2229,12 @@ 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()) {
QueueDeferredEmit(
[this, flags, held = std::string(data)]() { 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
Expand Down Expand Up @@ -2312,6 +2357,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<Session>(this));
}
}
return true;
}
Expand Down Expand Up @@ -2975,6 +3030,36 @@ 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 the ClientHello has been processed and wrapped; anything
// emitted before then has no JS wrapper to receive it and must be held
// for replay.
return is_server() && !impl_->state()->wrapped;
}

bool Session::tls_info_ready() const {
// hello_processed_ is set server-side, handshake_completed covers
// the client. Together they mark the point when SNI/ALPN are final.
return hello_processed_ || impl_->state()->handshake_completed;
}

void Session::QueueDeferredEmit(std::function<void()> fn) {
impl_->deferred_emits_.emplace_back(std::move(fn));
}

void Session::ReplayDeferredEmits() {
if (is_destroyed()) return;
DCHECK(impl_->state()->wrapped);
// Runs synchronously immediately after the new-session callback
// returns (still within first-flight processing).
auto emits = std::move(impl_->deferred_emits_);
for (auto& emit : emits) {
if (is_destroyed()) return;
emit();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since each of these is a C++-to-JavaScript call, I wonder if it would be possible to batch them to a new callback. Essentially, rather than enqueuing a function, enqueue the arguments and type of emit, send them all to JavaScript at once and process each one there instead. Calls from C++-to-JavaScript can be a bit expensive to set up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can be looked at later tho.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've left this for now. It looks likely to be a little fiddly, and I think it rarely comes up (just a couple of 0RTT events, or keylog/qlog when they're enabled) but we can explore as an optimization later.

}
}

void Session::set_priority_supported(bool on) {
DCHECK(!is_destroyed());
impl_->state()->priority_supported = on ? 1 : 0;
Expand Down Expand Up @@ -3284,6 +3369,10 @@ bool Session::HandshakeCompleted() {

Debug(this, "Session handshake completed");
impl_->state()->handshake_completed = 1;
// This implies fully completing a handshake without setting hello_processed
// (set during ALPN negotiation). Should be impossible unless ALPN flow is
// changed drastically, but good to check as it'd lose sessions.
DCHECK(!is_server() || hello_processed_);

STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
SetStreamOpenAllowed();
Expand Down Expand Up @@ -3482,6 +3571,7 @@ void Session::set_max_datagram_size(uint16_t size) {

void Session::EmitGoaway(stream_id last_stream_id) {
if (is_destroyed()) return;
if (DeferEmit([this, last_stream_id] { EmitGoaway(last_stream_id); })) return;
if (!env()->can_call_into_js()) return;

CallbackScope<Session> cb_scope(this);
Expand All @@ -3496,6 +3586,14 @@ void Session::EmitGoaway(stream_id last_stream_id) {

void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
DCHECK(!is_destroyed());

if (must_defer_emits()) {
QueueDeferredEmit([this, datagram = std::move(datagram), flag]() mutable {
EmitDatagram(std::move(datagram), flag);
});
return;
}

if (!env()->can_call_into_js()) return;

CallbackScope<Session> cbv_scope(this);
Expand All @@ -3511,6 +3609,8 @@ void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
void Session::EmitDatagramStatus(datagram_id id, quic::DatagramStatus status) {
DCHECK(!is_destroyed());

if (DeferEmit([this, id, status] { EmitDatagramStatus(id, status); })) return;

if (!env()->can_call_into_js()) return;

CallbackScope<Session> cb_scope(this);
Expand Down Expand Up @@ -3672,6 +3772,7 @@ void Session::EmitSessionTicket(Store&& ticket) {

void Session::EmitApplication() {
if (is_destroyed()) return;
if (DeferEmit([this] { EmitApplication(); })) return;
if (!env()->can_call_into_js()) return;

if (!has_application()) {
Expand Down Expand Up @@ -3742,6 +3843,10 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) {
void Session::EmitStream(const BaseObjectWeakPtr<Stream>& stream) {
DCHECK(!is_destroyed());

if (DeferEmit([this, stream] { EmitStream(stream); })) return;

if (!stream) return;

if (!env()->can_call_into_js()) return;
CallbackScope<Session> cb_scope(this);

Expand Down Expand Up @@ -3797,6 +3902,14 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,

void Session::EmitOrigins(std::vector<std::string>&& origins) {
DCHECK(!is_destroyed());

if (must_defer_emits()) {
QueueDeferredEmit([this, origins = std::move(origins)]() mutable {
EmitOrigins(std::move(origins));
});
return;
}

if (!HasListenerFlag(impl_->state()->listener_flags,
SessionListenerFlags::ORIGIN))
return;
Expand All @@ -3822,11 +3935,17 @@ void Session::EmitOrigins(std::vector<std::string>&& origins) {

void Session::EmitKeylog(const char* line) {
DCHECK(!is_destroyed());

if (must_defer_emits()) {
QueueDeferredEmit(
[this, str = std::string(line)]() { EmitKeylog(str.c_str()); });
return;
}

if (!env()->can_call_into_js()) return;

auto str = std::string(line);
Local<Value> argv[] = {Undefined(env()->isolate())};
if (!ToV8Value(env()->context(), str).ToLocal(&argv[0])) {
if (!ToV8Value(env()->context(), std::string(line)).ToLocal(&argv[0])) {
Debug(this, "Failed to convert keylog line to V8 string");
return;
}
Expand Down
Loading
Loading