Description
When a server response's DATA payload exactly exhausts the flow-control window (e.g. the default 65535-byte window) in the same WriteForStream() call that also submits trailers, the trailing HEADERS frame can be left stranded in the internal frames_ queue instead of being flushed to the wire. This was found while diagnosing envoyproxy/envoy#40821 ("gRPC trailers dropped by oghttp2 when the response is exactly 65535 bytes"), which enables this codec via envoy.reloadable_features.http2_use_oghttp2.
Mechanism
OgHttp2Session::WriteForStream() (quiche/http2/adapter/oghttp2_session.cc) drains the final chunk of a stream's body via the DataFrameSource/visitor callback. When that callback reports end-of-data and trailers are available, WriteForStream calls SubmitTrailer() (nested, mid-loop) and then SendTrailers(), which does EnqueueFrame(...) — queuing the HEADERS frame into frames_, not writing it immediately.
- If sending that same final DATA frame also drives
connection_send_window_ to exactly 0 (i.e. body size == window size), WriteForStream()'s final return statement:
return connection_send_window_ <= 0 ? SendResult::SEND_BLOCKED
: SendResult::SEND_OK;
returns SEND_BLOCKED purely due to flow-control exhaustion — even though the only thing left to do is flush an already-queued, non-flow-controlled frame.
- In
OgHttp2Session::Send(), the trailing flush is gated on SEND_OK:
if (continue_writing == SendResult::SEND_OK) {
continue_writing = SendQueuedFrames();
}
so when WriteForStream returned SEND_BLOCKED, SendQueuedFrames() is skipped, and the queued trailers HEADERS frame is not sent in this Send() call.
Additionally, SubmitTrailer()'s trailers_ready_ bypass mechanism (meant to let trailers skip the normal window-gated readiness check) doesn't help here: it's inserted and then immediately erased within the same nested call before Send()'s outer loop ever observes it, so it can't trigger a fresh wake-up.
In practice the trailers are usually recovered on the next call to Send(), since SendQueuedFrames() also runs unconditionally at the top of Send(). However, in a production proxy (Envoy) chained in front of a real client/server, this can result in the trailers never being sent at all, and an empty DATA frame with END_STREAM being emitted instead — see the pcaps and analysis in the linked Envoy issue.
Suggested fix
WriteForStream() returning SEND_BLOCKED should not, by itself, cause a pending flush of already-queued frames to be skipped. Two options:
- Have
OgHttp2Session::Send() always attempt the trailing SendQueuedFrames(), regardless of WriteForStream's return value (it will separately report SEND_BLOCKED/SEND_ERROR if the socket write itself is blocked).
- Have
WriteForStream() not report SEND_BLOCKED merely because connection_send_window_ <= 0 while frames_ still has entries queued for the stream.
Version
Confirmed present in the current google/quiche HEAD (7b9f65bf5d62e3f6d2dd5da7fd4413be827f2001, as of 2026-07-23) — Send(), WriteForStream(), SubmitTrailer(), and SendTrailers() are unchanged from the version Envoy currently vendors (c5087ecf2fb9691bbc6de0e7f7506ea4974aaddb).
Related
Description
When a server response's DATA payload exactly exhausts the flow-control window (e.g. the default 65535-byte window) in the same
WriteForStream()call that also submits trailers, the trailing HEADERS frame can be left stranded in the internalframes_queue instead of being flushed to the wire. This was found while diagnosing envoyproxy/envoy#40821 ("gRPC trailers dropped by oghttp2 when the response is exactly 65535 bytes"), which enables this codec viaenvoy.reloadable_features.http2_use_oghttp2.Mechanism
OgHttp2Session::WriteForStream()(quiche/http2/adapter/oghttp2_session.cc) drains the final chunk of a stream's body via theDataFrameSource/visitor callback. When that callback reports end-of-data and trailers are available,WriteForStreamcallsSubmitTrailer()(nested, mid-loop) and thenSendTrailers(), which doesEnqueueFrame(...)— queuing the HEADERS frame intoframes_, not writing it immediately.connection_send_window_to exactly 0 (i.e. body size == window size),WriteForStream()'s final return statement:SEND_BLOCKEDpurely due to flow-control exhaustion — even though the only thing left to do is flush an already-queued, non-flow-controlled frame.OgHttp2Session::Send(), the trailing flush is gated onSEND_OK:WriteForStreamreturnedSEND_BLOCKED,SendQueuedFrames()is skipped, and the queued trailers HEADERS frame is not sent in thisSend()call.Additionally,
SubmitTrailer()'strailers_ready_bypass mechanism (meant to let trailers skip the normal window-gated readiness check) doesn't help here: it's inserted and then immediately erased within the same nested call beforeSend()'s outer loop ever observes it, so it can't trigger a fresh wake-up.In practice the trailers are usually recovered on the next call to
Send(), sinceSendQueuedFrames()also runs unconditionally at the top ofSend(). However, in a production proxy (Envoy) chained in front of a real client/server, this can result in the trailers never being sent at all, and an empty DATA frame with END_STREAM being emitted instead — see the pcaps and analysis in the linked Envoy issue.Suggested fix
WriteForStream()returningSEND_BLOCKEDshould not, by itself, cause a pending flush of already-queued frames to be skipped. Two options:OgHttp2Session::Send()always attempt the trailingSendQueuedFrames(), regardless ofWriteForStream's return value (it will separately reportSEND_BLOCKED/SEND_ERRORif the socket write itself is blocked).WriteForStream()not reportSEND_BLOCKEDmerely becauseconnection_send_window_ <= 0whileframes_still has entries queued for the stream.Version
Confirmed present in the current
google/quicheHEAD (7b9f65bf5d62e3f6d2dd5da7fd4413be827f2001, as of 2026-07-23) —Send(),WriteForStream(),SubmitTrailer(), andSendTrailers()are unchanged from the version Envoy currently vendors (c5087ecf2fb9691bbc6de0e7f7506ea4974aaddb).Related