Skip to content

Commit 0ded51b

Browse files
authored
Fix race condition when cancelling pending HTTP connection attempts (#110765)
1 parent 599c02d commit 0ded51b

File tree

2 files changed

+85
-5
lines changed

2 files changed

+85
-5
lines changed

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -492,8 +492,7 @@ private async Task AddHttp11ConnectionAsync(RequestQueue<HttpConnection>.QueueIt
492492
HttpConnection? connection = null;
493493
Exception? connectionException = null;
494494

495-
CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource();
496-
waiter.ConnectionCancellationTokenSource = cts;
495+
CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource(waiter);
497496
try
498497
{
499498
connection = await CreateHttp11ConnectionAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
@@ -691,8 +690,7 @@ private async Task AddHttp2ConnectionAsync(RequestQueue<Http2Connection?>.QueueI
691690
Exception? connectionException = null;
692691
HttpConnectionWaiter<Http2Connection?> waiter = queueItem.Waiter;
693692

694-
CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource();
695-
waiter.ConnectionCancellationTokenSource = cts;
693+
CancellationTokenSource cts = GetConnectTimeoutCancellationTokenSource(waiter);
696694
try
697695
{
698696
(Stream stream, TransportContext? transportContext, IPEndPoint? remoteEndPoint) = await ConnectAsync(queueItem.Request, true, cts.Token).ConfigureAwait(false);
@@ -1520,7 +1518,27 @@ public ValueTask<HttpResponseMessage> SendAsync(HttpRequestMessage request, bool
15201518
return SendWithProxyAuthAsync(request, async, doRequestAuth, cancellationToken);
15211519
}
15221520

1523-
private CancellationTokenSource GetConnectTimeoutCancellationTokenSource() => new CancellationTokenSource(Settings._connectTimeout);
1521+
private CancellationTokenSource GetConnectTimeoutCancellationTokenSource<T>(HttpConnectionWaiter<T> waiter)
1522+
where T : HttpConnectionBase?
1523+
{
1524+
var cts = new CancellationTokenSource(Settings._connectTimeout);
1525+
1526+
lock (waiter)
1527+
{
1528+
waiter.ConnectionCancellationTokenSource = cts;
1529+
1530+
// The initiating request for this connection attempt may complete concurrently at any time.
1531+
// If it completed before we've set the CTS, CancelIfNecessary would no-op.
1532+
// Check it again now that we're holding the lock and ensure we always set a timeout.
1533+
if (waiter.Task.IsCompleted)
1534+
{
1535+
CancelIfNecessary(waiter, requestCancelled: waiter.Task.IsCanceled);
1536+
waiter.ConnectionCancellationTokenSource = null;
1537+
}
1538+
}
1539+
1540+
return cts;
1541+
}
15241542

15251543
private async ValueTask<(Stream, TransportContext?, IPEndPoint?)> ConnectAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
15261544
{

src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,68 @@ public void PendingConnectionTimeout_HighValue_PendingConnectionIsNotCancelled(i
393393
}, UseVersion.ToString(), timeout.ToString()).Dispose();
394394
}
395395

396+
[OuterLoop("We wait for PendingConnectionTimeout which defaults to 5 seconds.")]
397+
[Fact]
398+
public async Task PendingConnectionTimeout_SignalsAllConnectionAttempts()
399+
{
400+
if (UseVersion == HttpVersion.Version30)
401+
{
402+
// HTTP3 does not support ConnectCallback
403+
return;
404+
}
405+
406+
int pendingConnectionAttempts = 0;
407+
bool connectionAttemptTimedOut = false;
408+
409+
using var handler = new SocketsHttpHandler
410+
{
411+
ConnectCallback = async (context, cancellation) =>
412+
{
413+
Interlocked.Increment(ref pendingConnectionAttempts);
414+
try
415+
{
416+
await Assert.ThrowsAsync<TaskCanceledException>(() => Task.Delay(-1, cancellation)).WaitAsync(TestHelper.PassingTestTimeout);
417+
cancellation.ThrowIfCancellationRequested();
418+
throw new UnreachableException();
419+
}
420+
catch (TimeoutException)
421+
{
422+
connectionAttemptTimedOut = true;
423+
throw;
424+
}
425+
finally
426+
{
427+
Interlocked.Decrement(ref pendingConnectionAttempts);
428+
}
429+
}
430+
};
431+
432+
using HttpClient client = CreateHttpClient(handler);
433+
client.Timeout = TimeSpan.FromSeconds(2);
434+
435+
// Many of these requests should trigger new connection attempts, and all of those should eventually be cleaned up.
436+
await Parallel.ForAsync(0, 100, async (_, _) =>
437+
{
438+
await Assert.ThrowsAnyAsync<TaskCanceledException>(() => client.GetAsync("https://dummy"));
439+
});
440+
441+
Stopwatch stopwatch = Stopwatch.StartNew();
442+
443+
while (Volatile.Read(ref pendingConnectionAttempts) > 0)
444+
{
445+
Assert.False(connectionAttemptTimedOut);
446+
447+
if (stopwatch.Elapsed > 2 * TestHelper.PassingTestTimeout)
448+
{
449+
Assert.Fail("Connection attempts took too long to get cleaned up");
450+
}
451+
452+
await Task.Delay(100);
453+
}
454+
455+
Assert.False(connectionAttemptTimedOut);
456+
}
457+
396458
private sealed class SetTcsContent : StreamContent
397459
{
398460
private readonly TaskCompletionSource<bool> _tcs;

0 commit comments

Comments
 (0)