mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 18:27:23 +01:00
Add Happy Eyeballs Support (#1187)
This commit is contained in:
parent
2e2ce241e2
commit
6a0b4e5ad7
11 changed files with 271 additions and 18 deletions
|
|
@ -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<HappyHttpClient>.Get().SharedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UniversalisMarketBoardUploader"/> class.
|
||||
/// </summary>
|
||||
|
|
@ -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);
|
||||
|
||||
// ====================================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HappyHttpClient>.Get().SharedHttpClient;
|
||||
this.branches = await client.GetFromJsonAsync<Dictionary<string, VersionEntry>>(BranchInfoUrl);
|
||||
Debug.Assert(this.branches != null, "this.branches != null");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<InterfaceManager.InterfaceManagerWithScene>.Get();
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly HappyHttpClient happyHttpClient = Service<HappyHttpClient>.Get();
|
||||
|
||||
private readonly BlockingCollection<Tuple<ulong, Func<Task>>> downloadQueue = new();
|
||||
private readonly BlockingCollection<Func<Task>> loadQueue = new();
|
||||
private readonly CancellationTokenSource cancelToken = new();
|
||||
|
|
@ -535,7 +539,7 @@ internal class PluginImageCache : IDisposable, IServiceType
|
|||
var bytes = await this.RunInDownloadQueue<byte[]?>(
|
||||
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<byte[]?>(
|
||||
async () =>
|
||||
{
|
||||
var data = await Util.HttpClient.GetAsync(url);
|
||||
var httpClient = Service<HappyHttpClient>.Get().SharedHttpClient;
|
||||
|
||||
var data = await httpClient.GetAsync(url);
|
||||
if (data.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
public async Task ReloadChangelogAsync()
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var client = Service<HappyHttpClient>.Get().SharedHttpClient;
|
||||
this.Changelogs = null;
|
||||
|
||||
var dalamudChangelogs = await client.GetFromJsonAsync<List<DalamudChangelog>>(DalamudChangelogUrl);
|
||||
|
|
|
|||
133
Dalamud/Networking/Http/HappyEyeballsCallback.cs
Normal file
133
Dalamud/Networking/Http/HappyEyeballsCallback.cs
Normal file
|
|
@ -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
|
||||
|
||||
/// <summary>
|
||||
/// A class to provide a <see cref="SocketsHttpHandler.ConnectCallback"/> 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.
|
||||
/// </summary>
|
||||
public class HappyEyeballsCallback : IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<DnsEndPoint, AddressFamily> addressFamilyCache = new();
|
||||
|
||||
private readonly AddressFamily? forcedAddressFamily;
|
||||
private readonly int ipv6GracePeriod;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HappyEyeballsCallback"/> class.
|
||||
/// </summary>
|
||||
/// <param name="forcedAddressFamily">Optional override to force a specific AddressFamily.</param>
|
||||
/// <param name="ipv6GracePeriod">Grace period for IPv6 connectivity before starting IPv4 attempt.</param>
|
||||
public HappyEyeballsCallback(AddressFamily? forcedAddressFamily = null, int ipv6GracePeriod = 100)
|
||||
{
|
||||
this.forcedAddressFamily = forcedAddressFamily;
|
||||
this.ipv6GracePeriod = ipv6GracePeriod;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this.addressFamilyCache.Clear();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The connection callback to provide to a <see cref="SocketsHttpHandler"/>.
|
||||
/// </summary>
|
||||
/// <param name="context">The context for an HTTP connection.</param>
|
||||
/// <param name="token">The cancellation token to abort this request.</param>
|
||||
/// <returns>Returns a Stream for consumption by HttpClient.</returns>
|
||||
public async ValueTask<Stream> 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<Task<NetworkStream>>
|
||||
{
|
||||
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<NetworkStream> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Dalamud/Networking/Http/HappyHttpClient.cs
Normal file
56
Dalamud/Networking/Http/HappyHttpClient.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Dalamud.Networking.Http;
|
||||
|
||||
/// <summary>
|
||||
/// A service to help build and manage HttpClients with some semblance of Happy Eyeballs (RFC 8305 - IPv4 fallback)
|
||||
/// awareness.
|
||||
/// </summary>
|
||||
[ServiceManager.BlockingEarlyLoadedService]
|
||||
internal class HappyHttpClient : IDisposable, IServiceType
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HappyHttpClient"/> class.
|
||||
///
|
||||
/// A service to talk to the Smileton Loporrits to build an HTTP Client aware of Happy Eyeballs.
|
||||
/// </summary>
|
||||
[ServiceManager.ServiceConstructor]
|
||||
private HappyHttpClient()
|
||||
{
|
||||
this.SharedHappyEyeballsCallback = new HappyEyeballsCallback();
|
||||
|
||||
this.SharedHttpClient = new HttpClient(new SocketsHttpHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
ConnectCallback = new HappyEyeballsCallback().ConnectCallback,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a <see cref="HttpClient"/> 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.
|
||||
/// </summary>
|
||||
public HttpClient SharedHttpClient { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a <see cref="HappyEyeballsCallback"/> meant to be shared across any custom <see cref="HttpClient"/>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.
|
||||
/// </summary>
|
||||
public HappyEyeballsCallback SharedHappyEyeballsCallback { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
this.SharedHttpClient.Dispose();
|
||||
this.SharedHappyEyeballsCallback.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DalamudStartInfo>.Get();
|
||||
|
||||
[ServiceManager.ServiceDependency]
|
||||
private readonly HappyHttpClient happyHttpClient = Service<HappyHttpClient>.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));
|
||||
|
|
|
|||
|
|
@ -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<HappyHttpClient>.Get().SharedHappyEyeballsCallback.ConnectCallback,
|
||||
})
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20),
|
||||
DefaultRequestHeaders =
|
||||
|
|
|
|||
|
|
@ -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<HappyHttpClient>.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();
|
||||
}
|
||||
|
|
|
|||
45
Dalamud/Utility/AsyncUtils.cs
Normal file
45
Dalamud/Utility/AsyncUtils.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Dalamud.Utility;
|
||||
|
||||
/// <summary>
|
||||
/// A set of utilities around and for better asynchronous behavior.
|
||||
/// </summary>
|
||||
public static class AsyncUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>Derived from <a href="https://stackoverflow.com/a/37529395">this StackOverflow post</a>.</remarks>
|
||||
/// <param name="tasks">A list of tasks to race.</param>
|
||||
/// <typeparam name="T">The return type of all raced tasks.</typeparam>
|
||||
/// <exception cref="AggregateException">Thrown when all tasks given to this method fail.</exception>
|
||||
/// <returns>Returns the first task that completes, according to <see cref="Task{TResult}.IsCompletedSuccessfully"/>.</returns>
|
||||
public static Task<T> FirstSuccessfulTask<T>(ICollection<Task<T>> tasks)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<T>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
public static HttpClient HttpClient { get; } = new();
|
||||
[Obsolete($"Use Service<{nameof(HappyHttpClient)}> instead.")]
|
||||
public static HttpClient HttpClient { get; } = Service<HappyHttpClient>.Get().SharedHttpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assembly version of Dalamud.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue