Improve reliability of HttpRequestError.NameResolutionError (#89948)
authorAnton Firszov <antonfir@gmail.com>
Fri, 4 Aug 2023 22:03:01 +0000 (00:03 +0200)
committerGitHub <noreply@github.com>
Fri, 4 Aug 2023 22:03:01 +0000 (15:03 -0700)
* map TryAgain to NameResolutionError

* Quic: always resolve DNS in managed code

* oops

* ignore ScopId when comparing ipv6 endpoints in tests

* do not reuse QuicAddr

* Skip NameResolutionError() test on Windows7

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectHelper.cs
src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs
src/libraries/System.Net.Quic/src/System/Net/Quic/QuicConnection.cs
src/libraries/System.Net.Quic/tests/FunctionalTests/QuicConnectionTests.cs
src/libraries/System.Net.Quic/tests/FunctionalTests/QuicTestBase.cs

index 8bf0f39..d8d43a4 100644 (file)
@@ -143,13 +143,17 @@ namespace System.Net.Http
 
             static HttpRequestError DeduceError(Exception exception)
             {
-                // TODO: Deduce quic errors from QuicException.TransportErrorCode once https://github.com/dotnet/runtime/issues/87262 is implemented.
                 if (exception is AuthenticationException)
                 {
                     return HttpRequestError.SecureConnectionError;
                 }
 
-                if (exception is SocketException socketException && socketException.SocketErrorCode == SocketError.HostNotFound)
+                // Resolving a non-existent hostname often leads to EAI_AGAIN/TryAgain on Linux, indicating a non-authoritative failure, eg. timeout.
+                // Getting EAGAIN/TryAgain from a TCP connect() is not possible on Windows or Mac according to the docs and indicates lack of kernel resources on Linux,
+                // which should be a very rare error in practice. As a result, mapping SocketError.TryAgain to HttpRequestError.NameResolutionError
+                // leads to a more reliable distinction between NameResolutionError and ConnectionError.
+                if (exception is SocketException socketException &&
+                    socketException.SocketErrorCode is SocketError.HostNotFound or SocketError.TryAgain)
                 {
                     return HttpRequestError.NameResolutionError;
                 }
index e07a193..b7dfcdc 100644 (file)
@@ -4365,7 +4365,8 @@ namespace System.Net.Http.Functional.Tests
         {
         }
 
-        [Fact]
+        // On Windows7 DNS may return SocketError.NoData (WSANO_DATA), which we currently don't map to NameResolutionError.
+        [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotWindows7))]
         public async Task NameResolutionError()
         {
             using HttpClient client = CreateHttpClient();
@@ -4376,10 +4377,8 @@ namespace System.Net.Http.Functional.Tests
             };
 
             HttpRequestException ex = await Assert.ThrowsAsync<HttpRequestException>(() => client.SendAsync(message));
-
-            // TODO: Some platforms fail to detect NameResolutionError reliably, we should investigate this.
-            // Also, System.Net.Quic does not report DNS resolution errors yet.
-            Assert.True(ex.HttpRequestError is HttpRequestError.NameResolutionError or HttpRequestError.ConnectionError);
+            Assert.Equal(HttpRequestError.NameResolutionError, ex.HttpRequestError);
+            Assert.IsType<SocketException>(ex.InnerException);
         }
 
         [Fact]
index cccdda4..a25ce5b 100644 (file)
@@ -258,48 +258,31 @@ public sealed partial class QuicConnection : IAsyncDisposable
             {
                 throw new ArgumentException(SR.Format(SR.net_quic_unsupported_endpoint_type, options.RemoteEndPoint.GetType()), nameof(options));
             }
-            int addressFamily = QUIC_ADDRESS_FAMILY_UNSPEC;
 
