feat(client): standardize connection outcomes (#13)
quality-gate / quality (push) Successful in 59s

This commit is contained in:
KyuubiYoru
2026-07-16 10:18:41 +02:00
parent b4b6072fe1
commit 94aba8a3bb
47 changed files with 2727 additions and 178 deletions
@@ -1,4 +1,5 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
@@ -12,12 +13,21 @@ public sealed class RendezvousClientCoordinator : IDisposable
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 bool _disposed;
private int _disposed;
public RendezvousClientCoordinator(
NetManager manager,
@@ -49,23 +59,31 @@ public sealed class RendezvousClientCoordinator : IDisposable
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions())
_options = (options ?? new RendezvousCoordinatorOptions())
.CopyAndValidate();
_retry = new(validated, _clock);
_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 <= _clock.UtcNow)
|| _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;
}
@@ -73,7 +91,8 @@ public sealed class RendezvousClientCoordinator : IDisposable
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
public NetPeer? ConnectedPeer { get; private set; }
public bool IsCompleted => IsTerminal(State);
public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
public bool IsCompleted => Outcome is not null;
public void Cancel() => Volatile.Write(ref _cancelRequested, true);
@@ -91,6 +110,23 @@ public sealed class RendezvousClientCoordinator : IDisposable
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();
@@ -109,13 +145,18 @@ public sealed class RendezvousClientCoordinator : IDisposable
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
return;
}
if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
CompleteManagerStopped();
return;
}
@@ -127,35 +168,67 @@ public sealed class RendezvousClientCoordinator : IDisposable
}
DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (Volatile.Read(ref _cancelRequested))
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled);
Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
}
else if (!_manager.IsRunning)
{
Complete(RendezvousConnectionState.ManagerStopped);
CompleteManagerStopped();
}
else if (now >= _attempt.ExpiresAt)
else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
{
DisconnectPendingPeer();
Complete(RendezvousConnectionState.TimedOut);
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization);
}
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now))
else if (State == RendezvousConnectionState.Punching)
{
if (_retry.IsExhausted)
if (elapsed >= _punchDeadline
|| _retry.IsExhausted && _retry.IsDue(elapsed))
{
Complete(RendezvousConnectionState.TimedOut);
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
return;
}
_manager.NatPunchModule.SendNatIntroduceRequest(
_mediator,
NatPunchRequestTokenCodec.Encode(
NatPunchPeerRole.Client,
_attempt.MediationHandle,
_attempt.ClientPunchCapability));
_retry.RecordRequest();
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
@@ -166,18 +239,22 @@ public sealed class RendezvousClientCoordinator : IDisposable
public void Dispose()
{
if (_disposed)
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (!IsCompleted)
{
Complete(RendezvousConnectionState.Disposed);
Complete(
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
}
ReleaseSubscriptions();
_disposed = true;
}
public override string ToString() =>
@@ -205,16 +282,25 @@ public sealed class RendezvousClientCoordinator : IDisposable
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);
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)
@@ -225,17 +311,60 @@ public sealed class RendezvousClientCoordinator : IDisposable
return;
}
ConnectedPeer = peer;
Complete(RendezvousConnectionState.Connected, peer);
Complete(
RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
}
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{
_ = disconnectInfo;
if (State == RendezvousConnectionState.Connecting
&& ReferenceEquals(peer, _connectingPeer))
{
Complete(RendezvousConnectionState.Rejected);
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);
}
}
@@ -247,42 +376,85 @@ public sealed class RendezvousClientCoordinator : IDisposable
}
}
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null)
private void Complete(
RendezvousConnectionState terminalState,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null)
{
if (IsCompleted)
RendezvousConnectionCompletedEventArgs completion;
lock (_completionGate)
{
return;
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);
}
State = terminalState;
ReleaseSubscriptions();
Completed?.Invoke(this, new(terminalState, peer));
Completed?.Invoke(this, completion);
}
private void ReleaseSubscriptions()
{
if (_subscriptionsReleased)
lock (_completionGate)
{
return;
}
if (_subscriptionsReleased)
{
return;
}
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_networkEvents.RendezvousNetworkError -= OnNetworkError;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
}
private static bool IsTerminal(RendezvousConnectionState state) => state is
RendezvousConnectionState.Connected
or RendezvousConnectionState.Cancelled
or RendezvousConnectionState.TimedOut
or RendezvousConnectionState.Rejected
or RendezvousConnectionState.ManagerStopped
or RendezvousConnectionState.Disposed;
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 (_disposed)
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
}