463 lines
17 KiB
C#
463 lines
17 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using FinalFactory.Rendezvous.Contracts;
|
|
using LiteNetLib;
|
|
|
|
namespace FinalFactory.Rendezvous.Client;
|
|
|
|
public sealed class RendezvousClientCoordinator : IDisposable
|
|
{
|
|
private readonly NetManager _manager;
|
|
private readonly RendezvousNetListener _networkEvents;
|
|
private readonly EventBasedNatPunchListener _punchEvents;
|
|
private readonly IPEndPoint _mediator;
|
|
private readonly CreateJoinAttemptResponse _attempt;
|
|
private readonly IRendezvousCoordinatorClock _clock;
|
|
private readonly RendezvousCoordinatorOptions _options;
|
|
private readonly RendezvousPunchRetrySchedule _retry;
|
|
private readonly object _completionGate = new();
|
|
private readonly TimeSpan _startedAt;
|
|
private readonly TimeSpan _attemptDeadline;
|
|
private readonly TimeSpan _punchDeadline;
|
|
private readonly NetworkEndpoint? _dedicatedFallback;
|
|
private NetPeer? _connectingPeer;
|
|
private IPEndPoint? _directEndpoint;
|
|
private TimeSpan? _directDeadline;
|
|
private RendezvousConnectionOutcome? _outcome;
|
|
private bool _cancelRequested;
|
|
private int _polling;
|
|
private bool _subscriptionsReleased;
|
|
private int _disposed;
|
|
|
|
public RendezvousClientCoordinator(
|
|
NetManager manager,
|
|
RendezvousNetListener networkEvents,
|
|
IPEndPoint mediator,
|
|
CreateJoinAttemptResponse attempt,
|
|
RendezvousCoordinatorOptions? options = null)
|
|
: this(
|
|
manager,
|
|
networkEvents,
|
|
mediator,
|
|
attempt,
|
|
options,
|
|
new SystemRendezvousCoordinatorClock())
|
|
{
|
|
}
|
|
|
|
internal RendezvousClientCoordinator(
|
|
NetManager manager,
|
|
RendezvousNetListener networkEvents,
|
|
IPEndPoint mediator,
|
|
CreateJoinAttemptResponse attempt,
|
|
RendezvousCoordinatorOptions? options,
|
|
IRendezvousCoordinatorClock clock)
|
|
{
|
|
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
|
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
|
_punchEvents = _networkEvents.PunchEvents;
|
|
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
|
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
|
|
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
|
_options = (options ?? new RendezvousCoordinatorOptions())
|
|
.CopyAndValidate();
|
|
_retry = new(_options, _clock);
|
|
|
|
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
|
DateTimeOffset startedUtc = _clock.UtcNow;
|
|
if (_mediator.Port is < 1 or > 65_535
|
|
|| _attempt.AttemptId.Value == Guid.Empty
|
|
|| _attempt.MediationHandle.Value == Guid.Empty
|
|
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|
|
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|
|
|| _attempt.ExpiresAt <= startedUtc)
|
|
{
|
|
throw new ArgumentException("The client traversal inputs are invalid.");
|
|
}
|
|
|
|
_startedAt = _clock.Elapsed;
|
|
_attemptDeadline = _startedAt + (_attempt.ExpiresAt - startedUtc);
|
|
_punchDeadline = Min(_attemptDeadline, _startedAt + _options.PunchTimeout);
|
|
_dedicatedFallback = RendezvousEndpoint.Copy(
|
|
_options.DedicatedFallbackOverride ?? _attempt.DedicatedFallback);
|
|
|
|
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
|
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
|
_networkEvents.RendezvousNetworkError += OnNetworkError;
|
|
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
|
}
|
|
|
|
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
|
|
|
|
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
|
|
public NetPeer? ConnectedPeer { get; private set; }
|
|
public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
|
|
public bool IsCompleted => Outcome is not null;
|
|
|
|
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
|
|
|
|
public async Task<RendezvousClientResult<bool>> CancelAsync(
|
|
IRendezvousJoinClient joinClient,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (joinClient is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(joinClient));
|
|
}
|
|
|
|
ThrowIfDisposed();
|
|
Cancel();
|
|
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
|
|
IRendezvousJoinClient joinClient,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (joinClient is null)
|
|
{
|
|
throw new ArgumentNullException(nameof(joinClient));
|
|
}
|
|
ThrowIfDisposed();
|
|
if (Outcome is null)
|
|
{
|
|
throw new InvalidOperationException("The connection attempt has not completed.");
|
|
}
|
|
|
|
return joinClient.ReportOutcomeAsync(_attempt, Outcome, cancellationToken);
|
|
}
|
|
|
|
public void Poll()
|
|
{
|
|
ThrowIfDisposed();
|
|
if (IsCompleted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
|
{
|
|
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (Volatile.Read(ref _cancelRequested))
|
|
{
|
|
DisconnectPendingPeer();
|
|
Complete(
|
|
RendezvousConnectionState.Cancelled,
|
|
ConnectionOutcomeKind.Cancelled,
|
|
RendezvousConnectionOutcomeSource.Caller,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
CurrentPhase());
|
|
return;
|
|
}
|
|
|
|
if (!_manager.IsRunning)
|
|
{
|
|
CompleteManagerStopped();
|
|
return;
|
|
}
|
|
|
|
_manager.PollEvents();
|
|
_manager.NatPunchModule.PollEvents();
|
|
if (IsCompleted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DateTimeOffset now = _clock.UtcNow;
|
|
TimeSpan elapsed = _clock.Elapsed;
|
|
if (Volatile.Read(ref _cancelRequested))
|
|
{
|
|
DisconnectPendingPeer();
|
|
Complete(
|
|
RendezvousConnectionState.Cancelled,
|
|
ConnectionOutcomeKind.Cancelled,
|
|
RendezvousConnectionOutcomeSource.Caller,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
CurrentPhase());
|
|
}
|
|
else if (!_manager.IsRunning)
|
|
{
|
|
CompleteManagerStopped();
|
|
}
|
|
else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
|
|
{
|
|
DisconnectPendingPeer();
|
|
Complete(
|
|
RendezvousConnectionState.TimedOut,
|
|
ConnectionOutcomeKind.AttemptExpired,
|
|
RendezvousConnectionOutcomeSource.RendezvousService,
|
|
RendezvousConnectionFailureCategory.Authorization,
|
|
RendezvousConnectionPhase.Authorization);
|
|
}
|
|
else if (State == RendezvousConnectionState.Punching)
|
|
{
|
|
if (elapsed >= _punchDeadline
|
|
|| _retry.IsExhausted && _retry.IsDue(elapsed))
|
|
{
|
|
Complete(
|
|
RendezvousConnectionState.TimedOut,
|
|
ConnectionOutcomeKind.PunchTimedOut,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.NatTraversal,
|
|
RendezvousConnectionPhase.NatTraversal);
|
|
return;
|
|
}
|
|
|
|
if (_retry.IsDue(elapsed))
|
|
{
|
|
_manager.NatPunchModule.SendNatIntroduceRequest(
|
|
_mediator,
|
|
NatPunchRequestTokenCodec.Encode(
|
|
NatPunchPeerRole.Client,
|
|
_attempt.MediationHandle,
|
|
_attempt.ClientPunchCapability));
|
|
_retry.RecordRequest();
|
|
}
|
|
}
|
|
else if (State == RendezvousConnectionState.Connecting
|
|
&& _directDeadline is TimeSpan directDeadline
|
|
&& directDeadline <= elapsed)
|
|
{
|
|
DisconnectPendingPeer();
|
|
Complete(
|
|
RendezvousConnectionState.TimedOut,
|
|
ConnectionOutcomeKind.DirectConnectTimedOut,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.DirectConnection,
|
|
RendezvousConnectionPhase.DirectConnection);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
Volatile.Write(ref _polling, 0);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!IsCompleted)
|
|
{
|
|
Complete(
|
|
RendezvousConnectionState.Disposed,
|
|
ConnectionOutcomeKind.Disposed,
|
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
CurrentPhase());
|
|
}
|
|
|
|
ReleaseSubscriptions();
|
|
}
|
|
|
|
public override string ToString() =>
|
|
$"[RendezvousClientCoordinator {_attempt.AttemptId}; credentials redacted]";
|
|
|
|
private void OnNatIntroductionSuccess(
|
|
IPEndPoint target,
|
|
NatAddressType addressType,
|
|
string encodedIntroduction)
|
|
{
|
|
_ = addressType;
|
|
if (State != RendezvousConnectionState.Punching
|
|
|| !NatIntroductionTokenCodec.TryDecode(
|
|
encodedIntroduction,
|
|
out NatIntroductionToken? introduction)
|
|
|| introduction is null
|
|
|| introduction.AttemptId != _attempt.AttemptId
|
|
|| !NatIntroductionTokenCodec.MatchesDigest(
|
|
introduction.ConnectionTicket,
|
|
_attempt.ConnectionTicketDigest))
|
|
{
|
|
return;
|
|
}
|
|
|
|
byte[] connectionData = DirectConnectionRequestCodec.Encode(
|
|
introduction.AttemptId,
|
|
introduction.ConnectionTicket);
|
|
_directEndpoint = target;
|
|
_connectingPeer = _manager.Connect(target, connectionData);
|
|
if (_connectingPeer is null
|
|
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
|
|
{
|
|
_connectingPeer = null;
|
|
Complete(
|
|
RendezvousConnectionState.Rejected,
|
|
ConnectionOutcomeKind.TransportError,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.DirectConnection,
|
|
RendezvousConnectionPhase.DirectConnection);
|
|
return;
|
|
}
|
|
|
|
State = RendezvousConnectionState.Connecting;
|
|
_directDeadline = Min(
|
|
_attemptDeadline,
|
|
_clock.Elapsed + _options.DirectConnectTimeout);
|
|
}
|
|
|
|
private void OnPeerConnected(NetPeer peer)
|
|
{
|
|
if (State != RendezvousConnectionState.Connecting
|
|
|| !ReferenceEquals(peer, _connectingPeer))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Complete(
|
|
RendezvousConnectionState.Connected,
|
|
ConnectionOutcomeKind.Connected,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.None,
|
|
RendezvousConnectionPhase.Complete,
|
|
peer);
|
|
}
|
|
|
|
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
|
{
|
|
if (State == RendezvousConnectionState.Connecting
|
|
&& ReferenceEquals(peer, _connectingPeer))
|
|
{
|
|
ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
|
|
? ConnectionOutcomeKind.DirectConnectTimedOut
|
|
: disconnectInfo.Reason == DisconnectReason.ConnectionFailed
|
|
? ConnectionOutcomeKind.TransportError
|
|
: ConnectionOutcomeKind.HostRejected;
|
|
Complete(
|
|
kind == ConnectionOutcomeKind.DirectConnectTimedOut
|
|
? RendezvousConnectionState.TimedOut
|
|
: RendezvousConnectionState.Rejected,
|
|
kind,
|
|
kind == ConnectionOutcomeKind.HostRejected
|
|
? RendezvousConnectionOutcomeSource.RemoteHost
|
|
: RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.DirectConnection,
|
|
RendezvousConnectionPhase.DirectConnection);
|
|
}
|
|
}
|
|
|
|
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
|
|
{
|
|
_ = socketError;
|
|
if (State == RendezvousConnectionState.Punching && endpoint.Equals(_mediator))
|
|
{
|
|
Complete(
|
|
RendezvousConnectionState.Rejected,
|
|
ConnectionOutcomeKind.MediatorUnavailable,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.Mediation,
|
|
RendezvousConnectionPhase.Mediation);
|
|
}
|
|
else if (State == RendezvousConnectionState.Connecting
|
|
&& endpoint.Equals(_directEndpoint))
|
|
{
|
|
DisconnectPendingPeer();
|
|
Complete(
|
|
RendezvousConnectionState.Rejected,
|
|
ConnectionOutcomeKind.TransportError,
|
|
RendezvousConnectionOutcomeSource.LocalTraversal,
|
|
RendezvousConnectionFailureCategory.DirectConnection,
|
|
RendezvousConnectionPhase.DirectConnection);
|
|
}
|
|
}
|
|
|
|
private void DisconnectPendingPeer()
|
|
{
|
|
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
|
|
{
|
|
_connectingPeer.Disconnect();
|
|
}
|
|
}
|
|
|
|
private void Complete(
|
|
RendezvousConnectionState terminalState,
|
|
ConnectionOutcomeKind kind,
|
|
RendezvousConnectionOutcomeSource source,
|
|
RendezvousConnectionFailureCategory category,
|
|
RendezvousConnectionPhase phase,
|
|
NetPeer? peer = null)
|
|
{
|
|
RendezvousConnectionCompletedEventArgs completion;
|
|
lock (_completionGate)
|
|
{
|
|
if (_outcome is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
|
|
kind,
|
|
source,
|
|
category,
|
|
phase,
|
|
_clock.Elapsed - _startedAt,
|
|
ShouldOfferFallback(kind) ? _dedicatedFallback : null,
|
|
peer);
|
|
State = terminalState;
|
|
if (kind == ConnectionOutcomeKind.Connected)
|
|
{
|
|
ConnectedPeer = peer;
|
|
}
|
|
Volatile.Write(ref _outcome, outcome);
|
|
ReleaseSubscriptions();
|
|
completion = new(terminalState, outcome);
|
|
}
|
|
|
|
Completed?.Invoke(this, completion);
|
|
}
|
|
|
|
private void ReleaseSubscriptions()
|
|
{
|
|
lock (_completionGate)
|
|
{
|
|
if (_subscriptionsReleased)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
|
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
|
_networkEvents.RendezvousNetworkError -= OnNetworkError;
|
|
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
|
_subscriptionsReleased = true;
|
|
}
|
|
}
|
|
|
|
private void CompleteManagerStopped() => Complete(
|
|
RendezvousConnectionState.ManagerStopped,
|
|
ConnectionOutcomeKind.ManagerStopped,
|
|
RendezvousConnectionOutcomeSource.Lifecycle,
|
|
RendezvousConnectionFailureCategory.Lifecycle,
|
|
CurrentPhase());
|
|
|
|
private RendezvousConnectionPhase CurrentPhase() => State switch
|
|
{
|
|
RendezvousConnectionState.Punching => RendezvousConnectionPhase.NatTraversal,
|
|
RendezvousConnectionState.Connecting => RendezvousConnectionPhase.DirectConnection,
|
|
_ => RendezvousConnectionPhase.Complete,
|
|
};
|
|
|
|
private static bool ShouldOfferFallback(ConnectionOutcomeKind kind) => kind is not (
|
|
ConnectionOutcomeKind.Connected
|
|
or ConnectionOutcomeKind.Cancelled
|
|
or ConnectionOutcomeKind.Disposed);
|
|
|
|
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
|
|
left <= right ? left : right;
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (Volatile.Read(ref _disposed) != 0)
|
|
{
|
|
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
|
|
}
|
|
}
|
|
}
|