Skip to content

Commit

Permalink
Add fallback URL system for CDNs.
Browse files Browse the repository at this point in the history
Russia is a shit and blocks a lot of western services. We want to be able to switch to an off-the-shelf CDN for engine downloads, but Russia gets in the way of that.

The fallback system allows 99% of users to use the main CDN, while the unfortunate ones that have Russia problems just go directly to our servers.
  • Loading branch information
PJB3005 committed May 17, 2024
1 parent a96e3dc commit 4aefa5e
Show file tree
Hide file tree
Showing 5 changed files with 178 additions and 85 deletions.
13 changes: 11 additions & 2 deletions SS14.Launcher/ConfigConstants.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using SS14.Launcher.Utility;

namespace SS14.Launcher;

Expand Down Expand Up @@ -35,8 +36,16 @@ public static class ConfigConstants
public const string WebsiteUrl = "https://spacestation14.com";
public const string DownloadUrl = "https://spacestation14.com/about/nightlies/";
public const string LauncherVersionUrl = "https://central.spacestation14.io/launcher_version.txt";
public const string RobustBuildsManifest = "https://central.spacestation14.io/builds/robust/manifest.json";
public const string RobustModulesManifest = "https://central.spacestation14.io/builds/robust/modules.json";

public static readonly UrlFallbackSet RobustBuildsManifest = new([
"https://robust-builds.cdn.spacestation14.com/manifest.json",
"https://robust-builds.fallback.cdn.spacestation14.com/manifest.json"
]);

public static readonly UrlFallbackSet RobustModulesManifest = new([
"https://robust-builds.cdn.spacestation14.com/modules.json",
"https://robust-builds.fallback.cdn.spacestation14.com/modules.json"
]);

// How long to keep cached copies of Robust manifests.
// TODO: Take this from Cache-Control header responses instead.
Expand Down
178 changes: 98 additions & 80 deletions SS14.Launcher/HappyEyeballsHttp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,88 +76,15 @@ private static async ValueTask<Stream> OnConnect(

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>>();
var (socket, index) = await ParallelTask(
ips.Length,
(i, cancel) => AttemptConnection(i, ips[i], endPoint.Port, cancel),
TimeSpan.FromMilliseconds(ConnectionAttemptDelay),
cancellationToken);

// 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.
Log.Verbose("Successfully connected {EndPoint} to address: {Address}", endPoint, ips[index]);

Task<Socket>? successTask = null;
while (successTask == null && (allTasks.Count < ips.Length || tasks.Count > 0))
{
if (allTasks.Count < ips.Length)
{
// 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);
}

var whenAnyDone = Task.WhenAny(tasks);
Task<Socket> completedTask;

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

completedTask = whenAnyDone.Result;
}
else
{
completedTask = await whenAnyDone;
}

if (completedTask.IsCompletedSuccessfully)
{
// We did it. We have success.
successTask = completedTask;
Log.Verbose("Successfully connected to endpoint: {Address}", completedTask.Result.RemoteEndPoint);
break;
}
else
{
// Faulted. Remove it.
tasks.Remove(completedTask);
}
}

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);
return new NetworkStream(socket, ownsSocket: true);
}

private static async Task<Socket> AttemptConnection(
Expand Down Expand Up @@ -227,4 +154,95 @@ private static IPAddress[] SortInterleaved(IPAddress[] addresses)

return result;
}

internal static async Task<(T, int)> ParallelTask<T>(
int candidateCount,
Func<int, CancellationToken, Task<T>> taskBuilder,
TimeSpan delay,
CancellationToken cancel) where T : IDisposable
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(candidateCount);

using var successCts = CancellationTokenSource.CreateLinkedTokenSource(cancel);

// All tasks we have ever tried.
var allTasks = new List<Task<T>>();
// Tasks we are still waiting on.
var tasks = new List<Task<T>>();

// 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 delay 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<T>? successTask = null;
while (successTask == null && (allTasks.Count < candidateCount || tasks.Count > 0))
{
if (allTasks.Count < candidateCount)
{
// We have to queue another task this iteration.
var newTask = taskBuilder(allTasks.Count, successCts.Token);
tasks.Add(newTask);
allTasks.Add(newTask);
}

var whenAnyDone = Task.WhenAny(tasks);
Task<T> completedTask;

if (allTasks.Count < candidateCount)
{
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(delay, successCts.Token);
var whenAnyOrTimeout = await Task.WhenAny(whenAnyDone, timeoutTask).ConfigureAwait(false);
if (whenAnyOrTimeout != whenAnyDone)
{
// Timeout finished. Go to next iteration so we queue another one.
continue;
}

completedTask = whenAnyDone.Result;
}
else
{
completedTask = await whenAnyDone.ConfigureAwait(false);
}

if (completedTask.IsCompletedSuccessfully)
{
// We did it. We have success.
successTask = completedTask;
break;
}
else
{
// Faulted. Remove it.
tasks.Remove(completedTask);
}
}

Debug.Assert(allTasks.Count > 0);

cancel.ThrowIfCancellationRequested();
await successCts.CancelAsync().ConfigureAwait(false);

if (successTask == null)
{
// We didn't get a single successful connection. Well heck.
throw new AggregateException(
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 (successTask.Result, allTasks.IndexOf(successTask));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ private async Task UpdateBuildManifest(CancellationToken cancel)

Log.Debug("Loading manifest from {manifestUrl}...", ConfigConstants.RobustBuildsManifest);
_cachedRobustVersionInfo =
await _http.GetFromJsonAsync<Dictionary<string, VersionInfo>>(
ConfigConstants.RobustBuildsManifest, cancellationToken: cancel);
await ConfigConstants.RobustBuildsManifest.GetFromJsonAsync<Dictionary<string, VersionInfo>>(
_http, cancel);

_robustCacheValidUntil = _manifestStopwatch.Elapsed + ConfigConstants.RobustManifestCacheTime;
}
Expand Down
2 changes: 1 addition & 1 deletion SS14.Launcher/Models/EngineManager/EngineManagerDynamic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ private static unsafe bool VerifyModuleSignature(FileStream stream, string signa

public async Task<EngineModuleManifest> GetEngineModuleManifest(CancellationToken cancel = default)
{
return await _http.GetFromJsonAsync<EngineModuleManifest>(ConfigConstants.RobustModulesManifest, cancel) ??
return await ConfigConstants.RobustModulesManifest.GetFromJsonAsync<EngineModuleManifest>(_http, cancel) ??
throw new InvalidDataException();
}

Expand Down
66 changes: 66 additions & 0 deletions SS14.Launcher/Utility/UrlFallbackSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using System.Collections.Immutable;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Serilog;

namespace SS14.Launcher.Utility;

public sealed class UrlFallbackSet(ImmutableArray<string> urls)
{
public static readonly TimeSpan AttemptDelay = TimeSpan.FromSeconds(3);

public readonly ImmutableArray<string> Urls = urls;

public async Task<T?> GetFromJsonAsync<T>(HttpClient client, CancellationToken cancel = default) where T : notnull
{
var msg = await GetAsync(client, cancel).ConfigureAwait(false);
msg.EnsureSuccessStatusCode();

return await msg.Content.ReadFromJsonAsync<T>(cancel).ConfigureAwait(false);
}

public async Task<HttpResponseMessage> GetAsync(HttpClient httpClient, CancellationToken cancel = default)
{
return await SendAsync(httpClient, url => new HttpRequestMessage(HttpMethod.Get, url), cancel)
.ConfigureAwait(false);
}

public async Task<HttpResponseMessage> SendAsync(
HttpClient httpClient,
Func<string, HttpRequestMessage> builder,
CancellationToken cancel = default)
{
var (response, index) = await HappyEyeballsHttp.ParallelTask(
Urls.Length,
(i, token) => AttemptConnection(httpClient, builder(Urls[i]), token),
AttemptDelay,
cancel).ConfigureAwait(false);

Log.Verbose("Successfully connected to {Url}", Urls[index]);

return response;
}

private static async Task<HttpResponseMessage> AttemptConnection(
HttpClient httpClient,
HttpRequestMessage message,
CancellationToken cancel)
{
if (new Random().Next(2) == 0)
{
Log.Error("Dropped the URL: {Message}", message);
throw new InvalidOperationException("OOPS");
}

var response = await httpClient.SendAsync(
message,
HttpCompletionOption.ResponseHeadersRead,
cancel
).ConfigureAwait(false);

return response;
}
}

0 comments on commit 4aefa5e

Please sign in to comment.