feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s
quality-gate / quality (push) Successful in 56s
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousJoinClient : IRendezvousJoinClient
|
||||
{
|
||||
private const string LeaseTokenHeader = "X-Rendezvous-Lease-Token";
|
||||
private const string ClientPunchCapabilityHeader = "X-Rendezvous-Client-Punch-Capability";
|
||||
|
||||
private readonly RendezvousHttpTransport _transport;
|
||||
|
||||
public RendezvousJoinClient(
|
||||
HttpClient httpClient,
|
||||
RendezvousClientOptions? options = null,
|
||||
IRendezvousDelay? delay = null)
|
||||
{
|
||||
_transport = new(httpClient, options, delay);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
CreateJoinAttemptRequest body = new()
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
IdempotencyKey = request.IdempotencyKey,
|
||||
GameId = request.GameId,
|
||||
EnvironmentId = request.EnvironmentId,
|
||||
ListingId = request.ListingId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
};
|
||||
return _transport.SendSafeAsync<CreateJoinAttemptResponse>(
|
||||
() => RendezvousHttpTransport.JsonRequest(HttpMethod.Post, "v1/join-attempts", body),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (attempt is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(attempt));
|
||||
}
|
||||
return _transport.SendSafeAsync<bool>(
|
||||
() => HeaderRequest(
|
||||
HttpMethod.Delete,
|
||||
$"v1/join-attempts/{attempt.AttemptId}",
|
||||
ClientPunchCapabilityHeader,
|
||||
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt))),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
if (pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
}
|
||||
|
||||
string query = $"v1/sessions/{session.ListingId}/join-attempts"
|
||||
+ $"?contractVersion={ContractLimits.ContractVersion}"
|
||||
+ $"&pageSize={pageSize}"
|
||||
+ (cursor is null ? string.Empty : $"&cursor={Uri.EscapeDataString(cursor)}");
|
||||
return _transport.SendSafeAsync<BrowseHostJoinAttemptsResponse>(
|
||||
() => HeaderRequest(
|
||||
HttpMethod.Get,
|
||||
query,
|
||||
LeaseTokenHeader,
|
||||
RequireHeaderValue(session.LeaseToken, nameof(session))),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
if (maximumPages is < 1 or > 1_000)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumPages));
|
||||
}
|
||||
|
||||
List<HostJoinAttempt> attempts = [];
|
||||
string? cursor = null;
|
||||
for (int page = 0; page < maximumPages; page++)
|
||||
{
|
||||
RendezvousClientResult<BrowseHostJoinAttemptsResponse> result =
|
||||
await BrowseForHostAsync(
|
||||
session,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
cursor,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
attempts.AddRange(result.Value.Items);
|
||||
cursor = result.Value.NextCursor;
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return RendezvousClientResult.Success<IReadOnlyList<HostJoinAttempt>>(
|
||||
attempts.AsReadOnly());
|
||||
}
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Failure<IReadOnlyList<HostJoinAttempt>>(
|
||||
RendezvousErrorCode.CapacityExceeded,
|
||||
$"Host invitation polling exceeded the configured {maximumPages}-page limit.");
|
||||
}
|
||||
|
||||
private static HttpRequestMessage HeaderRequest(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
string header,
|
||||
string value)
|
||||
{
|
||||
HttpRequestMessage request = new(method, uri);
|
||||
request.Headers.TryAddWithoutValidation(header, value);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static string RequireHeaderValue(string value, string parameterName) =>
|
||||
!string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: throw new ArgumentException("The required capability is missing.", parameterName);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# FinalFactory.Rendezvous.Client
|
||||
|
||||
Godot-independent .NET publisher and session-browser SDK for Rendezvous v1.
|
||||
Godot-independent .NET publisher, browser, join, and LiteNetLib traversal SDK for Rendezvous v1.
|
||||
The package targets `netstandard2.1` and uses a caller-owned `HttpClient`.
|
||||
|
||||
```csharp
|
||||
@@ -53,10 +53,75 @@ string presenceToken = NatPunchRequestTokenCodec.Encode(
|
||||
gameplayNetManager.NatPunchModule.SendNatIntroduceRequest(mediator, presenceToken);
|
||||
```
|
||||
|
||||
The same codec creates `Host` tokens for host-polled invitations and `Client`
|
||||
tokens for a created join attempt. Always send them from the same LiteNetLib
|
||||
socket that will carry the direct game connection; the mediator ignores any
|
||||
caller-supplied public endpoint.
|
||||
For direct connections, let the SDK drive those tokens from the same caller-owned
|
||||
LiteNetLib socket that carries gameplay. Ask the routing listener to create the
|
||||
bound manager, then configure and start that caller-owned manager yourself. The
|
||||
factory does not open a socket, and synchronized events must remain enabled:
|
||||
|
||||
```csharp
|
||||
RendezvousNetListener networkEvents = new();
|
||||
NetManager gameplayNetManager = networkEvents.CreateManager();
|
||||
if (!gameplayNetManager.Start(0))
|
||||
{
|
||||
throw new InvalidOperationException("The gameplay UDP socket could not start.");
|
||||
}
|
||||
```
|
||||
|
||||
The host polls join invitations asynchronously; that method only queues a
|
||||
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
|
||||
LiteNetLib and dispatches its synchronized callbacks. Call it once per game
|
||||
frame on the thread that owns the manager:
|
||||
|
||||
```csharp
|
||||
RendezvousJoinClient joins = new(http);
|
||||
using RendezvousHostCoordinator host = new(
|
||||
gameplayNetManager,
|
||||
networkEvents,
|
||||
mediatorEndPoint,
|
||||
session,
|
||||
joins);
|
||||
|
||||
// Run periodically from the game's normal async scheduling path.
|
||||
await host.RefreshJoinAttemptsAsync(cancellationToken);
|
||||
|
||||
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||
host.Poll();
|
||||
```
|
||||
|
||||
Do not also call `gameplayNetManager.PollEvents()` or
|
||||
`gameplayNetManager.NatPunchModule.PollEvents()` when a coordinator owns polling.
|
||||
The host coordinator refreshes host presence, punches for queued invitations,
|
||||
validates the introduction ticket, and accepts the direct request. Subscribe to
|
||||
`AttemptCompleted`; a `Connected` result is raised only after LiteNetLib reports
|
||||
the accepted peer as connected. Register ordinary gameplay callbacks on
|
||||
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
|
||||
requests for ticket validation and forwards every other callback normally.
|
||||
|
||||
The joining game first creates the HTTP attempt, then uses its own already-started
|
||||
gameplay manager in the same frame loop:
|
||||
|
||||
```csharp
|
||||
CreateJoinAttemptResponse attempt = (await joins.CreateAsync(
|
||||
createJoinRequest,
|
||||
cancellationToken)).Value
|
||||
?? throw new InvalidOperationException("Join issuance failed.");
|
||||
using RendezvousClientCoordinator client = new(
|
||||
gameplayNetManager,
|
||||
networkEvents,
|
||||
mediatorEndPoint,
|
||||
attempt);
|
||||
|
||||
// Godot _Process, Update, or the equivalent main-thread frame callback.
|
||||
client.Poll();
|
||||
```
|
||||
|
||||
NAT introduction changes the client state to `Connecting`; it is not success.
|
||||
Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for
|
||||
local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the
|
||||
service attempt. Terminal client paths release all event subscriptions. Disposing
|
||||
a coordinator never stops or disposes the caller-owned manager and does not touch
|
||||
an in-flight peer; call `Cancel()` followed by `Poll()` first when that peer must
|
||||
also be disconnected.
|
||||
|
||||
Lease renewal is explicit and caller-controlled:
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ public static class RendezvousClientResult
|
||||
|
||||
public sealed class PublishedSession
|
||||
{
|
||||
private readonly object _timingGate = new();
|
||||
private DateTimeOffset _expiresAt;
|
||||
private int _leaseRenewAfterSeconds;
|
||||
|
||||
internal PublishedSession(RegisterSessionResponse response)
|
||||
{
|
||||
ListingId = response.ListingId;
|
||||
@@ -49,8 +53,8 @@ public sealed class PublishedSession
|
||||
LeaseToken = response.LeaseToken;
|
||||
HostPresenceHandle = response.HostPresenceHandle;
|
||||
HostPresenceCapability = response.HostPresenceCapability;
|
||||
ExpiresAt = response.ExpiresAt;
|
||||
LeaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
_expiresAt = response.ExpiresAt;
|
||||
_leaseRenewAfterSeconds = response.LeaseRenewAfterSeconds;
|
||||
HostPresenceRefreshAfterSeconds = response.HostPresenceRefreshAfterSeconds;
|
||||
}
|
||||
|
||||
@@ -59,8 +63,41 @@ public sealed class PublishedSession
|
||||
public string LeaseToken { get; }
|
||||
public MediationHandle HostPresenceHandle { get; }
|
||||
public string HostPresenceCapability { get; }
|
||||
public DateTimeOffset ExpiresAt { get; internal set; }
|
||||
public int LeaseRenewAfterSeconds { get; internal set; }
|
||||
public DateTimeOffset ExpiresAt
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
return _expiresAt;
|
||||
}
|
||||
}
|
||||
internal set
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
_expiresAt = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int LeaseRenewAfterSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
return _leaseRenewAfterSeconds;
|
||||
}
|
||||
}
|
||||
internal set
|
||||
{
|
||||
lock (_timingGate)
|
||||
{
|
||||
_leaseRenewAfterSeconds = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
public int HostPresenceRefreshAfterSeconds { get; }
|
||||
|
||||
public override string ToString() => $"[PublishedSession {ListingId}; credentials redacted]";
|
||||
@@ -109,6 +146,28 @@ public interface IRendezvousSessionBrowserClient
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousJoinClient
|
||||
{
|
||||
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousDelay
|
||||
{
|
||||
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class DirectConnectionRequest
|
||||
{
|
||||
public JoinAttemptId AttemptId { get; set; }
|
||||
public string ConnectionTicket { get; set; } = string.Empty;
|
||||
|
||||
public override string ToString() =>
|
||||
$"[DirectConnectionRequest {AttemptId}; ticket redacted]";
|
||||
}
|
||||
|
||||
public static class DirectConnectionRequestCodec
|
||||
{
|
||||
public const int EncodedLength = 63;
|
||||
|
||||
private const int MagicLength = 4;
|
||||
private const int AttemptIdLength = 16;
|
||||
private const int TicketLength = ContractLimits.DerivedCredentialCharacters;
|
||||
private static readonly byte[] Magic = [(byte)'R', (byte)'V', (byte)'D', (byte)'1'];
|
||||
|
||||
public static bool IsRendezvousRequest(ReadOnlySpan<byte> encoded) =>
|
||||
encoded.Length >= MagicLength && encoded[..MagicLength].SequenceEqual(Magic);
|
||||
|
||||
public static byte[] Encode(JoinAttemptId attemptId, string connectionTicket)
|
||||
{
|
||||
if (attemptId.Value == Guid.Empty
|
||||
|| connectionTicket is null
|
||||
|| connectionTicket.Length != TicketLength
|
||||
|| !ContractValidation.IsConnectionTicketValid(connectionTicket))
|
||||
{
|
||||
throw new ArgumentException("The direct connection request fields are invalid.");
|
||||
}
|
||||
|
||||
byte[] encoded = new byte[EncodedLength];
|
||||
Magic.CopyTo(encoded, 0);
|
||||
if (!attemptId.Value.TryWriteBytes(encoded.AsSpan(MagicLength, AttemptIdLength)))
|
||||
{
|
||||
throw new InvalidOperationException("The join attempt identifier could not be encoded.");
|
||||
}
|
||||
|
||||
Encoding.ASCII.GetBytes(
|
||||
connectionTicket,
|
||||
0,
|
||||
connectionTicket.Length,
|
||||
encoded,
|
||||
MagicLength + AttemptIdLength);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> encoded,
|
||||
out DirectConnectionRequest? request)
|
||||
{
|
||||
request = null;
|
||||
if (encoded.Length != EncodedLength
|
||||
|| !encoded[..MagicLength].SequenceEqual(Magic))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Guid attemptId = new(encoded.Slice(MagicLength, AttemptIdLength));
|
||||
if (attemptId == Guid.Empty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string ticket = Encoding.ASCII.GetString(encoded[(MagicLength + AttemptIdLength)..]);
|
||||
if (!ContractValidation.IsConnectionTicketValid(ticket))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new DirectConnectionRequest
|
||||
{
|
||||
AttemptId = new JoinAttemptId(attemptId),
|
||||
ConnectionTicket = ticket,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
using System.Net;
|
||||
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 RendezvousPunchRetrySchedule _retry;
|
||||
private NetPeer? _connectingPeer;
|
||||
private bool _cancelRequested;
|
||||
private int _polling;
|
||||
private bool _subscriptionsReleased;
|
||||
private bool _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));
|
||||
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions())
|
||||
.CopyAndValidate();
|
||||
_retry = new(validated, _clock);
|
||||
|
||||
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||
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)
|
||||
{
|
||||
throw new ArgumentException("The client traversal inputs are invalid.");
|
||||
}
|
||||
|
||||
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||
}
|
||||
|
||||
public event EventHandler<RendezvousConnectionCompletedEventArgs>? Completed;
|
||||
|
||||
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
|
||||
public NetPeer? ConnectedPeer { get; private set; }
|
||||
public bool IsCompleted => IsTerminal(State);
|
||||
|
||||
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 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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_manager.IsRunning)
|
||||
{
|
||||
Complete(RendezvousConnectionState.ManagerStopped);
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.PollEvents();
|
||||
_manager.NatPunchModule.PollEvents();
|
||||
if (IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = _clock.UtcNow;
|
||||
if (Volatile.Read(ref _cancelRequested))
|
||||
{
|
||||
DisconnectPendingPeer();
|
||||
Complete(RendezvousConnectionState.Cancelled);
|
||||
}
|
||||
else if (!_manager.IsRunning)
|
||||
{
|
||||
Complete(RendezvousConnectionState.ManagerStopped);
|
||||
}
|
||||
else if (now >= _attempt.ExpiresAt)
|
||||
{
|
||||
DisconnectPendingPeer();
|
||||
Complete(RendezvousConnectionState.TimedOut);
|
||||
}
|
||||
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now))
|
||||
{
|
||||
if (_retry.IsExhausted)
|
||||
{
|
||||
Complete(RendezvousConnectionState.TimedOut);
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||
_mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Client,
|
||||
_attempt.MediationHandle,
|
||||
_attempt.ClientPunchCapability));
|
||||
_retry.RecordRequest();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _polling, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsCompleted)
|
||||
{
|
||||
Complete(RendezvousConnectionState.Disposed);
|
||||
}
|
||||
|
||||
ReleaseSubscriptions();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
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);
|
||||
_connectingPeer = _manager.Connect(target, connectionData);
|
||||
if (_connectingPeer is null
|
||||
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing)
|
||||
{
|
||||
_connectingPeer = null;
|
||||
Complete(RendezvousConnectionState.Rejected);
|
||||
return;
|
||||
}
|
||||
|
||||
State = RendezvousConnectionState.Connecting;
|
||||
}
|
||||
|
||||
private void OnPeerConnected(NetPeer peer)
|
||||
{
|
||||
if (State != RendezvousConnectionState.Connecting
|
||||
|| !ReferenceEquals(peer, _connectingPeer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectedPeer = peer;
|
||||
Complete(RendezvousConnectionState.Connected, peer);
|
||||
}
|
||||
|
||||
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||
{
|
||||
_ = disconnectInfo;
|
||||
if (State == RendezvousConnectionState.Connecting
|
||||
&& ReferenceEquals(peer, _connectingPeer))
|
||||
{
|
||||
Complete(RendezvousConnectionState.Rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void DisconnectPendingPeer()
|
||||
{
|
||||
if (_connectingPeer is not null && State == RendezvousConnectionState.Connecting)
|
||||
{
|
||||
_connectingPeer.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null)
|
||||
{
|
||||
if (IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State = terminalState;
|
||||
ReleaseSubscriptions();
|
||||
Completed?.Invoke(this, new(terminalState, peer));
|
||||
}
|
||||
|
||||
private void ReleaseSubscriptions()
|
||||
{
|
||||
if (_subscriptionsReleased)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||
_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 ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Cryptography;
|
||||
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(
|
||||
RendezvousConnectionState state,
|
||||
NetPeer? peer = null) : EventArgs
|
||||
{
|
||||
public RendezvousConnectionState State { get; } = state;
|
||||
public NetPeer? Peer { get; } = peer;
|
||||
}
|
||||
|
||||
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 ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
|
||||
public double JitterRatio { get; set; } = 0.2;
|
||||
|
||||
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)
|
||||
|| ConnectionTicketLifetime <= TimeSpan.Zero
|
||||
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|
||||
|| JitterRatio is < 0 or > 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
|
||||
}
|
||||
|
||||
return new RendezvousCoordinatorOptions
|
||||
{
|
||||
MaximumPunchRequests = MaximumPunchRequests,
|
||||
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
|
||||
InitialPunchRetryDelay = InitialPunchRetryDelay,
|
||||
MaximumPunchRetryDelay = MaximumPunchRetryDelay,
|
||||
ConnectionTicketLifetime = ConnectionTicketLifetime,
|
||||
JitterRatio = JitterRatio,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IRendezvousCoordinatorClock
|
||||
{
|
||||
DateTimeOffset UtcNow { get; }
|
||||
}
|
||||
|
||||
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
|
||||
{
|
||||
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
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 DateTimeOffset NextRequestAt { get; private set; } = DateTimeOffset.MinValue;
|
||||
|
||||
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
|
||||
|
||||
public bool IsDue(DateTimeOffset now) => now >= 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.UtcNow + TimeSpan.FromMilliseconds(milliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public enum RendezvousHostState
|
||||
{
|
||||
Active = 1,
|
||||
ManagerStopped = 2,
|
||||
Disposed = 3,
|
||||
}
|
||||
|
||||
public sealed class RendezvousHostAttemptCompletedEventArgs(
|
||||
JoinAttemptId attemptId,
|
||||
RendezvousConnectionState state,
|
||||
NetPeer? peer = null) : EventArgs
|
||||
{
|
||||
public JoinAttemptId AttemptId { get; } = attemptId;
|
||||
public RendezvousConnectionState State { get; } = state;
|
||||
public NetPeer? Peer { get; } = peer;
|
||||
}
|
||||
|
||||
public sealed class RendezvousHostCoordinator : IDisposable
|
||||
{
|
||||
private readonly NetManager _manager;
|
||||
private readonly RendezvousNetListener _networkEvents;
|
||||
private readonly EventBasedNatPunchListener _punchEvents;
|
||||
private readonly IPEndPoint _mediator;
|
||||
private readonly PublishedSession _session;
|
||||
private readonly IRendezvousJoinClient _joinClient;
|
||||
private readonly RendezvousCoordinatorOptions _options;
|
||||
private readonly IRendezvousCoordinatorClock _clock;
|
||||
private readonly ConnectionTicketValidator _tickets;
|
||||
private readonly Dictionary<JoinAttemptId, PendingHostAttempt> _attempts = [];
|
||||
private readonly Dictionary<NetPeer, JoinAttemptId> _acceptedPeers = [];
|
||||
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
|
||||
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
|
||||
private readonly Queue<JoinAttemptId> _attemptSchedule = [];
|
||||
private readonly List<JoinAttemptId> _cleanupScratch = [];
|
||||
private HostJoinAttempt[]? _latestSnapshot;
|
||||
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
|
||||
private DateTimeOffset _nextTerminalCleanupAt = DateTimeOffset.MinValue;
|
||||
private int _refreshing;
|
||||
private int _polling;
|
||||
private bool _subscriptionsReleased;
|
||||
private int _disposed;
|
||||
|
||||
public RendezvousHostCoordinator(
|
||||
NetManager manager,
|
||||
RendezvousNetListener networkEvents,
|
||||
IPEndPoint mediator,
|
||||
PublishedSession session,
|
||||
IRendezvousJoinClient joinClient,
|
||||
RendezvousCoordinatorOptions? options = null)
|
||||
: this(
|
||||
manager,
|
||||
networkEvents,
|
||||
mediator,
|
||||
session,
|
||||
joinClient,
|
||||
options,
|
||||
new SystemRendezvousCoordinatorClock(),
|
||||
null)
|
||||
{
|
||||
}
|
||||
|
||||
internal RendezvousHostCoordinator(
|
||||
NetManager manager,
|
||||
RendezvousNetListener networkEvents,
|
||||
IPEndPoint mediator,
|
||||
PublishedSession session,
|
||||
IRendezvousJoinClient joinClient,
|
||||
RendezvousCoordinatorOptions? options,
|
||||
IRendezvousCoordinatorClock clock,
|
||||
ConnectionTicketValidator? tickets)
|
||||
{
|
||||
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
|
||||
_networkEvents = networkEvents ?? throw new ArgumentNullException(nameof(networkEvents));
|
||||
_punchEvents = _networkEvents.PunchEvents;
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_joinClient = joinClient ?? throw new ArgumentNullException(nameof(joinClient));
|
||||
_options = (options ?? new RendezvousCoordinatorOptions()).CopyAndValidate();
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_tickets = tickets ?? new ConnectionTicketValidator();
|
||||
|
||||
RendezvousManagerGuard.Validate(_manager, _networkEvents);
|
||||
ValidateInputs();
|
||||
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
|
||||
_networkEvents.RendezvousPeerConnected += OnPeerConnected;
|
||||
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
|
||||
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
|
||||
}
|
||||
|
||||
public event EventHandler<RendezvousHostAttemptCompletedEventArgs>? AttemptCompleted;
|
||||
|
||||
public RendezvousHostState State { get; private set; } = RendezvousHostState.Active;
|
||||
public int PendingAttemptCount => _attempts.Count;
|
||||
internal int DeferredRequestCount => _deferredRequests.Count;
|
||||
|
||||
public async Task<RendezvousClientResult<int>> RefreshJoinAttemptsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (Interlocked.Exchange(ref _refreshing, 1) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("A host invitation refresh is already running.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
|
||||
await _joinClient.BrowseAllForHostAsync(
|
||||
_session,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
return RendezvousClientResult.Failure<int>(
|
||||
result.Error,
|
||||
result.Message,
|
||||
result.RetryAfterSeconds);
|
||||
}
|
||||
|
||||
HostJoinAttempt[] snapshot = result.Value.Select(CopyAttempt).ToArray();
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _latestSnapshot, snapshot);
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||
}
|
||||
|
||||
return RendezvousClientResult.Success(snapshot.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _refreshing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Poll()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (State != RendezvousHostState.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.Exchange(ref _polling, 1) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("The Rendezvous coordinator cannot be polled concurrently or recursively.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ApplySnapshots();
|
||||
if (!_manager.IsRunning)
|
||||
{
|
||||
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.NatPunchModule.PollEvents();
|
||||
_manager.PollEvents();
|
||||
_manager.NatPunchModule.PollEvents();
|
||||
if (State != RendezvousHostState.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = _clock.UtcNow;
|
||||
if (!_manager.IsRunning)
|
||||
{
|
||||
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshPresence(now);
|
||||
int checks = Math.Min(
|
||||
_attemptSchedule.Count,
|
||||
_options.MaximumAttemptChecksPerPoll);
|
||||
for (int index = 0; index < checks; index++)
|
||||
{
|
||||
JoinAttemptId attemptId = _attemptSchedule.Dequeue();
|
||||
if (!_attempts.TryGetValue(attemptId, out PendingHostAttempt? attempt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now >= attempt.Invitation.ExpiresAt)
|
||||
{
|
||||
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attempt.State == RendezvousConnectionState.Punching
|
||||
&& attempt.Retry.IsDue(now))
|
||||
{
|
||||
if (attempt.Retry.IsExhausted)
|
||||
{
|
||||
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
|
||||
continue;
|
||||
}
|
||||
|
||||
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||
_mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.Host,
|
||||
attempt.Invitation.MediationHandle,
|
||||
attempt.Invitation.HostPunchCapability));
|
||||
attempt.Retry.RecordRequest();
|
||||
}
|
||||
|
||||
_attemptSchedule.Enqueue(attemptId);
|
||||
}
|
||||
|
||||
if (now >= _nextTerminalCleanupAt)
|
||||
{
|
||||
_cleanupScratch.Clear();
|
||||
foreach (KeyValuePair<JoinAttemptId, DateTimeOffset> terminal in _terminalAttempts)
|
||||
{
|
||||
if (terminal.Value <= now)
|
||||
{
|
||||
_cleanupScratch.Add(terminal.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JoinAttemptId attemptId in _cleanupScratch)
|
||||
{
|
||||
_terminalAttempts.Remove(attemptId);
|
||||
}
|
||||
|
||||
_nextTerminalCleanupAt = now + TimeSpan.FromSeconds(1);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _polling, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed);
|
||||
Interlocked.Exchange(ref _latestSnapshot, null);
|
||||
_attemptSchedule.Clear();
|
||||
_terminalAttempts.Clear();
|
||||
_cleanupScratch.Clear();
|
||||
_tickets.Dispose();
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"[RendezvousHostCoordinator {_session.ListingId}; credentials redacted]";
|
||||
|
||||
private void ApplySnapshots()
|
||||
{
|
||||
HostJoinAttempt[]? latest = Interlocked.Exchange(ref _latestSnapshot, null);
|
||||
|
||||
if (latest is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DateTimeOffset now = _clock.UtcNow;
|
||||
foreach (HostJoinAttempt invitation in latest)
|
||||
{
|
||||
if (invitation.AttemptId.Value == Guid.Empty
|
||||
|| invitation.MediationHandle.Value == Guid.Empty
|
||||
|| !ContractValidation.IsCapabilityValid(invitation.HostPunchCapability)
|
||||
|| !ContractValidation.IsConnectionTicketValid(
|
||||
invitation.ConnectionTicketDigest))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (invitation.IsCancelled)
|
||||
{
|
||||
if (_attempts.ContainsKey(invitation.AttemptId))
|
||||
{
|
||||
CompleteAttempt(
|
||||
invitation.AttemptId,
|
||||
RendezvousConnectionState.Cancelled);
|
||||
}
|
||||
|
||||
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (invitation.ExpiresAt <= now
|
||||
|| _attempts.ContainsKey(invitation.AttemptId)
|
||||
|| _terminalAttempts.ContainsKey(invitation.AttemptId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_attempts.Add(
|
||||
invitation.AttemptId,
|
||||
new PendingHostAttempt(
|
||||
CopyAttempt(invitation),
|
||||
new RendezvousPunchRetrySchedule(_options, _clock)));
|
||||
_attemptSchedule.Enqueue(invitation.AttemptId);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPresence(DateTimeOffset now)
|
||||
{
|
||||
if (now < _nextPresenceAt || now >= _session.ExpiresAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.NatPunchModule.SendNatIntroduceRequest(
|
||||
_mediator,
|
||||
NatPunchRequestTokenCodec.Encode(
|
||||
NatPunchPeerRole.HostPresence,
|
||||
_session.HostPresenceHandle,
|
||||
_session.HostPresenceCapability));
|
||||
_nextPresenceAt = now + TimeSpan.FromSeconds(_session.HostPresenceRefreshAfterSeconds);
|
||||
}
|
||||
|
||||
private void OnNatIntroductionSuccess(
|
||||
IPEndPoint target,
|
||||
NatAddressType addressType,
|
||||
string encodedIntroduction)
|
||||
{
|
||||
_ = target;
|
||||
_ = addressType;
|
||||
if (!NatIntroductionTokenCodec.TryDecode(
|
||||
encodedIntroduction,
|
||||
out NatIntroductionToken? introduction)
|
||||
|| introduction is null
|
||||
|| !_attempts.TryGetValue(introduction.AttemptId, out PendingHostAttempt? attempt)
|
||||
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||
introduction.ConnectionTicket,
|
||||
attempt.Invitation.ConnectionTicketDigest)
|
||||
|| !_tickets.TryAuthorize(
|
||||
introduction.AttemptId,
|
||||
introduction.ConnectionTicket,
|
||||
Min(
|
||||
attempt.Invitation.ExpiresAt,
|
||||
_clock.UtcNow + _options.ConnectionTicketLifetime)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
attempt.State = RendezvousConnectionState.Connecting;
|
||||
if (_deferredRequests.Remove(
|
||||
introduction.AttemptId,
|
||||
out DeferredConnectionRequest? deferred))
|
||||
{
|
||||
AcceptAuthorizedRequest(
|
||||
introduction.AttemptId,
|
||||
attempt,
|
||||
deferred.Request,
|
||||
deferred.ConnectionTicket);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionRequest(ConnectionRequest request)
|
||||
{
|
||||
ReadOnlySpan<byte> data = request.Data.GetRemainingBytesSpan();
|
||||
if (!DirectConnectionRequestCodec.IsRendezvousRequest(data))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DirectConnectionRequestCodec.TryDecode(data, out DirectConnectionRequest? connection)
|
||||
|| connection is null
|
||||
|| !_attempts.TryGetValue(connection.AttemptId, out PendingHostAttempt? attempt)
|
||||
|| !NatIntroductionTokenCodec.MatchesDigest(
|
||||
connection.ConnectionTicket,
|
||||
attempt.Invitation.ConnectionTicketDigest))
|
||||
{
|
||||
request.RejectForce([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt.State == RendezvousConnectionState.Punching)
|
||||
{
|
||||
_deferredRequests[connection.AttemptId] = new(
|
||||
request,
|
||||
connection.ConnectionTicket);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt.State != RendezvousConnectionState.Connecting)
|
||||
{
|
||||
request.RejectForce([]);
|
||||
return;
|
||||
}
|
||||
|
||||
AcceptAuthorizedRequest(
|
||||
connection.AttemptId,
|
||||
attempt,
|
||||
request,
|
||||
connection.ConnectionTicket);
|
||||
}
|
||||
|
||||
private void OnPeerConnected(NetPeer peer)
|
||||
{
|
||||
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||
{
|
||||
CompleteAttempt(attemptId, RendezvousConnectionState.Connected, peer);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||
{
|
||||
_ = disconnectInfo;
|
||||
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
|
||||
{
|
||||
CompleteAttempt(attemptId, RendezvousConnectionState.Rejected);
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteAttempt(
|
||||
JoinAttemptId attemptId,
|
||||
RendezvousConnectionState state,
|
||||
NetPeer? peer = null)
|
||||
{
|
||||
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt.AcceptedPeer is not null)
|
||||
{
|
||||
_acceptedPeers.Remove(attempt.AcceptedPeer);
|
||||
}
|
||||
|
||||
_deferredRequests.Remove(attemptId);
|
||||
_tickets.Revoke(attemptId);
|
||||
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
|
||||
AttemptCompleted?.Invoke(this, new(attemptId, state, peer));
|
||||
}
|
||||
|
||||
private void Stop(RendezvousHostState hostState, RendezvousConnectionState attemptState)
|
||||
{
|
||||
if (State != RendezvousHostState.Active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
State = hostState;
|
||||
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
|
||||
{
|
||||
CompleteAttempt(attemptId, attemptState);
|
||||
}
|
||||
|
||||
ReleaseSubscriptions();
|
||||
}
|
||||
|
||||
private void ReleaseSubscriptions()
|
||||
{
|
||||
if (_subscriptionsReleased)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
|
||||
_networkEvents.RendezvousPeerConnected -= OnPeerConnected;
|
||||
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
|
||||
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
|
||||
_subscriptionsReleased = true;
|
||||
}
|
||||
|
||||
private void ValidateInputs()
|
||||
{
|
||||
if (_mediator.Port is < 1 or > 65_535
|
||||
|| _session.HostPresenceHandle.Value == Guid.Empty
|
||||
|| !ContractValidation.IsCapabilityValid(_session.HostPresenceCapability)
|
||||
|| _session.HostPresenceRefreshAfterSeconds < 1
|
||||
|| _session.ExpiresAt <= _clock.UtcNow)
|
||||
{
|
||||
throw new ArgumentException("The host traversal inputs are invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private static HostJoinAttempt CopyAttempt(HostJoinAttempt attempt) => new()
|
||||
{
|
||||
AttemptId = attempt.AttemptId,
|
||||
MediationHandle = attempt.MediationHandle,
|
||||
HostPunchCapability = attempt.HostPunchCapability,
|
||||
ConnectionTicketDigest = attempt.ConnectionTicketDigest,
|
||||
IsCancelled = attempt.IsCancelled,
|
||||
ExpiresAt = attempt.ExpiresAt,
|
||||
};
|
||||
|
||||
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
|
||||
left <= right ? left : right;
|
||||
|
||||
private void AcceptAuthorizedRequest(
|
||||
JoinAttemptId attemptId,
|
||||
PendingHostAttempt attempt,
|
||||
ConnectionRequest request,
|
||||
string connectionTicket)
|
||||
{
|
||||
ConnectionTicketConsumptionResult consumption = _tickets.Consume(
|
||||
attemptId,
|
||||
connectionTicket);
|
||||
if (consumption != ConnectionTicketConsumptionResult.Accepted)
|
||||
{
|
||||
request.RejectForce([]);
|
||||
return;
|
||||
}
|
||||
|
||||
NetPeer peer = request.Accept();
|
||||
attempt.AcceptedPeer = peer;
|
||||
_acceptedPeers[peer] = attemptId;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(RendezvousHostCoordinator));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PendingHostAttempt(
|
||||
HostJoinAttempt invitation,
|
||||
RendezvousPunchRetrySchedule retry)
|
||||
{
|
||||
internal HostJoinAttempt Invitation { get; } = invitation;
|
||||
internal RendezvousPunchRetrySchedule Retry { get; } = retry;
|
||||
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
|
||||
internal NetPeer? AcceptedPeer { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DeferredConnectionRequest(
|
||||
ConnectionRequest request,
|
||||
string connectionTicket)
|
||||
{
|
||||
internal ConnectionRequest Request { get; } = request;
|
||||
internal string ConnectionTicket { get; } = connectionTicket;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using LiteNetLib;
|
||||
using LiteNetLib.Utils;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
|
||||
public sealed class RendezvousNetListener : INetEventListener
|
||||
{
|
||||
private NetManager? _manager;
|
||||
|
||||
public EventBasedNetListener GameplayEvents { get; } = new();
|
||||
public EventBasedNatPunchListener PunchEvents { get; } = new();
|
||||
|
||||
public NetManager CreateManager()
|
||||
{
|
||||
if (_manager is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This Rendezvous listener is already bound to a LiteNetLib manager.");
|
||||
}
|
||||
|
||||
NetManager manager = new(this) { NatPunchEnabled = true };
|
||||
manager.NatPunchModule.Init(PunchEvents);
|
||||
_manager = manager;
|
||||
return manager;
|
||||
}
|
||||
|
||||
internal event Action<NetPeer>? RendezvousPeerConnected;
|
||||
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
|
||||
internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
|
||||
|
||||
internal void ValidateManager(NetManager manager)
|
||||
{
|
||||
if (!ReferenceEquals(_manager, manager))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The LiteNetLib manager must be created by this Rendezvous listener.");
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPeerConnected(NetPeer peer)
|
||||
{
|
||||
RendezvousPeerConnected?.Invoke(peer);
|
||||
((INetEventListener)GameplayEvents).OnPeerConnected(peer);
|
||||
}
|
||||
|
||||
public void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
|
||||
{
|
||||
RendezvousPeerDisconnected?.Invoke(peer, disconnectInfo);
|
||||
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
|
||||
}
|
||||
|
||||
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) =>
|
||||
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
|
||||
|
||||
public void OnNetworkReceive(
|
||||
NetPeer peer,
|
||||
NetPacketReader reader,
|
||||
byte channelNumber,
|
||||
DeliveryMethod deliveryMethod) =>
|
||||
((INetEventListener)GameplayEvents).OnNetworkReceive(
|
||||
peer,
|
||||
reader,
|
||||
channelNumber,
|
||||
deliveryMethod);
|
||||
|
||||
public void OnNetworkReceiveUnconnected(
|
||||
IPEndPoint remoteEndPoint,
|
||||
NetPacketReader reader,
|
||||
UnconnectedMessageType messageType) =>
|
||||
((INetEventListener)GameplayEvents).OnNetworkReceiveUnconnected(
|
||||
remoteEndPoint,
|
||||
reader,
|
||||
messageType);
|
||||
|
||||
public void OnNetworkLatencyUpdate(NetPeer peer, int latency) =>
|
||||
((INetEventListener)GameplayEvents).OnNetworkLatencyUpdate(peer, latency);
|
||||
|
||||
public void OnConnectionRequest(ConnectionRequest request)
|
||||
{
|
||||
int position = request.Data.Position;
|
||||
bool isRendezvous = DirectConnectionRequestCodec.IsRendezvousRequest(
|
||||
request.Data.GetRemainingBytesSpan());
|
||||
request.Data.SetPosition(position);
|
||||
if (!isRendezvous)
|
||||
{
|
||||
((INetEventListener)GameplayEvents).OnConnectionRequest(request);
|
||||
return;
|
||||
}
|
||||
|
||||
Action<ConnectionRequest>? handler = RendezvousConnectionRequest;
|
||||
if (handler is null)
|
||||
{
|
||||
request.RejectForce([]);
|
||||
return;
|
||||
}
|
||||
|
||||
handler(request);
|
||||
}
|
||||
|
||||
public void OnMessageDelivered(NetPeer peer, object userData) =>
|
||||
((INetEventListener)GameplayEvents).OnMessageDelivered(peer, userData);
|
||||
|
||||
public void OnNtpResponse(NtpPacket packet) =>
|
||||
((INetEventListener)GameplayEvents).OnNtpResponse(packet);
|
||||
|
||||
public void OnPeerAddressChanged(NetPeer peer, IPEndPoint previousAddress) =>
|
||||
((INetEventListener)GameplayEvents).OnPeerAddressChanged(peer, previousAddress);
|
||||
}
|
||||
Reference in New Issue
Block a user