GeezerVison is showing github.com — quic: improve backend quic packet processing · nodejs/node@ff0cd5b
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: src/quic/bindingdata.cc +55Lines changed: 55 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -22,6 +22,7 @@ namespace node { | 22 | 22 | using mem::kReserveSizeAndAlign; | 23 | 23 | using v8::Function; | 24 | 24 | using v8::FunctionTemplate; | | 25 | +using v8::HandleScope; | 25 | 26 | using v8::Local; | 26 | 27 | using v8::Object; | 27 | 28 | using v8::String; | @@ -154,6 +155,16 @@ BindingData& BindingData::Get(Environment* env) { | 154 | 155 |
| 155 | 156 | BindingData::~BindingData() { | 156 | 157 | quic_alloc_state.binding = nullptr; | | 158 | + if (flush_check_initialized_) { | | 159 | + uv_check_stop(&flush_check_); | | 160 | + flush_check_started_ = false; | | 161 | + // The check handle is closed inline here. Because BindingData destruction | | 162 | + // happens during Environment cleanup, the handle will be finalized by | | 163 | + // libuv's close phase. | | 164 | + uv_close(reinterpret_cast<uv_handle_t*>(&flush_check_), nullptr); | | 165 | + flush_check_initialized_ = false; | | 166 | + } | | 167 | + pending_flush_sessions_.clear(); | 157 | 168 | } | 158 | 169 |
| 159 | 170 | ngtcp2_mem* BindingData::ngtcp2_allocator() { | @@ -221,6 +232,11 @@ void BindingData::RegisterExternalReferences( | 221 | 232 | BindingData::BindingData(Realm* realm, Local<Object> object) | 222 | 233 | : BaseObject(realm, object) { | 223 | 234 | MakeWeak(); | | 235 | + CHECK_EQ(uv_check_init(env()->event_loop(), &flush_check_), 0); | | 236 | + flush_check_.data = this; | | 237 | + // Unref so the check handle doesn't keep the event loop alive on its own. | | 238 | + uv_unref(reinterpret_cast<uv_handle_t*>(&flush_check_)); | | 239 | + flush_check_initialized_ = true; | 224 | 240 | } | 225 | 241 |
| 226 | 242 | SessionManager& BindingData::session_manager() { | @@ -230,6 +246,45 @@ SessionManager& BindingData::session_manager() { | 230 | 246 | return *session_manager_; | 231 | 247 | } | 232 | 248 |
| | 249 | +void BindingData::ScheduleSessionFlush(const BaseObjectPtr<Session>& session) { | | 250 | + pending_flush_sessions_.push_back(session); | | 251 | + if (!flush_check_started_) { | | 252 | + uv_check_start(&flush_check_, OnFlushCheck); | | 253 | + flush_check_started_ = true; | | 254 | + } | | 255 | +} | | 256 | + | | 257 | +void BindingData::OnFlushCheck(uv_check_t* handle) { | | 258 | + auto* binding = static_cast<BindingData*>(handle->data); | | 259 | + if (binding->pending_flush_sessions_.empty()) { | | 260 | + uv_check_stop(&binding->flush_check_); | | 261 | + binding->flush_check_started_ = false; | | 262 | + return; | | 263 | + } | | 264 | + | | 265 | + HandleScope scope(binding->env()->isolate()); | | 266 | + | | 267 | + // Swap to a local vector before iterating. SendPendingData may trigger | | 268 | + // MakeCallback which runs JS that could cause more packet receives via | | 269 | + // re-entry (e.g., a stream data callback that synchronously writes to | | 270 | + // another session). Any sessions added during the flush remain in | | 271 | + // pending_flush_sessions_ and are picked up on the next check tick. | | 272 | + auto sessions = std::move(binding->pending_flush_sessions_); | | 273 | + for (auto& session : sessions) { | | 274 | + session->pending_flush_ = false; | | 275 | + if (!session->is_destroyed()) { | | 276 | + session->FlushPendingData(); | | 277 | + } | | 278 | + } | | 279 | + | | 280 | + // If no new sessions were added during the flush, stop the check | | 281 | + // to avoid per-tick callback overhead when idle. | | 282 | + if (binding->pending_flush_sessions_.empty()) { | | 283 | + uv_check_stop(&binding->flush_check_); | | 284 | + binding->flush_check_started_ = false; | | 285 | + } | | 286 | +} | | 287 | + | 233 | 288 | void BindingData::MemoryInfo(MemoryTracker* tracker) const { | 234 | 289 | #define V(name, _) tracker->TrackField(#name, name##_callback()); | 235 | 290 |
| |
Collapse file Copy file name to clipboardExpand all lines: src/quic/bindingdata.h +20Lines changed: 20 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 10 | 10 | #include <ngtcp2/ngtcp2_crypto.h> | 11 | 11 | #include <node.h> | 12 | 12 | #include <node_mem.h> | | 13 | +#include <uv.h> | 13 | 14 | #include <v8.h> | 14 | 15 | #include <memory> | 15 | 16 | #include <unordered_map> | | 17 | +#include <vector> | 16 | 18 | #include "defs.h" | 17 | 19 |
| 18 | 20 | namespace node::quic { | @@ -202,6 +204,13 @@ class BindingData final | 202 | 204 | // routing so that any endpoint can route packets to any session. | 203 | 205 | SessionManager& session_manager(); | 204 | 206 |
| | 207 | + // Schedule a session for deferred SendPendingData. Sessions are accumulated | | 208 | + // during the I/O poll phase (via Endpoint::Receive -> Session::ReadPacket) | | 209 | + // and flushed in a uv_check callback immediately after poll completes. | | 210 | + // This batches multiple received packets before generating responses, | | 211 | + // allowing ngtcp2 to make better ACK coalescing decisions. | | 212 | + void ScheduleSessionFlush(const BaseObjectPtr<Session>& session); | | 213 | + | 205 | 214 | std::unordered_map<Endpoint*, BaseObjectPtr<BaseObject>> listening_endpoints; | 206 | 215 |
| 207 | 216 | size_t current_ngtcp2_memory_ = 0; | @@ -248,6 +257,17 @@ class BindingData final | 248 | 257 | #undef V | 249 | 258 |
| 250 | 259 | std::unique_ptr<SessionManager> session_manager_; | | 260 | + | | 261 | + // Deferred send flush state. The uv_check_t fires immediately after | | 262 | + // the I/O poll phase in the same event loop tick, allowing batched | | 263 | + // receive processing: all packets are read during poll, then | | 264 | + // SendPendingData is called once per dirty session in the check callback. | | 265 | + uv_check_t flush_check_; | | 266 | + std::vector<BaseObjectPtr<Session>> pending_flush_sessions_; | | 267 | + bool flush_check_started_ = false; | | 268 | + bool flush_check_initialized_ = false; | | 269 | + | | 270 | + static void OnFlushCheck(uv_check_t* handle); | 251 | 271 | }; | 252 | 272 |
| 253 | 273 | JS_METHOD_IMPL(IllegalConstructor); | |
Collapse file Copy file name to clipboardExpand all lines: src/quic/endpoint.cc +60-1Lines changed: 60 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -492,6 +492,14 @@ int Endpoint::UDP::Send(Packet::Ptr packet) { | 492 | 492 | return err; | 493 | 493 | } | 494 | 494 |
| | 495 | +int Endpoint::UDP::TrySend(Packet* packet) { | | 496 | + DCHECK_NOT_NULL(packet); | | 497 | + if (is_closed_or_closing()) return UV_EBADF; | | 498 | + uv_buf_t buf = *packet; | | 499 | + return uv_udp_try_send( | | 500 | + &impl_->handle_, &buf, 1, packet->destination().data()); | | 501 | +} | | 502 | + | 495 | 503 | void Endpoint::UDP::MemoryInfo(MemoryTracker* tracker) const { | 496 | 504 | if (impl_) tracker->TrackField("impl", impl_); | 497 | 505 | } | @@ -812,6 +820,45 @@ void Endpoint::Send(Packet::Ptr packet) { | 812 | 820 | STAT_INCREMENT(Stats, packets_sent); | 813 | 821 | } | 814 | 822 |
| | 823 | +void Endpoint::SendOrTrySend(Packet::Ptr packet) { | | 824 | +#ifdef DEBUG | | 825 | + if (is_diagnostic_packet_loss(options_.tx_loss)) [[unlikely]] { | | 826 | + return; | | 827 | + } | | 828 | +#endif | | 829 | + | | 830 | + if (is_closed() || is_closing() || packet->length() == 0) { | | 831 | + return; | | 832 | + } | | 833 | + | | 834 | + Debug(this, "TrySend %s", packet->ToString()); | | 835 | + size_t packet_length = packet->length(); | | 836 | + | | 837 | + // Attempt synchronous send. On success (returns number of bytes sent), | | 838 | + // the packet is delivered immediately — no callback overhead, no | | 839 | + // waiting for the next poll cycle. | | 840 | + int err = udp_.TrySend(packet.get()); | | 841 | + if (err >= 0) { | | 842 | + // Synchronous send succeeded. Release the packet immediately. | | 843 | + STAT_INCREMENT_N(Stats, bytes_sent, packet_length); | | 844 | + STAT_INCREMENT(Stats, packets_sent); | | 845 | + // Ptr destructor releases back to arena pool. | | 846 | + return; | | 847 | + } | | 848 | + | | 849 | + if (err == UV_EAGAIN) { | | 850 | + // Socket not writable or async sends are queued. Fall back to the | | 851 | + // async path — the packet will be queued and flushed on the next | | 852 | + // POLLOUT cycle. | | 853 | + Debug(this, "TrySend got EAGAIN, falling back to async Send"); | | 854 | + return Send(std::move(packet)); | | 855 | + } | | 856 | + | | 857 | + // Other errors are fatal. | | 858 | + Debug(this, "TrySend failed with error %d", err); | | 859 | + Destroy(CloseContext::SEND_FAILURE, err); | | 860 | +} | | 861 | + | 815 | 862 | void Endpoint::SendRetry(const PathDescriptor& options) { | 816 | 863 | // Generating and sending retry packets does consume some system resources, | 817 | 864 | // and it is possible for a malicious peer to trigger sending a large number | @@ -1152,10 +1199,22 @@ void Endpoint::Receive(const uv_buf_t& buf, | 1152 | 1199 | DCHECK_NOT_NULL(session); | 1153 | 1200 | if (session->is_destroyed()) return; | 1154 | 1201 | size_t len = store.length(); | 1155 | | - if (session->Receive(std::move(store), local_address, remote_address)) { | | 1202 | + // Use ReadPacket (no SendPendingDataScope) so that multiple packets | | 1203 | + // received in the same I/O burst are processed before any responses | | 1204 | + // are generated. The deferred flush via BindingData's uv_check | | 1205 | + // callback calls SendPendingData once per dirty session after all | | 1206 | + // packets in the burst have been read. | | 1207 | + if (session->ReadPacket(std::move(store), local_address, remote_address)) { | 1156 | 1208 | STAT_INCREMENT_N(Stats, bytes_received, len); | 1157 | 1209 | STAT_INCREMENT(Stats, packets_received); | 1158 | 1210 | } | | 1211 | + // Schedule the session for deferred SendPendingData if it hasn't | | 1212 | + // been scheduled already in this burst. | | 1213 | + if (!session->is_destroyed() && !session->pending_flush_) { | | 1214 | + session->pending_flush_ = true; | | 1215 | + BindingData::Get(env()).ScheduleSessionFlush( | | 1216 | + BaseObjectPtr<Session>(session)); | | 1217 | + } | 1159 | 1218 | }; | 1160 | 1219 |
| 1161 | 1220 | const auto accept = [&](const Session::Config& config, Store&& store) { | |
Collapse file Copy file name to clipboardExpand all lines: src/quic/endpoint.h +13Lines changed: 13 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -228,6 +228,13 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { | 228 | 228 |
| 229 | 229 | void Send(Packet::Ptr packet); | 230 | 230 |
| | 231 | + // Attempt synchronous send via uv_udp_try_send. If the socket is | | 232 | + // writable, the packet is sent immediately and the Ptr is released. | | 233 | + // If the socket is not writable (UV_EAGAIN), falls back to the | | 234 | + // async Send path. Used by the deferred flush callback to avoid | | 235 | + // the one-tick latency of async uv_udp_send. | | 236 | + void SendOrTrySend(Packet::Ptr packet); | | 237 | + | 231 | 238 | // Acquire a Packet from the pool. length sets the initial working | 232 | 239 | // size (must be <= pool capacity). The slot is always allocated at | 233 | 240 | // full capacity to avoid fragmentation. | @@ -301,6 +308,12 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { | 301 | 308 | void Close(); | 302 | 309 | int Send(Packet::Ptr packet); | 303 | 310 |
| | 311 | + // Synchronous send using uv_udp_try_send. Returns 0 on success, | | 312 | + // UV_EAGAIN if the socket is not writable or the send queue is | | 313 | + // non-empty, or another negative error code on failure. | | 314 | + // On success, the caller is responsible for releasing the packet. | | 315 | + int TrySend(Packet* packet); | | 316 | + | 304 | 317 | // Returns the local UDP socket address to which we are bound, | 305 | 318 | // or fail with an assert if we are not bound. | 306 | 319 | SocketAddress local_address() const; | |
Collapse file Copy file name to clipboardExpand all lines: src/quic/session.cc +38-7Lines changed: 38 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -2112,13 +2112,21 @@ void Session::SetLastError(QuicError&& error) { | 2112 | 2112 | bool Session::Receive(Store&& store, | 2113 | 2113 | const SocketAddress& local_address, | 2114 | 2114 | const SocketAddress& remote_address) { | | 2115 | + // Convenience wrapper: reads the packet and immediately triggers | | 2116 | + // SendPendingData. Used by paths that need an immediate response | | 2117 | + // (e.g., Endpoint::Connect for client Initial packets). | | 2118 | + // The hot receive path uses ReadPacket() directly with deferred | | 2119 | + // flush via BindingData's uv_check callback. | | 2120 | + SendPendingDataScope send_scope(this); | | 2121 | + return ReadPacket(std::move(store), local_address, remote_address); | | 2122 | +} | | 2123 | + | | 2124 | +bool Session::ReadPacket(Store&& store, | | 2125 | + const SocketAddress& local_address, | | 2126 | + const SocketAddress& remote_address) { | 2115 | 2127 | DCHECK(!is_destroyed()); | 2116 | 2128 | impl_->remote_address_ = remote_address; | 2117 | 2129 |
| 2118 | | - // When we are done processing this packet, we arrange to send any | 2119 | | - // pending data for this session. | 2120 | | - SendPendingDataScope send_scope(this); | 2121 | | - | 2122 | 2130 | ngtcp2_vec vec = store; | 2123 | 2131 | Path path(local_address, remote_address); | 2124 | 2132 |
| @@ -2133,14 +2141,16 @@ bool Session::Receive(Store&& store, | 2133 | 2141 | // ensures that any deferred destroy waits until all callbacks for this | 2134 | 2142 | // packet have completed. After calling ngtcp2_conn_read_pkt here, we | 2135 | 2143 | // will need to double check that the session is not destroyed before | 2136 | | - // we try doing anything with it (like updating stats, sending pending | 2137 | | - // data, etc). | | 2144 | + // we try doing anything with it (like updating stats, etc). | 2138 | 2145 | int err; | 2139 | 2146 | { | 2140 | 2147 | NgTcp2CallbackScope callback_scope(this); | | 2148 | + // ECN codepoint (ngtcp2_pkt_info.ecn) is not yet populated because | | 2149 | + // libuv does not currently deliver per-packet ECN metadata. When | | 2150 | + // libuv gains ECN receive reporting, the pkt_info should be | | 2151 | + // populated from the per-packet metadata and passed through here. | 2141 | 2152 | err = ngtcp2_conn_read_pkt(*this, | 2142 | 2153 | &path, | 2143 | | - // TODO(@jasnell): ECN pkt_info blocked on libuv | 2144 | 2154 | nullptr, | 2145 | 2155 | vec.base, | 2146 | 2156 | vec.len, | @@ -2253,6 +2263,17 @@ bool Session::Receive(Store&& store, | 2253 | 2263 | return false; | 2254 | 2264 | } | 2255 | 2265 |
| | 2266 | +void Session::FlushPendingData() { | | 2267 | + DCHECK(!is_destroyed()); | | 2268 | + if (impl_->application_) { | | 2269 | + // Prefer synchronous sends during the deferred flush to avoid the | | 2270 | + // one-tick latency of async uv_udp_send from the uv_check callback. | | 2271 | + prefer_try_send_ = true; | | 2272 | + application().SendPendingData(); | | 2273 | + prefer_try_send_ = false; | | 2274 | + } | | 2275 | +} | | 2276 | + | 2256 | 2277 | void Session::Send(Packet::Ptr packet) { | 2257 | 2278 | // Sending a Packet is generally best effort. If we're not in a state | 2258 | 2279 | // where we can send a packet, it's ok to drop it on the floor. The | @@ -2269,6 +2290,16 @@ void Session::Send(Packet::Ptr packet) { | 2269 | 2290 | return; | 2270 | 2291 | } | 2271 | 2292 |
| | 2293 | + // When called from the deferred flush path (uv_check callback), | | 2294 | + // prefer synchronous send to avoid the one-tick latency of async | | 2295 | + // uv_udp_send. SendOrTrySend uses uv_udp_try_send first, falling | | 2296 | + // back to uv_udp_send on EAGAIN. | | 2297 | + if (prefer_try_send_) { | | 2298 | + Debug(this, "Session is sending (try_send) %s", packet->ToString()); | | 2299 | + endpoint().SendOrTrySend(std::move(packet)); | | 2300 | + return; | | 2301 | + } | | 2302 | + | 2272 | 2303 | Debug(this, "Session is sending %s", packet->ToString()); | 2273 | 2304 | endpoint().Send(std::move(packet)); | 2274 | 2305 | } | |
Collapse file Copy file name to clipboardExpand all lines: src/quic/session.h +28Lines changed: 28 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -357,6 +357,23 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { | 357 | 357 | const SocketAddress& local_address, | 358 | 358 | const SocketAddress& remote_address); | 359 | 359 |
| | 360 | + // ReadPacket processes a single inbound packet through ngtcp2 without | | 361 | + // triggering SendPendingData. This is the building block for batched | | 362 | + // receive processing: the caller (Endpoint::Receive) accumulates | | 363 | + // dirty sessions and a uv_check callback flushes them after all | | 364 | + // packets in the I/O burst have been read. | | 365 | + // Receive() is kept as a convenience wrapper that calls ReadPacket() | | 366 | + // then triggers SendPendingData (for paths like Connect that need | | 367 | + // immediate response). | | 368 | + bool ReadPacket(Store&& store, | | 369 | + const SocketAddress& local_address, | | 370 | + const SocketAddress& remote_address); | | 371 | + | | 372 | + // Called by BindingData's flush callback to trigger SendPendingData | | 373 | + // on this session. Encapsulates the application() access so that | | 374 | + // bindingdata.cc doesn't need the full Application type definition. | | 375 | + void FlushPendingData(); | | 376 | + | 360 | 377 | void Send(Packet::Ptr packet); | 361 | 378 | void Send(Packet::Ptr packet, const PathStorage& path); | 362 | 379 | datagram_id SendDatagram(Store&& data); | @@ -572,11 +589,22 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { | 572 | 589 | bool in_ngtcp2_callback_scope_ = false; | 573 | 590 | bool in_nghttp3_callback_scope_ = false; | 574 | 591 | bool destroy_deferred_ = false; | | 592 | + // Set when this session is in BindingData's pending_flush_sessions_ vector. | | 593 | + // Cleared by the flush callback before calling SendPendingData. | | 594 | + // Provides O(1) dedup so a session receiving multiple packets in one I/O | | 595 | + // burst is only scheduled for flush once. | | 596 | + bool pending_flush_ = false; | | 597 | + // When true, Session::Send prefers synchronous delivery via | | 598 | + // Endpoint::SendOrTrySend (uv_udp_try_send with async fallback). | | 599 | + // Set during FlushPendingData to avoid the one-tick latency of | | 600 | + // async-only sends from the uv_check callback. | | 601 | + bool prefer_try_send_ = false; | 575 | 602 | QuicConnectionPointer connection_; | 576 | 603 | std::unique_ptr<TLSSession> tls_session_; | 577 | 604 | friend struct NgTcp2CallbackScope; | 578 | 605 | friend struct NgHttp3CallbackScope; | 579 | 606 | friend class Application; | | 607 | + friend class BindingData; | 580 | 608 | friend class DefaultApplication; | 581 | 609 | friend class Http3ApplicationImpl; | 582 | 610 | friend class Endpoint; | |
|