Skip to content

Commit

Permalink
Rewrite Happy Eyeballs so IPv6 actually works properly.
Browse files Browse the repository at this point in the history
The previous implementation was far too crude and instantly bailed on IPv6 if *any* error occured... including attempting to connect to an IPv4-only endpoint with an IPv6 socket. Yeah.

The new implementation actually properly staggers with interval and does stuff per request and all that stuff.
  • Loading branch information
PJB3005 committed May 14, 2024
1 parent 45a7382 commit 82fa2dd
Showing 1 changed file with 167 additions and 45 deletions.
212 changes: 167 additions & 45 deletions SS14.Launcher/HappyEyeballsHttp.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
Expand All @@ -11,98 +14,217 @@ namespace SS14.Launcher;

public static class HappyEyeballsHttp
{
private const int ConnectionAttemptDelay = 250;

#if DEBUG

private const int SlowIpv6 = 0;
private const bool BrokenIpv6 = false;

#endif

// .NET does not implement Happy Eyeballs at the time of writing.
// https://github.com/space-wizards/SS14.Launcher/issues/38
// This is the workaround.
//
// Implementation taken from https://github.com/ppy/osu-framework/pull/4191/files
// What's Happy Eyeballs? It makes the launcher try both IPv6 and IPv4,
// the former with priority, so that if IPv6 is broken your launcher still works.
//
// Implementation originally based on,
// rewritten as to be nigh-impossible to recognize https://github.com/ppy/osu-framework/pull/4191/files
//
// This is a simple implementation. It does not fully implement RFC 8305:
// * We do not separately handle parallel A and AAAA DNS requests as optimization.
// * We don't sort IPs as specified in RFC 6724. I can't tell if GetHostEntryAsync does.
// * Look I wanted to keep this simple OK?
// We don't do any fancy shit like statefulness or incremental sorting
// or incremental DNS updates who cares about that.
public static HttpClient CreateHttpClient(bool autoRedirect = true)
{
var handler = new SocketsHttpHandler
{
ConnectCallback = OnConnect,
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = autoRedirect
AllowAutoRedirect = autoRedirect,
// PooledConnectionLifetime = TimeSpan.FromSeconds(1)
};

return new HttpClient(handler);
}

/// <summary>
/// Whether IPv6 should be preferred. Value may change based on runtime failures.
/// </summary>
private static bool _useIPv6 = Socket.OSSupportsIPv6;

/// <summary>
/// Whether the initial IPv6 check has been performed (to determine whether v6 is available or not).
/// </summary>
private static bool _hasResolvedIPv6Availability;

private const int FirstTryTimeout = 2000;

private static async ValueTask<Stream> OnConnect(
SocketsHttpConnectionContext context,
CancellationToken cancellationToken)
{
if (_useIPv6)
// Get IPs via DNS.
// Note that we do not attempt to exclude IPv6 if the user doesn't have IPv6.
// According to the docs, GetHostEntryAsync will not return them if there's no address.
// BUT! I tested and that's a lie at least on Linux.
// Regardless, if you don't have IPv6,
// an attempt to connect to an IPv6 socket *should* immediately give a "network unreachable" socket error.
// This will cause the code to immediately try the next address,
// so IPv6 just gets "skipped over" if you don't have it.
// I could find no other robust way to check "is there a chance in hell IPv6 works" other than "try it",
// so... try it we will.
var endPoint = context.DnsEndPoint;
var hostEntry = await Dns.GetHostEntryAsync(endPoint.Host, cancellationToken).ConfigureAwait(false);
if (hostEntry.AddressList.Length == 0)
throw new Exception($"Host {context.DnsEndPoint.Host} resolved to no IPs!");

// Sort as specified in the RFC, interleaving.
var ips = SortInterleaved(hostEntry.AddressList);

Debug.Assert(ips.Length > 0);

using var successCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// All tasks we have ever tried.
var allTasks = new List<Task<Socket>>();
// Tasks we are still waiting on.
var tasks = new List<Task<Socket>>();

// The general loop here is as follows:
// 1. Add a new task for the next IP to try.
// 2. Wait until any task completes OR the ConnectionAttemptDelay happens.
// If an error occurs, we stop checking that task and continue checking the next.
// Every iteration we add another task, until we're full on them.
// We keep looping until we have SUCCESS, or we run out of attempt tasks entirely.

Task<Socket>? successTask = null;
while (successTask == null && (allTasks.Count < ips.Length || tasks.Count > 0))
{
try
if (allTasks.Count < ips.Length)
{
var localToken = cancellationToken;
// We have to queue another task this iteration.
var newTask = AttemptConnection(allTasks.Count, ips[allTasks.Count], context.DnsEndPoint.Port,
successCts.Token);
tasks.Add(newTask);
allTasks.Add(newTask);
}

if (!_hasResolvedIPv6Availability)
{
// to make things move fast, use a very low timeout for the initial ipv6 attempt.
var quickFailCts = new CancellationTokenSource(FirstTryTimeout);
var linkedTokenSource =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, quickFailCts.Token);
var whenAnyDone = Task.WhenAny(tasks);
Task<Socket> completedTask;

localToken = linkedTokenSource.Token;
if (allTasks.Count < ips.Length)
{
Log.Verbose("Waiting on ConnectionAttemptDelay");
// If we have another one to queue, wait for a timeout instead of *just* waiting for a connection task.
var timeoutTask = Task.Delay(ConnectionAttemptDelay, successCts.Token);
var whenAnyOrTimeout = await Task.WhenAny(whenAnyDone, timeoutTask);
if (whenAnyOrTimeout != whenAnyDone)
{
// Timeout finished. Go to next iteration so we queue another one.
continue;
}

return await AttemptConnection(AddressFamily.InterNetworkV6, context, localToken);
completedTask = whenAnyDone.Result;
}
else
{
completedTask = await whenAnyDone;
}
catch (Exception e)

if (completedTask.IsCompletedSuccessfully)
{
// very naively fallback to ipv4 permanently for this execution based on the response of the first connection attempt.
// note that this may cause users to eventually get switched to ipv4 (on a random failure when they are switching networks, for instance)
// but in the interest of keeping this implementation simple, this is acceptable.
Log.Error(e, "Error occured in HappyEyeballsHttp, disabling IPv6");
_useIPv6 = false;
// We did it. We have success.
successTask = completedTask;
Log.Verbose("Successfully connected to endpoint: {Address}", completedTask.Result.RemoteEndPoint);
break;
}
finally
else
{
_hasResolvedIPv6Availability = true;
// Faulted. Remove it.
tasks.Remove(completedTask);
}
}

// fallback to IPv4.
return await AttemptConnection(AddressFamily.InterNetwork, context, cancellationToken);
Debug.Assert(allTasks.Count > 0);

cancellationToken.ThrowIfCancellationRequested();
await successCts.CancelAsync();

if (successTask == null)
{
// We didn't get a single successful connection. Well heck.
throw new AggregateException($"Connecting to host {context.DnsEndPoint.Host} failed",
allTasks.Where(x => x.IsFaulted).SelectMany(x => x.Exception!.InnerExceptions));
}

// I don't know if this is possible but MAKE SURE that we don't get two sockets completing at once.
// Just a safety measure.
foreach (var task in allTasks)
{
if (task.IsCompletedSuccessfully && task != successTask)
task.Result.Dispose();
}

return new NetworkStream(successTask.Result, ownsSocket: true);
}

private static async ValueTask<Stream> AttemptConnection(
AddressFamily addressFamily,
SocketsHttpConnectionContext context,
CancellationToken cancellationToken)
private static async Task<Socket> AttemptConnection(
int index,
IPAddress address,
int port,
CancellationToken cancel)
{
Log.Verbose("Trying IP {Address} for happy eyeballs [{Index}]", address, index);

// The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
var socket = new Socket(addressFamily, SocketType.Stream, ProtocolType.Tcp)
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
// Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
NoDelay = true
};

try
{
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
// The stream should take the ownership of the underlying socket,
// closing it when it's disposed.
return new NetworkStream(socket, ownsSocket: true);
#if DEBUG
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
await Task.Delay(SlowIpv6, cancel).ConfigureAwait(false);

if (BrokenIpv6)
throw new Exception("Oh no I can't reach the network this is SO SAD.");
}
#endif

await socket.ConnectAsync(new IPEndPoint(address, port), cancel).ConfigureAwait(false);
return socket;
}
catch
catch (Exception e)
{
Log.Verbose(e, "Happy Eyeballs to {Address} [{Index}] failed", address, index);
socket.Dispose();
throw;
}
}

private static IPAddress[] SortInterleaved(IPAddress[] addresses)
{
// Interleave returned addresses so that they are IPv6 -> IPv4 -> IPv6 -> IPv4.
// Assuming we have multiple addresses of the same type that is.
// As described in the RFC.

var ipv6 = addresses.Where(x => x.AddressFamily == AddressFamily.InterNetworkV6).ToArray();
var ipv4 = addresses.Where(x => x.AddressFamily == AddressFamily.InterNetwork).ToArray();

var commonLength = Math.Min(ipv6.Length, ipv4.Length);

var result = new IPAddress[addresses.Length];
for (var i = 0; i < commonLength; i++)
{
result[i * 2] = ipv6[i];
result[1 + i * 2] = ipv4[i];
}

if (ipv4.Length > ipv6.Length)
{
ipv4.AsSpan(commonLength).CopyTo(result.AsSpan(commonLength * 2));
}
else if (ipv6.Length > ipv4.Length)
{
ipv4.AsSpan(commonLength).CopyTo(result.AsSpan(commonLength * 2));
}

return result;
}
}

0 comments on commit 82fa2dd

Please sign in to comment.