Skip to content

GOAWAY kills in-progress response when client sends GOAWAY(0) #885

@ofek-sha

Description

@ofek-sha

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

  1. Client opens a connection and sends a request (stream 1)
  2. Server sends response headers (no body yet)
  3. Client sends GOAWAY(NO_ERROR, last_stream_id=0) (e.g. client that doesn't want to reuse the connection after receiving response headers)
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions