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,272 @@
|
||||
using System.Net;
|
||||
using FinalFactory.Rendezvous.Client;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.JoinAttempts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Server.Transport;
|
||||
using FinalFactory.Rendezvous.Tests.JoinAttempts;
|
||||
using LiteNetLib;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Client;
|
||||
|
||||
public sealed class RendezvousCoordinatorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CallerOwnedManagersCompleteAuthenticatedDirectConnectionAndRejectTicketReplay()
|
||||
{
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(8));
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "sdk-direct-connect");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
NatMediationProcessor processor = new(
|
||||
fixture.Sessions.Store,
|
||||
fixture.Sessions.Capabilities,
|
||||
fixture.Service);
|
||||
using UdpMediatorService mediatorService = new(
|
||||
Options.Create(new UdpMediatorOptions
|
||||
{
|
||||
ListenAddress = IPAddress.Loopback.ToString(),
|
||||
Port = 0,
|
||||
PollIntervalMilliseconds = 1,
|
||||
}),
|
||||
NullLogger<UdpMediatorService>.Instance,
|
||||
processor);
|
||||
await mediatorService.StartAsync(timeout.Token);
|
||||
|
||||
RendezvousNetListener hostEvents = new();
|
||||
RendezvousNetListener clientEvents = new();
|
||||
bool gameplayConnectionRequestHandled = false;
|
||||
hostEvents.GameplayEvents.ConnectionRequestEvent += _ =>
|
||||
gameplayConnectionRequestHandled = true;
|
||||
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||
EventBasedNatPunchListener clientPunch = clientEvents.PunchEvents;
|
||||
NetManager hostManager = hostEvents.CreateManager();
|
||||
NetManager clientManager = clientEvents.CreateManager();
|
||||
string? introductionToken = null;
|
||||
string? hostIntroductionToken = null;
|
||||
clientPunch.NatIntroductionSuccess += (_, _, token) => introductionToken = token;
|
||||
hostPunch.NatIntroductionSuccess += (_, _, token) => hostIntroductionToken = token;
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
IPEndPoint mediator = Assert.IsType<IPEndPoint>(mediatorService.LocalEndpoint);
|
||||
FakeJoinClient joinClient = new([hostAttempt]);
|
||||
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||
using ConnectionTicketValidator tickets = new(1_024, clock);
|
||||
using RendezvousHostCoordinator host = new(
|
||||
hostManager,
|
||||
hostEvents,
|
||||
mediator,
|
||||
new PublishedSession(registration),
|
||||
joinClient,
|
||||
FastOptions(),
|
||||
clock,
|
||||
tickets);
|
||||
using RendezvousClientCoordinator client = new(
|
||||
clientManager,
|
||||
clientEvents,
|
||||
mediator,
|
||||
created,
|
||||
FastOptions(),
|
||||
clock);
|
||||
List<RendezvousHostAttemptCompletedEventArgs> hostCompletions = [];
|
||||
List<RendezvousConnectionCompletedEventArgs> clientCompletions = [];
|
||||
host.AttemptCompleted += (_, completion) => hostCompletions.Add(completion);
|
||||
client.Completed += (_, completion) => clientCompletions.Add(completion);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync(timeout.Token)).IsSuccess);
|
||||
|
||||
while ((!client.IsCompleted || hostCompletions.Count == 0)
|
||||
&& !timeout.IsCancellationRequested)
|
||||
{
|
||||
host.Poll();
|
||||
client.Poll();
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
client.State == RendezvousConnectionState.Connected,
|
||||
$"Client ended in {client.State}; host pending={host.PendingAttemptCount}; "
|
||||
+ $"host completions={hostCompletions.Count}; introduction={introductionToken is not null}; "
|
||||
+ $"host introduction={hostIntroductionToken is not null}; "
|
||||
+ $"client digest={NatIntroductionTokenCodec.MatchesDigest(introductionToken, created.ConnectionTicketDigest)}; "
|
||||
+ $"host digest={NatIntroductionTokenCodec.MatchesDigest(hostIntroductionToken, hostAttempt.ConnectionTicketDigest)}.");
|
||||
Assert.NotNull(client.ConnectedPeer);
|
||||
Assert.Equal(
|
||||
RendezvousConnectionState.Connected,
|
||||
Assert.Single(clientCompletions).State);
|
||||
RendezvousHostAttemptCompletedEventArgs hostCompletion = Assert.Single(hostCompletions);
|
||||
Assert.Equal(created.AttemptId, hostCompletion.AttemptId);
|
||||
Assert.Equal(RendezvousConnectionState.Connected, hostCompletion.State);
|
||||
Assert.NotNull(hostCompletion.Peer);
|
||||
Assert.False(gameplayConnectionRequestHandled);
|
||||
Assert.True(NatIntroductionTokenCodec.TryDecode(
|
||||
introductionToken,
|
||||
out NatIntroductionToken? introduction));
|
||||
Assert.NotNull(introduction);
|
||||
|
||||
EventBasedNetListener replayEvents = new();
|
||||
bool replayConnected = false;
|
||||
replayEvents.PeerConnectedEvent += _ => replayConnected = true;
|
||||
NetManager replayManager = new(replayEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(replayManager.Start(0));
|
||||
replayManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
DirectConnectionRequestCodec.Encode(
|
||||
created.AttemptId,
|
||||
introduction.ConnectionTicket));
|
||||
DateTime replayDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||
while (DateTime.UtcNow < replayDeadline && !replayConnected)
|
||||
{
|
||||
host.Poll();
|
||||
replayManager.PollEvents();
|
||||
await Task.Delay(2, timeout.Token);
|
||||
}
|
||||
|
||||
Assert.False(replayConnected);
|
||||
Assert.False(gameplayConnectionRequestHandled);
|
||||
Assert.Single(hostCompletions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
replayManager.Stop();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
hostManager.Stop();
|
||||
clientManager.Stop();
|
||||
await mediatorService.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HostDefersADirectRequestUntilTheMatchingNatIntroductionArrives()
|
||||
{
|
||||
using JoinAttemptFixture fixture = new();
|
||||
(RegisterSessionResponse registration, _) = fixture.CreateHost();
|
||||
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId, "direct-before-nat");
|
||||
HostJoinAttempt hostAttempt = fixture.Service.BrowseForHost(
|
||||
registration.ListingId,
|
||||
ContractLimits.ContractVersion,
|
||||
registration.LeaseToken,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
null).Value!.Items.Single(item => item.AttemptId == created.AttemptId);
|
||||
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
|
||||
ConnectionTicketGrant grant = Assert.IsType<ConnectionTicketGrant>(
|
||||
fixture.Service.IssueConnectionTicket(introduction.Attempt).Value);
|
||||
RendezvousNetListener hostEvents = new();
|
||||
EventBasedNatPunchListener hostPunch = hostEvents.PunchEvents;
|
||||
EventBasedNetListener clientEvents = new();
|
||||
bool clientConnected = false;
|
||||
clientEvents.PeerConnectedEvent += _ => clientConnected = true;
|
||||
NetManager hostManager = hostEvents.CreateManager();
|
||||
NetManager clientManager = new(clientEvents);
|
||||
try
|
||||
{
|
||||
Assert.True(hostManager.Start(0));
|
||||
Assert.True(clientManager.Start(0));
|
||||
FixedCoordinatorClock clock = new(fixture.Sessions.Clock.UtcNow);
|
||||
FakeJoinClient joins = new([hostAttempt]);
|
||||
using ConnectionTicketValidator tickets = new(16, clock);
|
||||
using RendezvousHostCoordinator host = new(
|
||||
hostManager,
|
||||
hostEvents,
|
||||
new IPEndPoint(IPAddress.Loopback, 65_000),
|
||||
new PublishedSession(registration),
|
||||
joins,
|
||||
FastOptions(),
|
||||
clock,
|
||||
tickets);
|
||||
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
|
||||
host.AttemptCompleted += (_, completion) => completions.Add(completion);
|
||||
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
|
||||
host.Poll();
|
||||
bool observedDeferredRequest = false;
|
||||
hostEvents.RendezvousConnectionRequest += _ =>
|
||||
{
|
||||
observedDeferredRequest = host.DeferredRequestCount == 1;
|
||||
((INatPunchListener)hostPunch).OnNatIntroductionSuccess(
|
||||
new IPEndPoint(IPAddress.Loopback, clientManager.LocalPort),
|
||||
NatAddressType.External,
|
||||
grant.Ticket);
|
||||
};
|
||||
|
||||
clientManager.Connect(
|
||||
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
|
||||
DirectConnectionRequestCodec.Encode(created.AttemptId, grant.Ticket));
|
||||
DateTime connectedDeadline = DateTime.UtcNow.AddSeconds(1);
|
||||
while ((!clientConnected || completions.Count == 0)
|
||||
&& DateTime.UtcNow < connectedDeadline)
|
||||
{
|
||||
host.Poll();
|
||||
clientManager.PollEvents();
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
clientConnected,
|
||||
$"Deferred observed={observedDeferredRequest}; deferred={host.DeferredRequestCount}; "
|
||||
+ $"pending={host.PendingAttemptCount}; completions={completions.Count}.");
|
||||
Assert.True(observedDeferredRequest);
|
||||
Assert.Equal(0, host.DeferredRequestCount);
|
||||
Assert.Equal(
|
||||
RendezvousConnectionState.Connected,
|
||||
Assert.Single(completions).State);
|
||||
}
|
||||
finally
|
||||
{
|
||||
hostManager.Stop();
|
||||
clientManager.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static RendezvousCoordinatorOptions FastOptions() => new()
|
||||
{
|
||||
MaximumPunchRequests = 20,
|
||||
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
|
||||
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(100),
|
||||
JitterRatio = 0,
|
||||
};
|
||||
|
||||
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
|
||||
{
|
||||
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
|
||||
CreateJoinAttemptRequest request,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<bool>> CancelAsync(
|
||||
CreateJoinAttemptResponse attempt,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(
|
||||
PublishedSession session,
|
||||
int pageSize = ContractLimits.BrowserPageMaxItems,
|
||||
string? cursor = null,
|
||||
CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
||||
|
||||
public Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
|
||||
PublishedSession session,
|
||||
int maximumPages = 100,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(
|
||||
RendezvousClientResult.Success(attempts));
|
||||
}
|
||||
|
||||
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
|
||||
IRendezvousCoordinatorClock,
|
||||
IConnectionTicketClock
|
||||
{
|
||||
public DateTimeOffset UtcNow { get; } = now;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user