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 ]