diff --git a/src/libraries/System.Net.Http/ref/System.Net.Http.cs b/src/libraries/System.Net.Http/ref/System.Net.Http.cs
index b091575e7e9453..1d8cc8044d6a2d 100644
--- a/src/libraries/System.Net.Http/ref/System.Net.Http.cs
+++ b/src/libraries/System.Net.Http/ref/System.Net.Http.cs
@@ -476,6 +476,7 @@ public SocketsHttpHandler() { }
public bool EnableMultipleHttp2Connections { get { throw null; } set { } }
public bool EnableMultipleHttp3Connections { get { throw null; } set { } }
public System.TimeSpan Expect100ContinueTimeout { get { throw null; } set { } }
+ public int InitialHttp2MaxConcurrentStreams { get { throw null; } set { } }
public int InitialHttp2StreamWindowSize { get { throw null; } set { } }
[System.Runtime.Versioning.UnsupportedOSPlatformGuardAttribute("browser")]
public static bool IsSupported { get { throw null; } }
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs
index d0a0698464672f..4c90c6b83cfd1a 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/BrowserHttpHandler/SocketsHttpHandler.cs
@@ -148,6 +148,12 @@ public int InitialHttp2StreamWindowSize
set => throw new PlatformNotSupportedException();
}
+ public int InitialHttp2MaxConcurrentStreams
+ {
+ get => throw new PlatformNotSupportedException();
+ set => throw new PlatformNotSupportedException();
+ }
+
public TimeSpan KeepAlivePingDelay
{
get => throw new PlatformNotSupportedException();
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs
index 85ff8c7f4dd6d1..1d642a63433fbc 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpHandlerDefaults.cs
@@ -21,5 +21,14 @@ internal static partial class HttpHandlerDefaults
// Should not be confused with Http2Connection.DefaultInitialWindowSize, which defines the RFC default.
// Unlike that value, DefaultInitialHttp2StreamWindowSize might be changed in the future.
public const int DefaultInitialHttp2StreamWindowSize = 65535;
+
+ // This is the default value for SocketsHttpHandler.InitialHttp2MaxConcurrentStreams.
+ // It defines how many concurrent streams a new HTTP/2 connection may use before it
+ // observes the server's SETTINGS_MAX_CONCURRENT_STREAMS value.
+ // 100 is the lowest limit servers are recommended to advertise by
+ // https://www.rfc-editor.org/rfc/rfc9113.html#section-6.5.2 ("It is recommended that this
+ // value be no smaller than 100, so as to not unnecessarily limit parallelism"), which makes
+ // it a safe assumption for servers we haven't talked to yet.
+ public const int DefaultInitialHttp2MaxConcurrentStreams = 100;
}
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
index c52ea594d2c05b..57d5c2ec7be48c 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs
@@ -54,7 +54,10 @@ internal sealed partial class HttpConnectionPool : IDisposable
internal uint _lastSeenHttp3MaxHeaderListSize;
// Same as the above, but for SETTINGS_MAX_CONCURRENT_STREAMS.
- internal uint _lastSeenHttp2MaxConcurrentStreams = Http2Connection.InitialMaxConcurrentStreams;
+ // Unlike the values above, this one starts out at SocketsHttpHandler.InitialHttp2MaxConcurrentStreams,
+ // and we only ever memorize server-advertised values that are lower than that. That is, the setting
+ // acts as the upper bound for what every new connection starts with.
+ internal uint _lastSeenHttp2MaxConcurrentStreams;
/// Options specialized and cached for this pool and its key.
private readonly SslClientAuthenticationOptions? _sslOptionsHttp11;
@@ -85,6 +88,7 @@ public HttpConnectionPool(HttpConnectionPoolManager poolManager, HttpConnectionK
_proxyUri = proxyUri;
_maxHttp11Connections = Settings._maxConnectionsPerServer;
_telemetryServerAddress = telemetryServerAddress;
+ _lastSeenHttp2MaxConcurrentStreams = (uint)Settings._initialHttp2MaxConcurrentStreams;
// The only case where 'host' will not be set is if this is a Proxy connection pool. In that case the
// connection targets the proxy itself, so use the proxy's host and port for the origin authority.
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
index 77b82487063dfc..fdc6771d0b1d82 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs
@@ -81,9 +81,6 @@ internal sealed partial class Http2Connection : HttpConnectionBase
private const int MaxStreamId = int.MaxValue;
- // Temporary workaround for request burst handling on connection start.
- internal const int InitialMaxConcurrentStreams = 100;
-
private static ReadOnlySpan Http2ConnectionPreface => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8;
#if DEBUG
@@ -870,7 +867,14 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f
switch ((SettingId)settingId)
{
case SettingId.MaxConcurrentStreams:
- _pool._lastSeenHttp2MaxConcurrentStreams = settingValue;
+ // Only memorize the value for future connections if it's lower than what we're
+ // configured to start with. SocketsHttpHandler.InitialHttp2MaxConcurrentStreams
+ // acts as the upper bound for what every new connection starts with.
+ if (settingValue < _pool.Settings._initialHttp2MaxConcurrentStreams)
+ {
+ _pool._lastSeenHttp2MaxConcurrentStreams = settingValue;
+ }
+
ChangeMaxConcurrentStreams(settingValue);
maxConcurrentStreamsReceived = true;
break;
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
index 547726076809f5..a7a4dec8ee3306 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionSettings.cs
@@ -73,6 +73,8 @@ internal sealed class HttpConnectionSettings
// Http2 flow control settings:
internal int _initialHttp2StreamWindowSize = HttpHandlerDefaults.DefaultInitialHttp2StreamWindowSize;
+ internal int _initialHttp2MaxConcurrentStreams = HttpHandlerDefaults.DefaultInitialHttp2MaxConcurrentStreams;
+
internal ClientCertificateOption _clientCertificateOptions;
public HttpConnectionSettings()
@@ -130,6 +132,7 @@ public HttpConnectionSettings CloneAndNormalize()
_plaintextStreamFilter = _plaintextStreamFilter,
_shouldEvictConnection = _shouldEvictConnection,
_initialHttp2StreamWindowSize = _initialHttp2StreamWindowSize,
+ _initialHttp2MaxConcurrentStreams = _initialHttp2MaxConcurrentStreams,
_activityHeadersPropagator = _activityHeadersPropagator,
_defaultCredentialsUsedForProxy = _proxy != null && (_proxy.Credentials == CredentialCache.DefaultCredentials || _defaultProxyCredentials == CredentialCache.DefaultCredentials),
_defaultCredentialsUsedForServer = _credentials == CredentialCache.DefaultCredentials,
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
index 15cb77f9dd01d1..dc20170787cfdb 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs
@@ -298,6 +298,42 @@ public int InitialHttp2StreamWindowSize
}
}
+ ///
+ /// Gets or sets the maximum number of concurrent HTTP/2 streams a new connection may use before it observes
+ /// the server's SETTINGS_MAX_CONCURRENT_STREAMS value.
+ ///
+ ///
+ ///
+ /// HTTP/2 lets the client start sending requests as soon as the connection is established, before the server
+ /// has advertised how many concurrent streams it accepts. Until that SETTINGS frame arrives, the client
+ /// optimistically allows up to this many streams, after which the server's value takes over.
+ ///
+ ///
+ /// The default suits virtually all deployments and most users never need to change it. It is intended for the
+ /// small subset of deployments where the server is known ahead of time to use a lower concurrency limit, and
+ /// starting a connection with a matching value avoids the brief burst of requests above that limit.
+ ///
+ ///
+ /// If an earlier connection to the same host advertised a lower limit,
+ /// that lower value is used instead for new connections to that host.
+ ///
+ ///
+ /// The value must be greater than or equal to 1. Defaults to 100.
+ ///
+ ///
+ /// The value is zero or negative.
+ public int InitialHttp2MaxConcurrentStreams
+ {
+ get => _settings._initialHttp2MaxConcurrentStreams;
+ set
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
+
+ CheckDisposedOrStarted();
+ _settings._initialHttp2MaxConcurrentStreams = value;
+ }
+ }
+
///
/// Gets or sets the keep alive ping delay. The client will send a keep alive ping to the server if it
/// doesn't receive any frames on a connection for this period of time. This property is used together with
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs
index d9baf292681819..66088cb47fa4b3 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs
@@ -3694,6 +3694,34 @@ public void InitialHttp2StreamWindowSize_InvalidValue_ThrowsArgumentOutOfRangeEx
Assert.Throws(() => handler.InitialHttp2StreamWindowSize = value);
}
+ [Theory]
+ [InlineData(1)]
+ [InlineData(42)]
+ [InlineData(int.MaxValue)]
+ public void InitialHttp2MaxConcurrentStreams_GetSet_Roundtrips(int value)
+ {
+ using var handler = new SocketsHttpHandler();
+ Assert.Equal(100, handler.InitialHttp2MaxConcurrentStreams); // default value
+
+ handler.InitialHttp2MaxConcurrentStreams = value;
+ Assert.Equal(value, handler.InitialHttp2MaxConcurrentStreams);
+
+ handler.InitialHttp2MaxConcurrentStreams = 100;
+ Assert.Equal(100, handler.InitialHttp2MaxConcurrentStreams);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-42)]
+ [InlineData(int.MinValue)]
+ public void InitialHttp2MaxConcurrentStreams_InvalidValue_ThrowsArgumentOutOfRangeException(int value)
+ {
+ using var handler = new SocketsHttpHandler();
+ Assert.Throws(() => handler.InitialHttp2MaxConcurrentStreams = value);
+ Assert.Equal(100, handler.InitialHttp2MaxConcurrentStreams);
+ }
+
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -3737,6 +3765,7 @@ await Assert.ThrowsAnyAsync(() =>
Assert.Null(handler.ConnectCallback);
Assert.Null(handler.PlaintextStreamFilter);
Assert.Equal(HttpClientHandlerTestBase.DefaultInitialWindowSize, handler.InitialHttp2StreamWindowSize);
+ Assert.Equal(100, handler.InitialHttp2MaxConcurrentStreams);
Assert.Throws(expectedExceptionType, () => handler.AllowAutoRedirect = false);
Assert.Throws(expectedExceptionType, () => handler.AutomaticDecompression = DecompressionMethods.GZip);
@@ -3759,6 +3788,7 @@ await Assert.ThrowsAnyAsync(() =>
Assert.Throws(expectedExceptionType, () => handler.ConnectCallback = (context, token) => default);
Assert.Throws(expectedExceptionType, () => handler.PlaintextStreamFilter = (context, token) => default);
Assert.Throws(expectedExceptionType, () => handler.InitialHttp2StreamWindowSize = 128 * 1024);
+ Assert.Throws(expectedExceptionType, () => handler.InitialHttp2MaxConcurrentStreams = 1);
}
}
}
@@ -4210,6 +4240,140 @@ await conn.WriteFrameAsync(
await Task.WhenAll(connectionTasks).ConfigureAwait(false);
}
+ [ConditionalTheory(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))]
+ [InlineData(1)]
+ [InlineData(3)]
+ public async Task InitialHttp2MaxConcurrentStreams_LimitsStreamsUsedBeforeSettingsFrameIsReceived(int initialLimit)
+ {
+ const int RequestCount = 5;
+
+ using Http2LoopbackServer server = Http2LoopbackServer.CreateServer();
+
+ using SocketsHttpHandler handler = CreateHandler();
+ handler.EnableMultipleHttp2Connections = false;
+ handler.InitialHttp2MaxConcurrentStreams = initialLimit;
+ using HttpClient client = CreateHttpClient(handler);
+
+ var sendTasks = new List>();
+ AcquireAllStreamSlots(server, client, sendTasks, RequestCount);
+
+ await using Http2LoopbackConnection connection = await server.AcceptConnectionAsync().WaitAsync(TestHelper.PassingTestTimeout);
+
+ // Read the client's SETTINGS frame, but don't send ours yet.
+ await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout);
+
+ // Only 'initialLimit' requests may be sent before we advertise our own limit.
+ var streamIds = new List(await AcceptRequests(connection, initialLimit));
+ await AssertNoRequestHeadersSentAsync(connection);
+
+ // Advertise a higher limit. The remaining requests should now be sent.
+ await connection.SendSettingsAsync(ackTimeout: null, [new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = 100 }]);
+
+ streamIds.AddRange(await AcceptRequests(connection, RequestCount - initialLimit));
+
+ await SendResponses(connection, streamIds);
+ await VerifySendTasks(sendTasks);
+ }
+
+ [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))]
+ public async Task InitialHttp2MaxConcurrentStreams_HigherServerLimit_NotMemorizedForNewConnections()
+ {
+ const int InitialLimit = 1;
+
+ // The server advertises more streams than we're configured to start with.
+ // We must not memorize that higher value - the property is the upper bound for every new connection.
+ await TestMaxConcurrentStreamsMemoizationAsync(InitialLimit, [100], expectedStreamsOnNewConnection: InitialLimit);
+ }
+
+ [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))]
+ public async Task InitialHttp2MaxConcurrentStreams_LowerServerLimit_MemorizedForNewConnections()
+ {
+ const int ServerLimit = 2;
+
+ // The server advertised fewer streams than we're configured to start with, so we remember the lower value.
+ await TestMaxConcurrentStreamsMemoizationAsync(initialLimit: 5, [ServerLimit], expectedStreamsOnNewConnection: ServerLimit);
+ }
+
+ [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))]
+ public async Task InitialHttp2MaxConcurrentStreams_LowerServerLimitFollowedByHigherOne_LowerLimitStaysMemorized()
+ {
+ const int LowerServerLimit = 2;
+
+ // A subsequent connection where the server advertises more streams than we're configured to start with
+ // must not undo the lower limit we memorized before.
+ await TestMaxConcurrentStreamsMemoizationAsync(initialLimit: 5, [LowerServerLimit, 100], expectedStreamsOnNewConnection: LowerServerLimit);
+ }
+
+ private async Task TestMaxConcurrentStreamsMemoizationAsync(int initialLimit, uint[] serverLimits, int expectedStreamsOnNewConnection)
+ {
+ const int RequestCount = 6;
+
+ using Http2LoopbackServer server = Http2LoopbackServer.CreateServer();
+ server.AllowMultipleConnections = true;
+
+ using SocketsHttpHandler handler = CreateHandler();
+ handler.EnableMultipleHttp2Connections = false;
+ handler.InitialHttp2MaxConcurrentStreams = initialLimit;
+ using HttpClient client = CreateHttpClient(handler);
+
+ // Establish connections where the server advertises its limit, then shut them down.
+ foreach (uint serverLimit in serverLimits)
+ {
+ Task warmUpTask = client.GetAsync(server.Address);
+
+ Http2LoopbackConnection warmUpConnection = await server.EstablishConnectionAsync(timeout: null, ackTimeout: TimeSpan.FromSeconds(10),
+ new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = serverLimit }).WaitAsync(TestHelper.PassingTestTimeout);
+
+ (int warmUpStreamId, _) = await warmUpConnection.ReadAndParseRequestHeaderAsync().WaitAsync(TestHelper.PassingTestTimeout);
+ await warmUpConnection.SendDefaultResponseAsync(warmUpStreamId).WaitAsync(TestHelper.PassingTestTimeout);
+ using (HttpResponseMessage warmUpResponse = await warmUpTask.WaitAsync(TestHelper.PassingTestTimeout))
+ {
+ Assert.True(warmUpResponse.IsSuccessStatusCode);
+ }
+
+ await warmUpConnection.ShutdownIgnoringErrorsAsync(warmUpStreamId).WaitAsync(TestHelper.PassingTestTimeout);
+ await warmUpConnection.DisposeAsync();
+ }
+
+ // The next requests must be served by a new connection, which starts off with the memorized limit.
+ var sendTasks = new List>();
+ AcquireAllStreamSlots(server, client, sendTasks, RequestCount);
+
+ await using Http2LoopbackConnection connection = await server.AcceptConnectionAsync().WaitAsync(TestHelper.PassingTestTimeout);
+ await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout);
+
+ var streamIds = new List(await AcceptRequests(connection, expectedStreamsOnNewConnection));
+ await AssertNoRequestHeadersSentAsync(connection);
+
+ await connection.SendSettingsAsync(ackTimeout: null, [new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = 100 }]);
+
+ streamIds.AddRange(await AcceptRequests(connection, RequestCount - expectedStreamsOnNewConnection));
+
+ await SendResponses(connection, streamIds);
+ await VerifySendTasks(sendTasks);
+ }
+
+ private static async Task AssertNoRequestHeadersSentAsync(Http2LoopbackConnection connection)
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+
+ while (true)
+ {
+ Frame frame;
+ try
+ {
+ frame = await connection.ReadFrameAsync(cts.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ Assert.NotNull(frame);
+ Assert.NotEqual(FrameType.Headers, frame.Type);
+ }
+ }
+
[ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))]
public async Task Http2_SettingInFlightLimitExceeded()
{