@@ -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+
15021525void 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
15101531void Stream::BeforePull () {
@@ -1517,16 +1538,23 @@ void Stream::BeforePull() {
15171538
15181539void 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
15321560int 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.
0 commit comments