From 7999a7566211be9e78f6aac16f5c85333a738d1c Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 15:06:35 +0200 Subject: [PATCH 1/6] Implement SocketsHttpHandler.InitialHttp2MaxConcurrentStreams --- .../System.Net.Http/ref/System.Net.Http.cs | 1 + .../BrowserHttpHandler/SocketsHttpHandler.cs | 6 + .../System/Net/Http/HttpHandlerDefaults.cs | 5 + .../HttpConnectionPool.Http2.cs | 20 ++ .../ConnectionPool/HttpConnectionPool.cs | 6 +- .../SocketsHttpHandler/Http2Connection.cs | 13 +- .../HttpConnectionSettings.cs | 3 + .../SocketsHttpHandler/SocketsHttpHandler.cs | 37 ++++ .../FunctionalTests/SocketsHttpHandlerTest.cs | 204 ++++++++++++++++++ 9 files changed, 289 insertions(+), 6 deletions(-) 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..c7dee1ad7a507b 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,10 @@ 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. + public const int DefaultInitialHttp2MaxConcurrentStreams = 100; } } diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index c2b77774e0ae8a..f77e7c1dacc5e2 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -486,6 +486,18 @@ private void ReturnHttp2Connection(Http2Connection connection, bool isNewConnect { Debug.Assert(initialRequestWaiter is not null, "Expect request for a new connection"); + if (Settings._initialHttp2MaxConcurrentStreams == 0 && !connection.InitialSettingsReceived.Task.IsCompleted) + { + // The connection has no available streams only because we haven't received the server's SETTINGS + // frame yet (SocketsHttpHandler.InitialHttp2MaxConcurrentStreams is set to 0). + // Wait for the server to advertise its stream limit before deciding that the connection is unusable. + // The connection stays marked as pending, so we won't inject more connections in the meantime. + if (NetEventSource.Log.IsEnabled()) connection.Trace("Waiting for the server's SETTINGS frame before using the new HTTP2 connection."); + + _ = WaitForInitialSettingsAsync(connection, initialRequestWaiter); // ignore returned task + return; + } + // The new connection could not handle even one request, either because it shut down before we could use it for any requests, // or because it immediately set the max concurrent streams limit to 0. // We don't want to get stuck in a loop where we keep trying to create new connections for the same request. @@ -509,6 +521,14 @@ private void ReturnHttp2Connection(Http2Connection connection, bool isNewConnect // We need to wait until the connection is usable again. DisableHttp2Connection(connection); } + + async Task WaitForInitialSettingsAsync(Http2Connection connection, HttpConnectionWaiter initialRequestWaiter) + { + // Ignore any failures - the connection will be seen as shut down when we return it below. + await ((Task)connection.InitialSettingsReceived.Task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + + ReturnHttp2Connection(connection, isNewConnection: true, initialRequestWaiter); + } } /// 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..1a6519df2b8809 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 @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers.Binary; @@ -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,13 @@ 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 < (uint)_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..3c6763409f9ec1 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,43 @@ 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 allows the client to send requests as soon as the connection is established, before the server has + /// advertised how many concurrent streams it is willing to accept. Sending more requests than the server allows + /// may cause those requests to fail, or trigger server-side abuse mitigations. + /// Lowering this value avoids exceeding the server limit at the cost of reduced concurrency until the + /// server's SETTINGS frame is received. + /// + /// + /// If a previous connection in the same connection pool advertised a lower limit, + /// that lower value is used instead for new connections. + /// + /// + /// Setting this property to 0 means that no requests will be sent on a new connection + /// until the server's SETTINGS frame is received. + /// + /// + /// The value must be greater than or equal to 0. Defaults to 100. + /// + /// + /// The value is negative. + public int InitialHttp2MaxConcurrentStreams + { + get => _settings._initialHttp2MaxConcurrentStreams; + set + { + ArgumentOutOfRangeException.ThrowIfNegative(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..f4e3adfca7d309 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(0)] + [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(-1)] + [InlineData(-42)] + [InlineData(int.MinValue)] + public void InitialHttp2MaxConcurrentStreams_NegativeValue_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,180 @@ await conn.WriteFrameAsync( await Task.WhenAll(connectionTasks).ConfigureAwait(false); } + [ConditionalTheory(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] + [InlineData(0)] + [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 ReadRequestHeadersAsync(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 ReadRequestHeadersAsync(connection, RequestCount - initialLimit)); + + await SendResponses(connection, streamIds); + await VerifySendTasks(sendTasks); + } + + [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] + public async Task InitialHttp2MaxConcurrentStreams_ZeroAndConnectionClosedBeforeSettings_RequestFails() + { + using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); + + using SocketsHttpHandler handler = CreateHandler(); + handler.EnableMultipleHttp2Connections = false; + handler.InitialHttp2MaxConcurrentStreams = 0; + using HttpClient client = CreateHttpClient(handler); + + Task sendTask = client.GetAsync(server.Address); + + Http2LoopbackConnection connection = await server.AcceptConnectionAsync().WaitAsync(TestHelper.PassingTestTimeout); + await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout); + + // Tear the connection down without ever advertising our stream limit. + await connection.DisposeAsync(); + + await Assert.ThrowsAsync(() => sendTask).WaitAsync(TestHelper.PassingTestTimeout); + } + + [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 ReadRequestHeadersAsync(connection, expectedStreamsOnNewConnection)); + await AssertNoRequestHeadersSentAsync(connection); + + await connection.SendSettingsAsync(ackTimeout: null, [new SettingsEntry { SettingId = SettingId.MaxConcurrentStreams, Value = 100 }]); + + streamIds.AddRange(await ReadRequestHeadersAsync(connection, RequestCount - expectedStreamsOnNewConnection)); + + await SendResponses(connection, streamIds); + await VerifySendTasks(sendTasks); + } + + private static async Task ReadRequestHeadersAsync(Http2LoopbackConnection connection, int count) + { + var streamIds = new List(count); + + while (streamIds.Count < count) + { + Frame frame = await connection.ReadFrameAsync(TestHelper.PassingTestTimeout).ConfigureAwait(false); + Assert.NotNull(frame); + + if (frame.Type == FrameType.Headers) + { + streamIds.Add(frame.StreamId); + } + } + + return streamIds.ToArray(); + } + + private static async Task AssertNoRequestHeadersSentAsync(Http2LoopbackConnection connection) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + 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() { From bae3040bf57a54e021a1a24fecb6589581f974c4 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 15:25:29 +0200 Subject: [PATCH 2/6] Remove support for max of 0 --- .../HttpConnectionPool.Http2.cs | 20 -------------- .../SocketsHttpHandler/SocketsHttpHandler.cs | 10 +++---- .../FunctionalTests/SocketsHttpHandlerTest.cs | 26 ++----------------- 3 files changed, 5 insertions(+), 51 deletions(-) diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs index f77e7c1dacc5e2..c2b77774e0ae8a 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs @@ -486,18 +486,6 @@ private void ReturnHttp2Connection(Http2Connection connection, bool isNewConnect { Debug.Assert(initialRequestWaiter is not null, "Expect request for a new connection"); - if (Settings._initialHttp2MaxConcurrentStreams == 0 && !connection.InitialSettingsReceived.Task.IsCompleted) - { - // The connection has no available streams only because we haven't received the server's SETTINGS - // frame yet (SocketsHttpHandler.InitialHttp2MaxConcurrentStreams is set to 0). - // Wait for the server to advertise its stream limit before deciding that the connection is unusable. - // The connection stays marked as pending, so we won't inject more connections in the meantime. - if (NetEventSource.Log.IsEnabled()) connection.Trace("Waiting for the server's SETTINGS frame before using the new HTTP2 connection."); - - _ = WaitForInitialSettingsAsync(connection, initialRequestWaiter); // ignore returned task - return; - } - // The new connection could not handle even one request, either because it shut down before we could use it for any requests, // or because it immediately set the max concurrent streams limit to 0. // We don't want to get stuck in a loop where we keep trying to create new connections for the same request. @@ -521,14 +509,6 @@ private void ReturnHttp2Connection(Http2Connection connection, bool isNewConnect // We need to wait until the connection is usable again. DisableHttp2Connection(connection); } - - async Task WaitForInitialSettingsAsync(Http2Connection connection, HttpConnectionWaiter initialRequestWaiter) - { - // Ignore any failures - the connection will be seen as shut down when we return it below. - await ((Task)connection.InitialSettingsReceived.Task).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - - ReturnHttp2Connection(connection, isNewConnection: true, initialRequestWaiter); - } } /// 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 3c6763409f9ec1..fb4a1c4be6b56d 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 @@ -315,20 +315,16 @@ public int InitialHttp2StreamWindowSize /// that lower value is used instead for new connections. /// /// - /// Setting this property to 0 means that no requests will be sent on a new connection - /// until the server's SETTINGS frame is received. - /// - /// - /// The value must be greater than or equal to 0. Defaults to 100. + /// The value must be greater than or equal to 1. Defaults to 100. /// /// - /// The value is negative. + /// The value is zero or negative. public int InitialHttp2MaxConcurrentStreams { get => _settings._initialHttp2MaxConcurrentStreams; set { - ArgumentOutOfRangeException.ThrowIfNegative(value); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); CheckDisposedOrStarted(); _settings._initialHttp2MaxConcurrentStreams = value; diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index f4e3adfca7d309..f506313746cd7a 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -3695,7 +3695,6 @@ public void InitialHttp2StreamWindowSize_InvalidValue_ThrowsArgumentOutOfRangeEx } [Theory] - [InlineData(0)] [InlineData(1)] [InlineData(42)] [InlineData(int.MaxValue)] @@ -3712,10 +3711,11 @@ public void InitialHttp2MaxConcurrentStreams_GetSet_Roundtrips(int value) } [Theory] + [InlineData(0)] [InlineData(-1)] [InlineData(-42)] [InlineData(int.MinValue)] - public void InitialHttp2MaxConcurrentStreams_NegativeValue_ThrowsArgumentOutOfRangeException(int value) + public void InitialHttp2MaxConcurrentStreams_InvalidValue_ThrowsArgumentOutOfRangeException(int value) { using var handler = new SocketsHttpHandler(); Assert.Throws(() => handler.InitialHttp2MaxConcurrentStreams = value); @@ -4241,7 +4241,6 @@ await conn.WriteFrameAsync( } [ConditionalTheory(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] - [InlineData(0)] [InlineData(1)] [InlineData(3)] public async Task InitialHttp2MaxConcurrentStreams_LimitsStreamsUsedBeforeSettingsFrameIsReceived(int initialLimit) @@ -4276,27 +4275,6 @@ public async Task InitialHttp2MaxConcurrentStreams_LimitsStreamsUsedBeforeSettin await VerifySendTasks(sendTasks); } - [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] - public async Task InitialHttp2MaxConcurrentStreams_ZeroAndConnectionClosedBeforeSettings_RequestFails() - { - using Http2LoopbackServer server = Http2LoopbackServer.CreateServer(); - - using SocketsHttpHandler handler = CreateHandler(); - handler.EnableMultipleHttp2Connections = false; - handler.InitialHttp2MaxConcurrentStreams = 0; - using HttpClient client = CreateHttpClient(handler); - - Task sendTask = client.GetAsync(server.Address); - - Http2LoopbackConnection connection = await server.AcceptConnectionAsync().WaitAsync(TestHelper.PassingTestTimeout); - await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout); - - // Tear the connection down without ever advertising our stream limit. - await connection.DisposeAsync(); - - await Assert.ThrowsAsync(() => sendTask).WaitAsync(TestHelper.PassingTestTimeout); - } - [ConditionalFact(typeof(SocketsHttpHandlerTest_Http2), nameof(SupportsAlpn))] public async Task InitialHttp2MaxConcurrentStreams_HigherServerLimit_NotMemorizedForNewConnections() { From afb923b928e1c0dee0dfd7eb2f8e985c6edfd46f Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 15:34:52 +0200 Subject: [PATCH 3/6] Reword comments --- .../src/System/Net/Http/HttpHandlerDefaults.cs | 4 ++++ .../SocketsHttpHandler/SocketsHttpHandler.cs | 17 ++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) 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 c7dee1ad7a507b..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 @@ -25,6 +25,10 @@ internal static partial class HttpHandlerDefaults // 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/SocketsHttpHandler.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/SocketsHttpHandler.cs index fb4a1c4be6b56d..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 @@ -304,15 +304,18 @@ public int InitialHttp2StreamWindowSize /// /// /// - /// HTTP/2 allows the client to send requests as soon as the connection is established, before the server has - /// advertised how many concurrent streams it is willing to accept. Sending more requests than the server allows - /// may cause those requests to fail, or trigger server-side abuse mitigations. - /// Lowering this value avoids exceeding the server limit at the cost of reduced concurrency until the - /// server's SETTINGS frame is received. + /// 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. /// /// - /// If a previous connection in the same connection pool advertised a lower limit, - /// that lower value is used instead for new connections. + /// 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. From a2dc09ad3ff37201ddcd5f115711a9f683e69c46 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 15:51:07 +0200 Subject: [PATCH 4/6] Improve tests --- .../SocketsHttpHandler/Http2Connection.cs | 2 +- .../FunctionalTests/SocketsHttpHandlerTest.cs | 30 ++++--------------- 2 files changed, 7 insertions(+), 25 deletions(-) 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 1a6519df2b8809..b07ebc37268986 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 @@ -870,7 +870,7 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f // 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 < (uint)_pool.Settings._initialHttp2MaxConcurrentStreams) + if (settingValue < _pool.Settings._initialHttp2MaxConcurrentStreams) { _pool._lastSeenHttp2MaxConcurrentStreams = settingValue; } diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index f506313746cd7a..fe473beee4b909 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; @@ -4263,13 +4263,13 @@ public async Task InitialHttp2MaxConcurrentStreams_LimitsStreamsUsedBeforeSettin await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout); // Only 'initialLimit' requests may be sent before we advertise our own limit. - var streamIds = new List(await ReadRequestHeadersAsync(connection, initialLimit)); + 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 ReadRequestHeadersAsync(connection, RequestCount - initialLimit)); + streamIds.AddRange(await AcceptRequests(connection, RequestCount - initialLimit)); await SendResponses(connection, streamIds); await VerifySendTasks(sendTasks); @@ -4342,38 +4342,20 @@ private async Task TestMaxConcurrentStreamsMemoizationAsync(int initialLimit, ui await using Http2LoopbackConnection connection = await server.AcceptConnectionAsync().WaitAsync(TestHelper.PassingTestTimeout); await connection.ReadSettingsAsync().WaitAsync(TestHelper.PassingTestTimeout); - var streamIds = new List(await ReadRequestHeadersAsync(connection, expectedStreamsOnNewConnection)); + 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 ReadRequestHeadersAsync(connection, RequestCount - expectedStreamsOnNewConnection)); + streamIds.AddRange(await AcceptRequests(connection, RequestCount - expectedStreamsOnNewConnection)); await SendResponses(connection, streamIds); await VerifySendTasks(sendTasks); } - private static async Task ReadRequestHeadersAsync(Http2LoopbackConnection connection, int count) - { - var streamIds = new List(count); - - while (streamIds.Count < count) - { - Frame frame = await connection.ReadFrameAsync(TestHelper.PassingTestTimeout).ConfigureAwait(false); - Assert.NotNull(frame); - - if (frame.Type == FrameType.Headers) - { - streamIds.Add(frame.StreamId); - } - } - - return streamIds.ToArray(); - } - private static async Task AssertNoRequestHeadersSentAsync(Http2LoopbackConnection connection) { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); while (true) { From 0d289efbf018bc25b2c1c1331050000c2ac1b3c1 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 15:53:35 +0200 Subject: [PATCH 5/6] Revert BOM changes --- .../src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs | 2 +- .../tests/FunctionalTests/SocketsHttpHandlerTest.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 b07ebc37268986..b9a2e434249541 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 @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Buffers.Binary; diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index fe473beee4b909..66088cb47fa4b3 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; From 7d79d029dbe5dd745e1fc1077b0b56eef3790fbe Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Wed, 19 Aug 2026 16:20:29 +0200 Subject: [PATCH 6/6] Add empty line --- .../src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs | 1 + 1 file changed, 1 insertion(+) 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 b9a2e434249541..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 @@ -874,6 +874,7 @@ private void ProcessSettingsFrame(FrameHeader frameHeader, bool initialFrame = f { _pool._lastSeenHttp2MaxConcurrentStreams = settingValue; } + ChangeMaxConcurrentStreams(settingValue); maxConcurrentStreamsReceived = true; break;