Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/libraries/System.Net.Http/ref/System.Net.Http.cs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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; } }
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -148,6 +148,12 @@ public int InitialHttp2StreamWindowSize
set=>thrownewPlatformNotSupportedException();
}

publicintInitialHttp2MaxConcurrentStreams
{
get=>thrownewPlatformNotSupportedException();
set=>thrownewPlatformNotSupportedException();
}

publicTimeSpanKeepAlivePingDelay
{
get=>thrownewPlatformNotSupportedException();
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

/// <summary>Options specialized and cached for this pool and its key.</summary>
private readonly SslClientAuthenticationOptions? _sslOptionsHttp11;
Expand DownExpand Up@@ -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.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<byte> Http2ConnectionPreface => "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"u8;

#if DEBUG
Expand DownExpand Up@@ -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;
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -298,6 +298,42 @@ public int InitialHttp2StreamWindowSize
}
}

/// <summary>
/// Gets or sets the maximum number of concurrent HTTP/2 streams a new connection may use before it observes
/// the server's <c>SETTINGS_MAX_CONCURRENT_STREAMS</c> value.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>SETTINGS</c> frame arrives, the client
/// optimistically allows up to this many streams, after which the server's value takes over.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// If an earlier connection to the same host advertised a lower limit,
/// that lower value is used instead for new connections to that host.
/// </para>
/// <para>
/// The value must be greater than or equal to 1. Defaults to 100.
/// </para>
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">The value is zero or negative.</exception>
public int InitialHttp2MaxConcurrentStreams
{
get => _settings._initialHttp2MaxConcurrentStreams;
set
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);

CheckDisposedOrStarted();
_settings._initialHttp2MaxConcurrentStreams = value;
}
}

/// <summary>
/// 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
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3694,6 +3694,34 @@ public void InitialHttp2StreamWindowSize_InvalidValue_ThrowsArgumentOutOfRangeEx
Assert.Throws<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => handler.InitialHttp2MaxConcurrentStreams = value);
Assert.Equal(100, handler.InitialHttp2MaxConcurrentStreams);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
Expand DownExpand Up@@ -3737,6 +3765,7 @@ await Assert.ThrowsAnyAsync<Exception>(() =>
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);
Expand All@@ -3759,6 +3788,7 @@ await Assert.ThrowsAnyAsync<Exception>(() =>
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);
}
}
}
Expand DownExpand Up@@ -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<Task<HttpResponseMessage>>();
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<int>(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<HttpResponseMessage> 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<Task<HttpResponseMessage>>();
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<int>(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));
Comment thread
MihaZupan marked this conversation as resolved.

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()
{
Expand Down
Loading