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

cmcfarlen pushed a commit to branch 10.1.x
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/10.1.x by this push:
     new b3368148e2 Hard-enforce max_active_streams_in at HTTP/2 stream 
creation (#13387)
b3368148e2 is described below

commit b3368148e26e6a751ed6b6451280395520d18629
Author: Masakazu Kitajo <[email protected]>
AuthorDate: Wed Jul 15 09:03:58 2026 -0600

    Hard-enforce max_active_streams_in at HTTP/2 stream creation (#13387)
    
    proxy.config.http2.max_active_streams_in has only adjusted the advertised 
SETTINGS_MAX_CONCURRENT_STREAMS, leaving the proxy unable to bound buffered 
response memory against clients that open streams faster than the advisory 
throttle reacts. It now also carries a finite default so the cap is no longer 
effectively unlimited.
    
    The new knob proxy.config.http2.max_active_streams_policy_in selects 
enforcement. Value 0 keeps the advisory behavior that lowers advertised 
concurrency to proxy.config.http2.min_concurrent_streams_in. Value 1 refuses 
new inbound streams with REFUSED_STREAM once the process-wide active-stream 
count reaches the limit and leaves the advertised value untouched, so the 
min_concurrent_streams_in reduction that disrupts some clients no longer 
applies.
    
    max_active_streams_in now defaults to 200000 rather than 0. Operators 
should size it to their available memory budget; set it to 0 to disable the cap.
    
    Refusal happens after HPACK decoding so the dynamic table stays in sync 
with the client and the connection survives. 
proxy.process.http2.max_active_streams_exceeded_in counts each refusal.
    
    (cherry picked from commit c76a7c81220fca009ff4dc7de91647c3cf6d72d7)
    
     Conflicts:
            src/proxy/http2/HTTP2.cc
---
 doc/admin-guide/files/records.yaml.en.rst          |  37 ++++-
 include/proxy/http2/HTTP2.h                        |   2 +
 src/proxy/http2/HTTP2.cc                           |  45 +++---
 src/proxy/http2/Http2ConnectionState.cc            |  28 ++++
 src/records/RecordsConfig.cc                       |   4 +-
 .../gold_tests/h2/clients/h2_max_active_streams.py | 173 +++++++++++++++++++++
 .../gold_tests/h2/http2_max_active_streams.test.py |  80 ++++++++++
 .../http2_max_active_streams_advisory.replay.yaml  |  86 ++++++++++
 .../http2_max_active_streams_enforce.replay.yaml   |  55 +++++++
 9 files changed, 484 insertions(+), 26 deletions(-)

diff --git a/doc/admin-guide/files/records.yaml.en.rst 
b/doc/admin-guide/files/records.yaml.en.rst
index c376976bb7..c1f9e243f4 100644
--- a/doc/admin-guide/files/records.yaml.en.rst
+++ b/doc/admin-guide/files/records.yaml.en.rst
@@ -4491,14 +4491,43 @@ HTTP/2 Configuration
    This is used when :ts:cv:`proxy.config.http2.max_active_streams_out` is set
    larger than ``0``.
 
-.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_in INT 0
+.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_in INT 200000
    :reloadable:
 
-   Limits the maximum number of connection wide active streams.
-   When connection wide active streams are larger than this value,
+   Limits the maximum number of process-wide active inbound streams.
+   When the process-wide active stream count reaches this value,
    SETTINGS_MAX_CONCURRENT_STREAMS will be reduced to
    :ts:cv:`proxy.config.http2.min_concurrent_streams_in`.
-   To disable, set to zero (``0``).
+   To disable, set to zero (``0``). The default bounds worst-case
+   buffered response memory while staying well above any realistic
+   legitimate stream count; size it to your available memory budget.
+
+   See :ts:cv:`proxy.config.http2.max_active_streams_policy_in` to switch
+   from this advisory behavior to hard refusal of new streams once the
+   limit is reached.
+
+.. ts:cv:: CONFIG proxy.config.http2.max_active_streams_policy_in INT 0
+   :reloadable:
+
+   Selects how :ts:cv:`proxy.config.http2.max_active_streams_in` is
+   enforced for inbound HTTP/2 streams.
+
+   ===== ===================================================================
+   Value Description
+   ===== ===================================================================
+   ``0`` When the limit is reached, the advertised
+         ``SETTINGS_MAX_CONCURRENT_STREAMS`` is reduced to
+         :ts:cv:`proxy.config.http2.min_concurrent_streams_in` for new
+         connections. Already-admitted streams are not refused.
+   ``1`` New inbound streams are refused with ``REFUSED_STREAM`` once the
+         global active-stream count reaches the limit. The advertised
+         ``SETTINGS_MAX_CONCURRENT_STREAMS`` is left unchanged and
+         :ts:cv:`proxy.config.http2.min_concurrent_streams_in` is not
+         applied.
+   ===== ===================================================================
+
+   Has no effect when :ts:cv:`proxy.config.http2.max_active_streams_in`
+   is ``0``.
 
 .. ts:cv:: CONFIG proxy.config.http2.max_active_streams_out INT 0
    :reloadable:
diff --git a/include/proxy/http2/HTTP2.h b/include/proxy/http2/HTTP2.h
index 9f3c62b97e..a8923924b6 100644
--- a/include/proxy/http2/HTTP2.h
+++ b/include/proxy/http2/HTTP2.h
@@ -108,6 +108,7 @@ struct Http2StatsBlock {
   Metrics::Counter::AtomicType *insufficient_avg_window_update;
   Metrics::Counter::AtomicType *max_concurrent_streams_exceeded_in;
   Metrics::Counter::AtomicType *max_concurrent_streams_exceeded_out;
+  Metrics::Counter::AtomicType *max_active_streams_exceeded_in;
   Metrics::Counter::AtomicType *data_frames_in;
   Metrics::Counter::AtomicType *headers_frames_in;
   Metrics::Counter::AtomicType *priority_frames_in;
@@ -401,6 +402,7 @@ public:
   static uint32_t               max_concurrent_streams_in;
   static uint32_t               min_concurrent_streams_in;
   static uint32_t               max_active_streams_in;
+  static uint32_t               max_active_streams_policy_in;
   static bool                   throttling;
   static uint32_t               stream_priority_enabled;
   static uint32_t               initial_window_size_in;
diff --git a/src/proxy/http2/HTTP2.cc b/src/proxy/http2/HTTP2.cc
index 8323b05352..33ca73a8b6 100644
--- a/src/proxy/http2/HTTP2.cc
+++ b/src/proxy/http2/HTTP2.cc
@@ -459,16 +459,17 @@ http2_decode_header_blocks(HTTPHdr *hdr, const uint8_t 
*buf_start, const uint32_
 }
 
 // Initialize this subsystem with librecords configs (for now)
-uint32_t               Http2::max_concurrent_streams_in = 100;
-uint32_t               Http2::min_concurrent_streams_in = 10;
-uint32_t               Http2::max_active_streams_in     = 0;
-bool                   Http2::throttling                = false;
-uint32_t               Http2::stream_priority_enabled   = 0;
-uint32_t               Http2::initial_window_size_in    = 65535;
-Http2FlowControlPolicy Http2::flow_control_policy_in    = 
Http2FlowControlPolicy::STATIC_SESSION_AND_STATIC_STREAM;
-uint32_t               Http2::max_frame_size            = 16384;
-uint32_t               Http2::header_table_size         = 4096;
-uint32_t               Http2::max_header_list_size      = 4294967295;
+uint32_t               Http2::max_concurrent_streams_in    = 100;
+uint32_t               Http2::min_concurrent_streams_in    = 10;
+uint32_t               Http2::max_active_streams_in        = 200000;
+uint32_t               Http2::max_active_streams_policy_in = 0;
+bool                   Http2::throttling                   = false;
+uint32_t               Http2::stream_priority_enabled      = 0;
+uint32_t               Http2::initial_window_size_in       = 65535;
+Http2FlowControlPolicy Http2::flow_control_policy_in       = 
Http2FlowControlPolicy::STATIC_SESSION_AND_STATIC_STREAM;
+uint32_t               Http2::max_frame_size               = 16384;
+uint32_t               Http2::header_table_size            = 4096;
+uint32_t               Http2::max_header_list_size         = 4294967295;
 
 uint32_t Http2::accept_no_activity_timeout   = 120;
 uint32_t Http2::no_activity_timeout_in       = 120;
@@ -513,6 +514,7 @@ Http2::init()
   REC_EstablishStaticConfigInt32U(min_concurrent_streams_out, 
"proxy.config.http2.min_concurrent_streams_out");
 
   REC_EstablishStaticConfigInt32U(max_active_streams_in, 
"proxy.config.http2.max_active_streams_in");
+  REC_EstablishStaticConfigInt32U(max_active_streams_policy_in, 
"proxy.config.http2.max_active_streams_policy_in");
   REC_EstablishStaticConfigInt32U(stream_priority_enabled, 
"proxy.config.http2.stream_priority_enabled");
 
   REC_EstablishStaticConfigInt32U(initial_window_size_in, 
"proxy.config.http2.initial_window_size_in");
@@ -616,17 +618,18 @@ Http2::init()
     
Metrics::Counter::createPtr("proxy.process.http2.max_concurrent_streams_exceeded_in");
   http2_rsb.max_concurrent_streams_exceeded_out =
     
Metrics::Counter::createPtr("proxy.process.http2.max_concurrent_streams_exceeded_out");
-  http2_rsb.data_frames_in          = 
Metrics::Counter::createPtr("proxy.process.http2.data_frames_in"),
-  http2_rsb.headers_frames_in       = 
Metrics::Counter::createPtr("proxy.process.http2.headers_frames_in"),
-  http2_rsb.priority_frames_in      = 
Metrics::Counter::createPtr("proxy.process.http2.priority_frames_in"),
-  http2_rsb.rst_stream_frames_in    = 
Metrics::Counter::createPtr("proxy.process.http2.rst_stream_frames_in"),
-  http2_rsb.settings_frames_in      = 
Metrics::Counter::createPtr("proxy.process.http2.settings_frames_in"),
-  http2_rsb.push_promise_frames_in  = 
Metrics::Counter::createPtr("proxy.process.http2.push_promise_frames_in"),
-  http2_rsb.ping_frames_in          = 
Metrics::Counter::createPtr("proxy.process.http2.ping_frames_in"),
-  http2_rsb.goaway_frames_in        = 
Metrics::Counter::createPtr("proxy.process.http2.goaway_frames_in"),
-  http2_rsb.window_update_frames_in = 
Metrics::Counter::createPtr("proxy.process.http2.window_update_frames_in"),
-  http2_rsb.continuation_frames_in  = 
Metrics::Counter::createPtr("proxy.process.http2.continuation_frames_in"),
-  http2_rsb.unknown_frames_in       = 
Metrics::Counter::createPtr("proxy.process.http2.unknown_frames_in"),
+  http2_rsb.max_active_streams_exceeded_in = 
Metrics::Counter::createPtr("proxy.process.http2.max_active_streams_exceeded_in");
+  http2_rsb.data_frames_in                 = 
Metrics::Counter::createPtr("proxy.process.http2.data_frames_in"),
+  http2_rsb.headers_frames_in              = 
Metrics::Counter::createPtr("proxy.process.http2.headers_frames_in"),
+  http2_rsb.priority_frames_in             = 
Metrics::Counter::createPtr("proxy.process.http2.priority_frames_in"),
+  http2_rsb.rst_stream_frames_in           = 
Metrics::Counter::createPtr("proxy.process.http2.rst_stream_frames_in"),
+  http2_rsb.settings_frames_in             = 
Metrics::Counter::createPtr("proxy.process.http2.settings_frames_in"),
+  http2_rsb.push_promise_frames_in         = 
Metrics::Counter::createPtr("proxy.process.http2.push_promise_frames_in"),
+  http2_rsb.ping_frames_in                 = 
Metrics::Counter::createPtr("proxy.process.http2.ping_frames_in"),
+  http2_rsb.goaway_frames_in               = 
Metrics::Counter::createPtr("proxy.process.http2.goaway_frames_in"),
+  http2_rsb.window_update_frames_in        = 
Metrics::Counter::createPtr("proxy.process.http2.window_update_frames_in"),
+  http2_rsb.continuation_frames_in         = 
Metrics::Counter::createPtr("proxy.process.http2.continuation_frames_in"),
+  http2_rsb.unknown_frames_in              = 
Metrics::Counter::createPtr("proxy.process.http2.unknown_frames_in"),
 
   http2_frame_metrics_in[0]  = http2_rsb.data_frames_in;
   http2_frame_metrics_in[1]  = http2_rsb.headers_frames_in;
diff --git a/src/proxy/http2/Http2ConnectionState.cc 
b/src/proxy/http2/Http2ConnectionState.cc
index 56c43a09f5..2dfae087e4 100644
--- a/src/proxy/http2/Http2ConnectionState.cc
+++ b/src/proxy/http2/Http2ConnectionState.cc
@@ -507,6 +507,17 @@ Http2ConnectionState::rcv_headers_frame(const Http2Frame 
&frame)
                         "recv data bad payload length");
     }
 
+    // Hard-enforce the global active-streams cap on inbound client streams.
+    if (!stream->is_outbound_connection() && 
!stream->trailing_header_is_possible() && Http2::max_active_streams_policy_in 
== 1 &&
+        Http2::max_active_streams_in > 0) {
+      int64_t const current_streams = 
Metrics::Gauge::load(http2_rsb.current_client_stream_count);
+      if (current_streams >= Http2::max_active_streams_in) {
+        Metrics::Counter::increment(http2_rsb.max_active_streams_exceeded_in);
+        return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, 
Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM,
+                          "active streams cap reached");
+      }
+    }
+
     // Set up the State Machine
     if (!stream->is_outbound_connection() && 
!stream->trailing_header_is_possible()) {
       SCOPED_MUTEX_LOCK(stream_lock, stream->mutex, this_ethread());
@@ -1119,6 +1130,17 @@ Http2ConnectionState::rcv_continuation_frame(const 
Http2Frame &frame)
                         "recv data bad payload length");
     }
 