-            // RemoteEndPoint is either IPEndPoint or DnsEndPoint containing IPAddress string.
-            // --> Set the IP directly, no name resolution needed.
-            if (address is not null)
+            if (address is null)
             {
-                QuicAddr quicAddress = new IPEndPoint(address, port).ToQuicAddr();
-                MsQuicHelpers.SetMsQuicParameter(_handle, QUIC_PARAM_CONN_REMOTE_ADDRESS, quicAddress);
-            }
-            // RemoteEndPoint is DnsEndPoint containing hostname that is different from requested SNI.
-            // --> Resolve the hostname and set the IP directly, use requested SNI in ConnectionStart.
-            else if (host is not null &&
-                    !host.Equals(options.ClientAuthenticationOptions.TargetHost, StringComparison.OrdinalIgnoreCase))
-            {
-                IPAddress[] addresses = await Dns.GetHostAddressesAsync(host!, cancellationToken).ConfigureAwait(false);
+                Debug.Assert(host is not null);
+
+                // Given just a ServerName to connect to, msquic would also use the first address after the resolution
+                // (https://github.com/microsoft/msquic/issues/1181) and it would not return a well-known error code
+                // for resolution failures we could rely on. By doing the resolution in managed code, we can guarantee
+                // that a SocketException will surface to the user if the name resolution fails.
+                IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
                 cancellationToken.ThrowIfCancellationRequested();
                 if (addresses.Length == 0)
                 {
                     throw new SocketException((int)SocketError.HostNotFound);
                 }
-
-                QuicAddr quicAddress = new IPEndPoint(addresses[0], port).ToQuicAddr();
-                MsQuicHelpers.SetMsQuicParameter(_handle, QUIC_PARAM_CONN_REMOTE_ADDRESS, quicAddress);
-            }
-            // RemoteEndPoint is DnsEndPoint containing hostname that is the same as the requested SNI.
-            // --> Let MsQuic resolve the hostname/SNI, give address family hint is specified in DnsEndPoint.
-            else
-            {
-                if (options.RemoteEndPoint.AddressFamily == AddressFamily.InterNetwork)
-                {
-                    addressFamily = QUIC_ADDRESS_FAMILY_INET;
-                }
-                if (options.RemoteEndPoint.AddressFamily == AddressFamily.InterNetworkV6)
-                {
-                    addressFamily = QUIC_ADDRESS_FAMILY_INET6;
-                }
+                address = addresses[0];
             }
 
+            QuicAddr remoteQuicAddress = new IPEndPoint(address, port).ToQuicAddr();
+            MsQuicHelpers.SetMsQuicParameter(_handle, QUIC_PARAM_CONN_REMOTE_ADDRESS, remoteQuicAddress);
+
             if (options.LocalEndPoint is not null)
             {
-                QuicAddr quicAddress = options.LocalEndPoint.ToQuicAddr();
-                MsQuicHelpers.SetMsQuicParameter(_handle, QUIC_PARAM_CONN_LOCAL_ADDRESS, quicAddress);
+                QuicAddr localQuicAddress = options.LocalEndPoint.ToQuicAddr();
+                MsQuicHelpers.SetMsQuicParameter(_handle, QUIC_PARAM_CONN_LOCAL_ADDRESS, localQuicAddress);
             }
 
             _sslConnectionOptions = new SslConnectionOptions(
@@ -324,7 +307,7 @@ public sealed partial class QuicConnection : IAsyncDisposable
                     ThrowHelper.ThrowIfMsQuicError(MsQuicApi.Api.ConnectionStart(
                         _handle,
                         _configuration,
-                        (ushort)addressFamily,
+                        (ushort)remoteQuicAddress.Family,
                         (sbyte*)targetHostPtr,
                         (ushort)port),
                         "ConnectionStart failed");
index e633ae4..6d04d3d 100644 (file)
@@ -1,6 +1,8 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using System.Net.Sockets;
 using System.Security.Cryptography.X509Certificates;
 using System.Threading;
@@ -18,10 +20,12 @@ namespace System.Net.Quic.Tests
 
         public QuicConnectionTests(ITestOutputHelper output) : base(output) { }
 
-        [Fact]
-        public async Task TestConnect()
+        [Theory]
+        [InlineData(false)]
+        [InlineData(true)]
+        public async Task TestConnect(bool ipv6)
         {
-            await using QuicListener listener = await CreateQuicListener();
+            await using QuicListener listener = await CreateQuicListener(ipv6 ? IPAddress.IPv6Loopback : IPAddress.Loopback);
 
             var options = CreateQuicClientOptions(listener.LocalEndPoint);
             ValueTask<QuicConnection> connectTask = CreateQuicConnection(options);
@@ -31,15 +35,27 @@ namespace System.Net.Quic.Tests
             await using QuicConnection serverConnection = acceptTask.Result;
             await using QuicConnection clientConnection = connectTask.Result;
 
-            Assert.Equal(listener.LocalEndPoint, serverConnection.LocalEndPoint);
-            Assert.Equal(listener.LocalEndPoint, clientConnection.RemoteEndPoint);
-            Assert.Equal(clientConnection.LocalEndPoint, serverConnection.RemoteEndPoint);
+            IgnoreScopeIdIPEndpointComparer endPointComparer = new();
+            Assert.Equal(listener.LocalEndPoint, serverConnection.LocalEndPoint, endPointComparer);
+            Assert.Equal(listener.LocalEndPoint, clientConnection.RemoteEndPoint, endPointComparer);
+            Assert.Equal(clientConnection.LocalEndPoint, serverConnection.RemoteEndPoint, endPointComparer);
             Assert.Equal(ApplicationProtocol.ToString(), clientConnection.NegotiatedApplicationProtocol.ToString());
             Assert.Equal(ApplicationProtocol.ToString(), serverConnection.NegotiatedApplicationProtocol.ToString());
             Assert.Equal(options.ClientAuthenticationOptions.TargetHost, clientConnection.TargetHostName);
             Assert.Equal(options.ClientAuthenticationOptions.TargetHost, serverConnection.TargetHostName);
         }
 
+        private class IgnoreScopeIdIPEndpointComparer : IEqualityComparer<IPEndPoint>
+        {
+            public bool Equals(IPEndPoint x, IPEndPoint y)
+            {
+                byte[] xBytes = x.Address.GetAddressBytes();
+                byte[] yBytes = y.Address.GetAddressBytes();
+                return xBytes.AsSpan().SequenceEqual(yBytes) && x.Port == y.Port;
+            }
+            public int GetHashCode([DisallowNull] IPEndPoint obj) => obj.Port;
+        }
+
         private static async Task<QuicStream> OpenAndUseStreamAsync(QuicConnection c)
         {
             QuicStream s = await c.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
@@ -369,9 +385,10 @@ namespace System.Net.Quic.Tests
                 }).WaitAsync(TimeSpan.FromSeconds(5)));
         }
 
