feat(client): add rendezvous traversal coordinators (#12)
quality-gate / quality (push) Successful in 56s

This commit is contained in:
KyuubiYoru
2026-07-16 08:39:05 +02:00
parent 6d076c281a
commit b4b6072fe1
28 changed files with 2949 additions and 57 deletions
@@ -0,0 +1,616 @@
using System.Net;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorBehaviorTests
{
[Fact]
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Connecting, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
Assert.Equal(0, completions);
}
[Fact]
public void ClientRejectsASyntacticallyValidIntroductionWithTheWrongTicket()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
string forged = NatIntroductionTokenCodec.Encode(
harness.AttemptId,
Credential('F'));
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
forged);
Assert.Equal(RendezvousConnectionState.Punching, harness.Coordinator.State);
Assert.False(harness.Coordinator.IsCompleted);
}
[Fact]
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
List<RendezvousConnectionState> completions = [];
harness.Coordinator.Completed += (_, completion) => completions.Add(completion.State);
harness.Coordinator.Cancel();
harness.Coordinator.Poll();
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
}
[Fact]
public void ExhaustedPunchBudgetTimesOutExactlyOnceUnderAFakeClock()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
MaximumPunchRequests = 1,
InitialPunchRetryDelay = TimeSpan.FromMilliseconds(10),
MaximumPunchRetryDelay = TimeSpan.FromMilliseconds(10),
JitterRatio = 0,
});
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMilliseconds(10));
harness.Coordinator.Poll();
clock.Advance(TimeSpan.FromMinutes(1));
harness.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
Assert.Equal(1, completions);
}
[Fact]
public void ManagerShutdownAndDisposalEachReleaseTheirTerminalPathOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness stopped = new(clock);
int stoppedCompletions = 0;
stopped.Coordinator.Completed += (_, _) => stoppedCompletions++;
stopped.Manager.Stop();
stopped.Coordinator.Poll();
stopped.Coordinator.Poll();
Assert.Equal(RendezvousConnectionState.ManagerStopped, stopped.Coordinator.State);
Assert.Equal(1, stoppedCompletions);
using ClientHarness disposed = new(clock);
int disposedCompletions = 0;
disposed.Coordinator.Completed += (_, _) => disposedCompletions++;
disposed.Coordinator.Dispose();
disposed.Coordinator.Dispose();
((INatPunchListener)disposed.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
disposed.IntroductionToken);
Assert.Equal(RendezvousConnectionState.Disposed, disposed.Coordinator.State);
Assert.Equal(1, disposedCompletions);
Assert.Throws<ObjectDisposedException>(() => disposed.Coordinator.Poll());
}
[Fact]
public async Task DirectConnectionRejectionProducesOneTerminalTransition()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness client = new(clock);
EventBasedNetListener rejectingEvents = new();
rejectingEvents.ConnectionRequestEvent += request => request.Reject();
NetManager rejectingHost = new(rejectingEvents);
try
{
Assert.True(rejectingHost.Start(0));
int completions = 0;
client.Coordinator.Completed += (_, _) => completions++;
((INatPunchListener)client.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, rejectingHost.LocalPort),
NatAddressType.External,
client.IntroductionToken);
DateTime deadline = DateTime.UtcNow.AddSeconds(2);
while (!client.Coordinator.IsCompleted && DateTime.UtcNow < deadline)
{
rejectingHost.PollEvents();
client.Coordinator.Poll();
await Task.Delay(2);
}
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
Assert.Equal(1, completions);
client.Coordinator.Poll();
Assert.Equal(1, completions);
}
finally
{
rejectingHost.Stop();
}
}
[Fact]
public void UnsynchronizedLiteNetCallbacksAreRejectedAtConstruction()
{
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
manager.UnsyncedEvents = true;
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public void CoordinatorRejectsAManagerCreatedByAnotherRoutingListener()
{
RendezvousNetListener managerEvents = new();
NetManager manager = managerEvents.CreateManager();
RendezvousNetListener mismatchedEvents = new();
try
{
Assert.True(manager.Start(0));
Assert.Throws<InvalidOperationException>(() => new RendezvousClientCoordinator(
manager,
mismatchedEvents,
new IPEndPoint(IPAddress.Loopback, 9_050),
CreateAttempt(new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero))));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task PublishedSessionTimingRemainsValidDuringConcurrentRenewalReads()
{
DateTimeOffset firstExpiry = new(2030, 1, 1, 0, 1, 0, TimeSpan.Zero);
DateTimeOffset secondExpiry = new(2030, 1, 1, 0, 2, 0, TimeSpan.Zero);
PublishedSession session = CreateSession(firstExpiry);
Task writer = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
session.ExpiresAt = index % 2 == 0 ? firstExpiry : secondExpiry;
session.LeaseRenewAfterSeconds = index % 2 == 0 ? 10 : 20;
}
});
Task reader = Task.Run(() =>
{
for (int index = 0; index < 10_000; index++)
{
DateTimeOffset expiry = session.ExpiresAt;
int renewAfter = session.LeaseRenewAfterSeconds;
Assert.True(expiry == firstExpiry || expiry == secondExpiry);
Assert.True(renewAfter is 10 or 20 or 30);
}
});
await Task.WhenAll(writer, reader);
}
[Fact]
public async Task HostDoesNotMistakeAnIntroducedAttemptForCancellationWhenItLeavesPolling()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000111"));
HostJoinAttempt invitation = new()
{
AttemptId = attemptId,
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000112")),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(attemptId, Credential('T'))),
ExpiresAt = clock.UtcNow + TimeSpan.FromSeconds(30),
};
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
int completions = 0;
host.AttemptCompleted += (_, _) => completions++;
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')));
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal(0, completions);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostCancellationSnapshotRevokesAnAuthorizedTicketAndCompletesOnce()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
EventBasedNatPunchListener punchEvents = networkEvents.PunchEvents;
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000131"));
string ticket = NatIntroductionTokenCodec.Encode(attemptId, Credential('T'));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000132")),
ticket,
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
using ConnectionTicketValidator tickets = new(16, clock);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
tickets);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
((INatPunchListener)punchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_003),
NatAddressType.External,
ticket);
invitation.IsCancelled = true;
joins.Attempts = [invitation];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(
ConnectionTicketConsumptionResult.Revoked,
tickets.Consume(attemptId, ticket));
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostAppliesOnlyTheLatestUnpolledSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000141"));
HostJoinAttempt invitation = CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000142")),
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30));
MutableJoinClient joins = new([invitation]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
joins.Attempts = [];
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task DisposingHostDuringRefreshDropsTheLateSnapshot()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
BlockingJoinClient joins = new();
try
{
Assert.True(manager.Start(0));
RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
Task<RendezvousClientResult<int>> refresh = host.RefreshJoinAttemptsAsync();
await joins.WaitUntilCalled;
host.Dispose();
joins.Complete([]);
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await refresh);
Assert.Throws<ObjectDisposedException>(() => host.Poll());
}
finally
{
manager.Stop();
}
}
[Fact]
public async Task HostBoundsAttemptExpiryChecksPerPoll()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
NetManager manager = networkEvents.CreateManager();
JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000151"));
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000152"));
DateTimeOffset expiresAt = clock.UtcNow + TimeSpan.FromSeconds(1);
MutableJoinClient joins = new([
CreateHostAttempt(
firstId,
new(Guid.Parse("00000000-0000-0000-0000-000000000153")),
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
expiresAt),
CreateHostAttempt(
secondId,
new(Guid.Parse("00000000-0000-0000-0000-000000000154")),
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
expiresAt),
]);
try
{
Assert.True(manager.Start(0));
using RendezvousHostCoordinator host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions
{
MaximumAttemptChecksPerPoll = 1,
JitterRatio = 0,
},
clock,
null);
List<RendezvousConnectionState> completions = [];
host.AttemptCompleted += (_, completion) => completions.Add(completion.State);
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
clock.Advance(TimeSpan.FromSeconds(2));
host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.TimedOut], completions);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal(
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut],
completions);
}
finally
{
manager.Stop();
}
}
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000102")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000101")),
Credential('T'))),
ExpiresAt = expiresAt,
};
private static PublishedSession CreateSession(DateTimeOffset expiresAt) => new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000121")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000122")),
LeaseToken = "lease-token",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000123")),
HostPresenceCapability = Credential('P'),
ExpiresAt = expiresAt,
LeaseRenewAfterSeconds = 30,
HostPresenceRefreshAfterSeconds = 10,
});
private static HostJoinAttempt CreateHostAttempt(
JoinAttemptId attemptId,
MediationHandle mediationHandle,
string connectionTicket,
DateTimeOffset expiresAt) => new()
{
AttemptId = attemptId,
MediationHandle = mediationHandle,
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(connectionTicket),
ExpiresAt = expiresAt,
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private sealed class ClientHarness : IDisposable
{
internal ClientHarness(
ManualCoordinatorClock clock,
RendezvousCoordinatorOptions? options = null)
{
NetworkEvents = new();
PunchEvents = NetworkEvents.PunchEvents;
Manager = NetworkEvents.CreateManager();
Assert.True(Manager.Start(0));
CreateJoinAttemptResponse attempt = CreateAttempt(clock.UtcNow + TimeSpan.FromSeconds(30));
AttemptId = attempt.AttemptId;
IntroductionToken = NatIntroductionTokenCodec.Encode(
attempt.AttemptId,
Credential('T'));
Coordinator = new(
Manager,
NetworkEvents,
new IPEndPoint(IPAddress.Loopback, 65_001),
attempt,
options,
clock);
}
internal RendezvousNetListener NetworkEvents { get; }
internal EventBasedNatPunchListener PunchEvents { get; }
internal NetManager Manager { get; }
internal RendezvousClientCoordinator Coordinator { get; }
internal JoinAttemptId AttemptId { get; }
internal string IntroductionToken { get; }
public void Dispose()
{
Coordinator.Dispose();
Manager.Stop();
}
}
private sealed class MutableJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
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 BlockingJoinClient : IRendezvousJoinClient
{
private readonly TaskCompletionSource<bool> _called = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<IReadOnlyList<HostJoinAttempt>> _result = new(
TaskCreationOptions.RunContinuationsAsynchronously);
internal Task WaitUntilCalled => _called.Task;
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
_result.SetResult(attempts);
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 async Task<RendezvousClientResult<IReadOnlyList<HostJoinAttempt>>> BrowseAllForHostAsync(
PublishedSession session,
int maximumPages = 100,
CancellationToken cancellationToken = default)
{
_called.SetResult(true);
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
}
}
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
IRendezvousCoordinatorClock,
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; private set; } = now;
internal void Advance(TimeSpan amount) => UtcNow += amount;
}
}
@@ -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;
}
}
@@ -0,0 +1,154 @@
using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousJoinClientTests
{
[Fact]
public async Task JoinIssuanceRetriesTheSameIdempotentPayloadAndCancellationUsesCapability()
{
CreateJoinAttemptResponse created = CreateAttempt();
RecordingHandler handler = new(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
JsonResponse(HttpStatusCode.Created, created),
new HttpResponseMessage(HttpStatusCode.NoContent));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(
http,
new RendezvousClientOptions { JitterRatio = 0 },
new ImmediateDelay());
CreateJoinAttemptRequest request = new()
{
IdempotencyKey = "stable-join-key",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000201")),
ProtocolVersion = 7,
};
RendezvousClientResult<CreateJoinAttemptResponse> result = await client.CreateAsync(request);
RendezvousClientResult<bool> cancelled = await client.CancelAsync(created);
Assert.True(result.IsSuccess, result.Message);
Assert.True(cancelled.IsSuccess, cancelled.Message);
Assert.Equal(handler.Requests[0].Body, handler.Requests[1].Body);
Assert.Contains("stable-join-key", handler.Requests[0].Body, StringComparison.Ordinal);
RecordedRequest cancellation = handler.Requests[2];
Assert.Equal(HttpMethod.Delete, cancellation.Method);
Assert.Equal(
created.ClientPunchCapability,
cancellation.Headers["X-Rendezvous-Client-Punch-Capability"]);
}
[Fact]
public async Task HostInvitationPollingFollowsCursorsWithTheLeaseToken()
{
HostJoinAttempt first = CreateHostAttempt("00000000-0000-0000-0000-000000000211");
HostJoinAttempt second = CreateHostAttempt("00000000-0000-0000-0000-000000000212");
RecordingHandler handler = new(
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [first],
NextCursor = "next page+cursor",
}),
JsonResponse(HttpStatusCode.OK, new BrowseHostJoinAttemptsResponse
{
Items = [second],
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
PublishedSession session = new(new RegisterSessionResponse
{
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000220")),
LeaseId = new(Guid.Parse("00000000-0000-0000-0000-000000000221")),
LeaseToken = "lease-secret",
HostPresenceHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000222")),
HostPresenceCapability = Credential('P'),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero),
LeaseRenewAfterSeconds = 15,
HostPresenceRefreshAfterSeconds = 10,
});
RendezvousClientResult<IReadOnlyList<HostJoinAttempt>> result =
await client.BrowseAllForHostAsync(session);
Assert.True(result.IsSuccess, result.Message);
Assert.Equal([first.AttemptId, second.AttemptId], result.Value!.Select(item => item.AttemptId));
Assert.Equal(2, handler.Requests.Count);
Assert.All(handler.Requests, request =>
Assert.Equal("lease-secret", request.Headers["X-Rendezvous-Lease-Token"]));
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
}
private static CreateJoinAttemptResponse CreateAttempt() => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
MediationHandle = new(Guid.Parse("00000000-0000-0000-0000-000000000203")),
ClientPunchCapability = Credential('C'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("00000000-0000-0000-0000-000000000202")),
Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static HostJoinAttempt CreateHostAttempt(string id) => new()
{
AttemptId = new(Guid.Parse(id)),
MediationHandle = new(Guid.NewGuid()),
HostPunchCapability = Credential('H'),
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
NatIntroductionTokenCodec.Encode(new JoinAttemptId(Guid.Parse(id)), Credential('T'))),
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
private static HttpResponseMessage JsonResponse<T>(HttpStatusCode status, T value) => new(status)
{
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ContractJson.Options)),
};
private sealed class RecordingHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> _responses = new(responses);
internal List<RecordedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Dictionary<string, string> headers = request.Headers.ToDictionary(
static item => item.Key,
static item => string.Join(",", item.Value),
StringComparer.OrdinalIgnoreCase);
Requests.Add(new(
request.Method,
request.RequestUri!,
headers,
request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken)));
return _responses.Dequeue();
}
}
private sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,
IReadOnlyDictionary<string, string> Headers,
string Body);
private sealed class ImmediateDelay : IRendezvousDelay
{
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,59 @@
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Tests.Contracts;
public sealed class TraversalTokenCodecTests
{
[Fact]
public void IntroductionTokenBindsAttemptAndRedactsTheFixedTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000301"));
string authenticator = Credential('T');
string encoded = NatIntroductionTokenCodec.Encode(attemptId, authenticator);
Assert.Equal(NatIntroductionTokenCodec.EncodedLength, encoded.Length);
Assert.True(encoded.Length <= ContractLimits.LiteNetLibNatTokenMaxCharacters);
Assert.True(NatIntroductionTokenCodec.TryDecode(encoded, out NatIntroductionToken? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(encoded, decoded.ConnectionTicket);
Assert.DoesNotContain(encoded, decoded.ToString(), StringComparison.Ordinal);
}
[Fact]
public void IntroductionTokenRejectsNonCanonicalOrAlteredFields()
{
string valid = NatIntroductionTokenCodec.Encode(
new JoinAttemptId(Guid.Parse("abcdef00-0000-0000-0000-000000000302")),
Credential('T'));
Assert.False(NatIntroductionTokenCodec.TryDecode(null, out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1], out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(valid[..^1] + "!", out _));
Assert.False(NatIntroductionTokenCodec.TryDecode(new string('A', 43), out _));
}
[Fact]
public void DirectConnectionRequestRoundTripsFixedBoundedPayloadAndRedactsTicket()
{
JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000303"));
string ticket = Credential('D');
byte[] encoded = DirectConnectionRequestCodec.Encode(attemptId, ticket);
Assert.Equal(DirectConnectionRequestCodec.EncodedLength, encoded.Length);
Assert.True(DirectConnectionRequestCodec.TryDecode(encoded, out DirectConnectionRequest? decoded));
Assert.NotNull(decoded);
Assert.Equal(attemptId, decoded.AttemptId);
Assert.Equal(ticket, decoded.ConnectionTicket);
Assert.DoesNotContain(ticket, decoded.ToString(), StringComparison.Ordinal);
encoded[0] ^= 0xff;
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded, out _));
Assert.False(DirectConnectionRequestCodec.TryDecode(encoded.AsSpan(1), out _));
}
private static string Credential(char value) => new(value, ContractLimits.DerivedCredentialCharacters);
}
@@ -91,14 +91,16 @@ public sealed class JoinAttemptHttpEndpointTests
using HttpResponseMessage cancelled = await host.HttpClient.SendAsync(cancelRequest);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using HttpRequestMessage emptyPollRequest = new(
using HttpRequestMessage cancelledPollRequest = new(
HttpMethod.Get,
$"v1/sessions/{session.ListingId}/join-attempts?contractVersion=1&pageSize=10");
emptyPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage emptyPollResponse = await host.HttpClient.SendAsync(emptyPollRequest);
BrowseHostJoinAttemptsResponse empty = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await emptyPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
Assert.Empty(empty.Items);
cancelledPollRequest.Headers.Add("X-Rendezvous-Lease-Token", session.LeaseToken);
using HttpResponseMessage cancelledPollResponse = await host.HttpClient.SendAsync(cancelledPollRequest);
BrowseHostJoinAttemptsResponse cancelledPoll = Assert.IsType<BrowseHostJoinAttemptsResponse>(
await cancelledPollResponse.Content.ReadFromJsonAsync<BrowseHostJoinAttemptsResponse>(ContractJson.Options));
HostJoinAttempt cancelledAttempt = Assert.Single(cancelledPoll.Items);
Assert.Equal(created.AttemptId, cancelledAttempt.AttemptId);
Assert.True(cancelledAttempt.IsCancelled);
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
@@ -152,12 +152,47 @@ public sealed class JoinAttemptServiceTests
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
Assert.Empty(fixture.Service.BrowseForHost(
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
HostJoinAttempt cancelled = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
10,
null).Value!.Items);
Assert.Equal(created.AttemptId, cancelled.AttemptId);
Assert.True(cancelled.IsCancelled);
}
[Fact]
public void CancellationAfterIntroductionRevokesTicketIssuanceAndConsumption()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
IntroductionEndpoints introduction = fixture.Introduce(registration, created);
JoinAttemptServiceResult<ConnectionTicketGrant> issued =
fixture.Service.IssueConnectionTicket(introduction.Attempt);
Assert.True(issued.Succeeded);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
issued.Value!.Ticket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Service.Cancel(
created.AttemptId,
created.ClientPunchCapability).Succeeded);
StoredJoinAttempt cancelled = fixture.GetAttempt(registration, created.AttemptId);
Assert.True(cancelled.IsCancelled);
Assert.Equal(
RendezvousErrorCode.Conflict,
fixture.Service.IssueConnectionTicket(cancelled).Error);
Assert.Equal(
StoreResultCode.Conflict,
fixture.Sessions.Store.ConsumeConnectionTicket(new(
created.AttemptId,
ticketFingerprint)).Code);
}
[Fact]
@@ -56,11 +56,16 @@ public sealed class NatMediationProcessorTests
Assert.Equal(Endpoint("192.168.1.11", 42_000), plan.ClientLocal);
Assert.Equal(Endpoint("203.0.113.20", 51_000), plan.HostPublic);
Assert.Equal(Endpoint("203.0.113.20", 52_000), plan.ClientPublic);
Assert.Equal(43, plan.ConnectionTicket.Length);
Assert.DoesNotContain(plan.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(NatIntroductionTokenCodec.TryDecode(
plan.IntroductionToken,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(attempt.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.Length);
Assert.DoesNotContain(introduction.ConnectionTicket, plan.ToString(), StringComparison.Ordinal);
Assert.True(fixture.Sessions.Capabilities.TryFingerprint(
plan.ConnectionTicket,
introduction.ConnectionTicket,
out SecretFingerprint ticketFingerprint));
Assert.True(fixture.Sessions.Store.ConsumeConnectionTicket(new(
attempt.AttemptId,
@@ -187,7 +192,7 @@ public sealed class NatMediationProcessorTests
}
[Fact]
public async Task CancellationCannotReportSuccessAfterIntroductionIsConsumed()
public async Task CancellationAfterIntroductionCreatesAHostRevocationTombstone()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
@@ -209,9 +214,16 @@ public sealed class NatMediationProcessorTests
attempt.AttemptId,
attempt.ClientCapability);
Assert.Equal(RendezvousErrorCode.Conflict, cancelled.Error);
Assert.True(cancelled.Succeeded);
sink.Release();
Assert.Equal(NatMediationResult.Introduced, await completion);
HostJoinAttempt cancelledAttempt = Assert.Single(fixture.Service.BrowseForHost(
registration.ListingId,
ContractLimits.ContractVersion,
registration.LeaseToken,
ContractLimits.BrowserPageMaxItems,
null).Value!.Items);
Assert.True(cancelledAttempt.IsCancelled);
}
[Fact]
@@ -159,7 +159,12 @@ public sealed class UdpMediatorServiceTests
string hostTicket = Assert.Single(hostTickets.Distinct(StringComparer.Ordinal));
string clientTicket = Assert.Single(clientTickets.Distinct(StringComparer.Ordinal));
Assert.Equal(hostTicket, clientTicket);
Assert.Equal(43, hostTicket.Length);
Assert.True(NatIntroductionTokenCodec.TryDecode(
hostTicket,
out NatIntroductionToken? introduction));
Assert.NotNull(introduction);
Assert.Equal(created.AttemptId, introduction.AttemptId);
Assert.Equal(43, introduction.ConnectionTicket.Length);
}
finally
{
@@ -229,7 +234,9 @@ public sealed class UdpMediatorServiceTests
Assert.True(
hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length
<= clientDatagram.Length * 2,
"The completing authenticated contribution exceeded the 2.0 response-byte budget.");
$"The completing authenticated contribution exceeded the 2.0 response-byte budget: "
+ $"responses={hostIntroduction.Buffer.Length + clientIntroduction.Buffer.Length}, "
+ $"request={clientDatagram.Length}.");
}
finally
{
@@ -12,8 +12,23 @@ TYPE FinalFactory.Rendezvous.Client.ConnectionTicketValidator
METHOD System.Boolean Revoke(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId)
METHOD System.String ToString()
METHOD System.Boolean TryAuthorize(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket, System.DateTimeOffset expiresAt)
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequest
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicket {get;set;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.DirectConnectionRequestCodec
FIELD System.Int32 EncodedLength=63
METHOD System.Byte[] Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String connectionTicket)
METHOD System.Boolean IsRendezvousRequest(System.ReadOnlySpan<System.Byte> encoded)
METHOD System.Boolean TryDecode(System.ReadOnlySpan<System.Byte> encoded, FinalFactory.Rendezvous.Client.DirectConnectionRequest& request)
TYPE FinalFactory.Rendezvous.Client.IRendezvousDelay
METHOD System.Threading.Tasks.Task DelayAsync(System.TimeSpan delay, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Client.PublishedSession>> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken)
@@ -41,6 +56,17 @@ TYPE FinalFactory.Rendezvous.Client.PublishedSession
PROP System.String LeaseToken {get;}
PROP FinalFactory.Rendezvous.Contracts.SessionListingId ListingId {get;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
PROP LiteNetLib.NetPeer ConnectedPeer {get;}
PROP System.Boolean IsCompleted {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
METHOD System.Void Cancel()
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
METHOD System.Void Dispose()
METHOD System.Void Poll()
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
CTOR ()
PROP System.TimeSpan InitialRetryDelay {get;set;}
@@ -56,6 +82,66 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
PROP System.String Message {get;}
PROP System.Nullable<System.Int32> RetryAfterSeconds {get;}
PROP T Value {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
ENUM Punching=1
ENUM Connecting=2
ENUM Connected=3
ENUM Cancelled=4
ENUM TimedOut=5
ENUM Rejected=6
ENUM ManagerStopped=7
ENUM Disposed=8
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
CTOR ()
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
PROP System.TimeSpan InitialPunchRetryDelay {get;set;}
PROP System.Double JitterRatio {get;set;}
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
PROP System.Int32 MaximumPunchRequests {get;set;}
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Client.PublishedSession session, FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options)
PROP System.Int32 PendingAttemptCount {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousHostState State {get;}
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs> AttemptCompleted
METHOD System.Void Dispose()
METHOD System.Void Poll()
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Int32>> RefreshJoinAttemptsAsync(System.Threading.CancellationToken cancellationToken)
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousHostState
ENUM Active=1
ENUM ManagerStopped=2
ENUM Disposed=3
TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.HostJoinAttempt>>> BrowseAllForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseHostJoinAttemptsResponse>> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Boolean>> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse>> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
CTOR ()
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
PROP LiteNetLib.EventBasedNatPunchListener PunchEvents {get;}
METHOD LiteNetLib.NetManager CreateManager()
METHOD System.Void OnConnectionRequest(LiteNetLib.ConnectionRequest request)
METHOD System.Void OnMessageDelivered(LiteNetLib.NetPeer peer, System.Object userData)
METHOD System.Void OnNetworkError(System.Net.IPEndPoint endPoint, System.Net.Sockets.SocketError socketError)
METHOD System.Void OnNetworkLatencyUpdate(LiteNetLib.NetPeer peer, System.Int32 latency)
METHOD System.Void OnNetworkReceive(LiteNetLib.NetPeer peer, LiteNetLib.NetPacketReader reader, System.Byte channelNumber, LiteNetLib.DeliveryMethod deliveryMethod)
METHOD System.Void OnNetworkReceiveUnconnected(System.Net.IPEndPoint remoteEndPoint, LiteNetLib.NetPacketReader reader, LiteNetLib.UnconnectedMessageType messageType)
METHOD System.Void OnNtpResponse(LiteNetLib.Utils.NtpPacket packet)
METHOD System.Void OnPeerAddressChanged(LiteNetLib.NetPeer peer, System.Net.IPEndPoint previousAddress)
METHOD System.Void OnPeerConnected(LiteNetLib.NetPeer peer)
METHOD System.Void OnPeerDisconnected(LiteNetLib.NetPeer peer, LiteNetLib.DisconnectInfo disconnectInfo)
TYPE FinalFactory.Rendezvous.Client.RendezvousPublisherClient
CTOR (System.Net.Http.HttpClient httpClient, FinalFactory.Rendezvous.Client.RendezvousClientOptions options, FinalFactory.Rendezvous.Client.IRendezvousDelay delay)
METHOD FinalFactory.Rendezvous.Client.SessionLeaseMaintainer CreateLeaseMaintainer(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential)
@@ -98,6 +98,7 @@ TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ClientPunchCapability {get;set;}
PROP System.String ConnectionTicketDigest {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
@@ -137,8 +138,10 @@ TYPE FinalFactory.Rendezvous.Contracts.HealthResponse
TYPE FinalFactory.Rendezvous.Contracts.HostJoinAttempt
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicketDigest {get;set;}
PROP System.DateTimeOffset ExpiresAt {get;set;}
PROP System.String HostPunchCapability {get;set;}
PROP System.Boolean IsCancelled {get;set;}
PROP FinalFactory.Rendezvous.Contracts.MediationHandle MediationHandle {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.JoinAttemptId
CTOR (System.Guid value)
@@ -173,6 +176,17 @@ TYPE FinalFactory.Rendezvous.Contracts.MediationHandle
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.MediationHandle& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.MediationHandle left, FinalFactory.Rendezvous.Contracts.MediationHandle right)
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionToken
CTOR ()
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;set;}
PROP System.String ConnectionTicket {get;set;}
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Contracts.NatIntroductionTokenCodec
FIELD System.Int32 EncodedLength=43
METHOD System.String ComputeDigest(System.String connectionTicket)
METHOD System.String Encode(FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, System.String derivedAuthenticator)
METHOD System.Boolean MatchesDigest(System.String connectionTicket, System.String expectedDigest)
METHOD System.Boolean TryDecode(System.String encoded, FinalFactory.Rendezvous.Contracts.NatIntroductionToken& token)
TYPE FinalFactory.Rendezvous.Contracts.NatPunchPeerRole
ENUM HostPresence=1
ENUM Host=2
@@ -1 +1 @@
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}
{"contractVersion":1,"attemptId":"11112233-4455-6677-8899-aabbccddeeff","mediationHandle":"22222233-4455-6677-8899-aabbccddeeff","clientPunchCapability":"Abc_123-xYz","connectionTicketDigest":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expiresAt":"2026-07-16T12:00:00+00:00","dedicatedFallback":{"addressFamily":"ipv6","address":"2001:db8::10","port":9050}}