+    // Hard-enforce the global active-streams cap on inbound client streams.
+    if (!stream->is_outbound_connection() && 
!stream->trailing_header_is_possible() && Http2::max_active_streams_policy_in 
== 1 &&
+        Http2::max_active_streams_in > 0) {
+      int64_t const current_streams = 
Metrics::Gauge::load(http2_rsb.current_client_stream_count);
+      if (current_streams >= Http2::max_active_streams_in) {
+        Metrics::Counter::increment(http2_rsb.max_active_streams_exceeded_in);
+        return Http2Error(Http2ErrorClass::HTTP2_ERROR_CLASS_STREAM, 
Http2ErrorCode::HTTP2_ERROR_REFUSED_STREAM,
+                          "active streams cap reached");
+      }
+    }
+
     // Set up the State Machine
     SCOPED_MUTEX_LOCK(stream_lock, stream->mutex, this_ethread());
     stream->mark_milestone(Http2StreamMilestone::START_TXN);
@@ -2865,6 +2887,12 @@ Http2ConnectionState::_adjust_concurrent_stream()
     return max_concurrent_streams;
   }
 
+  if (!this->session->is_outbound() && Http2::max_active_streams_policy_in == 
1) {
+    // Under hard-enforcement the advertised value is left untouched; new 
streams
+    // are refused at creation instead of throttling down concurrency.
+    return max_concurrent_streams;
+  }
+
   int64_t current_client_streams = 
