229 lines
8.9 KiB
C#
229 lines
8.9 KiB
C#
using System.Diagnostics;
|
|
using System.Security.Cryptography;
|
|
using FinalFactory.Rendezvous.Contracts;
|
|
using LiteNetLib;
|
|
|
|
namespace FinalFactory.Rendezvous.Client;
|
|
|
|
public enum RendezvousConnectionState
|
|
{
|
|
Punching = 1,
|
|
Connecting = 2,
|
|
Connected = 3,
|
|
Cancelled = 4,
|
|
TimedOut = 5,
|
|
Rejected = 6,
|
|
ManagerStopped = 7,
|
|
Disposed = 8,
|
|
}
|
|
|
|
public sealed class RendezvousConnectionCompletedEventArgs : EventArgs
|
|
{
|
|
[Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
|
|
public RendezvousConnectionCompletedEventArgs(
|
|
RendezvousConnectionState state,
|
|
NetPeer? peer)
|
|
: this(state, RendezvousCompletionInvariant.FromLegacy(state, peer))
|
|
{
|
|
}
|
|
|
|
internal RendezvousConnectionCompletedEventArgs(
|
|
RendezvousConnectionState state,
|
|
RendezvousConnectionOutcome outcome)
|
|
{
|
|
RendezvousCompletionInvariant.Validate(state, outcome);
|
|
State = state;
|
|
Outcome = outcome;
|
|
}
|
|
|
|
public RendezvousConnectionState State { get; }
|
|
public RendezvousConnectionOutcome Outcome { get; }
|
|
public NetPeer? Peer => Outcome.Peer;
|
|
}
|
|
|
|
internal static class RendezvousCompletionInvariant
|
|
{
|
|
internal static RendezvousConnectionOutcome FromLegacy(
|
|
RendezvousConnectionState state,
|
|
NetPeer? peer) => state switch
|
|
{
|
|
RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.Connected,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.None,
|
|
RendezvousConnectionPhase.Complete,
|
|
TimeSpan.Zero,
|
|
peer: peer),
|
|
RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.Cancelled,
|
|
RendezvousConnectionOutcomeSource.Caller,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
RendezvousConnectionPhase.Complete,
|
|
TimeSpan.Zero),
|
|
RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.DirectConnection,
|
|
RendezvousConnectionPhase.DirectConnection,
|
|
TimeSpan.Zero),
|
|
RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.HostRejected,
|
|
RendezvousConnectionOutcomeSource.RemoteHost,
|
|
RendezvousConnectionFailureCategory.Authorization,
|
|
RendezvousConnectionPhase.Authorization,
|
|
TimeSpan.Zero),
|
|
RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.ManagerStopped,
|
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
RendezvousConnectionPhase.Complete,
|
|
TimeSpan.Zero),
|
|
RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create(
|
|
ConnectionOutcomeKind.Disposed,
|
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
RendezvousConnectionPhase.Complete,
|
|
TimeSpan.Zero),
|
|
RendezvousConnectionState.Connected => throw new ArgumentNullException(
|
|
nameof(peer),
|
|
"A connected completion requires a peer."),
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(state),
|
|
state,
|
|
"A completion event requires a terminal connection state."),
|
|
};
|
|
|
|
internal static void Validate(
|
|
RendezvousConnectionState state,
|
|
RendezvousConnectionOutcome outcome)
|
|
{
|
|
if (outcome is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(outcome));
|
|
}
|
|
if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess)
|
|
{
|
|
throw new ArgumentException(
|
|
"The connection state and typed outcome contradict each other.",
|
|
nameof(outcome));
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class RendezvousCoordinatorOptions
|
|
{
|
|
public int MaximumPunchRequests { get; set; } = 5;
|
|
public int MaximumAttemptChecksPerPoll { get; set; } = 128;
|
|
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
|
|
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
|
|
public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
|
public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
|
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
|
|
public double JitterRatio { get; set; } = 0.2;
|
|
public NetworkEndpoint? DedicatedFallbackOverride { get; set; }
|
|
|
|
internal RendezvousCoordinatorOptions CopyAndValidate()
|
|
{
|
|
if (MaximumPunchRequests is < 1 or > 20
|
|
|| MaximumAttemptChecksPerPoll is < 1 or > 1_024
|
|
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|
|
|| MaximumPunchRetryDelay < InitialPunchRetryDelay
|
|
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|
|
|| PunchTimeout <= TimeSpan.Zero
|
|
|| PunchTimeout > TimeSpan.FromSeconds(30)
|
|
|| DirectConnectTimeout <= TimeSpan.Zero
|
|
|| DirectConnectTimeout > TimeSpan.FromSeconds(30)
|
|
|| ConnectionTicketLifetime <= TimeSpan.Zero
|
|
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|
|
|| JitterRatio is < 0 or > 1
|
|
|| DedicatedFallbackOverride is not null
|
|
&& !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride))
|
|
{
|
|
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
|
|
}
|
|
|
|
return new RendezvousCoordinatorOptions
|
|
{
|
|
MaximumPunchRequests = MaximumPunchRequests,
|
|
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
|
|
InitialPunchRetryDelay = InitialPunchRetryDelay,
|
|
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
|
|
PunchTimeout = PunchTimeout,
|
|
DirectConnectTimeout = DirectConnectTimeout,
|
|
ConnectionTicketLifetime = ConnectionTicketLifetime,
|
|
JitterRatio = JitterRatio,
|
|
DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride),
|
|
};
|
|
}
|
|
}
|
|
|
|
internal interface IRendezvousCoordinatorClock
|
|
{
|
|
DateTimeOffset UtcNow { get; }
|
|
TimeSpan Elapsed { get; }
|
|
}
|
|
|
|
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
|
|
{
|
|
private readonly long _origin = Stopwatch.GetTimestamp();
|
|
|
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
|
public TimeSpan Elapsed => TimeSpan.FromSeconds(
|
|
(Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency);
|
|
}
|
|
|
|
internal static class RendezvousManagerGuard
|
|
{
|
|
internal static void Validate(
|
|
NetManager manager,
|
|
RendezvousNetListener networkEvents)
|
|
{
|
|
networkEvents.ValidateManager(manager);
|
|
if (!manager.IsRunning)
|
|
{
|
|
throw new InvalidOperationException("The caller-owned LiteNetLib manager must be running.");
|
|
}
|
|
|
|
if (!manager.NatPunchEnabled
|
|
|| manager.UnsyncedEvents
|
|
|| manager.NatPunchModule.UnsyncedEvents)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The caller-owned manager must enable NAT punching and synchronized event dispatch.");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal sealed class RendezvousPunchRetrySchedule(
|
|
RendezvousCoordinatorOptions options,
|
|
IRendezvousCoordinatorClock clock)
|
|
{
|
|
public int RequestsSent { get; private set; }
|
|
public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero;
|
|
|
|
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
|
|
|
|
public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt;
|
|
|
|
public void RecordRequest()
|
|
{
|
|
int exponent = Math.Min(RequestsSent, 30);
|
|
RequestsSent++;
|
|
double milliseconds = Math.Min(
|
|
options.InitialPunchRetryDelay.TotalMilliseconds * Math.Pow(2, exponent),
|
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
|
if (options.JitterRatio > 0)
|
|
{
|
|
Span<byte> random = stackalloc byte[1];
|
|
RandomNumberGenerator.Fill(random);
|
|
double unit = random[0] / 255d;
|
|
double multiplier = 1 - options.JitterRatio + (2 * options.JitterRatio * unit);
|
|
milliseconds = Math.Min(
|
|
milliseconds * multiplier,
|
|
options.MaximumPunchRetryDelay.TotalMilliseconds);
|
|
}
|
|
|
|
NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds);
|
|
}
|
|
}
|