diff --git a/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs b/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs
index 8964fdba9a720..cd1cfb6e8893d 100644
--- a/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs
+++ b/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs
@@ -8,7 +8,7 @@ namespace System.Threading.Tasks
/// s contain the relevant ,
/// while also avoiding unnecessary allocations for closure captures.
///
- internal sealed class TaskCompletionSourceWithCancellation : TaskCompletionSource
+ internal class TaskCompletionSourceWithCancellation : TaskCompletionSource
{
public TaskCompletionSourceWithCancellation() : base(TaskCreationOptions.RunContinuationsAsynchronously)
{
diff --git a/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs
index c3fb1512fbce8..adc60fdf4a89a 100644
--- a/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs
+++ b/src/libraries/Common/tests/System/Net/Http/GenericLoopbackServer.cs
@@ -152,6 +152,9 @@ public abstract class GenericLoopbackConnection : IAsyncDisposable
/// Waits for the client to signal cancellation.
public abstract Task WaitForCloseAsync(CancellationToken cancellationToken);
+ /// Reset the connection's internal state so it can process further requests.
+ public virtual void CompleteRequestProcessing() { }
+
/// Helper function to make it easier to convert old test with strings.
public async Task SendResponseBodyAsync(string content, bool isFinal = true)
{
diff --git a/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs b/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs
index 07a039da95349..4879258fab2c4 100644
--- a/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs
+++ b/src/libraries/Common/tests/System/Net/Http/LoopbackServer.cs
@@ -873,7 +873,7 @@ public override async Task ReadRequestBodyAsync()
return buffer;
}
- public void CompleteRequestProcessing()
+ public override void CompleteRequestProcessing()
{
_contentLength = 0;
_bodyRead = false;
diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
index 98d1da69d263c..872af7a630a65 100644
--- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj
+++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj
@@ -138,8 +138,6 @@
Link="Common\System\Net\HttpDateParser.cs" />
-
+
Common\System\Net\Http\aspnetcore\IHttpStreamHeadersHandler.cs
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs
index 0dd87f86745a1..072fbbd0ec16b 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/GlobalHttpSettings.cs
@@ -45,6 +45,10 @@ internal static class SocketsHttpHandler
// Defaults to 1.0. Higher values result in shorter window, but slower downloads.
public static double Http2StreamWindowScaleThresholdMultiplier { get; } = GetHttp2StreamWindowScaleThresholdMultiplier();
+ public static int PendingConnectionTimeoutOnRequestCompletion { get; } = RuntimeSettingParser.QueryRuntimeSettingInt32(
+ "System.Net.SocketsHttpHandler.PendingConnectionTimeoutOnRequestCompletion",
+ "DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_PENDINGCONNECTIONTIMEOUTONREQUESTCOMPLETION", 5000);
+
public const int DefaultHttp2MaxStreamWindowSize = 16 * 1024 * 1024;
public const double DefaultHttp2StreamWindowScaleThresholdMultiplier = 1.0;
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
index e42c83bbd60be..2d92a7b74e9a1 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
@@ -2074,7 +2074,7 @@ private void ReturnConnectionToPool()
_idleSinceTickCount = Environment.TickCount64;
// Put connection back in the pool.
- _pool.ReturnHttp11Connection(this, isNewConnection: false);
+ _pool.RecycleHttp11Connection(this);
}
}
diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
index 7a4767d1e13df..d909eef019de6 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
@@ -451,27 +451,41 @@ private async Task AddHttp11ConnectionAsync(RequestQueue.QueueIt
{
if (NetEventSource.Log.IsEnabled()) Trace("Creating new HTTP/1.1 connection for pool.");
- HttpConnection connection;
- using (CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource())
+ HttpConnectionWaiter waiter = queueItem.Waiter;
+ HttpConnection? connection = null;
+ Exception? connectionException = null;
+
+ CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource();
+ waiter.ConnectionCancellationTokenSource = cts;
+ try
{
- try
- {
- connection = await CreateHttp11ConnectionAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
- }
- catch (OperationCanceledException oce) when (oce.CancellationToken == cts.Token)
- {
- HandleHttp11ConnectionFailure(queueItem.Waiter, CreateConnectTimeoutException(oce));
- return;
- }
- catch (Exception e)
+ connection = await CreateHttp11ConnectionAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ connectionException = e is OperationCanceledException oce && oce.CancellationToken == cts.Token && !waiter.CancelledByOriginatingRequestCompletion ?
+ CreateConnectTimeoutException(oce) :
+ e;
+ }
+ finally
+ {
+ lock (waiter)
{
- HandleHttp11ConnectionFailure(queueItem.Waiter, e);
- return;
+ waiter.ConnectionCancellationTokenSource = null;
+ cts.Dispose();
}
}
- // Add the established connection to the pool.
- ReturnHttp11Connection(connection, isNewConnection: true, queueItem.Waiter);
+ if (connection is not null)
+ {
+ // Add the established connection to the pool.
+ ReturnHttp11Connection(connection, isNewConnection: true, queueItem.Waiter);
+ }
+ else
+ {
+ Debug.Assert(connectionException is not null);
+ HandleHttp11ConnectionFailure(waiter, connectionException);
+ }
}
private void CheckForHttp11ConnectionInjection()
@@ -480,19 +494,22 @@ private void CheckForHttp11ConnectionInjection()
_http11RequestQueue.PruneCompletedRequestsFromHeadOfQueue(this);
+ // Determine if we can and should add a new connection to the pool.
+ bool willInject = _availableHttp11Connections.Count == 0 && // No available connections
+ _http11RequestQueue.Count > _pendingHttp11ConnectionCount && // More requests queued than pending connections
+ _associatedHttp11ConnectionCount < _maxHttp11Connections && // Under the connection limit
+ _http11RequestQueue.RequestsWithoutAConnectionAttempt > 0; // There are requests we haven't issued a connection attempt for
+
if (NetEventSource.Log.IsEnabled())
{
Trace($"Available HTTP/1.1 connections: {_availableHttp11Connections.Count}, Requests in the queue: {_http11RequestQueue.Count}, " +
$"Requests without a connection attempt: {_http11RequestQueue.RequestsWithoutAConnectionAttempt}, " +
$"Pending HTTP/1.1 connections: {_pendingHttp11ConnectionCount}, Total associated HTTP/1.1 connections: {_associatedHttp11ConnectionCount}, " +
- $"Max HTTP/1.1 connection limit: {_maxHttp11Connections}.");
+ $"Max HTTP/1.1 connection limit: {_maxHttp11Connections}, " +
+ $"Will inject connection: {willInject}.");
}
- // Determine if we can and should add a new connection to the pool.
- if (_availableHttp11Connections.Count == 0 && // No available connections
- _http11RequestQueue.Count > _pendingHttp11ConnectionCount && // More requests queued than pending connections
- _associatedHttp11ConnectionCount < _maxHttp11Connections && // Under the connection limit
- _http11RequestQueue.RequestsWithoutAConnectionAttempt > 0) // There are requests we haven't issued a connection attempt for
+ if (willInject)
{
_associatedHttp11ConnectionCount++;
_pendingHttp11ConnectionCount++;
@@ -507,13 +524,10 @@ private void CheckForHttp11ConnectionInjection()
}
}
- private async ValueTask GetHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
+ private bool TryGetPooledHttp11Connection(HttpRequestMessage request, bool async, [NotNullWhen(true)] out HttpConnection? connection, [NotNullWhen(false)] out HttpConnectionWaiter? waiter)
{
- // Look for a usable idle connection.
- TaskCompletionSourceWithCancellation waiter;
while (true)
{
- HttpConnection? connection = null;
lock (SyncObj)
{
_usedSinceLastCleanup = true;
@@ -533,8 +547,10 @@ private async ValueTask GetHttp11ConnectionAsync(HttpRequestMess
CheckForHttp11ConnectionInjection();
- // Break out of the loop and continue processing below.
- break;
+ // There were no available idle connections. This request has been added to the request queue.
+ if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/1.1 connections; request queued.");
+ connection = null;
+ return false;
}
}
@@ -553,23 +569,8 @@ private async ValueTask GetHttp11ConnectionAsync(HttpRequestMess
}
if (NetEventSource.Log.IsEnabled()) connection.Trace("Found usable HTTP/1.1 connection in pool.");
- return connection;
- }
-
- // There were no available idle connections. This request has been added to the request queue.
- if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/1.1 connections; request queued.");
-
- long startingTimestamp = Stopwatch.GetTimestamp();
- try
- {
- return await waiter.WaitWithCancellationAsync(async, cancellationToken).ConfigureAwait(false);
- }
- finally
- {
- if (HttpTelemetry.Log.IsEnabled())
- {
- HttpTelemetry.Log.Http11RequestLeftQueue(Stopwatch.GetElapsedTime(startingTimestamp).TotalMilliseconds);
- }
+ waiter = null;
+ return true;
}
}
@@ -578,7 +579,7 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stre
if (NetEventSource.Log.IsEnabled()) Trace("Server does not support HTTP2; disabling HTTP2 use and proceeding with HTTP/1.1 connection");
bool canUse = true;
- TaskCompletionSourceWithCancellation? waiter = null;
+ HttpConnectionWaiter? waiter = null;
lock (SyncObj)
{
Debug.Assert(_pendingHttp2Connection);
@@ -608,7 +609,10 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Stream stre
{
if (NetEventSource.Log.IsEnabled()) Trace("Downgrading queued HTTP2 request to HTTP/1.1");
- // We don't care if this fails; that means the request was previously canceled or handled by a different connection.
+ // We are done with the HTTP2 connection attempt, no point to cancel it.
+ Volatile.Write(ref waiter.ConnectionCancellationTokenSource, null);
+
+ // We don't care if this fails; that means the request was previously canceled or handeled by a different connection.
waiter.TrySetResult(null);
lock (SyncObj)
@@ -647,64 +651,80 @@ private async Task AddHttp2ConnectionAsync(RequestQueue.QueueI
{
if (NetEventSource.Log.IsEnabled()) Trace("Creating new HTTP/2 connection for pool.");
- Http2Connection connection;
- using (CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource())
+ Http2Connection? connection = null;
+ Exception? connectionException = null;
+ HttpConnectionWaiter waiter = queueItem.Waiter;
+
+ CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource();
+ waiter.ConnectionCancellationTokenSource = cts;
+ try
{
- try
+ (Stream stream, TransportContext? transportContext) = await ConnectAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
+
+ if (IsSecure)
{
- (Stream stream, TransportContext? transportContext) = await ConnectAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
+ SslStream sslStream = (SslStream)stream;
- if (IsSecure)
+ if (sslStream.NegotiatedApplicationProtocol == SslApplicationProtocol.Http2)
{
- SslStream sslStream = (SslStream)stream;
+ // The server accepted our request for HTTP2.
- if (sslStream.NegotiatedApplicationProtocol == SslApplicationProtocol.Http2)
+ if (sslStream.SslProtocol < SslProtocols.Tls12)
{
- // The server accepted our request for HTTP2.
-
- if (sslStream.SslProtocol < SslProtocols.Tls12)
- {
- stream.Dispose();
- throw new HttpRequestException(SR.Format(SR.net_ssl_http2_requires_tls12, sslStream.SslProtocol));
- }
-
- connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, cts.Token).ConfigureAwait(false);
+ stream.Dispose();
+ connectionException = new HttpRequestException(SR.Format(SR.net_ssl_http2_requires_tls12, sslStream.SslProtocol));
}
else
{
- // We established an SSL connection, but the server denied our request for HTTP2.
- await HandleHttp11Downgrade(queueItem.Request, stream, transportContext, cts.Token).ConfigureAwait(false);
- return;
+ connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, cts.Token).ConfigureAwait(false);
}
}
else
{
- connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, cts.Token).ConfigureAwait(false);
+ // We established an SSL connection, but the server denied our request for HTTP2.
+ await HandleHttp11Downgrade(queueItem.Request, stream, transportContext, cts.Token).ConfigureAwait(false);
+ return;
}
}
- catch (OperationCanceledException oce) when (oce.CancellationToken == cts.Token)
+ else
{
- HandleHttp2ConnectionFailure(queueItem.Waiter, CreateConnectTimeoutException(oce));
- return;
+ connection = await ConstructHttp2ConnectionAsync(stream, queueItem.Request, cts.Token).ConfigureAwait(false);
}
- catch (Exception e)
+ }
+ catch (Exception e)
+ {
+ connectionException = e is OperationCanceledException oce && oce.CancellationToken == cts.Token && !waiter.CancelledByOriginatingRequestCompletion ?
+ CreateConnectTimeoutException(oce) :
+ e;
+ }
+ finally
+ {
+ lock (waiter)
{
- HandleHttp2ConnectionFailure(queueItem.Waiter, e);
- return;
+ waiter.ConnectionCancellationTokenSource = null;
+ cts.Dispose();
}
}
- // Register for shutdown notification.
- // Do this before we return the connection to the pool, because that may result in it being disposed.
- ValueTask shutdownTask = connection.WaitForShutdownAsync();
+ if (connection is not null)
+ {
+ // Register for shutdown notification.
+ // Do this before we return the connection to the pool, because that may result in it being disposed.
+ ValueTask shutdownTask = connection.WaitForShutdownAsync();
- // Add the new connection to the pool.
- ReturnHttp2Connection(connection, isNewConnection: true, queueItem.Waiter);
+ // Add the new connection to the pool.
+ ReturnHttp2Connection(connection, isNewConnection: true, queueItem.Waiter);
- // Wait for connection shutdown.
- await shutdownTask.ConfigureAwait(false);
+ // Wait for connection shutdown.
+ await shutdownTask.ConfigureAwait(false);
- InvalidateHttp2Connection(connection);
+ InvalidateHttp2Connection(connection);
+ }
+ else
+ {
+ Debug.Assert(connectionException is not null);
+ HandleHttp2ConnectionFailure(waiter, connectionException);
+ }
}
private void CheckForHttp2ConnectionInjection()
@@ -714,11 +734,24 @@ private void CheckForHttp2ConnectionInjection()
_http2RequestQueue.PruneCompletedRequestsFromHeadOfQueue(this);
// Determine if we can and should add a new connection to the pool.
- if ((_availableHttp2Connections?.Count ?? 0) == 0 && // No available connections
+ int availableHttp2ConnectionCount = _availableHttp2Connections?.Count ?? 0;
+ bool willInject = availableHttp2ConnectionCount == 0 && // No available connections
!_pendingHttp2Connection && // Only allow one pending HTTP2 connection at a time
_http2RequestQueue.Count > 0 && // There are requests left on the queue
(_associatedHttp2ConnectionCount == 0 || EnableMultipleHttp2Connections) && // We allow multiple connections, or don't have a connection currently
- _http2RequestQueue.RequestsWithoutAConnectionAttempt > 0) // There are requests we haven't issued a connection attempt for
+ _http2RequestQueue.RequestsWithoutAConnectionAttempt > 0; // There are requests we haven't issued a connection attempt for
+
+ if (NetEventSource.Log.IsEnabled())
+ {
+ Trace($"Available HTTP/2.0 connections: {availableHttp2ConnectionCount}, " +
+ $"Pending HTTP/2.0 connection: {_pendingHttp2Connection}" +
+ $"Requests in the queue: {_http2RequestQueue.Count}, " +
+ $"Requests without a connection attempt: {_http2RequestQueue.RequestsWithoutAConnectionAttempt}, " +
+ $"Total associated HTTP/2.0 connections: {_associatedHttp2ConnectionCount}, " +
+ $"Will inject connection: {willInject}.");
+ }
+
+ if (willInject)
{
_associatedHttp2ConnectionCount++;
_pendingHttp2Connection = true;
@@ -733,22 +766,22 @@ private void CheckForHttp2ConnectionInjection()
}
}
- private async ValueTask GetHttp2ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
+ private bool TryGetPooledHttp2Connection(HttpRequestMessage request, bool async, [NotNullWhen(true)] out Http2Connection? connection, out HttpConnectionWaiter? waiter)
{
Debug.Assert(_kind == HttpConnectionKind.Https || _kind == HttpConnectionKind.SslProxyTunnel || _kind == HttpConnectionKind.Http || _kind == HttpConnectionKind.SocksTunnel || _kind == HttpConnectionKind.SslSocksTunnel);
// Look for a usable connection.
- TaskCompletionSourceWithCancellation waiter;
while (true)
{
- Http2Connection connection;
lock (SyncObj)
{
_usedSinceLastCleanup = true;
if (!_http2Enabled)
{
- return null;
+ waiter = null;
+ connection = null;
+ return false;
}
int availableConnectionCount = _availableHttp2Connections?.Count ?? 0;
@@ -765,8 +798,10 @@ private void CheckForHttp2ConnectionInjection()
CheckForHttp2ConnectionInjection();
- // Break out of the loop and continue processing below.
- break;
+ // There were no available connections. This request has been added to the request queue.
+ if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/2 connections; request queued.");
+ connection = null;
+ return false;
}
}
@@ -802,23 +837,8 @@ private void CheckForHttp2ConnectionInjection()
}
if (NetEventSource.Log.IsEnabled()) connection.Trace("Found usable HTTP/2 connection in pool.");
- return connection;
- }
-
- // There were no available connections. This request has been added to the request queue.
- if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/2 connections; request queued.");
-
- long startingTimestamp = Stopwatch.GetTimestamp();
- try
- {
- return await waiter.WaitWithCancellationAsync(async, cancellationToken).ConfigureAwait(false);
- }
- finally
- {
- if (HttpTelemetry.Log.IsEnabled())
- {
- HttpTelemetry.Log.Http20RequestLeftQueue(Stopwatch.GetElapsedTime(startingTimestamp).TotalMilliseconds);
- }
+ waiter = null;
+ return true;
}
}
@@ -989,6 +1009,8 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn
int retryCount = 0;
while (true)
{
+ HttpConnectionWaiter? http11ConnectionWaiter = null;
+ HttpConnectionWaiter? http2ConnectionWaiter = null;
try
{
HttpResponseMessage? response = null;
@@ -1016,7 +1038,12 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn
(request.Version.Major >= 2 || (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrHigher && IsSecure)) &&
(request.VersionPolicy != HttpVersionPolicy.RequestVersionOrLower || IsSecure)) // prefer HTTP/1.1 if connection is not secured and downgrade is possible
{
- Http2Connection? connection = await GetHttp2ConnectionAsync(request, async, cancellationToken).ConfigureAwait(false);
+ if (!TryGetPooledHttp2Connection(request, async, out Http2Connection? connection, out http2ConnectionWaiter) &&
+ http2ConnectionWaiter != null)
+ {
+ connection = await http2ConnectionWaiter.WaitForConnectionAsync(async, cancellationToken).ConfigureAwait(false);
+ }
+
Debug.Assert(connection is not null || !_http2Enabled);
if (connection is not null)
{
@@ -1044,7 +1071,11 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn
}
// Use HTTP/1.x.
- HttpConnection connection = await GetHttp11ConnectionAsync(request, async, cancellationToken).ConfigureAwait(false);
+ if (!TryGetPooledHttp11Connection(request, async, out HttpConnection? connection, out http11ConnectionWaiter))
+ {
+ connection = await http11ConnectionWaiter.WaitForConnectionAsync(async, cancellationToken).ConfigureAwait(false);
+ }
+
connection.Acquire(); // In case we are doing Windows (i.e. connection-based) auth, we need to ensure that we hold on to this specific connection while auth is underway.
try
{
@@ -1113,6 +1144,52 @@ public async ValueTask SendWithVersionDetectionAndRetryAsyn
// Eat exception and try again.
}
+ finally
+ {
+ // We never cancel both attempts at the same time. When downgrade happens, it's possible that both waiters are non-null,
+ // but in that case http2ConnectionWaiter.ConnectionCancellationTokenSource shall be null.
+ Debug.Assert(http11ConnectionWaiter is null || http2ConnectionWaiter?.ConnectionCancellationTokenSource is null);
+ CancelIfNecessary(http11ConnectionWaiter, cancellationToken.IsCancellationRequested);
+ CancelIfNecessary(http2ConnectionWaiter, cancellationToken.IsCancellationRequested);
+ }
+ }
+ }
+
+ private void CancelIfNecessary(HttpConnectionWaiter? waiter, bool requestCancelled)
+ {
+ int timeout = GlobalHttpSettings.SocketsHttpHandler.PendingConnectionTimeoutOnRequestCompletion;
+ if (waiter?.ConnectionCancellationTokenSource is null ||
+ timeout == Timeout.Infinite ||
+ Settings._connectTimeout != Timeout.InfiniteTimeSpan && timeout > (int)Settings._connectTimeout.TotalMilliseconds) // Do not override shorter ConnectTimeout
+ {
+ return;
+ }
+
+ lock (waiter)
+ {
+ if (waiter.ConnectionCancellationTokenSource is null)
+ {
+ return;
+ }
+
+ if (NetEventSource.Log.IsEnabled())
+ {
+ Trace($"Initiating cancellation of a pending connection attempt with delay of {timeout} ms, " +
+ $"Reason: {(requestCancelled ? "Request cancelled" : "Request served by another connection")}.");
+ }
+
+ waiter.CancelledByOriginatingRequestCompletion = true;
+ if (timeout > 0)
+ {
+ // Cancel after the specified timeout. This cancellation will not fire if the connection
+ // succeeds within the delay and the CTS becomes disposed.
+ waiter.ConnectionCancellationTokenSource.CancelAfter(timeout);
+ }
+ else
+ {
+ // Cancel immediately if no timeout specified.
+ waiter.ConnectionCancellationTokenSource.Cancel();
+ }
}
}
@@ -1675,7 +1752,7 @@ private async ValueTask EstablishSocksTunnel(HttpRequestMessage request,
return stream;
}
- private void HandleHttp11ConnectionFailure(TaskCompletionSourceWithCancellation? requestWaiter, Exception e)
+ private void HandleHttp11ConnectionFailure(HttpConnectionWaiter? requestWaiter, Exception e)
{
if (NetEventSource.Log.IsEnabled()) Trace($"HTTP/1.1 connection failed: {e}");
@@ -1695,7 +1772,7 @@ private void HandleHttp11ConnectionFailure(TaskCompletionSourceWithCancellation<
}
}
- private void HandleHttp2ConnectionFailure(TaskCompletionSourceWithCancellation requestWaiter, Exception e)
+ private void HandleHttp2ConnectionFailure(HttpConnectionWaiter requestWaiter, Exception e)
{
if (NetEventSource.Log.IsEnabled()) Trace($"HTTP2 connection failed: {e}");
@@ -1776,7 +1853,9 @@ private bool CheckExpirationOnReturn(HttpConnectionBase connection)
return false;
}
- public void ReturnHttp11Connection(HttpConnection connection, bool isNewConnection, TaskCompletionSourceWithCancellation? initialRequestWaiter = null)
+ public void RecycleHttp11Connection(HttpConnection connection) => ReturnHttp11Connection(connection, false);
+
+ private void ReturnHttp11Connection(HttpConnection connection, bool isNewConnection, HttpConnectionWaiter? initialRequestWaiter = null)
{
if (NetEventSource.Log.IsEnabled()) connection.Trace($"{nameof(isNewConnection)}={isNewConnection}");
@@ -1792,7 +1871,7 @@ public void ReturnHttp11Connection(HttpConnection connection, bool isNewConnecti
// Loop in case we get a request that has already been canceled or handled by a different connection.
while (true)
{
- TaskCompletionSourceWithCancellation? waiter = null;
+ HttpConnectionWaiter? waiter = null;
bool added = false;
lock (SyncObj)
{
@@ -1869,7 +1948,7 @@ public void ReturnHttp11Connection(HttpConnection connection, bool isNewConnecti
}
}
- public void ReturnHttp2Connection(Http2Connection connection, bool isNewConnection, TaskCompletionSourceWithCancellation? initialRequestWaiter = null)
+ private void ReturnHttp2Connection(Http2Connection connection, bool isNewConnection, HttpConnectionWaiter? initialRequestWaiter = null)
{
if (NetEventSource.Log.IsEnabled()) connection.Trace($"{nameof(isNewConnection)}={isNewConnection}");
@@ -1894,7 +1973,7 @@ public void ReturnHttp2Connection(Http2Connection connection, bool isNewConnecti
// Loop in case we get a request that has already been canceled or handled by a different connection.
while (true)
{
- TaskCompletionSourceWithCancellation? waiter = null;
+ HttpConnectionWaiter? waiter = null;
bool added = false;
lock (SyncObj)
{
@@ -2307,7 +2386,7 @@ private struct RequestQueue
public struct QueueItem
{
public HttpRequestMessage Request;
- public TaskCompletionSourceWithCancellation Waiter;
+ public HttpConnectionWaiter Waiter;
}
// This implementation mimics that of Queue, but without version checks and with an extra head pointer
@@ -2406,9 +2485,9 @@ private void Grow()
}
- public TaskCompletionSourceWithCancellation EnqueueRequest(HttpRequestMessage request)
+ public HttpConnectionWaiter EnqueueRequest(HttpRequestMessage request)
{
- var waiter = new TaskCompletionSourceWithCancellation();
+ var waiter = new HttpConnectionWaiter();
Enqueue(new QueueItem { Request = request, Waiter = waiter });
return waiter;
}
@@ -2428,7 +2507,7 @@ public void PruneCompletedRequestsFromHeadOfQueue(HttpConnectionPool pool)
}
}
- public bool TryDequeueWaiter(HttpConnectionPool pool, [MaybeNullWhen(false)] out TaskCompletionSourceWithCancellation waiter)
+ public bool TryDequeueWaiter(HttpConnectionPool pool, [MaybeNullWhen(false)] out HttpConnectionWaiter waiter)
{
PruneCompletedRequestsFromHeadOfQueue(pool);
@@ -2442,7 +2521,7 @@ public bool TryDequeueWaiter(HttpConnectionPool pool, [MaybeNullWhen(false)] out
return false;
}
- public void TryDequeueSpecificWaiter(TaskCompletionSourceWithCancellation waiter)
+ public void TryDequeueSpecificWaiter(HttpConnectionWaiter waiter)
{
if (TryPeek(out QueueItem queueItem) && queueItem.Waiter == waiter)
{
@@ -2470,5 +2549,34 @@ public QueueItem PeekNextRequestForConnectionAttempt()
public int RequestsWithoutAConnectionAttempt => _size - _attemptedConnectionsOffset;
}
+
+ private sealed class HttpConnectionWaiter : TaskCompletionSourceWithCancellation
+ {
+ // When a connection attempt is pending, reference the connection's CTS, so we can tear it down if the initiating request is cancelled
+ // or completes on a different connection.
+ public CancellationTokenSource? ConnectionCancellationTokenSource;
+
+ // Distinguish connection cancellation that happens because the initiating request is cancelled or completed on a different connection.
+ public bool CancelledByOriginatingRequestCompletion { get; set; }
+
+ public async ValueTask WaitForConnectionAsync(bool async, CancellationToken requestCancellationToken)
+ {
+ long startingTimestamp = Stopwatch.GetTimestamp();
+ try
+ {
+ return await WaitWithCancellationAsync(async, requestCancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (HttpTelemetry.Log.IsEnabled())
+ {
+ if (typeof(T) == typeof(HttpConnection))
+ HttpTelemetry.Log.Http11RequestLeftQueue(Stopwatch.GetElapsedTime(startingTimestamp).TotalMilliseconds);
+ else if (typeof(T) == typeof(Http2Connection))
+ HttpTelemetry.Log.Http20RequestLeftQueue(Stopwatch.GetElapsedTime(startingTimestamp).TotalMilliseconds);
+ }
+ }
+ }
+ }
}
}
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.NonParallel.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.NonParallel.cs
new file mode 100644
index 0000000000000..1f8e681cab25c
--- /dev/null
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.NonParallel.cs
@@ -0,0 +1,140 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IO;
+using System.Net.Sockets;
+using System.Net.Test.Common;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.DotNet.RemoteExecutor;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace System.Net.Http.Functional.Tests
+{
+ [Collection(nameof(DisableParallelization))] // Reduces chance of timing-related issues
+ [ConditionalClass(typeof(SocketsHttpHandler), nameof(SocketsHttpHandler.IsSupported))]
+ public class SocketsHttpHandler_Cancellation_Test_NonParallel : HttpClientHandlerTestBase
+ {
+ public SocketsHttpHandler_Cancellation_Test_NonParallel(ITestOutputHelper output) : base(output)
+ {
+ }
+
+ [OuterLoop("Incurs significant delay.")]
+ [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+ [InlineData("1.1", 10_000, 1_000, 100)]
+ [InlineData("2.0", 10_000, 1_000, 100)]
+ [InlineData("1.1", 20_000, 10_000, null)]
+ [InlineData("2.0", 20_000, 10_000, null)]
+ public static void CancelPendingRequest_DropsStalledConnectionAttempt(string versionString, int firstConnectionDelayMs, int requestTimeoutMs, int? pendingConnectionTimeoutOnRequestCompletion)
+ {
+ RemoteInvokeOptions options = new RemoteInvokeOptions();
+ if (pendingConnectionTimeoutOnRequestCompletion is not null)
+ {
+ options.StartInfo.EnvironmentVariables["DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_PENDINGCONNECTIONTIMEOUTONREQUESTCOMPLETION"] = pendingConnectionTimeoutOnRequestCompletion.ToString();
+ }
+
+ RemoteExecutor.Invoke(CancelPendingRequest_DropsStalledConnectionAttempt_Impl, versionString, firstConnectionDelayMs.ToString(), requestTimeoutMs.ToString(), options).Dispose();
+ }
+
+ private static async Task CancelPendingRequest_DropsStalledConnectionAttempt_Impl(string versionString, string firstConnectionDelayMsString, string requestTimeoutMsString)
+ {
+ var version = Version.Parse(versionString);
+ LoopbackServerFactory factory = GetFactoryForVersion(version);
+
+ const int AttemptCount = 3;
+ int firstConnectionDelayMs = int.Parse(firstConnectionDelayMsString);
+ int requestTimeoutMs = int.Parse(requestTimeoutMsString);
+ bool firstConnection = true;
+
+ using CancellationTokenSource cts0 = new CancellationTokenSource(requestTimeoutMs);
+
+ await factory.CreateClientAndServerAsync(async uri =>
+ {
+ using var handler = CreateHttpClientHandler(version);
+ GetUnderlyingSocketsHttpHandler(handler).ConnectCallback = DoConnect;
+ using var client = new HttpClient(handler) { DefaultRequestVersion = version };
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await client.GetAsync(uri, cts0.Token);
+ });
+
+ for (int i = 0; i < AttemptCount; i++)
+ {
+ using var cts1 = new CancellationTokenSource(requestTimeoutMs);
+ using var response = await client.GetAsync(uri, cts1.Token);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+ }, async server =>
+ {
+ await server.AcceptConnectionAsync(async connection =>
+ {
+ for (int i = 0; i < AttemptCount; i++)
+ {
+ await connection.ReadRequestDataAsync();
+ await connection.SendResponseAsync();
+ connection.CompleteRequestProcessing();
+ }
+ });
+ });
+
+ async ValueTask DoConnect(SocketsHttpConnectionContext ctx, CancellationToken cancellationToken)
+ {
+ if (firstConnection)
+ {
+ firstConnection = false;
+ await Task.Delay(100, cancellationToken); // Wait for the request to be pushed to the queue
+ cts0.Cancel(); // cancel the first request faster than RequestTimeoutMs
+ await Task.Delay(firstConnectionDelayMs, cancellationToken); // Simulate stalled connection
+ }
+ var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ await s.ConnectAsync(ctx.DnsEndPoint, cancellationToken);
+
+ return new NetworkStream(s, ownsSocket: true);
+ }
+ }
+
+ [OuterLoop("Incurs significant delay.")]
+ [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
+ [InlineData(20_000)]
+ [InlineData(Timeout.Infinite)]
+ public void PendingConnectionTimeout_HighValue_PendingConnectionIsNotCancelled(int timeout)
+ {
+ RemoteExecutor.Invoke(async timoutStr =>
+ {
+ // Setup "infinite" timeout of int.MaxValue milliseconds
+ AppContext.SetData("System.Net.SocketsHttpHandler.PendingConnectionTimeoutOnRequestCompletion", int.Parse(timoutStr));
+
+ bool connected = false;
+ CancellationTokenSource cts = new CancellationTokenSource();
+
+ await new Http11LoopbackServerFactory().CreateClientAndServerAsync(async uri =>
+ {
+ using var handler = CreateHttpClientHandler(HttpVersion.Version11);
+ GetUnderlyingSocketsHttpHandler(handler).ConnectCallback = DoConnect;
+ using var client = new HttpClient(handler) { DefaultRequestVersion = HttpVersion.Version11 };
+
+ await Assert.ThrowsAnyAsync(() => client.GetAsync(uri, cts.Token));
+ },
+ async server => {
+ await server.AcceptConnectionAsync(_ => Task.CompletedTask).WaitAsync(30_000);
+ });
+
+ async ValueTask DoConnect(SocketsHttpConnectionContext ctx, CancellationToken cancellationToken)
+ {
+ var s = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+ await Task.Delay(100, cancellationToken); // Wait for the request to be pushed to the queue
+ cts.Cancel();
+
+ await Task.Delay(10_000, cancellationToken);
+ await s.ConnectAsync(ctx.DnsEndPoint, cancellationToken);
+ connected = true;
+ return new NetworkStream(s, ownsSocket: true);
+ }
+
+ Assert.True(connected);
+ }, timeout.ToString()).Dispose();
+ }
+ }
+}
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj
index 57b3d2065fce4..6c05b3cc0f35b 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj
@@ -1,4 +1,4 @@
-
+
../../src/Resources/Strings.resx
$(DefineConstants);SYSNETHTTP_NO_OPENSSL;HTTP3
@@ -159,6 +159,7 @@
Link="Common\TestUtilities\System\DisableParallelization.cs" />
+