-
-
Notifications
You must be signed in to change notification settings - Fork 36k
quic: defer server session emit until TLS ClientHello is processed #64132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
|
@@ -2877,6 +2879,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(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's cache these values after the first call to avoid the repeated calls across the C++ boundary. |
||
| } | ||
|
|
||
| /** | ||
| * 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); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) \ | ||
|
|
@@ -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_; | ||
|
|
@@ -1001,6 +1005,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<Value> 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<Value> ret; | ||
| if (ToV8Value(env->context(), proto).ToLocal(&ret)) { | ||
| args.GetReturnValue().Set(ret); | ||
| } | ||
| } | ||
|
|
||
| JS_METHOD(GetRemoteAddress) { | ||
| auto env = Environment::GetCurrent(args); | ||
| Session* session; | ||
|
|
@@ -2190,6 +2224,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 | ||
|
|
@@ -2312,6 +2352,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; | ||
| } | ||
|
|
@@ -2975,6 +3025,30 @@ 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; | ||
| } | ||
|
|
||
| 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(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be looked at later tho. |
||
| } | ||
| } | ||
|
|
||
| void Session::set_priority_supported(bool on) { | ||
| DCHECK(!is_destroyed()); | ||
| impl_->state()->priority_supported = on ? 1 : 0; | ||
|
|
@@ -3284,6 +3358,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(); | ||
|
|
@@ -3482,6 +3560,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); | ||
|
|
@@ -3496,6 +3575,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); | ||
|
|
@@ -3511,6 +3598,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); | ||
|
|
@@ -3672,6 +3761,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()) { | ||
|
|
@@ -3742,6 +3832,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); | ||
|
|
||
|
|
@@ -3797,6 +3891,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; | ||
|
|
@@ -3822,11 +3924,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; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering if
string|nullmight be more semantically correct. Just a nit tho... feel free to ignore.Either way, this should explain what
undefinedmeans.