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() {