Fix SocketsHttpHandler connection pool accounting for dropped connections (dotnet...
authorStephen Toub <stoub@microsoft.com>
Tue, 13 Feb 2018 13:38:32 +0000 (08:38 -0500)
committerGitHub <noreply@github.com>
Tue, 13 Feb 2018 13:38:32 +0000 (08:38 -0500)
When MaxConnectionsPerServer is set to anything other than int.MaxValue, the SocketsHttpHandler pool keeps track of the number of connections handed out, and this count is updated when a connection is disposed.  But if a response stream isn't disposed of, resulting in the connection never being disposed of, the count may never be updated.  This fix adds an HttpConnection derived type that simply adds a finalizer, making it pay-for-play when MaxConnectionsPerServer is set to something other than the default.

Commit migrated from https://github.com/dotnet/corefx/commit/ffa5db357252913c52e0cdad63c1573020185f6e

14 files changed:
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ChunkedEncodingReadStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ChunkedEncodingWriteStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionCloseReadStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ContentLengthReadStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ContentLengthWriteStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/EmptyReadStream.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnection.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionContent.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionHandler.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPool.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPools.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpProxyConnectionHandler.cs
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/RawConnectionStream.cs
src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.MaxConnectionsPerServer.cs

index 0fb55a7..ab8865e 100644 (file)
@@ -10,7 +10,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection
+    internal partial class HttpConnection
     {
         private sealed class ChunkedEncodingReadStream : HttpContentReadStream
         {
index 16fad17..04bcdc1 100644 (file)
@@ -7,7 +7,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class ChunkedEncodingWriteStream : HttpContentWriteStream
         {
index f3f7a11..ab0a28b 100644 (file)
@@ -8,7 +8,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class ConnectionCloseReadStream : HttpContentReadStream
         {
index d7b9f73..65fd114 100644 (file)
@@ -9,7 +9,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class ContentLengthReadStream : HttpContentReadStream
         {
index de00dd7..0329f43 100644 (file)
@@ -7,7 +7,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class ContentLengthWriteStream : HttpContentWriteStream
         {
index 2de3143..bcc4e60 100644 (file)
@@ -7,7 +7,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class EmptyReadStream : HttpContentReadStream
         {
index 6bffb0a..dfbedcb 100644 (file)
@@ -10,14 +10,13 @@ using System.IO;
 using System.Net.Http.Headers;
 using System.Net.Security;
 using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         /// <summary>Default size of the read buffer used for the connection.</summary>
         private const int InitialReadBufferSize =
@@ -104,7 +103,9 @@ namespace System.Net.Http
             }
         }
 
-        public void Dispose()
+        public void Dispose() => Dispose(disposing: true);
+
+        protected void Dispose(bool disposing)
         {
             // Ensure we're only disposed once.  Dispose could be called concurrently, for example,
             // if the request and the response were running concurrently and both incurred an exception.
@@ -112,7 +113,11 @@ namespace System.Net.Http
             {
                 if (NetEventSource.IsEnabled) Trace("Connection closing.");
                 _pool.DecrementConnectionCount();
-                _stream.Dispose();
+                if (disposing)
+                {
+                    GC.SuppressFinalize(this);
+                    _stream.Dispose();
+                }
             }
         }
 
@@ -1220,7 +1225,7 @@ namespace System.Net.Http
             return true;
         }
 
-        public override string ToString() => $"{nameof(HttpConnection)}({_pool.Key})"; // Description for diagnostic purposes
+        public sealed override string ToString() => $"{nameof(HttpConnection)}({_pool.Key})"; // Description for diagnostic purposes
 
         private static void ThrowInvalidHttpResponse() => throw new HttpRequestException(SR.net_http_invalid_response);
 
@@ -1234,4 +1239,13 @@ namespace System.Net.Http
                 memberName,                   // method name
                 ToString() + ": " + message); // message
     }
+
+    internal sealed class HttpConnectionWithFinalizer : HttpConnection
+    {
+        public HttpConnectionWithFinalizer(HttpConnectionPool pool, Stream stream, TransportContext transportContext) : base(pool, stream, transportContext) { }
+
+        // This class is separated from HttpConnection so we only pay the price of having a finalizer
+        // when it's actually needed, e.g. when MaxConnectionsPerServer is enabled.
+        ~HttpConnectionWithFinalizer() => Dispose(disposing: false);
+    }
 }
index bf6bfb5..e77ff03 100644 (file)
@@ -9,7 +9,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class HttpConnectionContent : HttpContent
         {
index c30ce52..6d1155e 100644 (file)
@@ -13,7 +13,7 @@ namespace System.Net.Http
 
         public HttpConnectionHandler(HttpConnectionSettings settings)
         {
-            _connectionPools = new HttpConnectionPools(settings, settings._maxConnectionsPerServer, usingProxy: false);
+            _connectionPools = new HttpConnectionPools(settings, usingProxy: false);
         }
 
         protected internal override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
index 96567b0..9bc65a9 100644 (file)
@@ -209,7 +209,9 @@ namespace System.Net.Http
                 transportContext = sslStream.TransportContext;
             }
 
-            return new HttpConnection(this, stream, transportContext);
+            return _maxConnections == int.MaxValue ?
+                new HttpConnection(this, stream, transportContext) :
+                new HttpConnectionWithFinalizer(this, stream, transportContext); // finalizer needed to signal the pool when a connection is dropped
         }
 
         /// <summary>Enqueues a waiter to the waiters list.</summary>
index 9e48110..479f798 100644 (file)
@@ -43,11 +43,11 @@ namespace System.Net.Http
         // CONSIDER: We are passing HttpConnectionSettings here, but all we really need are the SSL settings.
         // When we refactor the SSL settings to use SslAuthenticationOptions, just pass that here.
 
-        public HttpConnectionPools(HttpConnectionSettings settings, int maxConnectionsPerServer, bool usingProxy)
+        public HttpConnectionPools(HttpConnectionSettings settings, bool usingProxy)
         {
             _settings = settings;
             _usingProxy = usingProxy;
-            _maxConnectionsPerServer = maxConnectionsPerServer;
+            _maxConnectionsPerServer = settings._maxConnectionsPerServer;
             _pools = new ConcurrentDictionary<HttpConnectionKey, HttpConnectionPool>();
             // Start out with the timer not running, since we have no pools.
 
index 725b6df..1894e8e 100644 (file)
@@ -26,7 +26,7 @@ namespace System.Net.Http
             _innerHandler = innerHandler;
             _proxy = settings._proxy ?? ConstructSystemProxy();
             _defaultCredentials = settings._defaultProxyCredentials;
-            _connectionPools = new HttpConnectionPools(settings, settings._maxConnectionsPerServer, usingProxy: true);
+            _connectionPools = new HttpConnectionPools(settings, usingProxy: true);
         }
 
         protected internal override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
index 375006b..7073afc 100644 (file)
@@ -8,7 +8,7 @@ using System.Threading.Tasks;
 
 namespace System.Net.Http
 {
-    internal sealed partial class HttpConnection : IDisposable
+    internal partial class HttpConnection : IDisposable
     {
         private sealed class RawConnectionStream : HttpContentDuplexStream
         {
index f53be76..18ed38f 100644 (file)
@@ -3,7 +3,9 @@
 // See the LICENSE file in the project root for more information.
 
 using System.Linq;
+using System.Net.Test.Common;
 using System.Runtime.InteropServices;
+using System.Threading;
 using System.Threading.Tasks;
 
 using Xunit;
@@ -76,5 +78,45 @@ namespace System.Net.Http.Functional.Tests
                     select client.GetAsync(secure ? Configuration.Http.RemoteEchoServer : Configuration.Http.SecureRemoteEchoServer));
             }
         }
+
+        [OuterLoop("Relies on kicking off GC and waiting for finalizers")]
+        [Fact]
+        public async Task GetAsync_DontDisposeResponse_EventuallyUnblocksWaiters()
+        {
+            if (!UseSocketsHttpHandler)
+            {
+                // Issue #27067. Hang.
+                return;
+            }
+
+            await LoopbackServer.CreateServerAsync(async (server, uri) =>
+            {
+                using (HttpClientHandler handler = CreateHttpClientHandler())
+                using (HttpClient client = new HttpClient(handler))
+                {
+                    handler.MaxConnectionsPerServer = 1;
+
+                    // Let server handle two requests.
+                    const string Response = "HTTP/1.1 200 OK\r\nContent-Length: 26\r\n\r\nabcdefghijklmnopqrstuvwxyz";
+                    Task serverTask1 = LoopbackServer.ReadRequestAndSendResponseAsync(server, Response);
+                    Task serverTask2 = LoopbackServer.ReadRequestAndSendResponseAsync(server, Response);
+
+                    // Make first request and drop the response, not explicitly disposing of it.
+                    void MakeAndDropRequest() => client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); // separated out to enable GC of response
+                    MakeAndDropRequest();
+
+                    // A second request should eventually succeed, once the first one is cleaned up.
+                    Task<HttpResponseMessage> secondResponse = client.GetAsync(uri);
+                    Assert.True(SpinWait.SpinUntil(() =>
+                    {
+                        GC.Collect();
+                        GC.WaitForPendingFinalizers();
+                        return secondResponse.IsCompleted;
+                    }, 30 * 1000), "Expected second response to have completed");
+
+                    await new[] { serverTask1, serverTask2, secondResponse }.WhenAllOrAnyFailed();
+                }
+            });
+        }
     }
 }