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