Skip to content

fix: drain a GOAWAY with a streamed body in flight too - #962

Merged
benoitc merged 1 commit into
benoitc:masterfrom
lennartschoch:fix/h2-goaway-drain-streaming
Oct 3, 2026
Merged

benoitc merged 1 commit into
benoitc:masterfrom
lennartschoch:fix/h2-goaway-drain-streaming

Conversation

@lennartschoch

Copy link
Copy Markdown
Contributor

Follow-up to #961. That PR made hackney honour a GOAWAY's last_stream_id: streams the server accepted are left to finish, only the ones above it fail with {goaway, _}. It left one case alone, where the connection still closed everything at once: a GOAWAY arriving while a streamed request or response body is in flight. This covers that case.

hackney has two ways to stream a body over HTTP/2, and they need different handling.

Streaming the response is hackney:connect/4 followed by hackney:send_request/2, and then reading the body in pieces with stream_body/1. The caller sits inside send_request until the status and headers arrive. That's the same shape as a plain request, so nothing new was needed: with the guard gone, the stream either completes normally or, if the GOAWAY refused it, the parked caller gets {error, {goaway, _}} right away.

Streaming the request body is hackney:request/5 with stream in place of a body, then send_body/2 for each chunk, finish_send_body/1, and start_response/1 for the reply. Between those calls the caller is off doing something else, typically producing the next chunk. If the GOAWAY refuses that stream in one of those gaps, there is nobody waiting inside hackney to hand the error to. Dropping the stream quietly would make the caller's next call misbehave: send_body would push bytes onto a stream the server has already forgotten (h2 still accepts send_data after a GOAWAY, so the call would succeed), and start_response would come back as {error, no_stream}, which reads like a bug in the caller.

So a refused request stream stays in the stream table, marked refused, and the connection moves from streaming_body back to connected, since there is no body being sent any more. Whichever of send_body, finish_send_body or start_response the caller makes next is answered with {error, {goaway, ErrorCode}}, and that ends the stream. If the caller dies first, the stream's existing monitor ends it instead. Once it was the last stream on the connection, the connection closes, the same way the rest of the drain does. A request stream the GOAWAY accepted needs nothing: the remaining chunks go out, the response comes back, and the connection closes after it has been read.

Worth flagging: a caller that stays alive but never makes another call keeps the drained connection around until the server closes its end. That is also what happens today with an abandoned streamed request, so it isn't new, but it is the one place this PR waits on the caller.

rebar3 eunit is green, 1176 tests, xref and dialyzer clean. The six new ones in test/hackney_http2_goaway_streaming_tests.erl all fail without the change:

  • a streamed request the GOAWAY accepts (sent after its first chunk): the rest of the body goes out, the response is read, and the next request dials a fresh connection;
  • a streamed request the GOAWAY refuses, finding out on send_body, on finish_send_body, and on start_response, while the plain request beside it completes;
  • a streamed response on each side of the GOAWAY: the accepted one completes, the refused one fails at once.

The frame-level server from #961's tests now lives in test/hackney_h2_goaway_server.erl, with request bodies added, so both GOAWAY test modules use the same one. The drain tests are unchanged apart from calling it.

The drain so far closed everything at once when a streamed request or
response body was in flight. A streamed response needs nothing new: its
parked caller is answered like any other. A streamed request body the GOAWAY
refuses has nobody parked on it, since its caller is between send_body_chunk,
finish_send_body and start_response calls, so it stays tracked as refused,
the connection leaves streaming_body, and the caller's next call is answered
with {error, {goaway, _}}. An accepted one carries on as before and the
connection closes when its response has been read.

The frame-level server of the GOAWAY tests moves to hackney_h2_goaway_server,
with request bodies added, so both test modules share it.
@benoitc
benoitc merged commit 1fce9f5 into benoitc:master Oct 3, 2026
6 checks passed
@benoitc

benoitc commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Thanks @lennartschoch, merged. A small follow-up will tidy the refused-upload handling and the h2 send-error path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants