Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions include/proxy/http2/Http2ConnectionState.h
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ class Http2ConnectionState : public Continuation
Http2StreamId get_latest_stream_id_in() const;
Http2StreamId get_latest_stream_id_out() const;
int get_stream_requests() const;
Http2StreamId get_last_stream_id_tx() const;
bool get_goaway_sent() const;
void increment_stream_requests();
bool is_peer_concurrent_stream_ub() const;
bool is_peer_concurrent_stream_lb() const;
Expand Down Expand Up @@ -281,6 +283,10 @@ class Http2ConnectionState : public Continuation
Http2StreamId latest_streamid_out = 0;
std::atomic<int> stream_requests = 0;

// The last stream identifier in the GOAWAY frame
Http2StreamId last_stream_id_tx = 0;
bool goaway_sent = false;

// Counter for current active streams which are started by the client.
std::atomic<uint32_t> peer_streams_count_in = 0;

Expand Down Expand Up @@ -442,6 +448,18 @@ Http2ConnectionState::get_stream_requests() const
return stream_requests;
}

inline bool
Http2ConnectionState::get_goaway_sent() const
{
return goaway_sent;
}

inline Http2StreamId
Http2ConnectionState::get_last_stream_id_tx() const
{
return last_stream_id_tx;
}

inline void
Http2ConnectionState::increment_stream_requests()
{
Expand Down
5 changes: 4 additions & 1 deletion include/proxy/http2/Http2Stream.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class Http2Stream : public ProxyTransaction

Http2ErrorCode decode_header_blocks(HpackHandle &hpack_handle, uint32_t maximum_table_size);
void send_headers(Http2ConnectionState &cstate);
void initiating_close();
void initiating_close(bool suppress_rst = false);
bool is_outbound_connection() const;
bool is_tunneling() const;
void terminate_if_possible();
Expand Down Expand Up @@ -184,6 +184,9 @@ class Http2Stream : public ProxyTransaction
bool parsing_header_done = false;
bool is_first_transaction_flag = false;

bool reset_header_after_decoding = false;
bool free_stream_after_decoding = false;

HTTPHdr _send_header;
IOBufferReader *_send_reader = nullptr;
Http2DependencyTree::Node *priority_node = nullptr;
Expand Down
19 changes: 12 additions & 7 deletions src/proxy/http2/Http2CommonSession.cc
Original file line number Diff line number Diff line change
Expand Up @@ -362,25 +362,30 @@ Http2CommonSession::do_process_frame_read(int /* event ATS_UNUSED */, VIO *vio,

while (this->_read_buffer_reader->read_avail() >= static_cast<int64_t>(HTTP2_FRAME_HEADER_LEN)) {
// Cancel reading if there was an error or connection is closed
if (connection_state.tx_error_code.code != static_cast<uint32_t>(Http2ErrorCode::HTTP2_ERROR_NO_ERROR) ||
connection_state.is_state_closed()) {
const auto has_fatal_error_code =
(connection_state.tx_error_code.code != static_cast<uint32_t>(Http2ErrorCode::HTTP2_ERROR_NO_ERROR) &&
connection_state.tx_error_code.code != static_cast<uint32_t>(Http2ErrorCode::HTTP2_ERROR_ENHANCE_YOUR_CALM));
if (has_fatal_error_code || connection_state.is_state_closed()) {
Http2SsnDebug("reading a frame has been canceled (%u)", connection_state.tx_error_code.code);
break;
return 0;
}

Http2ErrorCode err = Http2ErrorCode::HTTP2_ERROR_NO_ERROR;
if (this->connection_state.get_stream_error_rate() > std::min(1.0, Http2::stream_error_rate_threshold * 2.0)) {
if (this->connection_state.get_stream_error_rate() > std::min(1.0, Http2::stream_error_rate_threshold * 2.0) &&
(!this->connection_state.get_goaway_sent() || this->connection_state.get_last_stream_id_tx() == INT32_MAX)) {
ip_port_text_buffer ipb;
const char *peer_ip = ats_ip_ntop(this->get_proxy_session()->get_remote_addr(), ipb, sizeof(ipb));
SiteThrottledWarning("HTTP/2 session error peer_ip=%s session_id=%" PRId64
" closing a connection, because its stream error rate (%f) exceeded the threshold (%f)",
peer_ip, this->get_connection_id(), this->connection_state.get_stream_error_rate(),
Http2::stream_error_rate_threshold);
err = Http2ErrorCode::HTTP2_ERROR_ENHANCE_YOUR_CALM;
this->connection_state.send_goaway_frame(this->connection_state.get_latest_stream_id_in(),
Http2ErrorCode::HTTP2_ERROR_ENHANCE_YOUR_CALM);
this->set_half_close_local_flag(true);
Comment on lines +373 to +383
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR fixes a significant behavioral issue (408 timeouts caused by ignoring DATA frames after GOAWAY), but doesn't include any test coverage for the new behavior. Given the existing test infrastructure in tests/gold_tests/h2/ and the complexity of this fix (continuing to process frames after GOAWAY, minimal HPACK processing for HEADERS/CONTINUATION on refused streams, flow-control counting for DATA), it would be valuable to add an integration test that validates the core scenario: sending multiple POST requests over a single HTTP/2 connection where the stream error rate threshold is exceeded, and verifying that in-flight requests with pending DATA frames complete successfully rather than timing out.

Copilot uses AI. Check for mistakes.
}

// Return if there was an error
if (err > Http2ErrorCode::HTTP2_ERROR_NO_ERROR || do_start_frame_read(err) < 0) {
auto err = Http2ErrorCode::HTTP2_ERROR_NO_ERROR;
if (do_start_frame_read(err) < 0) {
// send an error if specified. Otherwise, just go away
this->connection_state.restart_receiving(nullptr);
if (err > Http2ErrorCode::HTTP2_ERROR_NO_ERROR) {
Expand Down
100 changes: 84 additions & 16 deletions src/proxy/http2/Http2ConnectionState.cc
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ Http2ConnectionState::rcv_data_frame(const Http2Frame &frame)
"recv data bad frame client id");
}

// After sending a GOAWAY frame, the sender can discard frames for streams initiated by the receiver with
// identifiers higher than the identified last stream. However, DATA frames MUST be counted toward
// the connection flow-control window. (Details in [RFC 9113] 6.8.)
if (this->goaway_sent && id > this->last_stream_id_tx) {
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM,
"recv data with id higher than last stream id");
}

Http2Stream *stream = this->find_stream(id);
if (stream == nullptr) {
if (this->is_valid_streamid(id)) {
Expand Down Expand Up @@ -330,15 +338,25 @@ Http2ConnectionState::rcv_headers_frame(const Http2Frame &frame)
"recv headers bad client id");
}

// After sending a GOAWAY frame, the sender can discard frames for streams initiated by the receiver with
// identifiers higher than the identified last stream. However, HEADERS, PUSH_PROMISE, and CONTINUATION
// frames MUST be minimally processed to ensure that the state maintained for field section compression is
// consistent (Details in [RFC 9113] 6.8.)
if (this->goaway_sent && stream_id > this->last_stream_id_tx) {
reset_header_after_decoding = true;
}

if (!stream) {
if (reset_header_after_decoding) {
free_stream_after_decoding = true;
uint32_t const initial_local_stream_window = this->acknowledged_local_settings.get(HTTP2_SETTINGS_INITIAL_WINDOW_SIZE);
ink_assert(dynamic_cast<Http2CommonSession *>(this->session->get_proxy_session()));
ink_assert(this->session->is_outbound() == true);
stream = THREAD_ALLOC_INIT(http2StreamAllocator, this_ethread(), this->session->get_proxy_session(), stream_id,
this->peer_settings.get(HTTP2_SETTINGS_INITIAL_WINDOW_SIZE), initial_local_stream_window,
!STREAM_IS_REGISTERED);
stream = THREAD_ALLOC_INIT(http2StreamAllocator, this_ethread(), this->session->get_proxy_session(), stream_id,
this->peer_settings.get(HTTP2_SETTINGS_INITIAL_WINDOW_SIZE), initial_local_stream_window,
!STREAM_IS_REGISTERED);
stream->mutex = new_ProxyMutex();
SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
this->stream_list.enqueue(stream);
} else {
// Create new stream
Http2Error error(Http2ErrorClass::HTTP2_ERROR_CLASS_NONE);
Expand Down Expand Up @@ -370,6 +388,9 @@ Http2ConnectionState::rcv_headers_frame(const Http2Frame &frame)
}
}

stream->reset_header_after_decoding = reset_header_after_decoding;
stream->free_stream_after_decoding = free_stream_after_decoding;

Http2HeadersParameter params;
uint32_t header_block_fragment_offset = 0;
uint32_t header_block_fragment_length = payload_length;
Expand Down Expand Up @@ -477,13 +498,21 @@ Http2ConnectionState::rcv_headers_frame(const Http2Frame &frame)
Http2ErrorCode result = stream->decode_header_blocks(*this->local_hpack_handle,
this->acknowledged_local_settings.get(HTTP2_SETTINGS_HEADER_TABLE_SIZE));

// If this was an outbound connection and the state was already closed, just clear the
// headers after processing. We just processed the heaer blocks to keep the dynamic table in
// We just processed the header blocks to keep the dynamic table in
// sync with peer to avoid future HPACK compression errors
if (reset_header_after_decoding) {
stream->reset_receive_headers();
SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
this->stream_list.remove(stream);
if (free_stream_after_decoding) {
THREAD_FREE(stream, http2StreamAllocator, this_ethread());
// Suppress RST_STREAM(NO_ERROR): rcv_frame() will send RST_STREAM(REFUSED_STREAM)
// for this stream, so sending NO_ERROR here would produce duplicate frames.
stream->initiating_close(/* suppress_rst= */ true);
}
Comment on lines 507 to +511
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When initiating_close() is called on the temporary stream here, the stream's state has already transitioned from IDLE to OPEN (via change_state at line 481), making is_state_writeable() return true. This causes initiating_close() to send an RST_STREAM with HTTP2_ERROR_NO_ERROR. Then, when this function returns the HTTP2_ERROR_CLASS_STREAM error at line 512, rcv_frame() sends a second RST_STREAM with HTTP2_ERROR_REFUSED_STREAM (line 1556 of rcv_frame). This results in duplicate RST_STREAM frames for the same stream. Consider either not calling initiating_close() and instead using THREAD_FREE directly (as the old code did for the outbound case), or setting the stream state to CLOSED before calling initiating_close() to prevent the extra RST_STREAM.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a conclusion, I added a suppress_rst argument to the initiating_close method so that sending an RST_STREAM frame can be suppressed:
d98f026

Calling THREAD_FREE directly would be simpler. However, this is not feasible because it triggers the assertion in the Http2Stream destructor shown below, which results in a crash:

ink_release_assert(this->closed);

Regarding the alternative approach of modifying the stream state before calling initiating_close, this relies on the assumption that an RST_STREAM frame will be sent later in the subsequent processing flow. If that assumption changes in the future, it could introduce another issue. Therefore, I decided not to adopt that approach.


if (this->goaway_sent && stream_id > this->last_stream_id_tx) {
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM,
"recv headers with id higher than last stream id");
}
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_NONE);
}
Expand Down Expand Up @@ -1100,6 +1129,29 @@ Http2ConnectionState::rcv_continuation_frame(const Http2Frame &frame)
Http2ErrorCode result = stream->decode_header_blocks(*this->local_hpack_handle,
this->acknowledged_local_settings.get(HTTP2_SETTINGS_HEADER_TABLE_SIZE));

// We just processed the header blocks to keep the dynamic table in
// sync with peer to avoid future HPACK compression errors
if (stream->reset_header_after_decoding) {
stream->reset_receive_headers();
SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
this->stream_list.remove(stream);
if (stream->free_stream_after_decoding) {
// Suppress RST_STREAM(NO_ERROR): rcv_frame() will send RST_STREAM(REFUSED_STREAM)
// for this stream, so sending NO_ERROR here would produce duplicate frames.
stream->initiating_close(/* suppress_rst= */ true);
}
Comment on lines +1138 to +1142
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in rcv_headers_frame: initiating_close() may send an RST_STREAM(NO_ERROR) if the stream is in a writeable state, and then the HTTP2_ERROR_CLASS_STREAM return at line 1144 causes rcv_frame() to send a second RST_STREAM(REFUSED_STREAM). This results in duplicate RST_STREAM frames for the same stream.

Copilot uses AI. Check for mistakes.

// After sending a GOAWAY frame, the sender can discard frames for streams initiated by the receiver with
// identifiers higher than the identified last stream. However, HEADERS, PUSH_PROMISE, and CONTINUATION
// frames MUST be minimally processed to ensure that the state maintained for field section compression is
// consistent (Details in [RFC 9113] 6.8.)
if (this->goaway_sent && stream_id > this->last_stream_id_tx) {
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM,
"recv continuation with id higher than last stream id");
}
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_NONE);
}

if (result != Http2ErrorCode::HTTP2_ERROR_NO_ERROR) {
if (result == Http2ErrorCode::HTTP2_ERROR_COMPRESSION_ERROR) {
return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_CONNECTION, Http2ErrorCode::HTTP2_ERROR_COMPRESSION_ERROR,
Expand Down Expand Up @@ -1437,12 +1489,13 @@ Http2ConnectionState::rcv_frame(const Http2Frame *frame)
{
REMEMBER(NO_EVENT, this->recursion);
const Http2StreamId stream_id = frame->header().streamid;
const auto type = frame->header().type;
Http2Error error;

// [RFC 7540] 5.5. Extending HTTP/2
// Implementations MUST discard frames that have unknown or unsupported types.
if (frame->header().type >= HTTP2_FRAME_TYPE_MAX) {
Http2StreamDebug(session, stream_id, "Discard a frame which has unknown type, type=%x", frame->header().type);
if (type >= HTTP2_FRAME_TYPE_MAX) {
Http2StreamDebug(session, stream_id, "Discard a frame which has unknown type, type=%x", type);
return;
}

Expand All @@ -1457,15 +1510,28 @@ Http2ConnectionState::rcv_frame(const Http2Frame *frame)
// GOAWAY: NO
// WINDOW_UPDATE: YES
// CONTINUATION: YES (safe http methods only, same as HEADERS frame).
if (frame->is_from_early_data() &&
(frame->header().type == HTTP2_FRAME_TYPE_DATA || frame->header().type == HTTP2_FRAME_TYPE_RST_STREAM ||
frame->header().type == HTTP2_FRAME_TYPE_PUSH_PROMISE || frame->header().type == HTTP2_FRAME_TYPE_GOAWAY)) {
Http2StreamDebug(session, stream_id, "Discard a frame which is received from early data and has type=%x", frame->header().type);
if (frame->is_from_early_data() && (type == HTTP2_FRAME_TYPE_DATA || type == HTTP2_FRAME_TYPE_RST_STREAM ||
type == HTTP2_FRAME_TYPE_PUSH_PROMISE || type == HTTP2_FRAME_TYPE_GOAWAY)) {
Http2StreamDebug(session, stream_id, "Discard a frame which is received from early data and has type=%x", type);
return;
}

if (this->_frame_handlers[frame->header().type]) {
error = (this->*_frame_handlers[frame->header().type])(*frame);
// After sending a GOAWAY frame, the sender can discard frames for streams initiated by the receiver with
// identifiers higher than the identified last stream. However, HEADERS, PUSH_PROMISE, and CONTINUATION
// frames MUST be minimally processed to ensure that the state maintained for field section compression is
// consistent; similarly, DATA frames MUST be counted toward the connection flow-control window.
// (Details in [RFC 9113] 6.8.)
if (this->goaway_sent && stream_id > this->last_stream_id_tx) {
const auto is_discardable = (type != HTTP2_FRAME_TYPE_HEADERS && type != HTTP2_FRAME_TYPE_PUSH_PROMISE &&
type != HTTP2_FRAME_TYPE_CONTINUATION && type != HTTP2_FRAME_TYPE_DATA);
if (is_discardable) {
Http2StreamDebug(session, stream_id, "Discard a frame which is received after sending a GOAWAY and has type=%x", type);
return;
}
}

if (this->_frame_handlers[type]) {
error = (this->*_frame_handlers[type])(*frame);
} else {
error = Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_CONNECTION, Http2ErrorCode::HTTP2_ERROR_INTERNAL_ERROR, "no handler");
}
Expand Down Expand Up @@ -2744,7 +2810,9 @@ Http2ConnectionState::send_goaway_frame(Http2StreamId id, Http2ErrorCode ec)
Metrics::Counter::increment(http2_rsb.connection_errors_count);
}

this->tx_error_code = {ProxyErrorClass::SSN, static_cast<uint32_t>(ec)};
this->tx_error_code = {ProxyErrorClass::SSN, static_cast<uint32_t>(ec)};
this->last_stream_id_tx = id;
this->goaway_sent = true;

Http2Goaway goaway;
goaway.last_streamid = id;
Expand Down
5 changes: 3 additions & 2 deletions src/proxy/http2/Http2Stream.cc
Original file line number Diff line number Diff line change
Expand Up @@ -628,15 +628,16 @@ Http2Stream::terminate_if_possible()

// Initiated from the Http2 side
void
Http2Stream::initiating_close()
Http2Stream::initiating_close(bool suppress_rst)
{
if (!closed) {
SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
REMEMBER(NO_EVENT, this->reentrancy_count);
Http2StreamDebug("initiating_close client_window=%zd session_window=%zd", _peer_rwnd,
this->get_connection_state().get_peer_rwnd());

if (!this->is_outbound_connection() && this->is_state_writeable()) { // Let the other end know we are going away
if (!suppress_rst && !this->is_outbound_connection() &&
this->is_state_writeable()) { // Let the other end know we are going away
this->get_connection_state().send_rst_stream_frame(_id, Http2ErrorCode::HTTP2_ERROR_NO_ERROR);
}

Expand Down