Skip to content

Commit 02e0f3a

Browse files
jasnelladuh95
authored andcommitted
quic: apply multiple fixes to flow control signaling
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus PR-URL: #65309 Reviewed-By: Tim Perry <pimterry@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
1 parent f78b247 commit 02e0f3a

16 files changed

Lines changed: 1109 additions & 18 deletions

lib/internal/blob.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -604,12 +604,28 @@ function createBlobReaderStream(reader) {
604604
}, { highWaterMark: 0 });
605605
}
606606

607-
// Maximum number of chunks to collect in a single batch to prevent
608-
// unbounded memory growth when the DataQueue has a large burst of data.
607+
// Upper bound on the number of chunks collected in a single batch. This is
608+
// only a cap on the length of the yielded array -- the primary limit is the
609+
// byte budget below, since under a byte-budget backpressure model the size of
610+
// a batch is what matters, not how many pieces it arrives in.
609611
const kMaxBatchChunks = 16;
610612

613+
// Default number of bytes to collect in a single batch. Entries in the
614+
// DataQueue can each be as large as the peer's flow control window, so a
615+
// purely count-based limit could produce enormous batches (16 entries of
616+
// 1 MB each).
617+
//
618+
// This matters for more than just the size of the yielded array. Consumers
619+
// like QUIC return flow control credit from the reader's pull path -- once
620+
// per pull, not once per batch -- so every pull this loop performs invites
621+
// the peer to send that many more bytes. Pulling greedily therefore grants
622+
// credit for data the consumer has not looked at yet. Bounding the loop by
623+
// bytes limits how far ahead of actual consumption that credit can run,
624+
// which is what keeps the amount of data buffered in JS bounded.
625+
const kDefaultMaxBatchBytes = 65536;
626+
611627
async function* createBlobReaderIterable(reader, options = kEmptyObject) {
612-
const { getReadError } = options;
628+
const { getReadError, maxBatchBytes = kDefaultMaxBatchBytes } = options;
613629
let wakeup = PromiseWithResolvers();
614630
let immediate;
615631
let fin = false;
@@ -624,6 +640,7 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
624640
try {
625641
while (true) {
626642
const batch = [];
643+
let batchBytes = 0;
627644
let blocked = false;
628645
let eos = false;
629646
let error = null;
@@ -652,8 +669,15 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
652669
blocked = true;
653670
break;
654671
}
655-
ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer));
656-
if (batch.length >= kMaxBatchChunks) break;
672+
const chunk = new Uint8Array(pullResult.buffer);
673+
ArrayPrototypePush(batch, chunk);
674+
// Stop collecting once the batch is large enough. The byte budget is
675+
// the primary limit; the chunk count is a secondary bound so that a
676+
// long run of tiny chunks cannot produce an unwieldy array.
677+
batchBytes += chunk.byteLength;
678+
if (batchBytes >= maxBatchBytes || batch.length >= kMaxBatchChunks) {
679+
break;
680+
}
657681
}
658682

659683
if (batch.length > 0) {

src/dataqueue/queue.cc

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,35 @@ class DataQueueImpl final : public DataQueue,
174174
backpressure_listeners_.erase(listener);
175175
}
176176

177+
// Both notifications below can re-enter this DataQueue: a listener may end
178+
// up calling into JavaScript, which can destroy the owner of a listener and
179+
// so mutate backpressure_listeners_ (or drop the last reference to this
180+
// queue) while we are iterating. Hold a reference, iterate a snapshot, and
181+
// re-check membership so a listener removed mid-notification is not called.
177182
void NotifyBackpressure(size_t amount) {
178183
if (idempotent_) return;
179-
for (auto& listener : backpressure_listeners_) listener->EntryRead(amount);
184+
if (backpressure_listeners_.empty()) return;
185+
auto self = shared_from_this();
186+
std::vector<BackpressureListener*> listeners(
187+
backpressure_listeners_.begin(), backpressure_listeners_.end());
188+
for (auto* listener : listeners) {
189+
if (backpressure_listeners_.contains(listener)) {
190+
listener->EntryRead(amount);
191+
}
192+
}
180193
}
181194

182195
void NotifyBeforePull() {
183196
if (idempotent_) return;
184-
for (auto& listener : backpressure_listeners_) listener->BeforePull();
197+
if (backpressure_listeners_.empty()) return;
198+
auto self = shared_from_this();
199+
std::vector<BackpressureListener*> listeners(
200+
backpressure_listeners_.begin(), backpressure_listeners_.end());
201+
for (auto* listener : listeners) {
202+
if (backpressure_listeners_.contains(listener)) {
203+
listener->BeforePull();
204+
}
205+
}
185206
}
186207