Metrics::Gauge::load(http2_rsb.current_client_stream_count);
 
   Http2ConDebug(session, "current client streams: %" PRId64, 
current_client_streams);
diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc
index 21f3387716..8a83e08e84 100644
--- a/src/records/RecordsConfig.cc
+++ b/src/records/RecordsConfig.cc
@@ -1305,7 +1305,9 @@ static const RecordElement RecordsConfig[] =
   ,
   {RECT_CONFIG, "proxy.config.http2.min_concurrent_streams_out", RECD_INT, 
"10", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL}
   ,
-  {RECT_CONFIG, "proxy.config.http2.max_active_streams_in", RECD_INT, "0", 
RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL}
+  {RECT_CONFIG, "proxy.config.http2.max_active_streams_in", RECD_INT, 
"200000", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL}
+  ,
+  {RECT_CONFIG, "proxy.config.http2.max_active_streams_policy_in", RECD_INT, 
"0", RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-1]$", RECA_NULL}
   ,
   {RECT_CONFIG, "proxy.config.http2.max_active_streams_out", RECD_INT, "0", 
RECU_DYNAMIC, RR_NULL, RECC_STR, "^[0-9]+$", RECA_NULL}
   ,
diff --git a/tests/gold_tests/h2/clients/h2_max_active_streams.py 
b/tests/gold_tests/h2/clients/h2_max_active_streams.py
new file mode 100644
index 0000000000..e6aa01dd04
--- /dev/null
+++ b/tests/gold_tests/h2/clients/h2_max_active_streams.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+'''
+HTTP/2 client that opens N concurrent streams to test the global
+active-streams cap and the HPACK dynamic-table sync across refused streams.
+'''
+#  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 argparse
+import socket
+import ssl
+import sys
+from typing import Dict, Optional, Set, Tuple
+
+import hpack
+
+CONNECTION_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'
+
+FRAME_TYPE_DATA = 0x00
+FRAME_TYPE_HEADERS = 0x01
+FRAME_TYPE_RST_STREAM = 0x03
+FRAME_TYPE_SETTINGS = 0x04
+FRAME_TYPE_GOAWAY = 0x07
+
+FLAG_ACK = 0x01
+FLAG_END_STREAM = 0x01
+FLAG_END_HEADERS = 0x04
+
+ERROR_REFUSED_STREAM = 0x07
+
+# Reusable header value carried on streams >= the cap. Encoding the same
+# (name, value) pair on a later stream forces hpack to emit an indexed
+# reference into the dynamic-table entry added by the earlier encode, which
+# only resolves correctly if ATS decoded the earlier (refused) HEADERS frame.
+PROBE_HEADER_NAME = 'x-test-probe'
+PROBE_HEADER_VALUE = 'shared-probe-value'
+
+
+def make_socket(port: int) -> ssl.SSLSocket:
+    socket.setdefaulttimeout(15)
+    ctx = ssl.create_default_context()
+    ctx.check_hostname = False
+    ctx.verify_mode = ssl.CERT_NONE
+    ctx.set_alpn_protocols(['h2'])
+
+    raw = socket.create_connection(('127.0.0.1', port))
+    tls = ctx.wrap_socket(raw, server_hostname='localhost')
+    if tls.selected_alpn_protocol() != 'h2':
+        raise RuntimeError(f'failed to negotiate h2, got 
{tls.selected_alpn_protocol()!r}')
+    return tls
+
+
+def make_frame(frame_type: int, flags: int = 0, stream_id: int = 0, payload: 
bytes = b'') -> bytes:
+    return (len(payload).to_bytes(3, 'big') + bytes([frame_type, flags]) + 
(stream_id & 0x7fffffff).to_bytes(4, 'big') + payload)
+
+
+def read_exact(sock: ssl.SSLSocket, size: int) -> bytes:
+    chunks = []
+    remaining = size
+    while remaining > 0:
+        chunk = sock.recv(remaining)
+        if not chunk:
+            raise EOFError('socket closed')
+        chunks.append(chunk)
+        remaining -= len(chunk)
+    return b''.join(chunks)
+
+
+def read_frame(sock: ssl.SSLSocket) -> Tuple[int, int, int, bytes]:
+    header = read_exact(sock, 9)
+    length = int.from_bytes(header[0:3], 'big')
+    frame_type = header[3]
+    flags = header[4]
+    stream_id = int.from_bytes(header[5:9], 'big') & 0x7fffffff
+    payload = read_exact(sock, length)
+    return frame_type, flags, stream_id, payload
+
+
+def request_block(encoder: hpack.Encoder, stream_id: int, include_probe: bool) 
-> bytes:
+    headers = [
+        (':method', 'GET'),
+        (':scheme', 'https'),
+        (':authority', 'www.example.com'),
+        (':path', f'/stream/{stream_id}'),
+        ('uuid', f'max-active-streams-{stream_id}'),
+    ]
+    if include_probe:
+        headers.append((PROBE_HEADER_NAME, PROBE_HEADER_VALUE))
+    return encoder.encode(headers)
+
+
+def run(port: int, num_streams: int, probe_from: Optional[int]) -> int:
+    """Open @a num_streams concurrent streams.
+
+    Returns 0 on success.
+
+    Streams whose id is >= @a probe_from carry a fixed (name, value) header
+    so the second and later occurrences are encoded as indexed references
+    into the dynamic table established by the first occurrence. If ATS
+    fails to keep its decoder dynamic table in sync after refusing a
+    stream, the indexed reference fails to resolve and ATS sends a
+    COMPRESSION_ERROR GOAWAY, which this script reports as a failure.
+    """
+
+    stream_ids = [1 + 2 * i for i in range(num_streams)]
+    encoder = hpack.Encoder()
+    with make_socket(port) as sock:
+        sock.sendall(CONNECTION_PREFACE)
+        sock.sendall(make_frame(FRAME_TYPE_SETTINGS))
+        for sid in stream_ids:
+            include_probe = probe_from is not None and sid >= probe_from
+            block = request_block(encoder, sid, include_probe)
+            sock.sendall(make_frame(FRAME_TYPE_HEADERS, FLAG_END_HEADERS | 
FLAG_END_STREAM, sid, block))
+
+        ended: Set[int] = set()
+        statuses: Dict[int, str] = {}
+        try:
+            while ended != set(stream_ids):
+                frame_type, flags, stream_id, payload = read_frame(sock)
+                if frame_type == FRAME_TYPE_SETTINGS and not (flags & 
FLAG_ACK):
+                    sock.sendall(make_frame(FRAME_TYPE_SETTINGS, FLAG_ACK, 0))
+                    continue
+                if frame_type == FRAME_TYPE_GOAWAY:
+                    error_code = int.from_bytes(payload[4:8], 'big')
+                    print(f'GOAWAY error_code={error_code}')
+                    return 1
+                if stream_id in stream_ids:
+                    if frame_type == FRAME_TYPE_RST_STREAM:
+                        error_code = int.from_bytes(payload[0:4], 'big')
+                        statuses[stream_id] = f'rst:{error_code}'
+                        print(f'stream {stream_id}: RST_STREAM 
error_code={error_code}')
+                        ended.add(stream_id)
+                    elif frame_type in (FRAME_TYPE_DATA, FRAME_TYPE_HEADERS) 
and (flags & FLAG_END_STREAM):
+                        statuses[stream_id] = 'end_stream'
+                        print(f'stream {stream_id}: END_STREAM')
+                        ended.add(stream_id)
+        except (EOFError, socket.timeout) as exc:
+            print(f'socket terminated before all streams ended: {exc}', 
file=sys.stderr)
+            return 1
+
+    for sid, status in sorted(statuses.items()):
+        print(f'final stream {sid}: {status}')
+    return 0
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument('port', type=int, help='ATS TLS port')
+    parser.add_argument('--streams', type=int, default=4, help='number of 
concurrent streams to open')
+    parser.add_argument(
+        '--probe-from',
+        type=int,
+        default=None,
+        help='lowest stream id (odd) to carry the shared probe header; omit to 
disable probe')
+    args = parser.parse_args()
+    return run(args.port, args.streams, args.probe_from)
+
+
+if __name__ == '__main__':
+    raise SystemExit(main())
diff --git a/tests/gold_tests/h2/http2_max_active_streams.test.py 
b/tests/gold_tests/h2/http2_max_active_streams.test.py
new file mode 100644
index 0000000000..39f40ac716
--- /dev/null
+++ b/tests/gold_tests/h2/http2_max_active_streams.test.py
@@ -0,0 +1,80 @@
+'''
+Verify proxy.config.http2.max_active_streams_policy_in enforces the global
+active-streams cap and that HPACK stays in sync across refused streams.
+'''
+#  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.Summary = '''
+HTTP/2 max_active_streams_policy_in enforcement and HPACK sync test.
+'''
+
+CLIENT_SCRIPT = 'h2_max_active_streams.py'
+
+
+class Http2MaxActiveStreamsTest:
+    """Drive concurrent inbound streams past max_active_streams_in."""
+
+    def __init__(self, name: str, replay_file: str, policy: int):
+        self._name = name
+        self._replay_file = replay_file
+        self._policy = policy
+
+    def run(self) -> None:
+        tr = Test.AddTestRun(self._name)
+        server = tr.AddVerifierServerProcess(f'server-{self._name}', 
self._replay_file)
+        ts = tr.MakeATSProcess(f'ts-{self._name}', enable_tls=True, 
enable_cache=False)
+
+        ts.addDefaultSSLFiles()
+        ts.Setup.CopyAs(f'clients/{CLIENT_SCRIPT}', Test.RunDirectory)
+        ts.Disk.records_config.update(
+            {
+                'proxy.config.diags.debug.enabled': 1,
+                'proxy.config.diags.debug.tags': 'http2',
+                'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}',
+                'proxy.config.ssl.server.private_key.path': 
f'{ts.Variables.SSLDir}',
+                'proxy.config.http2.max_active_streams_in': 2,
+                'proxy.config.http2.max_active_streams_policy_in': 
self._policy,
+                'proxy.config.http2.max_concurrent_streams_in': 100,
+            })
+        ts.Disk.remap_config.AddLine(f'map / 
http://127.0.0.1:{server.Variables.http_port}')
+        ts.Disk.ssl_multicert_config.AddLine('dest_ip=* 
ssl_cert_name=server.pem ssl_key_name=server.key')
+
+        tr.Processes.Default.StartBefore(server)
+        tr.Processes.Default.StartBefore(ts)
+        tr.Processes.Default.Command = (f'{sys.executable} {CLIENT_SCRIPT} 
{ts.Variables.ssl_port} --streams 4 --probe-from 5')
+        tr.Processes.Default.ReturnCode = 0
+        tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression(
+            'GOAWAY', 'ATS must not tear down the connection; HPACK dynamic 
table must stay in sync.')
+
+        if self._policy == 1:
+            tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+                r'stream 5: RST_STREAM error_code=7', 'stream 5 must be 
refused with REFUSED_STREAM under enforce policy.')
+            tr.Processes.Default.Streams.stdout += Testers.ContainsExpression(
+                r'stream 7: RST_STREAM error_code=7',
+                'stream 7 must also be refused with REFUSED_STREAM, proving 
HPACK decode happened on stream 5.')
+            ts.Disk.diags_log.Content = Testers.ContainsExpression(
+                r'HTTP/2 stream error code=0x07.*active streams cap reached',
+                'ATS should log the cap-reached stream error under enforce 
policy.')
+        else:
+            tr.Processes.Default.Streams.stdout += Testers.ExcludesExpression(
+                r'RST_STREAM error_code=7', 'No stream should be refused under 
advisory policy.')
+
+
+Http2MaxActiveStreamsTest('enforce', 
'replay/http2_max_active_streams_enforce.replay.yaml', policy=1).run()
+Http2MaxActiveStreamsTest('advisory', 
'replay/http2_max_active_streams_advisory.replay.yaml', policy=0).run()
diff --git 
a/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml 
b/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml
new file mode 100644
index 0000000000..8b7ee1b427
--- /dev/null
+++ b/tests/gold_tests/h2/replay/http2_max_active_streams_advisory.replay.yaml
@@ -0,0 +1,86 @@
+#  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.
+#
+# Origin replay for the global active-streams cap test in advisory mode.
+# All four streams reach the origin since the proxy only adjusts the
+# advertised SETTINGS_MAX_CONCURRENT_STREAMS rather than refusing
+# streams. Responses are delayed so the gauge stays at the cap while
+# later HEADERS frames are admitted.
+
+meta:
+  version: "1.0"
+
+sessions:
+- transactions:
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/1 ]
+        - [ uuid, max-active-streams-1 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/3 ]
+        - [ uuid, max-active-streams-3 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/5 ]
+        - [ uuid, max-active-streams-5 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/7 ]
+        - [ uuid, max-active-streams-7 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]
diff --git 
a/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml 
b/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml
new file mode 100644
index 0000000000..651868516f
--- /dev/null
+++ b/tests/gold_tests/h2/replay/http2_max_active_streams_enforce.replay.yaml
@@ -0,0 +1,55 @@
+#  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.
+#
+# Origin replay for the global active-streams cap test in enforce mode.
+# Only streams 1 and 3 reach the origin; streams 5 and 7 are refused at
+# the proxy. Responses are delayed so the cap stays tripped while the
+# client sends the later HEADERS frames.
+
+meta:
+  version: "1.0"
+
+sessions:
+- transactions:
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/1 ]
+        - [ uuid, max-active-streams-1 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]
+
+  - client-request:
+      headers:
+        fields:
+        - [ :method, GET ]
+        - [ :scheme, https ]
+        - [ :authority, www.example.com ]
+        - [ :path, /stream/3 ]
+        - [ uuid, max-active-streams-3 ]
+    server-response:
+      delay: 1s
+      headers:
+        fields:
+        - [ :status, 200 ]
+        - [ Content-Length, 0 ]

Reply via email to