Skip to content

Commit d889ee8

Browse files
committed
quic: apply multiple fixes to flow control signaling
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus
1 parent 0c31886 commit d889ee8

15 files changed

Lines changed: 1120 additions & 13 deletions

lib/internal/blob.js

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -610,12 +610,28 @@ function createBlobReaderStream(reader) {
610610
}, { highWaterMark: 0 });
611611
}
612612

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

619+
// Default number of bytes to collect in a single batch. Entries in the
620+
// DataQueue can each be as large as the peer's flow control window, so a
621+
// purely count-based limit could produce enormous batches (16 entries of
622+
// 1 MB each).
623+
//
624+
// This matters for more than just the size of the yielded array. Consumers
625+
// like QUIC return flow control credit from the reader's pull path -- once
626+
// per pull, not once per batch -- so every pull this loop performs invites
627+
// the peer to send that many more bytes. Pulling greedily therefore grants
628+
// credit for data the consumer has not looked at yet. Bounding the loop by
629+
// bytes limits how far ahead of actual consumption that credit can run,
630+
// which is what keeps the amount of data buffered in JS bounded.
631+
const kDefaultMaxBatchBytes = 65536;
632+
617633
async function* createBlobReaderIterable(reader, options = kEmptyObject) {
618-
const { getReadError } = options;
634+
const { getReadError, maxBatchBytes = kDefaultMaxBatchBytes } = options;
619635
let wakeup = PromiseWithResolvers();
620636
let immediate;
621637
let fin = false;
@@ -630,6 +646,7 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
630646
try {
631647
while (true) {
632648
const batch = [];
649+
let batchBytes = 0;
633650
let blocked = false;
634651
let eos = false;
635652
let error = null;
@@ -658,8 +675,15 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {
658675
blocked = true;
659676
break;
660677
}
661-
ArrayPrototypePush(batch, new Uint8Array(pullResult.buffer));
662-
if (batch.length >= kMaxBatchChunks) break;
678+
const chunk = new Uint8Array(pullResult.buffer);
679+
ArrayPrototypePush(batch, chunk);
680+
// Stop collecting once the batch is large enough. The byte budget is
681+
// the primary limit; the chunk count is a secondary bound so that a
682+
// long run of tiny chunks cannot produce an unwieldy array.
683+
batchBytes += chunk.byteLength;
684+
if (batchBytes >= maxBatchBytes || batch.length >= kMaxBatchChunks) {
685+
break;
686+
}
663687
}
664688

665689
if (batch.length > 0) {

src/dataqueue/queue.cc

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

177+
// Both notifications can re-enter this DataQueue. A listener may, for
178+
// instance, extend a QUIC flow control window, which flushes packets, which
179+
// can call into JavaScript and end up destroying the stream that owns the
180+
// listener -- dropping its reference to this queue and removing itself from
181+
// backpressure_listeners_ while we are still iterating. Hold a reference so
182+
// this instance cannot be freed underneath us, iterate over a snapshot so
183+
// that mutation is safe, and re-check membership before each call so a
184+
// listener removed earlier in the same notification is not invoked after
185+
// the fact.
177186
void NotifyBackpressure(size_t amount) {
178187
if (idempotent_) return;
179-
for (auto& listener : backpressure_listeners_) listener->EntryRead(amount);
188+
if (backpressure_listeners_.empty()) return;
189+
auto self = shared_from_this();
190+
std::vector<BackpressureListener*> listeners(
191+
backpressure_listeners_.begin(), backpressure_listeners_.end());
192+
for (auto* listener : listeners) {
193+
if (backpressure_listeners_.contains(listener)) {
194+
listener->EntryRead(amount);
195+
}
196+
}
180197
}
181198

182199
void NotifyBeforePull() {
183200
if (idempotent_) return;
184-
for (auto& listener : backpressure_listeners_) listener->BeforePull();
201+
if (backpressure_listeners_.empty()) return;
202+
auto self = shared_from_this();
203+
std::vector<BackpressureListener*> listeners(
204+
backpressure_listeners_.begin(), backpressure_listeners_.end());
205+
for (auto* listener : listeners) {
206+
if (backpressure_listeners_.contains(listener)) {
207+
listener->BeforePull();
208+
}
209+
}
185210
}
186211

187212
bool HasBackpressureListeners() const noexcept {

src/quic/application.cc

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,34 @@ class DefaultApplication final : public Session::Application {
316316
void* stream_user_data) override {
317317
BaseObjectPtr<Stream> stream;
318318
if (stream_user_data == nullptr) {
319+
// A locally-initiated stream can only ever come into existence because
320+
// we created it, so a missing Stream means we already destroyed it.
321+
// Data the peer had already put in flight must not resurrect it:
322+
// re-creating it here would hand the application a bogus "incoming"
323+
// stream for a stream it just destroyed, and would do so again for
324+
// every frame still in flight.
325+
//
326+
// Discard the data instead, but return the connection-level flow
327+
// control credit for it. ngtcp2 has delivered these bytes to us, so we
328+
// own their credit; dropping them silently would shrink the session's
329+
// shared receive window for good.
330+
// Note the is_destroyed() check has to come first: a prior callback in
331+
// this same ngtcp2 batch may have destroyed the session, and neither the
332+
// ngtcp2 connection nor the flow control helpers below may be touched
333+
// once that has happened.
334+
if (!session().is_destroyed() &&
335+
ngtcp2_conn_is_local_stream(session(), id)) {
336+
Debug(&session(),
337+
"Discarding %zu bytes for destroyed local stream %" PRIi64,
338+
datalen,
339+
id);
340+
if (datalen > 0) {
341+
Session::SendPendingDataScope send_scope(&session());
342+
session().ExtendOffset(datalen);
343+
}
344+
return true;
345+
}
346+
319347
// This is the first time we're seeing this stream. Implicitly create it.
320348
stream = session().CreateStream(id);
321349
if (!stream || session().is_destroyed()) [[unlikely]] {

src/quic/http3.cc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,14 @@ class Http3ApplicationImpl final : public Session::Application {
10831083
if (auto stream = session->FindStream(id)) {
10841084
return stream;
10851085
}
1086+
// A locally-initiated stream can only exist because we created it, so if
1087+
// we have no record of it the application already destroyed it. Frames the
1088+
// peer had already put in flight must not bring it back to life -- see
1089+
// DefaultApplication::ReceiveStreamData for the same guard on the raw
1090+
// QUIC path.
1091+
if (!session->is_destroyed() && ngtcp2_conn_is_local_stream(*session, id)) {
1092+
return {};
1093+
}
10861094
if (auto stream = session->CreateStream(id)) {
10871095
return stream;
10881096
}
@@ -1224,6 +1232,31 @@ class Http3ApplicationImpl final : public Session::Application {
12241232
return NGHTTP3_ERR_CALLBACK_FAILURE;
12251233
}
12261234
auto& session = app.session();
1235+
1236+
// If the application destroyed a request stream it initiated, DATA frames
1237+
// the peer had already sent can still arrive. Ignore that payload rather
1238+
// than resurrecting the stream or tearing down the connection, but return
1239+
// its connection-level flow control credit: nghttp3 hands DATA payload to
1240+
// us uncredited (it is excluded from the framing bytes credited by the
1241+
// caller), so dropping it silently would permanently shrink the session's
1242+
// shared receive window.
1243+
// The is_destroyed() check has to come first: an earlier nghttp3 callback
1244+
// in this same batch may have destroyed the session (for example because a
1245+
// JS callback threw), and neither the ngtcp2 connection nor the flow
1246+
// control helpers below may be touched afterwards.
1247+
if (!session.is_destroyed() && !session.FindStream(id) &&
1248+
ngtcp2_conn_is_local_stream(session, id)) {
1249+
Debug(&session,
1250+
"HTTP/3 discarding %zu bytes for destroyed local stream %" PRIi64,
1251+
datalen,
1252+
id);
1253+
if (datalen > 0) {
1254+
Session::SendPendingDataScope send_scope(&session);
1255+
session.ExtendOffset(datalen);
1256+
}
1257+
return NGTCP2_SUCCESS;
1258+
}
1259+
12271260
if (auto stream = FindOrCreateStream(conn, &session, id)) [[likely]] {
12281261
stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{});
12291262
return NGTCP2_SUCCESS;

src/quic/streams.cc

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

1502+
void Stream::ReturnFlowControlCredit(uint64_t amount, CreditScope scope) {
1503+
if (amount == 0) return;
1504+
// The stream may outlive a destroyed session (the JS side can still hold a
1505+
// reader over the inbound queue), in which case there is no window left to
1506+
// extend.
1507+
if (!session_ || session_->is_destroyed()) return;
1508+
// Extending a window queues MAX_STREAM_DATA / MAX_DATA frames. The scope
1509+
// ensures they get flushed to the peer. When we are inside an ngtcp2
1510+
// callback the flush is a no-op (can_send_packets() is false) and the
1511+
// frames go out with the next scheduled send instead.
1512+
Session::SendPendingDataScope send_scope(&session());
1513+
if (scope == CreditScope::STREAM_AND_CONNECTION && !is_pending()) {
1514+
session().Consume(id(), amount);
1515+
} else {
1516+
session().ExtendOffset(amount);
1517+
}
1518+
}
1519+
1520+
void Stream::CreditConsumedBytes(uint64_t amount) {
1521+
uncredited_bytes_ -= std::min(uncredited_bytes_, amount);
1522+
ReturnFlowControlCredit(amount, CreditScope::STREAM_AND_CONNECTION);
1523+
}
1524+
15021525
void Stream::EntryRead(size_t amount) {
15031526
// Called when the JS consumer reads data from the inbound DataQueue.
15041527
// Extend the flow control window so the sender can transmit more.
1505-
if (session().is_destroyed()) return;
1506-
Session::SendPendingDataScope send_scope(&session());
1507-
session().Consume(id(), amount);
1528+
CreditConsumedBytes(amount);
15081529
}
15091530

15101531
void Stream::BeforePull() {
@@ -1517,16 +1538,23 @@ void Stream::BeforePull() {
15171538

15181539
void Stream::FlushAccumulation() {
15191540
if (!recv_accumulator_ || recv_accumulator_->available() == 0) return;
1541+
size_t flushed = recv_accumulator_->available();
15201542
auto entry = recv_accumulator_->Flush(env());
1521-
if (entry) {
1522-
inbound_->append(std::move(entry));
1543+
// Flush() always drains the accumulator, so the stat is reset either way.
1544+
STAT_SET(Stats, bytes_accumulated, 0);
1545+
if (entry && inbound_->append(std::move(entry)).value_or(false)) {
15231546
// Notify the reader that data is now available in the DataQueue.
15241547
// This is the only place we notify — not on every ReceiveData call —
15251548
// so the reader only wakes up when there is a well-sized entry to
15261549
// consume.
15271550
if (reader_) reader_->NotifyPull();
1551+
return;
15281552
}
1529-
STAT_SET(Stats, bytes_accumulated, 0);
1553+
// The bytes did not make it into the queue (it is capped and this data
1554+
// would push it past the final size), so they will never reach a reader
1555+
// and EntryRead() will never fire for them. Return their credit here
1556+
// instead of leaking it.
1557+
CreditConsumedBytes(flushed);
15301558
}
15311559

15321560
int Stream::DoPull(bob::Next<ngtcp2_vec> next,
@@ -1652,6 +1680,16 @@ void Stream::Destroy(QuicError error) {
16521680
// the ring buffer memory.
16531681
recv_accumulator_.reset();
16541682

1683+
// Any data that was received but never consumed is still holding inbound
1684+
// flow control credit. Once the backpressure listener is detached below,
1685+
// EntryRead() will never fire for it again, so return that credit now.
1686+
// The stream-level window is irrelevant at this point (the stream is going
1687+
// away) but the connection-level window is shared by the whole session:
1688+
// leaking it here would permanently shrink the session's receive window
1689+
// and, over enough streams, deadlock the connection.
1690+
ReturnFlowControlCredit(uncredited_bytes_, CreditScope::CONNECTION_ONLY);
1691+
uncredited_bytes_ = 0;
1692+
16551693
// We reset the inbound here also. However, it's important to note that
16561694
// the JavaScript side could still have a reader on the inbound DataQueue,
16571695
// which may keep that data alive a bit longer.
@@ -1691,6 +1729,15 @@ void Stream::ReceiveData(const uint8_t* data,
16911729
Debug(this, "Receiving %zu bytes of data", len);
16921730
if (state()->read_ended == 1 || len == 0) {
16931731
if (flags.fin) EndReadable();
1732+
// These bytes are being discarded, but ngtcp2 already charged them
1733+
// against both receive windows when it delivered them to us. Nothing
1734+
// downstream will ever consume them, so give the credit back now.
1735+
// This is reachable, for instance, when HTTP/3 replays DATA payload
1736+
// that it had buffered for QPACK head-of-line blocking after the
1737+
// readable side was already shut down.
1738+
if (len > 0) {
1739+
ReturnFlowControlCredit(len, CreditScope::STREAM_AND_CONNECTION);
1740+
}
16941741
return;
16951742
}
16961743

@@ -1699,6 +1746,11 @@ void Stream::ReceiveData(const uint8_t* data,
16991746
STAT_SET(Stats, max_offset_received, STAT_GET(Stats, bytes_received));
17001747
STAT_RECORD_TIMESTAMP(Stats, received_at);
17011748

1749+
// These bytes now hold inbound flow control credit. The credit is returned
1750+
// incrementally as the JS consumer reads them (EntryRead), and any
1751+
// remainder is returned when the stream is destroyed.
1752+
uncredited_bytes_ += len;
1753+
17021754
// Lazy-allocate the receive accumulation buffer on first data-carrying
17031755
// call. Streams that never receive data (write-only, immediately reset)
17041756
// pay zero cost.

src/quic/streams.h

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

398+
// Which receive windows a flow control credit return applies to.
399+
enum class CreditScope : uint8_t {
400+
// Extend both the stream-level and the connection-level window. This is
401+
// the normal case: the stream is still alive and the peer may send more
402+
// data on it.
403+
STREAM_AND_CONNECTION,
404+
// Extend only the connection-level window. Used when the stream is going
405+
// away or has no id yet, where a MAX_STREAM_DATA would be pointless or
406+
// impossible, but the connection-level window is shared by the whole
407+
// session and must never be leaked.
408+
CONNECTION_ONLY,
409+
};
410+
411+
// Returns `amount` bytes of inbound flow control credit to the peer.
412+
// Every byte that ngtcp2 delivers to us is charged against both the
413+
// stream-level and the connection-level receive windows, and it is the
414+
// application's responsibility to give that credit back once those bytes
415+
// have either been consumed or discarded.
416+
void ReturnFlowControlCredit(uint64_t amount, CreditScope scope);
417+
418+
// Drops `amount` bytes from uncredited_bytes_ (saturating at zero) and
419+
// returns their credit to both receive windows. Used when bytes leave our
420+
// custody, either read by the consumer or dropped before reaching one.
421+
void CreditConsumedBytes(uint64_t amount);
422+
398423
// Gets a reader for the data received for this stream from the peer,
399424
BaseObjectPtr<Blob::Reader> get_reader();
400425

@@ -458,6 +483,15 @@ class Stream final : public AsyncWrap,
458483
BaseObjectWeakPtr<Blob::Reader> reader_;
459484
std::unique_ptr<RecvAccumulator> recv_accumulator_;
460485

486+
// Number of bytes delivered to ReceiveData() that have not yet been handed
487+
// to the JavaScript consumer and so still hold inbound flow control credit.
488+
// Any remainder is returned to the connection-level window when the stream
489+
// is destroyed, otherwise abandoning a stream with unread data would
490+
// permanently shrink the session's receive window. Data still buffered
491+
// inside nghttp3 is deliberately not counted here: nghttp3 returns that
492+
// credit itself through its deferred_consume callback.
493+
uint64_t uncredited_bytes_ = 0;
494+
461495
// If the stream cannot be opened yet, it will be created in a pending state.
462496
// Once the owning session is able to, it will complete opening of the stream
463497
// and the stream id will be assigned.

test/common/quic.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,56 @@ async function connect(address, options = {}) {
5353
return quic.connect(address, { alpn, verifyPeer, ...rest });
5454
}
5555

56+
/**
57+
* Build a deterministic payload whose content depends on absolute position.
58+
*
59+
* Flow control bugs frequently show up as duplicated, dropped, or reordered
60+
* regions rather than as a wrong total length, so the pattern deliberately
61+
* varies over a long period (not a repeating 256-byte ramp) to make such
62+
* damage detectable by `hashBytes` below.
63+
* @param {number} size Number of bytes to generate.
64+
* @param {number} [seed] Offsets the pattern so callers can build distinct
65+
* payloads of the same length.
66+
* @returns {Uint8Array}
67+
*/
68+
function makePayload(size, seed = 0) {
69+
const out = new Uint8Array(size);
70+
let state = (seed * 2654435761 + 1) >>> 0;
71+
for (let i = 0; i < size; i++) {
72+
// xorshift32 -- cheap, deterministic, and position sensitive.
73+
state ^= state << 13; state >>>= 0;
74+
state ^= state >>> 17;
75+
state ^= state << 5; state >>>= 0;
76+
out[i] = state & 0xff;
77+
}
78+
return out;
79+
}
80+
81+
/**
82+
* Order-sensitive FNV-1a 32-bit hash.
83+
*
84+
* Note this is deliberately not a simple additive checksum: addition is
85+
* commutative, so it cannot distinguish correctly ordered data from
86+
* reordered data. Flow control errors can reorder or duplicate regions
87+
* while preserving the byte total, so verification needs to be sensitive to
88+
* position.
89+
* @param {Uint8Array} buf
90+
* @returns {number} Hash as an unsigned 32-bit integer.
91+
*/
92+
function hashBytes(buf) {
93+
let h = 0x811c9dc5;
94+
for (let i = 0; i < buf.byteLength; i++) {
95+
h ^= buf[i];
96+
h = Math.imul(h, 0x01000193) >>> 0;
97+
}
98+
return h >>> 0;
99+
}
100+
56101
export {
57102
key,
58103
cert,
59104
listen,
60105
connect,
106+
makePayload,
107+
hashBytes,
61108
};

0 commit comments

Comments
 (0)