187208
bool HasBackpressureListeners() const noexcept {

src/quic/application.cc

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ void Session::Application::ReceiveStreamReset(Stream* stream,
254254
stream->ReceiveStreamReset(final_size, std::move(error));
255255
}
256256

257+
void Session::Application::ReturnConnectionCredit(size_t datalen) {
258+
if (datalen == 0 || session().is_destroyed()) return;
259+
Session::SendPendingDataScope send_scope(&session());
260+
session().ExtendOffset(datalen);
261+
}
262+
257263
// ============================================================================
258264
// The DefaultApplication is the default implementation of Session::Application
259265
// that is used for all unrecognized ALPN identifiers.
@@ -316,6 +322,22 @@ class DefaultApplication final : public Session::Application {
316322
void* stream_user_data) override {
317323
BaseObjectPtr<Stream> stream;
318324
if (stream_user_data == nullptr) {
325+
// A locally-initiated stream only exists because we created it, so a
326+
// missing Stream means we already destroyed it. Data the peer had put in
327+
// flight must not resurrect it as a bogus "incoming" stream. Discard it
328+
// and return its credit instead. The is_destroyed() check must come
329+
// first: an earlier callback in this same ngtcp2 batch may have
330+
// destroyed the session, after which none of this may be touched.
331+
if (!session().is_destroyed() &&
332+
ngtcp2_conn_is_local_stream(session(), id)) {
333+
Debug(&session(),
334+
"Discarding %zu bytes for destroyed local stream %" PRIi64,
335+
datalen,
336+
id);
337+
ReturnConnectionCredit(datalen);
338+
return true;
339+
}
340+
319341
// This is the first time we're seeing this stream. Implicitly create it.
320342
stream = session().CreateStream(id);
321343
if (!stream || session().is_destroyed()) [[unlikely]] {
@@ -324,9 +346,11 @@ class DefaultApplication final : public Session::Application {
324346
return false;
325347
}
326348

327-
// The stream was created, but was immediately destroyed because there's
328-
// no onstream handler.
349+
// The stream was created but immediately destroyed, either because there
350+
// is no onstream handler or because the handler destroyed it. Nothing
351+
// will consume the data, so discard it and return its credit.
329352
if (stream->is_destroyed()) [[unlikely]] {
353+
ReturnConnectionCredit(datalen);
330354
return true;
331355
}
332356
} else {

src/quic/application.h

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ class Session::Application : public MemoryRetainer {
9898
virtual bool ReceiveStreamOpen(stream_id id) = 0;
9999

100100
// Session will forward all received stream data immediately on to the
101-
// Application. The only additional processing the Session does is to
102-
// automatically adjust the session-level flow control window. It is up to
103-
// the Application to do the same for the Stream-level flow control.
101+
// Application without any additional processing. Every byte delivered here
102+
// is charged against both the session-level and the stream-level receive
103+
// window, and it is up to the Application to return that credit (see
104+
// ReturnConnectionCredit and Stream::ReturnFlowControlCredit) once the
105+
// bytes have been consumed or discarded.
104106
virtual bool ReceiveStreamData(stream_id id,
105107
const uint8_t* data,
106108
size_t datalen,
@@ -266,6 +268,12 @@ class Session::Application : public MemoryRetainer {
266268
return *session_;
267269
}
268270

271+
// Returns the connection-level flow control credit for `datalen` bytes that
272+
// were delivered to the Application but discarded without ever reaching a
273+
// Stream. Dropping them silently would permanently shrink the session's
274+
// shared receive window.
275+
void ReturnConnectionCredit(size_t datalen);
276+
269277
private:
270278
Session* session_ = nullptr;
271279
};

src/quic/http3.cc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,13 @@ class Http3ApplicationImpl final : public Session::Application {
10911091
if (auto stream = session->FindStream(id)) {
10921092
return stream;
10931093
}
1094+
// No record of a locally-initiated stream means we already destroyed it,
1095+
// and frames still in flight must not bring it back to life. See
1096+
// DefaultApplication::ReceiveStreamData for the same guard on the raw
1097+
// QUIC path.
1098+
if (!session->is_destroyed() && ngtcp2_conn_is_local_stream(*session, id)) {
1099+
return {};
1100+
}
10941101
if (auto stream = session->CreateStream(id)) {
10951102
return stream;
10961103
}
@@ -1232,6 +1239,23 @@ class Http3ApplicationImpl final : public Session::Application {
12321239
return NGHTTP3_ERR_CALLBACK_FAILURE;
12331240
}
12341241
auto& session = app.session();
1242+
1243+
// DATA frames for a request stream the application already destroyed can
1244+
// still arrive. Drop the payload rather than resurrecting the stream or
1245+
// tearing down the connection, but return its credit: unlike framing
1246+
// bytes, DATA payload is not included in the count nghttp3 reports to
1247+
// ReceiveStreamData, so we own it. The is_destroyed() check must come
1248+
// first, see DefaultApplication::ReceiveStreamData.
1249+
if (!session.is_destroyed() && !session.FindStream(id) &&
1250+
ngtcp2_conn_is_local_stream(session, id)) {
1251+
Debug(&session,
1252+
"HTTP/3 discarding %zu bytes for destroyed local stream %" PRIi64,
1253+
datalen,
1254+
id);
1255+
app.ReturnConnectionCredit(datalen);
1256+
return NGTCP2_SUCCESS;
1257+
}
1258+
12351259
if (auto stream = FindOrCreateStream(conn, &session, id)) [[likely]] {
12361260
stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{});
12371261
return NGTCP2_SUCCESS;

src/quic/streams.cc

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,12 +1498,37 @@ void Stream::EndWriting() {
14981498
if (!is_pending()) session_->ResumeStream(id());
14991499
}
15001500

1501+
void Stream::ReturnFlowControlCredit(uint64_t amount, CreditScope scope) {
1502+
if (amount == 0) return;
1503+
// The stream can outlive a destroyed session (the JS side may still hold a
1504+
// reader over the inbound queue), leaving no window to extend.
1505+
if (!session_ || session_->is_destroyed()) return;
1506+
// Extending a window queues MAX_STREAM_DATA / MAX_DATA. The scope flushes
1507+
// them; inside an ngtcp2 callback the flush is a no-op and they go out with
1508+
// the next scheduled send instead.
1509+
Session::SendPendingDataScope send_scope(&session());
1510+
if (scope == CreditScope::STREAM_AND_CONNECTION) {
1511+
// Receiving data requires an id, so this should always hold.
1512+
DCHECK(!is_pending());
1513+
session().Consume(id(), amount);
1514+
} else {
1515+
session().ExtendOffset(amount);
1516+
}
1517+
}
1518+
1519+
void Stream::CreditConsumedBytes(uint64_t amount) {
1520+
// Clamped because Destroy() returns the outstanding credit in bulk and the
1521+
// flush that triggers can re-enter JS, which may then report some of those
1522+
// same bytes as read. Never give the peer more credit than we took.
1523+
amount = std::min(uncredited_bytes_, amount);
1524+
uncredited_bytes_ -= amount;
1525+
ReturnFlowControlCredit(amount, CreditScope::STREAM_AND_CONNECTION);
1526+
}
1527+
15011528
void Stream::EntryRead(size_t amount) {
15021529
// Called when the JS consumer reads data from the inbound DataQueue.
15031530
// Extend the flow control window so the sender can transmit more.
1504-
if (session().is_destroyed()) return;
1505-
Session::SendPendingDataScope send_scope(&session());
1506-
session().Consume(id(), amount);
1531+
CreditConsumedBytes(amount);
15071532
}
15081533

15091534
void Stream::BeforePull() {
@@ -1516,16 +1541,26 @@ void Stream::BeforePull() {
15161541

15171542
void Stream::FlushAccumulation() {
15181543
if (!recv_accumulator_ || recv_accumulator_->available() == 0) return;
1544+
size_t flushed = recv_accumulator_->available();
15191545
auto entry = recv_accumulator_->Flush(env());
1520-
if (entry) {
1521-
inbound_->append(std::move(entry));
1546+
// Flush() always drains the accumulator, so the stat is reset either way.
1547+
STAT_SET(Stats, bytes_accumulated, 0);
1548+
if (entry && inbound_->append(std::move(entry)).value_or(false)) {
15221549
// Notify the reader that data is now available in the DataQueue.
15231550
// This is the only place we notify — not on every ReceiveData call —
15241551
// so the reader only wakes up when there is a well-sized entry to
15251552
// consume.
15261553
if (reader_) reader_->NotifyPull();
1554+
return;
15271555
}
1528-
STAT_SET(Stats, bytes_accumulated, 0);
1556+
// Should be unreachable: append() only fails once the queue has been capped,
1557+
// EndReadable() flushes before capping, and ReceiveData() accumulates
1558+
// nothing once read_ended is set. Reaching here means received stream data
1559+
// is being dropped on the floor, so say so and at least do not also leak
1560+
// the flow control credit for it.
1561+
DCHECK(false);
1562+
Debug(this, "Inbound queue rejected %zu accumulated bytes", flushed);
1563+
CreditConsumedBytes(flushed);
15291564
}
15301565

15311566
int Stream::DoPull(bob::Next<ngtcp2_vec> next,
@@ -1651,6 +1686,16 @@ void Stream::Destroy(QuicError error) {
16511686
// the ring buffer memory.
16521687
recv_accumulator_.reset();
16531688

1689+
// Data that was received but never consumed still holds connection-level
1690+
// flow control credit, and EntryRead() will never fire for it once the
1691+
// listener is detached below. Leaking it would permanently shrink the
1692+
// session's shared receive window and, over enough streams, deadlock the
1693+
// connection. Zero the counter first: returning credit flushes packets,
1694+
// which can re-enter JS and report some of these bytes as read.
1695+
const uint64_t outstanding = uncredited_bytes_;
1696+
uncredited_bytes_ = 0;
1697+
ReturnFlowControlCredit(outstanding, CreditScope::CONNECTION_ONLY);
1698+
16541699
// We reset the inbound here also. However, it's important to note that
16551700
// the JavaScript side could still have a reader on the inbound DataQueue,
16561701
// which may keep that data alive a bit longer.
@@ -1693,6 +1738,13 @@ void Stream::ReceiveData(const uint8_t* data,
16931738
Debug(this, "Receiving %zu bytes of data", len);
16941739
if (state()->read_ended == 1 || len == 0) {
16951740
if (flags.fin) EndReadable();
1741+
// Nothing will ever consume these bytes, so return the connection-level
1742+
// credit ngtcp2 charged for them. The stream window is deliberately left
1743+
// alone: there is no point inviting more data onto a stream we have
1744+
// stopped reading. Reachable when, for example, HTTP/3 replays DATA
1745+
// payload it had buffered for QPACK head-of-line blocking after the
1746+
// readable side was shut down.
1747+
ReturnFlowControlCredit(len, CreditScope::CONNECTION_ONLY);
16961748
return;
16971749
}
16981750

@@ -1701,6 +1753,9 @@ void Stream::ReceiveData(const uint8_t* data,
17011753
STAT_SET(Stats, max_offset_received, STAT_GET(Stats, bytes_received));
17021754
STAT_RECORD_TIMESTAMP(Stats, received_at);
17031755

1756+
// These bytes now hold inbound flow control credit. See uncredited_bytes_.
1757+
uncredited_bytes_ += len;
1758+
17041759
// Lazy-allocate the receive accumulation buffer on first data-carrying
17051760
// call. Streams that never receive data (write-only, immediately reset)
17061761
// pay zero cost.

src/quic/streams.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,22 @@ class Stream final : public AsyncWrap,
395395
// inbound DataQueue as a single right-sized entry.
396396
void FlushAccumulation();
397397

398+
// Every byte ngtcp2 delivers is charged against both the stream-level and
399+
// the connection-level receive window until we hand the credit back.
400+
enum class CreditScope : uint8_t {
401+
// The stream is still readable, so the peer may usefully send more on it.
402+
STREAM_AND_CONNECTION,
403+
// The stream is finished, so only the session-wide window is extended.
404+
CONNECTION_ONLY,
405+
};
406+
407+
// Returns `amount` bytes of inbound flow control credit to the peer.
408+
void ReturnFlowControlCredit(uint64_t amount, CreditScope scope);
409+
410+
// Returns credit for bytes that have left our custody, either read by the
411+
// consumer or dropped before reaching one.
412+
void CreditConsumedBytes(uint64_t amount);
413+
398414
// Gets a reader for the data received for this stream from the peer,
399415
BaseObjectPtr<Blob::Reader> get_reader();
400416

@@ -459,6 +475,14 @@ class Stream final : public AsyncWrap,
459475
BaseObjectWeakPtr<Blob::Reader> reader_;
460476
std::unique_ptr<RecvAccumulator> recv_accumulator_;
461477

478+
// Bytes delivered to ReceiveData() that still hold inbound flow control
479+
// credit. Returned incrementally as the consumer reads them, and in bulk
480+
// when the stream is destroyed -- otherwise abandoning a stream with unread
481+
// data would permanently shrink the session's receive window. Data still
482+
// buffered inside nghttp3 is deliberately not counted: nghttp3 returns that
483+
// credit itself through its deferred_consume callback.
484+
uint64_t uncredited_bytes_ = 0;
485+
462486
// If the stream cannot be opened yet, it will be created in a pending state.
463487
// Once the owning session is able to, it will complete opening of the stream
464488
// and the stream id will be assigned.

0 commit comments

Comments
 (0)