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 75b83fbe8bae61..ca736201a58d34 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 @@ -455,12 +455,12 @@ private async Task AddHttp11ConnectionAsync(HttpRequestMessage request) } catch (OperationCanceledException oce) when (oce.CancellationToken == cts.Token) { - HandleHttp11ConnectionFailure(CreateConnectTimeoutException(oce)); + HandleHttp11ConnectionFailure(request, CreateConnectTimeoutException(oce)); return; } catch (Exception e) { - HandleHttp11ConnectionFailure(e); + HandleHttp11ConnectionFailure(request, e); return; } } @@ -473,7 +473,7 @@ private void CheckForHttp11ConnectionInjection() { Debug.Assert(HasSyncObjLock); - if (!_http11RequestQueue.TryPeekNextRequest(out HttpRequestMessage? request)) + if (!_http11RequestQueue.TryPeekRequest(out HttpRequestMessage? request)) { return; } @@ -561,6 +561,7 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Socket? soc 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; lock (SyncObj) { Debug.Assert(_pendingHttp2Connection); @@ -571,10 +572,6 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Socket? soc _associatedHttp2ConnectionCount--; _pendingHttp2Connection = false; - // Signal to any queued HTTP2 requests that they must downgrade. - while (_http2RequestQueue.TryDequeueNextRequest(null)) - ; - if (_associatedHttp11ConnectionCount < _maxHttp11Connections) { _associatedHttp11ConnectionCount++; @@ -585,6 +582,23 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Socket? soc // We are already at the limit for HTTP/1.1 connections, so do not proceed with this connection. canUse = false; } + + _http2RequestQueue.TryDequeueWaiter(out waiter); + } + + // Signal to any queued HTTP2 requests that they must downgrade. + while (waiter is not null) + { + 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. + bool success = waiter.TrySetResult(null); + Debug.Assert(success || waiter.Task.IsCanceled); + + lock (SyncObj) + { + _http2RequestQueue.TryDequeueWaiter(out waiter); + } } if (!canUse) @@ -601,12 +615,12 @@ private async Task HandleHttp11Downgrade(HttpRequestMessage request, Socket? soc } catch (OperationCanceledException oce) when (oce.CancellationToken == cancellationToken) { - HandleHttp11ConnectionFailure(CreateConnectTimeoutException(oce)); + HandleHttp11ConnectionFailure(request, CreateConnectTimeoutException(oce)); return; } catch (Exception e) { - HandleHttp11ConnectionFailure(e); + HandleHttp11ConnectionFailure(request, e); return; } @@ -654,12 +668,12 @@ private async Task AddHttp2ConnectionAsync(HttpRequestMessage request) } catch (OperationCanceledException oce) when (oce.CancellationToken == cts.Token) { - HandleHttp2ConnectionFailure(CreateConnectTimeoutException(oce)); + HandleHttp2ConnectionFailure(request, CreateConnectTimeoutException(oce)); return; } catch (Exception e) { - HandleHttp2ConnectionFailure(e); + HandleHttp2ConnectionFailure(request, e); return; } } @@ -669,7 +683,7 @@ private async Task AddHttp2ConnectionAsync(HttpRequestMessage request) ValueTask shutdownTask = connection.WaitForShutdownAsync(); // Add the new connection to the pool. - ReturnHttp2Connection(connection, isNewConnection: true); + ReturnHttp2Connection(connection, request, isNewConnection: true); // Wait for connection shutdown. await shutdownTask.ConfigureAwait(false); @@ -681,7 +695,7 @@ private void CheckForHttp2ConnectionInjection() { Debug.Assert(HasSyncObjLock); - if (!_http2RequestQueue.TryPeekNextRequest(out HttpRequestMessage? request)) + if (!_http2RequestQueue.TryPeekRequest(out HttpRequestMessage? request)) { return; } @@ -1573,10 +1587,12 @@ private async ValueTask EstablishProxyTunnelAsync(bool async, HttpReques return (socket, stream); } - private void HandleHttp11ConnectionFailure(Exception e) + private void HandleHttp11ConnectionFailure(HttpRequestMessage request, Exception e) { if (NetEventSource.Log.IsEnabled()) Trace("HTTP/1.1 connection failed"); + bool failRequest; + TaskCompletionSourceWithCancellation? waiter; lock (SyncObj) { Debug.Assert(_associatedHttp11ConnectionCount > 0); @@ -1585,17 +1601,28 @@ private void HandleHttp11ConnectionFailure(Exception e) _associatedHttp11ConnectionCount--; _pendingHttp11ConnectionCount--; - // Fail the next queued request (if any) with this error. - _http11RequestQueue.TryFailNextRequest(e); + // If the request that caused this connection attempt is still pending, fail it. + // Otherwise, the request must have been canceled or satisfied by another connection already. + failRequest = _http11RequestQueue.TryDequeueWaiterForSpecificRequest(request, out waiter); CheckForHttp11ConnectionInjection(); } + + if (failRequest) + { + // This may fail if the request was already canceled, but we don't care. + Debug.Assert(waiter is not null); + bool succeeded = waiter.TrySetException(e); + Debug.Assert(succeeded || waiter.Task.IsCanceled); + } } - private void HandleHttp2ConnectionFailure(Exception e) + private void HandleHttp2ConnectionFailure(HttpRequestMessage request, Exception e) { if (NetEventSource.Log.IsEnabled()) Trace("HTTP2 connection failed"); + bool failRequest; + TaskCompletionSourceWithCancellation? waiter; lock (SyncObj) { Debug.Assert(_associatedHttp2ConnectionCount > 0); @@ -1604,11 +1631,20 @@ private void HandleHttp2ConnectionFailure(Exception e) _associatedHttp2ConnectionCount--; _pendingHttp2Connection = false; - // Fail the next queued request (if any) with this error. - _http2RequestQueue.TryFailNextRequest(e); + // If the request that caused this connection attempt is still pending, fail it. + // Otherwise, the request must have been canceled or satisfied by another connection already. + failRequest = _http2RequestQueue.TryDequeueWaiterForSpecificRequest(request, out waiter); CheckForHttp2ConnectionInjection(); } + + if (failRequest) + { + // This may fail if the request was already canceled, but we don't care. + Debug.Assert(waiter is not null); + bool succeeded = waiter.TrySetException(e); + Debug.Assert(succeeded || waiter.Task.IsCanceled); + } } /// @@ -1684,50 +1720,77 @@ public void ReturnHttp11Connection(HttpConnection connection, bool isNewConnecti return; } - lock (SyncObj) + // Loop in case we get a cancelled request. + while (true) { - Debug.Assert(!_availableHttp11Connections.Contains(connection)); - - if (isNewConnection) + TaskCompletionSourceWithCancellation? waiter = null; + bool added = false; + lock (SyncObj) { - Debug.Assert(_pendingHttp11ConnectionCount > 0); - _pendingHttp11ConnectionCount--; - } + Debug.Assert(!_availableHttp11Connections.Contains(connection), $"Connection already in available list"); + Debug.Assert(_associatedHttp11ConnectionCount > _availableHttp11Connections.Count, + $"Expected _associatedHttp11ConnectionCount={_associatedHttp11ConnectionCount} > _availableHttp11Connections.Count={_availableHttp11Connections.Count}"); + Debug.Assert(_associatedHttp11ConnectionCount <= _maxHttp11Connections, + $"Expected _associatedHttp11ConnectionCount={_associatedHttp11ConnectionCount} <= _maxHttp11Connections={_maxHttp11Connections}"); - if (_http11RequestQueue.TryDequeueNextRequest(connection)) - { - Debug.Assert(_availableHttp11Connections.Count == 0, $"With {_availableHttp11Connections.Count} available HTTP/1.1 connections, we shouldn't have a waiter."); + if (isNewConnection) + { + Debug.Assert(_pendingHttp11ConnectionCount > 0); + _pendingHttp11ConnectionCount--; + isNewConnection = false; + } - if (NetEventSource.Log.IsEnabled()) connection.Trace("Dequeued waiting HTTP/1.1 request."); - return; + if (_http11RequestQueue.TryDequeueWaiter(out waiter)) + { + Debug.Assert(_availableHttp11Connections.Count == 0, $"With {_availableHttp11Connections.Count} available HTTP/1.1 connections, we shouldn't have a waiter."); + } + else if (!_disposed) + { + // Add connection to the pool. + added = true; + _availableHttp11Connections.Add(connection); + } + + // If the pool has been disposed of, we will dispose the connection below outside the lock. + // We do this after processing the queue above so that any queued requests will be handled by existing connections if possible. } - if (_disposed) + if (waiter is not null) + { + Debug.Assert(!added); + if (waiter.TrySetResult(connection)) + { + if (NetEventSource.Log.IsEnabled()) connection.Trace("Dequeued waiting HTTP/1.1 request."); + return; + } + else + { + Debug.Assert(waiter.Task.IsCanceled); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Discarding canceled HTTP/1.1 request from queue."); + // Loop and process the queue again + } + } + else if (added) { - // If the pool has been disposed of, dispose the connection being returned, - // as the pool is being deactivated. We do this after the above in order to - // use pooled connections to satisfy any requests that pended before the - // the pool was disposed of. - if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing connection returned to pool. Pool was disposed."); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Put HTTP/1.1 connection in pool."); + return; } else { - // Add connection to the pool. - _availableHttp11Connections.Add(connection); - Debug.Assert(_availableHttp11Connections.Count <= _maxHttp11Connections, $"Expected {_availableHttp11Connections.Count} <= {_maxHttp11Connections}"); - if (NetEventSource.Log.IsEnabled()) connection.Trace("Put connection in pool."); + Debug.Assert(_disposed); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing HTTP/1.1 connection returned to pool. Pool was disposed."); + connection.Dispose(); return; } } - - // We determined that the connection is no longer usable. - connection.Dispose(); } - public void ReturnHttp2Connection(Http2Connection connection, bool isNewConnection) + public void ReturnHttp2Connection(Http2Connection connection, HttpRequestMessage? request = null, bool isNewConnection = false) { if (NetEventSource.Log.IsEnabled()) connection.Trace($"{nameof(isNewConnection)}={isNewConnection}"); + Debug.Assert(isNewConnection || request is null, "Shouldn't have a request unless the connection is new"); + if (!isNewConnection && CheckExpirationOnReturn(connection)) { lock (SyncObj) @@ -1737,91 +1800,111 @@ public void ReturnHttp2Connection(Http2Connection connection, bool isNewConnecti _associatedHttp2ConnectionCount--; } - if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing HTTP/2 connection return to pool. Connection lifetime expired."); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing HTTP2 connection return to pool. Connection lifetime expired."); connection.Dispose(); return; } - bool usable = true; - bool poolDisposed = false; - lock (SyncObj) + while (connection.TryReserveStream()) { - Debug.Assert(_availableHttp2Connections is null || !_availableHttp2Connections.Contains(connection)); - Debug.Assert(_associatedHttp2ConnectionCount > (_availableHttp2Connections?.Count ?? 0)); - - if (isNewConnection) + // Loop in case we get a cancelled request. + while (true) { - Debug.Assert(_pendingHttp2Connection); - _pendingHttp2Connection = false; - } - - while (!_http2RequestQueue.IsEmpty) - { - Debug.Assert((_availableHttp2Connections?.Count ?? 0) == 0, $"With {_availableHttp11Connections.Count} available HTTP2 connections, we shouldn't have a waiter."); - - if (!connection.TryReserveStream()) + TaskCompletionSourceWithCancellation? waiter = null; + bool added = false; + lock (SyncObj) { - usable = false; + Debug.Assert(_availableHttp2Connections is null || !_availableHttp2Connections.Contains(connection), $"HTTP2 connection already in available list"); + Debug.Assert(_associatedHttp2ConnectionCount > (_availableHttp2Connections?.Count ?? 0), + $"Expected _associatedHttp2ConnectionCount={_associatedHttp2ConnectionCount} > _availableHttp2Connections.Count={(_availableHttp2Connections?.Count ?? 0)}"); + if (isNewConnection) { - // 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. - // Fail the next request, if any. - HttpRequestException hre = new HttpRequestException(SR.net_http_http2_connection_not_established); - ExceptionDispatchInfo.SetCurrentStackTrace(hre); - _http2RequestQueue.TryFailNextRequest(hre); + Debug.Assert(_pendingHttp2Connection); + _pendingHttp2Connection = false; + isNewConnection = false; } - break; - } - isNewConnection = false; + if (_http2RequestQueue.TryDequeueWaiter(out waiter)) + { + Debug.Assert((_availableHttp2Connections?.Count ?? 0) == 0, $"With {(_availableHttp2Connections?.Count ?? 0)} available HTTP2 connections, we shouldn't have a waiter."); + } + else if (_disposed) + { + // The pool has been disposed. We will dispose this connection below outside the lock. + // We do this check after processing the request queue so that any queued requests will be handled by existing connections if possible. + _associatedHttp2ConnectionCount--; + } + else + { + // Add connection to the pool. + added = true; + _availableHttp2Connections ??= new List(); + _availableHttp2Connections.Add(connection); + } + } - if (!_http2RequestQueue.TryDequeueNextRequest(connection)) + if (waiter is not null) { - connection.ReleaseStream(); - break; + Debug.Assert(!added); + if (waiter.TrySetResult(connection)) + { + if (NetEventSource.Log.IsEnabled()) connection.Trace("Dequeued waiting HTTP2 request."); + break; + } + else + { + Debug.Assert(waiter.Task.IsCanceled); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Discarding canceled HTTP2 request from queue."); + // Loop and process the queue again + } } - - if (NetEventSource.Log.IsEnabled()) connection.Trace("Dequeued waiting HTTP/2 request."); - } - - // Since we only inject one connection at a time, we may want to inject another now. - CheckForHttp2ConnectionInjection(); - - if (_disposed) - { - // If the pool has been disposed of, we want to dispose the connection being returned, as the pool is being deactivated. - // We do this after the above in order to satisfy any requests that were queued before the pool was disposed of. - Debug.Assert(_associatedHttp2ConnectionCount > (_availableHttp2Connections?.Count ?? 0)); - _associatedHttp2ConnectionCount--; - poolDisposed = true; - } - else if (usable) - { - if (_availableHttp2Connections is null) + else { - _availableHttp2Connections = new List(); + connection.ReleaseStream(); + if (added) + { + if (NetEventSource.Log.IsEnabled()) connection.Trace("Put HTTP2 connection in pool."); + return; + } + else + { + Debug.Assert(_disposed); + if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing HTTP2 connection returned to pool. Pool was disposed."); + connection.Dispose(); + return; + } } - - // Add connection to the pool. - _availableHttp2Connections.Add(connection); - if (NetEventSource.Log.IsEnabled()) connection.Trace("Put HTTP/2 connection in pool."); - return; } } - if (poolDisposed) + if (isNewConnection) { - if (NetEventSource.Log.IsEnabled()) connection.Trace("Disposing HTTP/2 connection returned to pool. Pool was disposed."); + Debug.Assert(request is not null, "Expect request for a new connection"); + + // 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. + // So, treat this as a connection failure. + + if (NetEventSource.Log.IsEnabled()) connection.Trace("New HTTP2 connection is unusable due to no available streams."); connection.Dispose(); - return; - } - Debug.Assert(!usable); + HttpRequestException hre = new HttpRequestException(SR.net_http_http2_connection_not_established); + ExceptionDispatchInfo.SetCurrentStackTrace(hre); + HandleHttp2ConnectionFailure(request, hre); + } + else + { + // Since we only inject one connection at a time, we may want to inject another now. + lock (SyncObj) + { + CheckForHttp2ConnectionInjection(); + } - // We need to wait until the connection is usable again. - DisableHttp2Connection(connection); + // We need to wait until the connection is usable again. + DisableHttp2Connection(connection); + } } /// @@ -1945,6 +2028,7 @@ public bool CleanCacheAndDisposeIfUnused() { TimeSpan pooledConnectionLifetime = _poolManager.Settings._pooledConnectionLifetime; TimeSpan pooledConnectionIdleTimeout = _poolManager.Settings._pooledConnectionIdleTimeout; + long nowTicks = Environment.TickCount64; List? toDispose = null; @@ -1965,8 +2049,6 @@ public bool CleanCacheAndDisposeIfUnused() // will be purged next time around. _usedSinceLastCleanup = false; - long nowTicks = Environment.TickCount64; - ScavengeConnectionList(_availableHttp11Connections, ref toDispose, nowTicks, pooledConnectionLifetime, pooledConnectionIdleTimeout); if (_availableHttp2Connections is not null) { @@ -2142,66 +2224,43 @@ public TaskCompletionSourceWithCancellation EnqueueRequest(HttpRequestMessage return waiter; } - public bool TryFailNextRequest(Exception e) + public bool TryDequeueWaiterForSpecificRequest(HttpRequestMessage request, [MaybeNullWhen(false)] out TaskCompletionSourceWithCancellation waiter) { - Debug.Assert(e is HttpRequestException or OperationCanceledException, "Unexpected exception type for connection failure"); - - if (_queue is not null) + if (_queue is not null && _queue.TryPeek(out QueueItem item) && item.Request == request) { - // Fail the next queued request (if any) with this error. - while (_queue.TryDequeue(out QueueItem item)) - { - // Try to complete the waiter task. If it's been cancelled already, this will fail. - if (item.Waiter.TrySetException(e)) - { - return true; - } - - // Couldn't transfer to that waiter because it was cancelled. Try again. - Debug.Assert(item.Waiter.Task.IsCanceled); - } + _queue.Dequeue(); + waiter = item.Waiter; + return true; } + waiter = null; return false; } - public bool TryDequeueNextRequest(T connection) + public bool TryDequeueWaiter([MaybeNullWhen(false)] out TaskCompletionSourceWithCancellation waiter) { - if (_queue is not null) + if (_queue is not null && _queue.TryDequeue(out QueueItem item)) { - while (_queue.TryDequeue(out QueueItem item)) - { - // Try to complete the task. If it's been cancelled already, this will return false. - if (item.Waiter.TrySetResult(connection)) - { - return true; - } - - // Couldn't transfer to that waiter because it was cancelled. Try again. - Debug.Assert(item.Waiter.Task.IsCanceled); - } + waiter = item.Waiter; + return true; } + waiter = null; return false; } - public bool TryPeekNextRequest([NotNullWhen(true)] out HttpRequestMessage? request) + public bool TryPeekRequest([MaybeNullWhen(false)] out HttpRequestMessage request) { - if (_queue is not null) + if (_queue is not null && _queue.TryPeek(out QueueItem item)) { - if (_queue.TryPeek(out QueueItem item)) - { - request = item.Request; - return true; - } + request = item.Request; + return true; } request = null; return false; } - public bool IsEmpty => Count == 0; - public int Count => (_queue?.Count ?? 0); } } diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.cs index e232ee94419897..0c62b3bc3c0c7b 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Cancellation.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; +using System.Net.Sockets; using System.Net.Test.Common; using System.Threading; using System.Threading.Tasks; @@ -104,6 +105,97 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => options: new GenericLoopbackOptions() { UseSsl = false }); } + [OuterLoop] + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConnectionFailure_AfterInitialRequestCancelled_SecondRequestSucceedsOnNewConnection(bool useSsl) + { + if (UseVersion == HttpVersion.Version30) + { + // HTTP3 does not support ConnectCallback + return; + } + + if (!TestAsync) + { + // Test relies on ordering of async operations, so we can't test the sync case + return; + } + + await LoopbackServerFactory.CreateClientAndServerAsync(async uri => + { + int connectCount = 0; + + TaskCompletionSource tcsFirstConnectionInitiated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource tcsFirstRequestCanceled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + using (var handler = CreateHttpClientHandler()) + using (var client = CreateHttpClient(handler)) + { + handler.ServerCertificateCustomValidationCallback = TestHelper.AllowAllCertificates; + var socketsHandler = GetUnderlyingSocketsHttpHandler(handler); + socketsHandler.ConnectCallback = async (context, token) => + { + // Note we force serialization of connection creation by waiting on tcsFirstConnectionInitiated below, + // so we don't need to worry about concurrent access to connectCount. + bool isFirstConnection = connectCount == 0; + connectCount++; + + Assert.True(connectCount <= 2); + + if (isFirstConnection) + { + tcsFirstConnectionInitiated.SetResult(); + } + else + { + Assert.True(tcsFirstConnectionInitiated.Task.IsCompletedSuccessfully); + } + + // Wait until first request is cancelled and has completed + await tcsFirstRequestCanceled.Task; + + if (isFirstConnection) + { + // Fail the first connection attempt + throw new Exception("Failing first connection"); + } + else + { + // Succeed the second connection attempt + Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true }; + await socket.ConnectAsync(context.DnsEndPoint, token); + return new NetworkStream(socket, ownsSocket: true); + } + }; + + using CancellationTokenSource cts = new CancellationTokenSource(); + Task t1 = client.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri) { Version = UseVersion, VersionPolicy = HttpVersionPolicy.RequestVersionExact }, cts.Token); + + // Wait for the connection attempt to be initiated before we send the second request, to avoid races in connection creation + await tcsFirstConnectionInitiated.Task; + Task t2 = client.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri) { Version = UseVersion, VersionPolicy = HttpVersionPolicy.RequestVersionExact }, default); + + // Cancel the first message and wait for it to complete + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => t1); + + // Signal connections to proceed + tcsFirstRequestCanceled.SetResult(); + + // Second request should succeed, even though the first connection failed + HttpResponseMessage resp2 = await t2; + Assert.Equal(HttpStatusCode.OK, resp2.StatusCode); + Assert.Equal("Hello world", await resp2.Content.ReadAsStringAsync()); + } + }, async server => + { + await server.AcceptConnectionSendResponseAndCloseAsync(content: "Hello world"); + }, + options: new GenericLoopbackOptions() { UseSsl = useSsl }); + } + [OuterLoop("Incurs significant delay")] [Fact] public async Task Expect100Continue_WaitsExpectedPeriodOfTimeBeforeSendingContent()