Add Happy Eyeballs Support (#1187)

This commit is contained in:
KazWolfe 2023-04-23 02:09:55 -07:00 committed by GitHub
parent 2e2ce241e2
commit 6a0b4e5ad7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 271 additions and 18 deletions

View file

@ -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);
// ====================================================================================

View file

@ -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");

View file

@ -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;

View file

@ -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);

View 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;
}
}
}

View 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);
}
}

View file

@ -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));

View file

@ -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 =

View file

@ -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();
}

View 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;
}
}

View file

@ -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.