This is an automated email from the ASF dual-hosted git repository.

masaori pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new c3a40a7911 HTTP/2: Wait END_STREAM flag on half-closed(local) stream 
state (#12257)
c3a40a7911 is described below

commit c3a40a79111818eb7be3a5805b2f7ebdb0fd59f2
Author: Masaori Koshiba <[email protected]>
AuthorDate: Wed Jun 25 10:00:35 2025 +0900

    HTTP/2: Wait END_STREAM flag on half-closed(local) stream state (#12257)
    
    * HTTP/2: Wait END_STREAM flag on half-closed(local) stream state
    
    * Actively close stream in half-closed(local) state
    
    * Add AuTest
    
    * Fix format of AuTests
    
    * Address comments
---
 src/proxy/http2/Http2ConnectionState.cc            |  23 ++--
 src/proxy/http2/Http2Stream.cc                     |  21 +++-
 tests/gold_tests/h2/clients/h2empty_data_frame.py  | 128 +++++++++++++++++++++
 tests/gold_tests/h2/http2_empty_data_frame.test.py |  71 ++++++++++++
 4 files changed, 232 insertions(+), 11 deletions(-)

diff --git a/src/proxy/http2/Http2ConnectionState.cc 
b/src/proxy/http2/Http2ConnectionState.cc
index 53d62d8169..49e9cf8be7 100644
--- a/src/proxy/http2/Http2ConnectionState.cc
+++ b/src/proxy/http2/Http2ConnectionState.cc
@@ -175,9 +175,15 @@ Http2ConnectionState::rcv_data_frame(const Http2Frame 
&frame)
 
     // Pure END_STREAM
     if (payload_length == 0) {
+      if (stream->get_state() == Http2StreamState::HTTP2_STREAM_STATE_CLOSED) {
+        stream->initiating_close();
+        return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_NONE);
+      }
+
       if (stream->is_read_enabled()) {
         stream->signal_read_event(VC_EVENT_READ_COMPLETE);
       }
+
       return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_NONE);
     }
   } else {
@@ -2380,13 +2386,16 @@ Http2ConnectionState::send_data_frames(Http2Stream 
*stream)
 
     if (result == Http2SendDataFrameResult::DONE) {
       if (!stream->is_outbound_connection()) {
-        // Delete a stream immediately
-        // TODO its should not be deleted for a several time to handling
-        // RST_STREAM and WINDOW_UPDATE.
-        // See 'closed' state written at [RFC 7540] 5.1.
-        Http2StreamDebug(this->session, stream->get_id(), "Shutdown stream");
-        stream->signal_write_event(VC_EVENT_WRITE_COMPLETE);
-        stream->do_io_close();
+        if (stream->get_state() == 
Http2StreamState::HTTP2_STREAM_STATE_CLOSED) {
+          // Delete a stream immediately
+          Http2StreamDebug(this->session, stream->get_id(), "Shutdown stream");
+          stream->signal_write_event(VC_EVENT_WRITE_COMPLETE);
+          stream->do_io_close();
+        } else {
+          // This stream waits for the END_STREAM in half-closed (local) state 
until `http.transaction_no_activity_timeout_in`.
+          // If no frame with END_STREAM is found, ATS actively closes the 
stream.
+          Http2StreamDebug(this->session, stream->get_id(), "waiting 
END_STREAM");
+        }
       } else if (stream->is_outbound_connection() && 
stream->is_write_vio_done()) {
         stream->signal_write_event(VC_EVENT_WRITE_COMPLETE);
       } else {
diff --git a/src/proxy/http2/Http2Stream.cc b/src/proxy/http2/Http2Stream.cc
index 3a98f19160..4eb6f77d07 100644
--- a/src/proxy/http2/Http2Stream.cc
+++ b/src/proxy/http2/Http2Stream.cc
@@ -31,6 +31,7 @@
 #include "tscore/Diags.h"
 #include "tscore/HTTPVersion.h"
 #include "tscore/ink_assert.h"
+#include "tsutil/DbgCtl.h"
 
 #include <numeric>
 
@@ -562,7 +563,10 @@ Http2Stream::do_io_close(int /* flags */)
     // We only need to do this for the client side since we only need to pass 
through RST_STREAM
     // from the server. If a client sends a RST_STREAM, we need to keep the 
server side alive so
     // the background fill can function as intended.
-    if (!this->is_outbound_connection() && this->is_state_writeable()) {
+    //
+    // In the half-closed(local) state, server can close stream actively by 
sending RST_STREAM frame
+    if (!this->is_outbound_connection() &&
+        (this->is_state_writeable() || _state == 
Http2StreamState::HTTP2_STREAM_STATE_HALF_CLOSED_LOCAL)) {
       this->get_connection_state().send_rst_stream_frame(_id, 
Http2ErrorCode::HTTP2_ERROR_NO_ERROR);
     }
 
@@ -590,7 +594,7 @@ Http2Stream::transaction_done()
   SCOPED_MUTEX_LOCK(lock, this->mutex, this_ethread());
   super::transaction_done();
 
-  if (!closed) {
+  if (!closed && _state == Http2StreamState::HTTP2_STREAM_STATE_CLOSED) {
     do_io_close(); // Make sure we've been closed.  If we didn't close the 
_proxy_ssn session better still be open
   }
   Http2ConnectionState &state = this->get_connection_state();
@@ -1081,11 +1085,20 @@ Http2Stream::clear_io_events()
   }
 }
 
-//  release and do_io_close are the same for the HTTP/2 protocol
+/**
+  Callback from HttpSM
+
+  release and do_io_close are the same for the HTTP/2 protocol
+ */
 void
 Http2Stream::release()
 {
-  this->do_io_close();
+  if (_state == Http2StreamState::HTTP2_STREAM_STATE_CLOSED) {
+    this->do_io_close();
+    return;
+  }
+
+  Http2StreamDebug("Delaying do_io_close() until stream is in the closed 
state");
 }
 
 void
diff --git a/tests/gold_tests/h2/clients/h2empty_data_frame.py 
b/tests/gold_tests/h2/clients/h2empty_data_frame.py
new file mode 100644
index 0000000000..610a02b626
--- /dev/null
+++ b/tests/gold_tests/h2/clients/h2empty_data_frame.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+'''
+HTTP/2 client that sends empty DATA frame with END_STREAM flag
+'''
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import socket
+import ssl
+
+import h2.connection
+import h2.events
+
+import argparse
+
+
+# TODO: cleanup with other HTTP/2 clients (h2active_timeout.py and h2client.py)
+def get_socket(port: int) -> socket.socket:
+    """Create a TLS-wrapped socket.
+
+    :param port: The port to connect to.
+
+    :returns: A TLS-wrapped socket.
+    """
+
+    SERVER_NAME = 'localhost'
+    SERVER_PORT = port
+
+    # generic socket and ssl configuration
+    socket.setdefaulttimeout(15)
+
+    # Configure an ssl client side context which will not check the server's 
certificate.
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    ctx.set_alpn_protocols(['h2'])
+
+    # open a socket to the server and initiate TLS/SSL
+    tls_socket = socket.create_connection((SERVER_NAME, SERVER_PORT))
+    tls_socket = ctx.wrap_socket(tls_socket, server_hostname=SERVER_NAME)
+    return tls_socket
+
+
+def make_request(port: int, path: str, n: int) -> None:
+    """Establish an HTTP/2 connection and send a request.
+
+    :param port: The port to connect to.
+    :param path: The path to request.
+    :param n: Number of streams to open.
+    """
+
+    tls_socket = get_socket(port)
+
+    h2_connection = h2.connection.H2Connection()
+    h2_connection.initiate_connection()
+    tls_socket.sendall(h2_connection.data_to_send())
+
+    headers = [
+        (':method', 'GET'),
+        (':path', path),
+        (':authority', 'localhost'),
+        (':scheme', 'https'),
+    ]
+
+    for stream_id in range(1, n * 2, 2):
+        h2_connection.send_headers(stream_id, headers, end_stream=False)
+        h2_connection.send_data(stream_id, b'', end_stream=True)
+
+    tls_socket.sendall(h2_connection.data_to_send())
+
+    # keep reading from server
+    terminated = False
+    error_code = 0
+    while not terminated:
+        # read raw data from the socket
+        data = tls_socket.recv(65536 * 1024)
+        if not data:
+            break
+
+        # feed raw data into h2, and process resulting events
+        events = h2_connection.receive_data(data)
+        for event in events:
+            print(event)
+            if isinstance(event, h2.events.ConnectionTerminated):
+                if not event.error_code == 0:
+                    error_code = event.error_code
+                terminated = True
+
+        # send any pending data to the server
+        tls_socket.sendall(h2_connection.data_to_send())
+
+    # tell the server we are closing the h2 connection
+    h2_connection.close_connection()
+    tls_socket.sendall(h2_connection.data_to_send())
+
+    # close the socket
+    tls_socket.close()
+
+    if error_code != 0:
+        exit(1)
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("port", type=int, help="Port to use")
+    parser.add_argument("path", help="The path to request")
+    parser.add_argument("-n", type=int, default=1, help="Number of streams to 
open")
+
+    args = parser.parse_args()
+
+    make_request(args.port, args.path, args.n)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/gold_tests/h2/http2_empty_data_frame.test.py 
b/tests/gold_tests/h2/http2_empty_data_frame.test.py
new file mode 100644
index 0000000000..d0e9038911
--- /dev/null
+++ b/tests/gold_tests/h2/http2_empty_data_frame.test.py
@@ -0,0 +1,71 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+import sys
+
+Test.Sumary = '''
+Verify Empty DATA Frame Handling
+'''
+
+
+class Http2EmptyDataFrameTest:
+
+    def __init__(self):
+        self.__setupOriginServer()
+        self.__setupTS()
+        self.__setupClient()
+
+    def __setupOriginServer(self):
+        self._server = Test.MakeHttpBinServer("httpbin")
+
+    def __setupTS(self):
+        self._ts = Test.MakeATSProcess(f"ts", enable_tls=True, 
enable_cache=True)
+        self._ts.addDefaultSSLFiles()
+        self._ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http2',
+                'proxy.config.ssl.server.cert.path': 
f"{self._ts.Variables.SSLDir}",
+                'proxy.config.ssl.server.private_key.path': 
f"{self._ts.Variables.SSLDir}",
+                'proxy.config.http.insert_response_via_str': 2,
+                'proxy.config.http2.active_timeout_in': 3,
+                'proxy.config.http2.stream_error_rate_threshold': 0.1  # 
default
+            })
+        self._ts.Disk.remap_config.AddLine(f"map / 
http://127.0.0.1:{self._server.Variables.Port}";)
+        self._ts.Disk.ssl_multicert_config.AddLine('dest_ip=* 
ssl_cert_name=server.pem ssl_key_name=server.key')
+
+    def __setupClient(self):
+        self._ts.Setup.CopyAs("clients/h2empty_data_frame.py", 
Test.RunDirectory)
+
+    def run(self):
+        tr = Test.AddTestRun("warm-up cache")
+
+        tr.Processes.Default.StartBefore(self._ts)
+        tr.Processes.Default.StartBefore(self._server)
+
+        # warm up the cache
+        tr.Processes.Default.Command = f"{sys.executable} 
h2empty_data_frame.py {self._ts.Variables.ssl_port} /cache/10 -n 1"
+        tr.Processes.Default.ReturnCode = 0
+
+        # verify 20 streams doesn't hit 
`proxy.config.http2.stream_error_rate_threshold`
+        tr = Test.AddTestRun("open 20 streams")
+        tr.Processes.Default.Command = f"{sys.executable} 
h2empty_data_frame.py {self._ts.Variables.ssl_port} /cache/10 -n 20"
+        tr.Processes.Default.ReturnCode = 0
+
+        tr.StillRunningAfter = self._ts
+
+
+Http2EmptyDataFrameTest().run()

Reply via email to