Bug
When a server receives a client-sent GOAWAY(NO_ERROR, last_stream_id=0), Inner::recv_go_away closes all streams with id > last_stream_id - including client-initiated streams that the server is still responding on.
This kills in-progress response bodies before any DATA frames are sent.
Steps to reproduce
- Client opens a connection and sends a request (stream 1)
- Server sends response headers (no body yet)
- Client sends
GOAWAY(NO_ERROR, last_stream_id=0) (e.g. client that doesn't want to reuse the connection after receiving response headers)
- Server-side h2 closes stream 1 and the connection, before the response body is sent
In my case, a pingora-based reverse proxy using h2 for upstream connections. The client is the AWS SDK's NodeHttp2Handler with disableConcurrentStreams: true, which calls session.close() after receiving response headers, sending GOAWAY(NO_ERROR, last_stream_id=0) and killing the in-progress streaming response body.
Connection{peer=Server}: h2::codec::framed_write: send frame=Headers { stream_id: StreamId(1), flags: (0x4: END_HEADERS) }
Connection{peer=Server}: h2::codec::framed_read: received frame=GoAway { error_code: NO_ERROR, last_stream_id: StreamId(0) }
Connection{peer=Server}: h2::codec::framed_write: send frame=GoAway { error_code: NO_ERROR, last_stream_id: StreamId(1) }
Connection{peer=Server}: h2::proto::connection: Connection::poll; connection error error=GoAway(b"", NO_ERROR, Library)
The server immediately sends its own GOAWAY and errors the connection instead of completing the response on stream 1.
Expected behavior
Stream 1 should continue to completion. The client's GOAWAY(last_stream_id=0) refers to server-push streams (even IDs), not client-initiated streams. The server should finish sending the response body, then close the connection gracefully.
Root cause
In src/proto/streams/streams.rs, Inner::recv_go_away:
self.store.for_each(|stream| {
if stream.id > last_stream_id {
// closes ALL streams - including client-initiated ones
}
});
Stream 1 is client-initiated (odd ID). 1 > 0 is true, so it gets closed. But the GOAWAY was sent by the client, so last_stream_id refers to server-push streams (even IDs), not client-initiated streams.
Suggested fix
let peer = counts.peer();
self.store.for_each(|stream| {
if stream.id > last_stream_id && peer.is_local_init(stream.id) {
// only close locally-initiated streams
}
});
peer.is_local_init(id) already exists and returns self.is_server() == id.is_server_initiated(). For a server receiving client GOAWAY:
| Stream |
ID |
is_local_init |
Closed? |
| Client request |
1 (odd) |
false |
No |
| Server push |
2 (even) |
true |
Yes (if > last_stream_id) |
RFC
RFC 9113 Section 6.8:
Once the GOAWAY is sent, the sender will ignore frames sent on streams initiated by the receiver if the stream has an identifier higher than the included last stream identifier.
When the client sends GOAWAY, "receiver" = server. "Streams initiated by the receiver" = server-push streams. Client-initiated streams are not affected.
Bug
When a server receives a client-sent
GOAWAY(NO_ERROR, last_stream_id=0),Inner::recv_go_awaycloses all streams withid > last_stream_id- including client-initiated streams that the server is still responding on.This kills in-progress response bodies before any DATA frames are sent.
Steps to reproduce
GOAWAY(NO_ERROR, last_stream_id=0)(e.g. client that doesn't want to reuse the connection after receiving response headers)In my case, a pingora-based reverse proxy using h2 for upstream connections. The client is the AWS SDK's
NodeHttp2HandlerwithdisableConcurrentStreams: true, which callssession.close()after receiving response headers, sendingGOAWAY(NO_ERROR, last_stream_id=0)and killing the in-progress streaming response body.The server immediately sends its own GOAWAY and errors the connection instead of completing the response on stream 1.
Expected behavior
Stream 1 should continue to completion. The client's
GOAWAY(last_stream_id=0)refers to server-push streams (even IDs), not client-initiated streams. The server should finish sending the response body, then close the connection gracefully.Root cause
In
src/proto/streams/streams.rs,Inner::recv_go_away:Stream 1 is client-initiated (odd ID).
1 > 0is true, so it gets closed. But the GOAWAY was sent by the client, solast_stream_idrefers to server-push streams (even IDs), not client-initiated streams.Suggested fix
peer.is_local_init(id)already exists and returnsself.is_server() == id.is_server_initiated(). For a server receiving client GOAWAY:RFC
RFC 9113 Section 6.8:
When the client sends GOAWAY, "receiver" = server. "Streams initiated by the receiver" = server-push streams. Client-initiated streams are not affected.