mirror of
https://github.com/goatcorp/Dalamud.git
synced 2026-02-13 03:17:43 +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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue