diff --git a/Dalamud/Networking/Http/HappyEyeballsCallback.cs b/Dalamud/Networking/Http/HappyEyeballsCallback.cs index 222fef622..af854fb2c 100644 --- a/Dalamud/Networking/Http/HappyEyeballsCallback.cs +++ b/Dalamud/Networking/Http/HappyEyeballsCallback.cs @@ -1,12 +1,14 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; + +using Dalamud.Logging.Internal; using Dalamud.Utility; namespace Dalamud.Networking.Http; @@ -14,34 +16,36 @@ namespace Dalamud.Networking.Http; // Inspired by and adapted from https://github.com/jellyfin/jellyfin/pull/8598 /// -/// A class to provide a method (and tracked state) to implement a -/// variant of the Happy Eyeballs algorithm for HTTP connections to dual-stack servers. -/// -/// Each instance of this class tracks its own state. +/// A class to provide a method to implement a variant of the Happy +/// Eyeballs algorithm for HTTP connections to dual-stack servers. /// public class HappyEyeballsCallback : IDisposable { - private readonly ConcurrentDictionary addressFamilyCache = new(); + private static readonly ModuleLog Log = new("HTTP"); - private readonly AddressFamily? forcedAddressFamily; - private readonly int ipv6GracePeriod; + /* + * ToDo: Eventually add in some kind of state management to cache DNS and IP Family. + * For now, this is ignored as the HTTPClient will keep connections alive, but there are benefits to sharing + * cached lookups between different clients. We just need to be able to easily expire those lookups first. + */ + + private readonly AddressFamily forcedAddressFamily; + private readonly int connectionBackoff; /// /// Initializes a new instance of the class. /// /// Optional override to force a specific AddressFamily. - /// Grace period for IPv6 connectivity before starting IPv4 attempt. - public HappyEyeballsCallback(AddressFamily? forcedAddressFamily = null, int ipv6GracePeriod = 100) + /// Backoff time between concurrent connection attempts. + public HappyEyeballsCallback(AddressFamily? forcedAddressFamily = null, int connectionBackoff = 75) { - this.forcedAddressFamily = forcedAddressFamily; - this.ipv6GracePeriod = ipv6GracePeriod; + this.forcedAddressFamily = forcedAddressFamily ?? AddressFamily.Unspecified; + this.connectionBackoff = connectionBackoff; } /// public void Dispose() { - this.addressFamilyCache.Clear(); - GC.SuppressFinalize(this); } @@ -53,75 +57,54 @@ public class HappyEyeballsCallback : IDisposable /// Returns a Stream for consumption by HttpClient. public async ValueTask ConnectCallback(SocketsHttpConnectionContext context, CancellationToken token) { - var addressFamilyOverride = this.GetAddressFamilyOverride(context); + var sortedRecords = await this.GetSortedAddresses(context.DnsEndPoint.Host, token); - if (addressFamilyOverride.HasValue) + var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(token); + var tasks = new List>(); + + var delayCts = CancellationTokenSource.CreateLinkedTokenSource(linkedToken.Token); + for (var i = 0; i < sortedRecords.Count; i++) { - return this.AttemptConnection(addressFamilyOverride.Value, context, token).GetAwaiter().GetResult(); + var record = sortedRecords[i]; + + delayCts.CancelAfter(this.connectionBackoff * i); + + var task = this.AttemptConnection(record, context.DnsEndPoint.Port, linkedToken.Token, delayCts.Token); + tasks.Add(task); + + var nextDelayCts = CancellationTokenSource.CreateLinkedTokenSource(linkedToken.Token); + _ = task.ContinueWith(_ => { nextDelayCts.Cancel(); }, TaskContinuationOptions.OnlyOnFaulted); + delayCts = nextDelayCts; } - using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(token); + var stream = await AsyncUtils.FirstSuccessfulTask(tasks).ConfigureAwait(false); + Log.Verbose($"Established connection to {context.DnsEndPoint.Host} at {stream.Socket.RemoteEndPoint}"); - NetworkStream stream; + // If we're here, it means we have a successful connection. A failure to connect would have caused the above + // line to explode, so we're safe to clean everything up. + linkedToken.Cancel(); + tasks.ForEach(task => { task.ContinueWith(this.CleanupConnectionTask); }); - // Give IPv6 a chance to connect first. - // However, only give it ipv4WaitMillis to connect before throwing IPv4 into the mix. - var tryConnectIPv6 = this.AttemptConnection(AddressFamily.InterNetworkV6, context, linkedToken.Token); - var timedV6Attempt = Task.WhenAny(tryConnectIPv6, Task.Delay(this.ipv6GracePeriod, linkedToken.Token)); - - if (await timedV6Attempt == tryConnectIPv6 && tryConnectIPv6.IsCompletedSuccessfully) - { - stream = tryConnectIPv6.GetAwaiter().GetResult(); - } - else - { - var race = AsyncUtils.FirstSuccessfulTask(new List> - { - tryConnectIPv6, - this.AttemptConnection(AddressFamily.InterNetwork, context, linkedToken.Token), - }); - - // If our connections all fail, this will explode with an exception. - stream = race.GetAwaiter().GetResult(); - } - - this.addressFamilyCache[context.DnsEndPoint] = stream.Socket.AddressFamily; return stream; } - private AddressFamily? GetAddressFamilyOverride(SocketsHttpConnectionContext context) + private async Task AttemptConnection(IPAddress address, int port, CancellationToken token, CancellationToken delayToken) { - if (this.forcedAddressFamily.HasValue) + await AsyncUtils.CancellableDelay(-1, delayToken).ConfigureAwait(false); + + if (token.IsCancellationRequested) { - return this.forcedAddressFamily.Value; + return Task.FromCanceled(token).Result; } - // Force IPv4 if IPv6 support isn't detected to avoid the resolution delay. - if (!Socket.OSSupportsIPv6) - { - return AddressFamily.InterNetwork; - } - - if (this.addressFamilyCache.TryGetValue(context.DnsEndPoint, out var cachedValue)) - { - // TODO: Find some way to delete this after a while. - return cachedValue; - } - - return null; - } - - private async Task AttemptConnection( - AddressFamily family, SocketsHttpConnectionContext context, CancellationToken token) - { - var socket = new Socket(family, SocketType.Stream, ProtocolType.Tcp) + var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true, }; try { - await socket.ConnectAsync(context.DnsEndPoint, token).ConfigureAwait(false); + await socket.ConnectAsync(address, port, token).ConfigureAwait(false); return new NetworkStream(socket, ownsSocket: true); } catch @@ -130,4 +113,31 @@ public class HappyEyeballsCallback : IDisposable throw; } } + + private async Task> GetSortedAddresses(string hostname, CancellationToken token) + { + // This method abuses DNS ordering and LINQ a bit. We can normally assume that "addresses" will be provided in + // the order the system wants to use. GroupBy will return its groups *in the order they're discovered*. Meaning, + // the first group created will always be the "preferred" group, and all other groups are in preference order. + // This means a straight zipper merge is nice and clean and gives us most -> least preferred, repeating. + var dnsRecords = await Dns.GetHostAddressesAsync(hostname, this.forcedAddressFamily, token); + + var groups = dnsRecords + .GroupBy(a => a.AddressFamily) + .Select(g => g.Select(v => v)).ToArray(); + + return Util.ZipperMerge(groups).ToList(); + } + + private void CleanupConnectionTask(Task task) + { + // marks the exception as handled as well, nifty! + // will also handle canceled cases, which aren't explicitly faulted. + var exception = task.Exception; + + if (task.IsFaulted) + { + Log.Verbose(exception!, "A HappyEyeballs connection task failed. Are there network issues?"); + } + } } diff --git a/Dalamud/Networking/Http/HappyHttpClient.cs b/Dalamud/Networking/Http/HappyHttpClient.cs index 7183255c1..8459f1453 100644 --- a/Dalamud/Networking/Http/HappyHttpClient.cs +++ b/Dalamud/Networking/Http/HappyHttpClient.cs @@ -24,7 +24,7 @@ internal class HappyHttpClient : IDisposable, IServiceType this.SharedHttpClient = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All, - ConnectCallback = new HappyEyeballsCallback().ConnectCallback, + ConnectCallback = this.SharedHappyEyeballsCallback.ConnectCallback, }); } @@ -40,8 +40,8 @@ internal class HappyHttpClient : IDisposable, IServiceType /// Gets a meant to be shared across any custom s that /// need to be made in other parts of the application. /// - /// This should be used when shared callback/IPv6 cache state is desired across multiple clients, as sharing the - /// SocketsHandler may lead to GC issues. + /// This should be used when shared callback state is desired across multiple clients, as sharing the SocketsHandler + /// may lead to GC issues. /// public HappyEyeballsCallback SharedHappyEyeballsCallback { get; } diff --git a/Dalamud/Utility/AsyncUtils.cs b/Dalamud/Utility/AsyncUtils.cs index c0e0d1cf0..d252bd5d5 100644 --- a/Dalamud/Utility/AsyncUtils.cs +++ b/Dalamud/Utility/AsyncUtils.cs @@ -13,7 +13,7 @@ public static class AsyncUtils { /// /// Race a set of tasks, returning either the first to succeed or an aggregate of all exceptions. This helper does - /// not perform any automatic cancellation of losing tasks. + /// not perform any automatic cancellation of losing tasks, nor does it handle exceptions of losing tasks. /// /// Derived from this StackOverflow post. /// A list of tasks to race. @@ -29,7 +29,7 @@ public static class AsyncUtils { task.ContinueWith(t => { - if (task.IsCompletedSuccessfully) + if (t.IsCompletedSuccessfully) { tcs.TrySetResult(t.Result); } @@ -42,4 +42,19 @@ public static class AsyncUtils return tcs.Task; } + + /// + /// Provide a that won't throw an exception when it's canceled. + /// + /// + public static async Task CancellableDelay(int millisecondsDelay, CancellationToken cancellationToken) + { + try + { + await Task.Delay(millisecondsDelay, cancellationToken); + } + catch (TaskCanceledException) + { + } + } } diff --git a/Dalamud/Utility/Util.cs b/Dalamud/Utility/Util.cs index a6d2ad01c..ecf672caf 100644 --- a/Dalamud/Utility/Util.cs +++ b/Dalamud/Utility/Util.cs @@ -556,6 +556,56 @@ public static class Util Process.Start(process); } + /// + /// Perform a "zipper merge" (A, 1, B, 2, C, 3) of multiple enumerables, allowing for lists to end early. + /// + /// A set of enumerable sources to combine. + /// The resulting type of the merged list to return. + /// A new enumerable, consisting of the final merge of all lists. + public static IEnumerable ZipperMerge(params IEnumerable[] sources) + { + // Borrowed from https://codereview.stackexchange.com/a/263451, thank you! + var enumerators = new IEnumerator[sources.Length]; + try + { + for (var i = 0; i < sources.Length; i++) + { + enumerators[i] = sources[i].GetEnumerator(); + } + + var hasNext = new bool[enumerators.Length]; + + bool MoveNext() + { + var anyHasNext = false; + for (var i = 0; i < enumerators.Length; i++) + { + anyHasNext |= hasNext[i] = enumerators[i].MoveNext(); + } + + return anyHasNext; + } + + while (MoveNext()) + { + for (var i = 0; i < enumerators.Length; i++) + { + if (hasNext[i]) + { + yield return enumerators[i].Current; + } + } + } + } + finally + { + foreach (var enumerator in enumerators) + { + enumerator?.Dispose(); + } + } + } + /// /// Dispose this object. ///