-        [Fact]
-        [OuterLoop("Uses external servers")]
-        public async Task ConnectAsync_InvalidName_ThrowsSocketException()
+        [Theory]
+        [InlineData(false)]
+        [InlineData(true)]
+        public async Task ConnectAsync_InvalidName_ThrowsSocketException(bool sameTargetHost)
         {
             string name = $"{Guid.NewGuid().ToString("N")}.microsoft.com.";
             var options = new QuicClientConnectionOptions()
@@ -379,7 +396,7 @@ namespace System.Net.Quic.Tests
                 DefaultStreamErrorCode = DefaultStreamErrorCodeClient,
                 DefaultCloseErrorCode = DefaultCloseErrorCodeClient,
                 RemoteEndPoint = new DnsEndPoint(name, 10000),
-                ClientAuthenticationOptions = GetSslClientAuthenticationOptions()
+                ClientAuthenticationOptions = GetSslClientAuthenticationOptions(sameTargetHost ? name : "localhost")
             };
 
             SocketException ex = await Assert.ThrowsAsync<SocketException>(() => QuicConnection.ConnectAsync(options).AsTask());
index 149ac5b..7388fcb 100644 (file)
@@ -108,13 +108,13 @@ namespace System.Net.Quic.Tests
             };
         }
 
-        public SslClientAuthenticationOptions GetSslClientAuthenticationOptions()
+        public SslClientAuthenticationOptions GetSslClientAuthenticationOptions(string targetHost = "localhost")
         {
             return new SslClientAuthenticationOptions()
             {
                 ApplicationProtocols = new List<SslApplicationProtocol>() { ApplicationProtocol },
                 RemoteCertificateValidationCallback = RemoteCertificateValidationCallback,
-                TargetHost = "localhost"
+                TargetHost = targetHost
             };
         }
 
@@ -140,19 +140,20 @@ namespace System.Net.Quic.Tests
             return QuicConnection.ConnectAsync(clientOptions);
         }
 
-        internal QuicListenerOptions CreateQuicListenerOptions()
+        internal QuicListenerOptions CreateQuicListenerOptions(IPAddress address = null)
         {
+            address ??= IPAddress.Loopback;
             return new QuicListenerOptions()
             {
-                ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
+                ListenEndPoint = new IPEndPoint(address, 0),
                 ApplicationProtocols = new List<SslApplicationProtocol>() { ApplicationProtocol },
                 ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(CreateQuicServerOptions())
             };
         }
 
-        internal ValueTask<QuicListener> CreateQuicListener(int MaxInboundUnidirectionalStreams = 100, int MaxInboundBidirectionalStreams = 100)
+        internal ValueTask<QuicListener> CreateQuicListener(IPAddress address = null)
         {
-            var options = CreateQuicListenerOptions();
+            var options = CreateQuicListenerOptions(address);
             return CreateQuicListener(options);
         }