diff --git a/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs b/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs index b31c4d217..b3175cad3 100644 --- a/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs +++ b/Dalamud/Game/Network/Internal/MarketBoardUploaders/Universalis/UniversalisMarketBoardUploader.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Dalamud.Game.Network.Internal.MarketBoardUploaders.Universalis.Types; using Dalamud.Game.Network.Structures; -using Dalamud.Utility; +using Dalamud.Networking.Http; using Newtonsoft.Json; using Serilog; @@ -22,6 +22,8 @@ internal class UniversalisMarketBoardUploader : IMarketBoardUploader private const string ApiKey = "GGD6RdSfGyRiHM5WDnAo0Nj9Nv7aC5NDhMj3BebT"; + private readonly HttpClient httpClient = Service.Get().SharedHttpClient; + /// /// Initializes a new instance of the class. /// @@ -97,7 +99,7 @@ internal class UniversalisMarketBoardUploader : IMarketBoardUploader var uploadPath = "/upload"; var uploadData = JsonConvert.SerializeObject(uploadObject); Log.Verbose("{ListingPath}: {ListingUpload}", uploadPath, uploadData); - await Util.HttpClient.PostAsync($"{ApiBase}{uploadPath}/{ApiKey}", new StringContent(uploadData, Encoding.UTF8, "application/json")); + await this.httpClient.PostAsync($"{ApiBase}{uploadPath}/{ApiKey}", new StringContent(uploadData, Encoding.UTF8, "application/json")); // ==================================================================================== @@ -133,7 +135,7 @@ internal class UniversalisMarketBoardUploader : IMarketBoardUploader var taxUpload = JsonConvert.SerializeObject(taxUploadObject); Log.Verbose("{TaxPath}: {TaxUpload}", taxPath, taxUpload); - await Util.HttpClient.PostAsync($"{ApiBase}{taxPath}/{ApiKey}", new StringContent(taxUpload, Encoding.UTF8, "application/json")); + await this.httpClient.PostAsync($"{ApiBase}{taxPath}/{ApiKey}", new StringContent(taxUpload, Encoding.UTF8, "application/json")); // ==================================================================================== @@ -175,7 +177,7 @@ internal class UniversalisMarketBoardUploader : IMarketBoardUploader message.Headers.Add("Authorization", ApiKey); message.Content = content; - await Util.HttpClient.SendAsync(message); + await this.httpClient.SendAsync(message); // ==================================================================================== diff --git a/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs b/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs index dd2d911ff..b599fb58f 100644 --- a/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs +++ b/Dalamud/Interface/Internal/Windows/BranchSwitcherWindow.cs @@ -3,13 +3,13 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; -using System.Net.Http; using System.Net.Http.Json; using System.Threading.Tasks; using Dalamud.Configuration.Internal; using Dalamud.Interface.Colors; using Dalamud.Interface.Windowing; +using Dalamud.Networking.Http; using ImGuiNET; using Newtonsoft.Json; @@ -40,7 +40,7 @@ public class BranchSwitcherWindow : Window { Task.Run(async () => { - using var client = new HttpClient(); + var client = Service.Get().SharedHttpClient; this.branches = await client.GetFromJsonAsync>(BranchInfoUrl); Debug.Assert(this.branches != null, "this.branches != null"); diff --git a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs index 4614fbad2..0aeb0722d 100644 --- a/Dalamud/Interface/Internal/Windows/PluginImageCache.cs +++ b/Dalamud/Interface/Internal/Windows/PluginImageCache.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Dalamud.Game; +using Dalamud.Networking.Http; using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; @@ -48,6 +49,9 @@ internal class PluginImageCache : IDisposable, IServiceType [ServiceManager.ServiceDependency] private readonly InterfaceManager.InterfaceManagerWithScene imWithScene = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly HappyHttpClient happyHttpClient = Service.Get(); + private readonly BlockingCollection>> downloadQueue = new(); private readonly BlockingCollection> loadQueue = new(); private readonly CancellationTokenSource cancelToken = new(); @@ -535,7 +539,7 @@ internal class PluginImageCache : IDisposable, IServiceType var bytes = await this.RunInDownloadQueue( async () => { - var data = await Util.HttpClient.GetAsync(url); + var data = await this.happyHttpClient.SharedHttpClient.GetAsync(url); if (data.StatusCode == HttpStatusCode.NotFound) return null; @@ -627,7 +631,9 @@ internal class PluginImageCache : IDisposable, IServiceType var bytes = await this.RunInDownloadQueue( async () => { - var data = await Util.HttpClient.GetAsync(url); + var httpClient = Service.Get().SharedHttpClient; + + var data = await httpClient.GetAsync(url); if (data.StatusCode == HttpStatusCode.NotFound) return null; diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs index 646285561..a3a965e80 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/DalamudChangelogManager.cs @@ -1,14 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; using System.Net.Http.Json; -using System.Threading; using System.Threading.Tasks; - +using Dalamud.Networking.Http; using Dalamud.Plugin.Internal; using Dalamud.Utility; -using Serilog; namespace Dalamud.Interface.Internal.Windows.PluginInstaller; @@ -42,7 +39,7 @@ internal class DalamudChangelogManager /// A representing the asynchronous operation. public async Task ReloadChangelogAsync() { - using var client = new HttpClient(); + var client = Service.Get().SharedHttpClient; this.Changelogs = null; var dalamudChangelogs = await client.GetFromJsonAsync>(DalamudChangelogUrl); diff --git a/Dalamud/Networking/Http/HappyEyeballsCallback.cs b/Dalamud/Networking/Http/HappyEyeballsCallback.cs new file mode 100644 index 000000000..222fef622 --- /dev/null +++ b/Dalamud/Networking/Http/HappyEyeballsCallback.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Dalamud.Utility; + +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. +/// +public class HappyEyeballsCallback : IDisposable +{ + private readonly ConcurrentDictionary addressFamilyCache = new(); + + private readonly AddressFamily? forcedAddressFamily; + private readonly int ipv6GracePeriod; + + /// + /// 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) + { + this.forcedAddressFamily = forcedAddressFamily; + this.ipv6GracePeriod = ipv6GracePeriod; + } + + /// + public void Dispose() + { + this.addressFamilyCache.Clear(); + + GC.SuppressFinalize(this); + } + + /// + /// The connection callback to provide to a . + /// + /// The context for an HTTP connection. + /// The cancellation token to abort this request. + /// Returns a Stream for consumption by HttpClient. + public async ValueTask ConnectCallback(SocketsHttpConnectionContext context, CancellationToken token) + { + var addressFamilyOverride = this.GetAddressFamilyOverride(context); + + if (addressFamilyOverride.HasValue) + { + return this.AttemptConnection(addressFamilyOverride.Value, context, token).GetAwaiter().GetResult(); + } + + using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(token); + + NetworkStream stream; + + // 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) + { + if (this.forcedAddressFamily.HasValue) + { + return this.forcedAddressFamily.Value; + } + + // 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) + { + NoDelay = true, + }; + + try + { + await socket.ConnectAsync(context.DnsEndPoint, token).ConfigureAwait(false); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } +} diff --git a/Dalamud/Networking/Http/HappyHttpClient.cs b/Dalamud/Networking/Http/HappyHttpClient.cs new file mode 100644 index 000000000..7183255c1 --- /dev/null +++ b/Dalamud/Networking/Http/HappyHttpClient.cs @@ -0,0 +1,56 @@ +using System; +using System.Net; +using System.Net.Http; + +namespace Dalamud.Networking.Http; + +/// +/// A service to help build and manage HttpClients with some semblance of Happy Eyeballs (RFC 8305 - IPv4 fallback) +/// awareness. +/// +[ServiceManager.BlockingEarlyLoadedService] +internal class HappyHttpClient : IDisposable, IServiceType +{ + /// + /// Initializes a new instance of the class. + /// + /// A service to talk to the Smileton Loporrits to build an HTTP Client aware of Happy Eyeballs. + /// + [ServiceManager.ServiceConstructor] + private HappyHttpClient() + { + this.SharedHappyEyeballsCallback = new HappyEyeballsCallback(); + + this.SharedHttpClient = new HttpClient(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = new HappyEyeballsCallback().ConnectCallback, + }); + } + + /// + /// Gets a meant to be shared across all (standard) requests made by the application, + /// where custom configurations are not required. + /// + /// May or may not have been properly tested by the Loporrits. + /// + public HttpClient SharedHttpClient { get; } + + /// + /// 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. + /// + public HappyEyeballsCallback SharedHappyEyeballsCallback { get; } + + /// + void IDisposable.Dispose() + { + this.SharedHttpClient.Dispose(); + this.SharedHappyEyeballsCallback.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/Dalamud/Plugin/Internal/PluginManager.cs b/Dalamud/Plugin/Internal/PluginManager.cs index 28d696a86..608c6a499 100644 --- a/Dalamud/Plugin/Internal/PluginManager.cs +++ b/Dalamud/Plugin/Internal/PluginManager.cs @@ -22,6 +22,7 @@ using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Internal; using Dalamud.IoC.Internal; using Dalamud.Logging.Internal; +using Dalamud.Networking.Http; using Dalamud.Plugin.Internal.Exceptions; using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; @@ -77,6 +78,9 @@ Thanks and have fun!"; [ServiceManager.ServiceDependency] private readonly DalamudStartInfo startInfo = Service.Get(); + [ServiceManager.ServiceDependency] + private readonly HappyHttpClient happyHttpClient = Service.Get(); + [ServiceManager.ServiceConstructor] private PluginManager() { @@ -732,7 +736,7 @@ Thanks and have fun!"; var downloadUrl = useTesting ? repoManifest.DownloadLinkTesting : repoManifest.DownloadLinkInstall; var version = useTesting ? repoManifest.TestingAssemblyVersion : repoManifest.AssemblyVersion; - var response = await Util.HttpClient.GetAsync(downloadUrl); + var response = await this.happyHttpClient.SharedHttpClient.GetAsync(downloadUrl); response.EnsureSuccessStatusCode(); var outputDir = new DirectoryInfo(Path.Combine(this.pluginDirectory.FullName, repoManifest.InternalName, version?.ToString() ?? string.Empty)); diff --git a/Dalamud/Plugin/Internal/Types/PluginRepository.cs b/Dalamud/Plugin/Internal/Types/PluginRepository.cs index e0373ff33..25189aadd 100644 --- a/Dalamud/Plugin/Internal/Types/PluginRepository.cs +++ b/Dalamud/Plugin/Internal/Types/PluginRepository.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; using Dalamud.Logging.Internal; +using Dalamud.Networking.Http; using Newtonsoft.Json; namespace Dalamud.Plugin.Internal.Types; @@ -24,7 +26,11 @@ internal class PluginRepository private static readonly ModuleLog Log = new("PLUGINR"); - private static readonly HttpClient HttpClient = new() + private static readonly HttpClient HttpClient = new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + ConnectCallback = Service.Get().SharedHappyEyeballsCallback.ConnectCallback, + }) { Timeout = TimeSpan.FromSeconds(20), DefaultRequestHeaders = diff --git a/Dalamud/Support/BugBait.cs b/Dalamud/Support/BugBait.cs index 084c49d9d..ce74c03ec 100644 --- a/Dalamud/Support/BugBait.cs +++ b/Dalamud/Support/BugBait.cs @@ -1,7 +1,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; - +using Dalamud.Networking.Http; using Dalamud.Plugin.Internal.Types; using Dalamud.Utility; using Newtonsoft.Json; @@ -42,9 +42,11 @@ internal static class BugBait { model.Exception = Troubleshooting.LastException == null ? "Was included, but none happened" : Troubleshooting.LastException?.ToString(); } + + var httpClient = Service.Get().SharedHttpClient; var postContent = new StringContent(JsonConvert.SerializeObject(model), Encoding.UTF8, "application/json"); - var response = await Util.HttpClient.PostAsync(BugBaitUrl, postContent); + var response = await httpClient.PostAsync(BugBaitUrl, postContent); response.EnsureSuccessStatusCode(); } diff --git a/Dalamud/Utility/AsyncUtils.cs b/Dalamud/Utility/AsyncUtils.cs new file mode 100644 index 000000000..c0e0d1cf0 --- /dev/null +++ b/Dalamud/Utility/AsyncUtils.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Dalamud.Utility; + +/// +/// A set of utilities around and for better asynchronous behavior. +/// +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. + /// + /// Derived from this StackOverflow post. + /// A list of tasks to race. + /// The return type of all raced tasks. + /// Thrown when all tasks given to this method fail. + /// Returns the first task that completes, according to . + public static Task FirstSuccessfulTask(ICollection> tasks) + { + var tcs = new TaskCompletionSource(); + var remainingTasks = tasks.Count; + + foreach (var task in tasks) + { + task.ContinueWith(t => + { + if (task.IsCompletedSuccessfully) + { + tcs.TrySetResult(t.Result); + } + else if (Interlocked.Decrement(ref remainingTasks) == 0) + { + tcs.SetException(new AggregateException(tasks.SelectMany(f => f.Exception?.InnerExceptions))); + } + }); + } + + return tcs.Task; + } +} diff --git a/Dalamud/Utility/Util.cs b/Dalamud/Utility/Util.cs index 7837e1bab..a6d2ad01c 100644 --- a/Dalamud/Utility/Util.cs +++ b/Dalamud/Utility/Util.cs @@ -16,6 +16,7 @@ using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Logging.Internal; +using Dalamud.Networking.Http; using ImGuiNET; using Microsoft.Win32; using Serilog; @@ -38,7 +39,8 @@ public static class Util /// Gets an httpclient for usage. /// Do NOT await this. /// - public static HttpClient HttpClient { get; } = new(); + [Obsolete($"Use Service<{nameof(HappyHttpClient)}> instead.")] + public static HttpClient HttpClient { get; } = Service.Get().SharedHttpClient; /// /// Gets the assembly version of Dalamud.