From c4d02c16660595fc4debd85a5f46c3ef3dfbb7d3 Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 14 Nov 2021 14:27:11 +0000 Subject: [PATCH] Fixed: Gracefully fall back to ipv4 if ipv6 is broken --- .../Http/Dispatchers/ManagedHttpDispatcher.cs | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 8b9770921..bcac5b42b 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -3,8 +3,10 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Sockets; using System.Text; using System.Threading; +using System.Threading.Tasks; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; @@ -16,6 +18,10 @@ public class ManagedHttpDispatcher : IHttpDispatcher { private const string NO_PROXY_KEY = "no-proxy"; + private const int connection_establish_timeout = 2000; + private static bool useIPv6 = Socket.OSSupportsIPv6; + private static bool hasResolvedIPv6Availability; + private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly ICreateManagedWebProxy _createManagedWebProxy; private readonly IUserAgentBuilder _userAgentBuilder; @@ -142,13 +148,14 @@ protected virtual System.Net.Http.HttpClient GetClient(HttpUri uri) protected virtual System.Net.Http.HttpClient CreateHttpClient(HttpProxySettings proxySettings) { - var handler = new HttpClientHandler() + var handler = new SocketsHttpHandler() { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Brotli, UseCookies = false, // sic - we don't want to use a shared cookie container AllowAutoRedirect = false, Credentials = GetCredentialCache(), - PreAuthenticate = true + PreAuthenticate = true, + ConnectCallback = onConnect, }; if (proxySettings != null) @@ -230,5 +237,67 @@ private CredentialCache GetCredentialCache() { return _credentialCache.Get("credentialCache", () => new CredentialCache()); } + + private static async ValueTask onConnect(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // Until .NET supports an implementation of Happy Eyeballs (https://tools.ietf.org/html/rfc8305#section-2), let's make IPv4 fallback work in a simple way. + // This issue is being tracked at https://github.com/dotnet/runtime/issues/26177 and expected to be fixed in .NET 6. + if (useIPv6) + { + try + { + var localToken = cancellationToken; + + if (!hasResolvedIPv6Availability) + { + // to make things move fast, use a very low timeout for the initial ipv6 attempt. + var quickFailCts = new CancellationTokenSource(connection_establish_timeout); + var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token); + + localToken = linkedTokenSource.Token; + } + + return await attemptConnection(AddressFamily.InterNetworkV6, context, localToken); + } + catch + { + // very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt. + // note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance) + // but in the interest of keeping this implementation simple, this is acceptable. + useIPv6 = false; + } + finally + { + hasResolvedIPv6Availability = true; + } + } + + // fallback to IPv4. + return await attemptConnection(AddressFamily.InterNetwork, context, cancellationToken); + } + + private static async ValueTask attemptConnection(AddressFamily addressFamily, SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + // The following socket constructor will create a dual-mode socket on systems where IPV6 is available. + var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp) + { + // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios. + NoDelay = true + }; + + try + { + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + + // The stream should take the ownership of the underlying socket, + // closing it when it's disposed. + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } } }