Add a description
Response body streaming (HttpHandlerBase::HandleStreamRequest /
HttpResponse::SetStreamBody) works over HTTP/1.1 but is disabled over
HTTP/2.0. A streaming handler that receives an HTTP/2 request currently trips
a hard UINVARIANT whose throw escapes into std::terminate, taking down the
entire process — not just the offending request.
The feature was fully implemented and tested in the past, then temporarily
disabled. Most of the machinery is still in the tree; the connection-loop
integration that drives it was dropped during a refactor and the tests were
skipped. This issue asks to restore it, and — independently and more urgently —
to make the disabled path fail gracefully instead of aborting the process.
Observed on 3.2-rc
(1838cdcec).
All line links below are permalinks pinned to that commit.
Reproduction
Any handler that overrides HandleStreamRequest (i.e. response-body-stream: true) and is served on an HTTP/2 listener (http-version: '2') aborts the
process the moment such a request is handled:
// core/src/server/http/http_response.cpp:465
UINVARIANT(false, "Streaming in HTTP/2.0 is not supported currently.");
→ http_response.cpp#L465
Because a single listener with http-version: '2' also accepts HTTP/1.1 and
h2c fallback, a service cannot simply "avoid" this: any h2-capable client
reaching a streaming handler crashes the whole service. Our current workaround
is to override IsStreamed() to return false for request.GetHttpMajor() == 2 and buffer the response instead — which is exactly the graceful degradation
we think the framework should do by default (see "Ask 1" below).
History (archeology)
d6f69258f
— feat core: add body streaming for HTTP/2.0 (Oct 2024). Added the feature
together with functional tests
(test_http2_streaming.py).
01bf20de1
— the streaming tests were skipped under ticket TAXICOMMON-10258 (Mar 2025).
f75379023
— separate HttpReader and ConnectionBase (Feb 2026). The connection refactor
removed the streaming-event wiring from the HTTP/2 connection loop and left the
UINVARIANT gate in place.
Per a userver maintainer, it was turned off because it broke after an
update and because it blocked concurrent HTTP/2 — one streaming response
head-of-line-blocked the other multiplexed streams on the connection. So the
re-enable is gated on solving the concurrency problem, not just flipping the
switch back on.
Current state of the code
The producer/consumer machinery is still present and intact:
The missing link: Http2Session::HandleStreamingEvents()
(L371)
and Http2Session::GetStreamingEvent()
(L369)
are defined but have zero callers. The HTTP/2 connection loop —
Http2Connection::ListenForRequests()
— builds a
WaitAnyContext
that only waits on two things: the socket becoming readable
(kSocketReadable) and per-request handler-task completion
(kTaskComputedResponse). It never appends session.GetStreamingEvent() to the
wait_any, and never calls session.HandleStreamingEvents(). As a result, when
a streaming handler pushes a body chunk it signals streaming_event_ and enqueues
onto streaming_queue_
(producer wired at L347),
but nothing wakes to drain the queue and resume the deferred nghttp2 data
provider. The producer end is connected; the consumer end in the connection loop
is not.
So restoring streaming is roughly:
- Wait on
session.GetStreamingEvent() in the
WaitAnyContext loop,
with a new WakeupKind (e.g. kStreamingReady) that calls
session.HandleStreamingEvents() to resume deferred streams and flush pending
frames.
- Remove the
UINVARIANT gate at
http_response.cpp#L465
(the emplace of Http2StreamEventProducer on the line below it is already
the correct branch).
- Solve the concurrency / head-of-line blocking that caused the original
disable: a streaming response must not stall other multiplexed streams on the
same connection. This is the real engineering work and the reason it was
parked — draining/interleaving must be per-stream and cooperative, respecting
flow-control windows, so a slow producer only defers its own stream.
Asks
Ask 1 (small, worth doing regardless of streaming): degrade gracefully instead
of std::terminate. Even if full streaming stays disabled, hitting a
streaming handler over HTTP/2 should not crash the whole process. Options: buffer
the streamed body transparently, or fail just that one request (e.g. 500), rather
than
UINVARIANT(false, ...).
Today a single h2 request to a streaming handler is a process-wide DoS.
Ask 2 (the feature): re-enable HTTP/2 body streaming by restoring the
connection-loop integration and resolving the concurrency blocker, then
un-skipping the tests.
Verifying it works
There is an existing, ready-made harness — it just needs un-skipping and one
addition:
Recommended acceptance criteria:
- All three tests un-skipped and green.
- Extend
test_body_stream_concurrent to assert progress interleaving — a
slow/large streamed response on one stream must not block completion of other
concurrent streams on the same connection (the exact failure that caused the
disable). A backpressure/flow-control case (WINDOW_UPDATE gated producer)
would make the guarantee explicit.
- No process-wide abort under any streaming-over-h2 scenario (covers Ask 1 too).
References
Add a description
Response body streaming (
HttpHandlerBase::HandleStreamRequest/HttpResponse::SetStreamBody) works over HTTP/1.1 but is disabled overHTTP/2.0. A streaming handler that receives an HTTP/2 request currently trips
a hard
UINVARIANTwhose throw escapes intostd::terminate, taking down theentire process — not just the offending request.
The feature was fully implemented and tested in the past, then temporarily
disabled. Most of the machinery is still in the tree; the connection-loop
integration that drives it was dropped during a refactor and the tests were
skipped. This issue asks to restore it, and — independently and more urgently —
to make the disabled path fail gracefully instead of aborting the process.
Observed on
3.2-rc(
1838cdcec).All line links below are permalinks pinned to that commit.
Reproduction
Any handler that overrides
HandleStreamRequest(i.e.response-body-stream: true) and is served on an HTTP/2 listener (http-version: '2') aborts theprocess the moment such a request is handled:
→
http_response.cpp#L465Because a single listener with
http-version: '2'also accepts HTTP/1.1 andh2c fallback, a service cannot simply "avoid" this: any h2-capable client
reaching a streaming handler crashes the whole service. Our current workaround
is to override
IsStreamed()to returnfalseforrequest.GetHttpMajor() == 2and buffer the response instead — which is exactly the graceful degradationwe think the framework should do by default (see "Ask 1" below).
History (archeology)
d6f69258f— feat core: add body streaming for HTTP/2.0 (Oct 2024). Added the feature
together with functional tests
(
test_http2_streaming.py).01bf20de1— the streaming tests were skipped under ticket
TAXICOMMON-10258(Mar 2025).f75379023— separate HttpReader and ConnectionBase (Feb 2026). The connection refactor
removed the streaming-event wiring from the HTTP/2 connection loop and left the
UINVARIANTgate in place.Per a userver maintainer, it was turned off because it broke after an
update and because it blocked concurrent HTTP/2 — one streaming response
head-of-line-blocked the other multiplexed streams on the connection. So the
re-enable is gated on solving the concurrency problem, not just flipping the
switch back on.
Current state of the code
The producer/consumer machinery is still present and intact:
http_response.cpp#L465http2_session.cpp#L347http2_session.hpp#L132-L134HandleStreamingEvents)http2_session.cpp#L371GetStreamingEvent)http2_session.cpp#L369NGHTTP2_ERR_DEFERREDread callback (GetMaxSize)http2_stream.cpp#L79-L93http2_writer.cpp#L119The missing link:
Http2Session::HandleStreamingEvents()(L371)
and
Http2Session::GetStreamingEvent()(L369)
are defined but have zero callers. The HTTP/2 connection loop —
Http2Connection::ListenForRequests()— builds a
WaitAnyContextthat only waits on two things: the socket becoming readable
(
kSocketReadable) and per-request handler-task completion(
kTaskComputedResponse). It never appendssession.GetStreamingEvent()to thewait_any, and never callssession.HandleStreamingEvents(). As a result, whena streaming handler pushes a body chunk it signals
streaming_event_and enqueuesonto
streaming_queue_(producer wired at L347),
but nothing wakes to drain the queue and resume the deferred nghttp2 data
provider. The producer end is connected; the consumer end in the connection loop
is not.
So restoring streaming is roughly:
session.GetStreamingEvent()in theWaitAnyContextloop,with a new
WakeupKind(e.g.kStreamingReady) that callssession.HandleStreamingEvents()to resume deferred streams and flush pendingframes.
UINVARIANTgate athttp_response.cpp#L465(the
emplaceofHttp2StreamEventProduceron the line below it is alreadythe correct branch).
disable: a streaming response must not stall other multiplexed streams on the
same connection. This is the real engineering work and the reason it was
parked — draining/interleaving must be per-stream and cooperative, respecting
flow-control windows, so a slow producer only defers its own stream.
Asks
Ask 1 (small, worth doing regardless of streaming): degrade gracefully instead
of
std::terminate. Even if full streaming stays disabled, hitting astreaming handler over HTTP/2 should not crash the whole process. Options: buffer
the streamed body transparently, or fail just that one request (e.g. 500), rather
than
UINVARIANT(false, ...).Today a single h2 request to a streaming handler is a process-wide DoS.
Ask 2 (the feature): re-enable HTTP/2 body streaming by restoring the
connection-loop integration and resolving the concurrency blocker, then
un-skipping the tests.
Verifying it works
There is an existing, ready-made harness — it just needs un-skipping and one
addition:
core/functional_tests/http2server/(has a
/http2server-streamstreaming endpoint).test_http2_streaming.py— three tests currently
@pytest.mark.skip(reason='TAXICOMMON-10258'):test_body_stream— streamed body arrives intact.
test_body_stream_small_pieces— small chunks reassemble correctly.
test_body_stream_concurrent— this is the one that guards the original regression: two clients, ten
streamed requests each, must all succeed concurrently without head-of-line
blocking.
Recommended acceptance criteria:
test_body_stream_concurrentto assert progress interleaving — aslow/large streamed response on one stream must not block completion of other
concurrent streams on the same connection (the exact failure that caused the
disable). A backpressure/flow-control case (
WINDOW_UPDATEgated producer)would make the guarantee explicit.
References
http_response.cpp#L465http2_session.cpp#L369-L373http2_connection.cpp#L87test_http2_streaming.pyd6f69258f(add),01bf20de1(skip tests, TAXICOMMON-10258),f75379023(refactor that dropped the wiring)