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

This commit is contained in:
KyuubiYoru
2026-07-16 10:18:41 +02:00
parent b4b6072fe1
commit 94aba8a3bb
47 changed files with 2727 additions and 178 deletions
@@ -102,6 +102,32 @@ public sealed class RendezvousClientBehaviorTests
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays));
}
[Fact]
public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout()
{
using HttpClient httpClient = new(new SilentHandler())
{
BaseAddress = new("http://rendezvous.test/"),
};
RendezvousSessionBrowserClient browser = new(
httpClient,
new RendezvousClientOptions
{
MaximumSafeRetries = 0,
RequestTimeout = TimeSpan.FromMilliseconds(20),
JitterRatio = 0,
});
RendezvousClientResult<BrowseSessionsResponse> result = await browser.BrowseAsync(new()
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
}).WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error);
}
[Fact]
public void SuccessResultRequiresAValue()
{
@@ -339,4 +365,15 @@ public sealed class RendezvousClientBehaviorTests
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
}
private sealed class SilentHandler : HttpMessageHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK);
}
}
}
@@ -7,6 +7,55 @@ namespace FinalFactory.Rendezvous.Tests.Client;
public sealed class RendezvousCoordinatorBehaviorTests
{
[Fact]
public void LegacyCompletionConstructorsRemainCompatibleWithoutAllowingNonterminalStates()
{
#pragma warning disable CS0618
RendezvousConnectionCompletedEventArgs client = new(
RendezvousConnectionState.Rejected,
(NetPeer?)null);
RendezvousHostAttemptCompletedEventArgs host = new(
new JoinAttemptId(Guid.NewGuid()),
RendezvousConnectionState.ManagerStopped,
(NetPeer?)null);
Assert.Throws<ArgumentOutOfRangeException>(() =>
new RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState.Punching,
(NetPeer?)null));
Assert.Throws<ArgumentException>(() =>
new RendezvousHostAttemptCompletedEventArgs(
default,
RendezvousConnectionState.Rejected,
(NetPeer?)null));
#pragma warning restore CS0618
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Outcome.Kind);
Assert.Equal(ConnectionOutcomeKind.ManagerStopped, host.Outcome.Kind);
}
[Theory]
[InlineData(RendezvousErrorCode.NotFound, ConnectionOutcomeKind.DirectoryNotFound, RendezvousConnectionFailureCategory.Directory)]
[InlineData(RendezvousErrorCode.Expired, ConnectionOutcomeKind.AttemptExpired, RendezvousConnectionFailureCategory.Authorization)]
[InlineData(RendezvousErrorCode.IncompatibleProtocol, ConnectionOutcomeKind.IncompatibleProtocol, RendezvousConnectionFailureCategory.Compatibility)]
[InlineData(RendezvousErrorCode.Forbidden, ConnectionOutcomeKind.Unauthorized, RendezvousConnectionFailureCategory.Authorization)]
[InlineData(RendezvousErrorCode.RateLimited, ConnectionOutcomeKind.RateLimited, RendezvousConnectionFailureCategory.Capacity)]
[InlineData(RendezvousErrorCode.StaleHost, ConnectionOutcomeKind.NoHostPresence, RendezvousConnectionFailureCategory.HostPresence)]
[InlineData(RendezvousErrorCode.ServiceUnavailable, ConnectionOutcomeKind.ServiceUnavailable, RendezvousConnectionFailureCategory.Service)]
public void AuthoritativeServiceErrorsMapToStableConnectionOutcomes(
RendezvousErrorCode error,
ConnectionOutcomeKind expectedKind,
RendezvousConnectionFailureCategory expectedCategory)
{
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.FromServiceError(
error,
TimeSpan.FromMilliseconds(250));
Assert.Equal(expectedKind, outcome.Kind);
Assert.Equal(expectedCategory, outcome.Category);
Assert.Equal(RendezvousConnectionOutcomeSource.RendezvousService, outcome.Source);
Assert.Equal(error, outcome.ServiceError);
}
[Fact]
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
{
@@ -43,6 +92,29 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.False(harness.Coordinator.IsCompleted);
}
[Fact]
public void MediatorNetworkErrorProducesOneTypedTerminalOutcome()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock);
int completions = 0;
harness.Coordinator.Completed += (_, _) => completions++;
harness.NetworkEvents.OnNetworkError(
new IPEndPoint(IPAddress.Loopback, 65_001),
System.Net.Sockets.SocketError.HostUnreachable);
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
harness.Coordinator.Outcome);
harness.NetworkEvents.OnNetworkError(
new IPEndPoint(IPAddress.Loopback, 65_001),
System.Net.Sockets.SocketError.HostUnreachable);
Assert.Equal(ConnectionOutcomeKind.MediatorUnavailable, outcome.Kind);
Assert.Equal(RendezvousConnectionFailureCategory.Mediation, outcome.Category);
Assert.Equal(1, completions);
Assert.Same(outcome, harness.Coordinator.Outcome);
}
[Fact]
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
{
@@ -61,6 +133,7 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(ConnectionOutcomeKind.Cancelled, harness.Coordinator.Outcome!.Kind);
}
[Fact]
@@ -85,6 +158,68 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
Assert.Equal(1, completions);
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
Assert.Equal(
RendezvousConnectionFailureCategory.NatTraversal,
harness.Coordinator.Outcome.Category);
}
[Fact]
public void WallClockRollbackCannotExtendTheMonotonicPunchDeadline()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
PunchTimeout = TimeSpan.FromSeconds(10),
JitterRatio = 0,
});
clock.AdjustWallClock(TimeSpan.FromHours(-1));
clock.Advance(TimeSpan.FromSeconds(11));
harness.Coordinator.Poll();
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind);
Assert.Equal(TimeSpan.FromSeconds(11), harness.Coordinator.Outcome.Elapsed);
}
[Fact]
public void DirectConnectTimeoutOffersFallbackWithoutConnectingIt()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
NetworkEndpoint fallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.90",
Port = 9_060,
};
using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions
{
DirectConnectTimeout = TimeSpan.FromMilliseconds(10),
DedicatedFallbackOverride = fallback,
JitterRatio = 0,
});
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_000),
NatAddressType.External,
harness.IntroductionToken);
clock.Advance(TimeSpan.FromMilliseconds(10));
harness.Coordinator.Poll();
RendezvousConnectionOutcome outcome = Assert.IsType<RendezvousConnectionOutcome>(
harness.Coordinator.Outcome);
Assert.Equal(ConnectionOutcomeKind.DirectConnectTimedOut, outcome.Kind);
Assert.Equal(RendezvousConnectionPhase.DirectConnection, outcome.Phase);
Assert.Equal("203.0.113.90", outcome.DedicatedFallback!.Address);
List<NetPeer> connectedPeers = [];
harness.Manager.GetConnectedPeers(connectedPeers);
Assert.Empty(connectedPeers);
((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess(
new IPEndPoint(IPAddress.Loopback, 65_001),
NatAddressType.External,
harness.IntroductionToken);
Assert.Same(outcome, harness.Coordinator.Outcome);
}
[Fact]
@@ -145,6 +280,10 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
Assert.Equal(1, completions);
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Coordinator.Outcome!.Kind);
Assert.Equal(
RendezvousConnectionOutcomeSource.RemoteHost,
client.Coordinator.Outcome.Source);
client.Coordinator.Poll();
Assert.Equal(1, completions);
}
@@ -409,7 +548,7 @@ public sealed class RendezvousCoordinatorBehaviorTests
}
[Fact]
public async Task HostBoundsAttemptExpiryChecksPerPoll()
public async Task HostDeadlinesAreNotDelayedByTheBoundedRetryQueue()
{
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new();
@@ -453,9 +592,6 @@ public sealed class RendezvousCoordinatorBehaviorTests
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],
@@ -467,6 +603,112 @@ public sealed class RendezvousCoordinatorBehaviorTests
}
}
[Fact]
public async Task HostStopPublishesEveryCompletionBeforeReentrantDisposalCanTearDownState()
{
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-000000000161"));
JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000162"));
MutableJoinClient joins = new([
CreateHostAttempt(
firstId,
new(Guid.Parse("00000000-0000-0000-0000-000000000163")),
NatIntroductionTokenCodec.Encode(firstId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
CreateHostAttempt(
secondId,
new(Guid.Parse("00000000-0000-0000-0000-000000000164")),
NatIntroductionTokenCodec.Encode(secondId, Credential('U')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
]);
RendezvousHostCoordinator? host = null;
try
{
Assert.True(manager.Start(0));
host = new(
manager,
networkEvents,
new IPEndPoint(IPAddress.Loopback, 65_002),
CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)),
joins,
new RendezvousCoordinatorOptions { JitterRatio = 0 },
clock,
null);
int completions = 0;
host.AttemptCompleted += (_, _) =>
{
completions++;
if (completions == 1)
{
host.Dispose();
}
};
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
manager.Stop();
host.Poll();
Assert.Equal(2, completions);
Assert.Equal(0, host.PendingAttemptCount);
}
finally
{
host?.Dispose();
manager.Stop();
}
}
[Fact]
public async Task HostPunchTimeoutUsesItsOwnFakeClockBudget()
{
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-000000000161"));
MutableJoinClient joins = new([
CreateHostAttempt(
attemptId,
new(Guid.Parse("00000000-0000-0000-0000-000000000162")),
NatIntroductionTokenCodec.Encode(attemptId, Credential('T')),
clock.UtcNow + TimeSpan.FromSeconds(30)),
]);
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
{
MaximumPunchRequests = 20,
PunchTimeout = TimeSpan.FromMilliseconds(10),
JitterRatio = 0,
},
clock,
null);
RendezvousHostAttemptCompletedEventArgs? completion = null;
host.AttemptCompleted += (_, value) => completion = value;
Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess);
host.Poll();
clock.Advance(TimeSpan.FromMilliseconds(10));
host.Poll();
Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, completion!.Outcome.Kind);
Assert.Equal(TimeSpan.FromMilliseconds(10), completion.Outcome.Elapsed);
}
finally
{
manager.Stop();
}
}
private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
@@ -548,6 +790,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
{
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts;
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -567,6 +814,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(Attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class BlockingJoinClient : IRendezvousJoinClient
@@ -581,6 +833,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
_result.SetResult(attempts);
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -603,6 +860,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
_called.SetResult(true);
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken));
}
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class ManualCoordinatorClock(DateTimeOffset now) :
@@ -610,7 +872,14 @@ public sealed class RendezvousCoordinatorBehaviorTests
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; private set; } = now;
public TimeSpan Elapsed { get; private set; }
internal void Advance(TimeSpan amount) => UtcNow += amount;
internal void Advance(TimeSpan amount)
{
UtcNow += amount;
Elapsed += amount;
}
internal void AdjustWallClock(TimeSpan amount) => UtcNow += amount;
}
}
@@ -242,6 +242,11 @@ public sealed class RendezvousCoordinatorIntegrationTests
private sealed class FakeJoinClient(IReadOnlyList<HostJoinAttempt> attempts) : IRendezvousJoinClient
{
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -261,6 +266,11 @@ public sealed class RendezvousCoordinatorIntegrationTests
int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class FixedCoordinatorClock(DateTimeOffset now) :
@@ -268,5 +278,6 @@ public sealed class RendezvousCoordinatorIntegrationTests
IConnectionTicketClock
{
public DateTimeOffset UtcNow { get; } = now;
public TimeSpan Elapsed => TimeSpan.Zero;
}
}
@@ -83,6 +83,110 @@ public sealed class RendezvousJoinClientTests
Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal);
}
[Fact]
public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket()
{
CreateJoinAttemptResponse attempt = CreateAttempt();
RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse
{
Accepted = true,
IsDuplicate = false,
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection,
TimeSpan.FromSeconds(6));
RendezvousClientResult<ReportConnectionOutcomeResponse> result =
await client.ReportOutcomeAsync(attempt, outcome);
Assert.True(result.IsSuccess, result.Message);
RecordedRequest request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(
attempt.ClientPunchCapability,
request.Headers["X-Rendezvous-Client-Punch-Capability"]);
Assert.Contains("\"outcome\":\"directConnectTimedOut\"", request.Body, StringComparison.Ordinal);
Assert.Contains("\"elapsedBucket\":\"fiveToFifteenSeconds\"", request.Body, StringComparison.Ordinal);
Assert.DoesNotContain("diagnostic", request.Body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ConnectionStartReturnsATypedServiceOutcomeInsteadOfAnUnboundedFailure()
{
RecordingHandler handler = new(JsonResponse(HttpStatusCode.NotFound, new ApiError
{
Code = RendezvousErrorCode.NotFound,
Message = "listing unavailable",
}));
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
NetworkEndpoint fallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.93",
Port = 9_063,
};
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
new CreateJoinAttemptRequest
{
IdempotencyKey = "typed-start",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
},
fallback);
Assert.True(result.IsCompleted);
Assert.False(result.IsReadyForTraversal);
Assert.Null(result.Attempt);
Assert.Equal(ConnectionOutcomeKind.DirectoryNotFound, result.Outcome!.Kind);
Assert.Equal("203.0.113.93", result.Outcome.DedicatedFallback!.Address);
}
[Fact]
public async Task ConnectionStartReturnsCancelledForAPrecancelledCallerToken()
{
RecordingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
cancellation.Cancel();
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-before-send"),
cancellationToken: cancellation.Token);
Assert.Empty(handler.Requests);
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
[Fact]
public async Task ConnectionStartReturnsCancelledWhenCallerStopsASilentRequest()
{
CancellingHandler handler = new();
using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") };
RendezvousJoinClient client = new(http);
using CancellationTokenSource cancellation = new();
Task<RendezvousConnectionStartResult> pending = client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-in-flight"),
cancellationToken: cancellation.Token);
await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
await cancellation.CancelAsync();
RendezvousConnectionStartResult result = await pending;
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
}
private static CreateJoinAttemptResponse CreateAttempt() => new()
{
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")),
@@ -95,6 +199,15 @@ public sealed class RendezvousJoinClientTests
ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero),
};
private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new()
{
IdempotencyKey = idempotencyKey,
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")),
ProtocolVersion = 7,
};
private static HostJoinAttempt CreateHostAttempt(string id) => new()
{
AttemptId = new(Guid.Parse(id)),
@@ -137,6 +250,22 @@ public sealed class RendezvousJoinClientTests
}
}
private sealed class CancellingHandler : HttpMessageHandler
{
internal TaskCompletionSource Started { get; } = new(
TaskCreationOptions.RunContinuationsAsynchronously);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_ = request;
Started.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
throw new InvalidOperationException("The silent request unexpectedly completed.");
}
}
private sealed record RecordedRequest(
HttpMethod Method,
Uri Uri,
@@ -0,0 +1,175 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Tests.JoinAttempts;
namespace FinalFactory.Rendezvous.Tests.ConnectionOutcomes;
public sealed class ConnectionOutcomeServiceTests
{
[Fact]
public void ReportRemainsAuthenticatedAfterAttemptExpiryAndCountsOnlyOnce()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeMetrics metrics = new();
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
metrics);
ReportConnectionOutcomeRequest report = new()
{
Outcome = ConnectionOutcomeKind.PunchTimedOut,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
};
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
ConnectionOutcomeServiceResult first = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
report);
ConnectionOutcomeServiceResult duplicate = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
report);
ConnectionOutcomeServiceResult conflict = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Connected,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
});
Assert.True(first.Succeeded);
Assert.False(first.Value!.IsDuplicate);
Assert.True(duplicate.Succeeded);
Assert.True(duplicate.Value!.IsDuplicate);
Assert.Equal(RendezvousErrorCode.ReplayRejected, conflict.Error);
Assert.Equal(
1,
metrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
new string('X', ContractLimits.DerivedCredentialCharacters),
report).Error);
}
[Theory]
[InlineData(ConnectionOutcomeKind.DirectoryNotFound)]
[InlineData(ConnectionOutcomeKind.IncompatibleProtocol)]
[InlineData(ConnectionOutcomeKind.Unauthorized)]
[InlineData(ConnectionOutcomeKind.RateLimited)]
public void ReportRejectsOutcomesThatCouldNotHaveAnIssuedAttempt(
ConnectionOutcomeKind outcome)
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
ConnectionOutcomeServiceResult result = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = outcome,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
});
Assert.Equal(RendezvousErrorCode.InvalidRequest, result.Error);
}
[Fact]
public void ListingDeletionRemovesRetainedOutcomeAuthorization()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
Assert.True(fixture.Sessions.Store.RevokeListing(registration.ListingId).Succeeded);
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Cancelled,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
}).Error);
}
[Fact]
public void PrincipalRevocationRemovesReportAuthorizationAfterAttemptExpiry()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
new ConnectionOutcomeMetrics());
fixture.Sessions.Clock.Advance(fixture.Sessions.StoreOptions.JoinAttemptLifetime);
Assert.True(fixture.Sessions.Store.RevokePrincipal(
fixture.ClientSubject,
TimeSpan.FromMinutes(1)).Succeeded);
Assert.Equal(
RendezvousErrorCode.NotFound,
service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Cancelled,
ElapsedBucket = ConnectionElapsedBucket.UnderOneSecond,
}).Error);
}
[Fact]
public void FrozenV1ReportFieldsAreAcceptedButNormalizedBeforeRetention()
{
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse registration, _) = fixture.CreateHost();
CreateJoinAttemptResponse attempt = fixture.Create(registration.ListingId);
ConnectionOutcomeMetrics metrics = new();
ConnectionOutcomeService service = new(
fixture.Sessions.Store,
fixture.Sessions.Capabilities,
metrics);
#pragma warning disable CS0618 // Deliberately exercises the frozen legacy input surface.
ReportConnectionOutcomeRequest legacy = new()
{
Outcome = ConnectionOutcomeKind.TimedOut,
ElapsedMilliseconds = 6_000,
DiagnosticCode = "legacy-text-is-discarded",
};
#pragma warning restore CS0618
ConnectionOutcomeServiceResult result = service.Report(
attempt.AttemptId,
attempt.ClientPunchCapability,
legacy);
Assert.True(result.Succeeded);
Assert.Equal(
1,
metrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
}
}
@@ -70,10 +70,10 @@ public sealed class ContractSerializationTests
public void UnknownEnumNamesAndNumericValuesAreRejected()
{
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}",
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}",
"{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options));
}
@@ -98,6 +98,7 @@ public sealed class ContractSerializationTests
[Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{
Assert.Equal(9, ContractJson.Options.MaxDepth);
Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true);
@@ -22,6 +22,7 @@ public sealed class OpenApiCompatibilityTests
"buildVersion",
"capacity",
"contractVersion",
"dedicatedFallback",
"displayName",
"environmentId",
"gameId",
@@ -63,14 +64,46 @@ public sealed class OpenApiCompatibilityTests
Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.GetProperty("dedicatedFallback");
JsonElement fallbackReference = Assert.Single(
dedicatedFallback.GetProperty("oneOf").EnumerateArray(),
static schema => schema.TryGetProperty("$ref", out _));
Assert.Equal(
"#/components/schemas/NetworkEndpoint",
fallbackReference.GetProperty("$ref").GetString());
JsonElement outcomeReportProperties = schemas.GetProperty("ReportConnectionOutcomeRequest")
.GetProperty("properties");
Assert.True(outcomeReportProperties.TryGetProperty("elapsedBucket", out _));
Assert.True(outcomeReportProperties.TryGetProperty("elapsedMilliseconds", out _));
Assert.True(outcomeReportProperties.TryGetProperty("diagnosticCode", out _));
string[] outcomeNames = schemas.GetProperty("ConnectionOutcomeKind")
.GetProperty("enum")
.EnumerateArray()
.Select(static value => value.GetString()!)
.ToArray();
Assert.Contains("timedOut", outcomeNames);
Assert.Contains("staleHost", outcomeNames);
Assert.Contains("transportFailed", outcomeNames);
Assert.Contains("punchTimedOut", outcomeNames);
Assert.Contains("directConnectTimedOut", outcomeNames);
Assert.Contains("transportError", outcomeNames);
JsonElement publisherBearer = root.GetProperty("components")
.GetProperty("securitySchemes")
.GetProperty("PublisherBearer");
Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString());
JsonElement attemptCapability = root.GetProperty("components")
.GetProperty("securitySchemes")
.GetProperty("JoinAttemptCapability");
Assert.Equal("apiKey", attemptCapability.GetProperty("type").GetString());
Assert.Equal(
"X-Rendezvous-Client-Punch-Capability",
attemptCapability.GetProperty("name").GetString());
(string Path, string Method)[] publisherOperations =
[
("/v1/sessions", "post"),
@@ -96,6 +129,18 @@ public sealed class OpenApiCompatibilityTests
&& parameter.GetProperty("name").GetString()
== "X-Rendezvous-Client-Punch-Capability");
Assert.True(cancelCapability.GetProperty("required").GetBoolean());
foreach ((string operationPath, string method) in new[]
{
("/v1/join-attempts/{attemptId}", "delete"),
("/v1/join-attempts/{attemptId}/outcome", "post"),
})
{
JsonElement security = root.GetProperty("paths")
.GetProperty(operationPath)
.GetProperty(method)
.GetProperty("security");
Assert.True(security[0].TryGetProperty("JoinAttemptCapability", out _));
}
JsonElement hostPollParameters = root.GetProperty("paths")
.GetProperty("/v1/sessions/{listingId}/join-attempts")
.GetProperty("get")
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
@@ -103,6 +104,139 @@ public sealed class JoinAttemptHttpEndpointTests
Assert.True(cancelledAttempt.IsCancelled);
}
[Fact]
public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp()
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
Assert.True(host.Capabilities.TryFingerprint(
session.HostPresenceCapability,
out SecretFingerprint presenceFingerprint));
Assert.True(host.Store.BindHostPresence(new(
session.HostPresenceHandle,
presenceFingerprint,
new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000),
null)).Succeeded);
using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = "outcome-report-1",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = 7,
},
ContractJson.Options);
CreateJoinAttemptResponse created = Assert.IsType<CreateJoinAttemptResponse>(
await createdResponse.Content.ReadFromJsonAsync<CreateJoinAttemptResponse>(ContractJson.Options));
ReportConnectionOutcomeRequest report = new()
{
Outcome = ConnectionOutcomeKind.PunchTimedOut,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
};
ReportConnectionOutcomeResponse first = await SendOutcomeAsync(
host.HttpClient,
created,
report);
ReportConnectionOutcomeResponse duplicate = await SendOutcomeAsync(
host.HttpClient,
created,
report);
Assert.True(first.Accepted);
Assert.False(first.IsDuplicate);
Assert.True(duplicate.Accepted);
Assert.True(duplicate.IsDuplicate);
Assert.Equal(
1,
host.OutcomeMetrics.GetCount(
ConnectionOutcomeKind.PunchTimedOut,
ConnectionElapsedBucket.FiveToFifteenSeconds));
using HttpRequestMessage conflictRequest = OutcomeRequest(
created,
new ReportConnectionOutcomeRequest
{
Outcome = ConnectionOutcomeKind.Connected,
ElapsedBucket = ConnectionElapsedBucket.FiveToFifteenSeconds,
});
using HttpResponseMessage conflict = await host.HttpClient.SendAsync(conflictRequest);
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
using HttpRequestMessage unauthorizedRequest = OutcomeRequest(created, report);
unauthorizedRequest.Headers.Remove("X-Rendezvous-Client-Punch-Capability");
unauthorizedRequest.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
new string('X', ContractLimits.DerivedCredentialCharacters));
using HttpResponseMessage unauthorized = await host.HttpClient.SendAsync(unauthorizedRequest);
Assert.Equal(HttpStatusCode.NotFound, unauthorized.StatusCode);
}
[Theory]
[InlineData(7u, HttpStatusCode.Gone, RendezvousErrorCode.StaleHost)]
[InlineData(8u, HttpStatusCode.Conflict, RendezvousErrorCode.IncompatibleProtocol)]
public async Task JoinCreationPreservesTypedTerminalErrorsOverHttp(
uint protocolVersion,
HttpStatusCode expectedStatus,
RendezvousErrorCode expectedError)
{
await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync();
RendezvousPublisherClient publisher = new(host.HttpClient);
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
CreateRegistration(),
host.PublisherCredential));
using HttpResponseMessage response = await host.HttpClient.PostAsJsonAsync(
"v1/join-attempts",
new CreateJoinAttemptRequest
{
IdempotencyKey = $"typed-http-error-{protocolVersion}",
GameId = new("space-game"),
EnvironmentId = new("production"),
ListingId = session.ListingId,
ProtocolVersion = protocolVersion,
},
ContractJson.Options);
Assert.Equal(expectedStatus, response.StatusCode);
ApiError error = Assert.IsType<ApiError>(
await response.Content.ReadFromJsonAsync<ApiError>(ContractJson.Options));
Assert.Equal(expectedError, error.Code);
}
private static async Task<ReportConnectionOutcomeResponse> SendOutcomeAsync(
HttpClient client,
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
using HttpRequestMessage request = OutcomeRequest(attempt, report);
using HttpResponseMessage response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return Assert.IsType<ReportConnectionOutcomeResponse>(
await response.Content.ReadFromJsonAsync<ReportConnectionOutcomeResponse>(ContractJson.Options));
}
private static HttpRequestMessage OutcomeRequest(
CreateJoinAttemptResponse attempt,
ReportConnectionOutcomeRequest report)
{
HttpRequestMessage request = new(
HttpMethod.Post,
$"v1/join-attempts/{attempt.AttemptId}/outcome")
{
Content = JsonContent.Create(report, options: ContractJson.Options),
};
request.Headers.Add(
"X-Rendezvous-Client-Punch-Capability",
attempt.ClientPunchCapability);
return request;
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
@@ -132,18 +266,21 @@ public sealed class JoinAttemptHttpEndpointTests
HttpClient httpClient,
InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities,
ConnectionOutcomeMetrics outcomeMetrics,
string publisherCredential)
{
_application = application;
HttpClient = httpClient;
Store = store;
Capabilities = capabilities;
OutcomeMetrics = outcomeMetrics;
PublisherCredential = publisherCredential;
}
internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; }
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync()
@@ -181,6 +318,9 @@ public sealed class JoinAttemptHttpEndpointTests
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
ConnectionOutcomeMetrics outcomeMetrics = new();
builder.Services.AddSingleton(outcomeMetrics);
builder.Services.AddSingleton<ConnectionOutcomeService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
@@ -193,6 +333,7 @@ public sealed class JoinAttemptHttpEndpointTests
new HttpClient { BaseAddress = new Uri(address) },
store,
capabilities,
outcomeMetrics,
credential);
}
@@ -1,6 +1,8 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
@@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests
using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal(
RendezvousErrorCode.NotFound,
RendezvousErrorCode.StaleHost,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost();
@@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error);
}
[Fact]
public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed()
{
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
policy.ProtocolVersions.Add(8);
using JoinAttemptFixture fixture = new(joinPolicy: policy);
(RegisterSessionResponse active, _) = fixture.CreateHost();
CreateJoinAttemptRequest request = fixture.Request(active.ListingId);
request.ProtocolVersion = 8;
Assert.Equal(
RendezvousErrorCode.IncompatibleProtocol,
fixture.Service.Create(fixture.ClientSubject, request).Error);
}
[Fact]
public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate()
{
using JoinAttemptFixture fixture = new();
RegisterSessionRequest registrationRequest = fixture.Sessions.Request();
registrationRequest.DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.91",
Port = 9_061,
};
RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest);
Assert.True(fixture.Sessions.BindPresence(registration).Succeeded);
CreateJoinAttemptResponse created = fixture.Create(registration.ListingId);
Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address);
Assert.Equal(9_061, created.DedicatedFallback.Port);
}
[Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{
@@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable
{
private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null)
public JoinAttemptFixture(
EphemeralStoreOptions? options = null,
GamePolicyOptions? joinPolicy = null)
{
Sessions = new(options);
Cursors = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
GamePolicyRegistry policies = GamePolicyRegistry.Create([
joinPolicy ?? ProvisioningTestData.CreatePolicy(),
]);
Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40"));
}
@@ -3,6 +3,7 @@ using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Sessions;
@@ -118,6 +119,12 @@ public sealed class SessionLeaseServiceTests
["mode"] = "co-op",
["map"] = "europa",
},
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.92",
Port = 9_062,
},
});
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!;
@@ -128,6 +135,7 @@ public sealed class SessionLeaseServiceTests
Assert.Equal(fixture.Scope, stored.Definition.Scope);
Assert.Equal(8, stored.Definition.CurrentPlayers);
Assert.Equal(8, stored.Definition.MaximumPlayers);
Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address);
Assert.True(fixture.Service.Delete(
fixture.Principal,
registration.ListingId,
@@ -139,6 +147,45 @@ public sealed class SessionLeaseServiceTests
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code);
}
[Fact]
public void DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints()
{
GamePolicyOptions policy = ProvisioningTestData.CreatePolicy();
policy.FallbackPolicy = FallbackPolicyMode.Disabled;
using SessionLeaseFixture fixture = new(policyOptions: policy);
RegisterSessionRequest registrationRequest = fixture.Request();
registrationRequest.DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.94",
Port = 9_064,
};
Assert.Equal(
RendezvousErrorCode.Forbidden,
fixture.Service.Register(fixture.Principal, registrationRequest).Error);
RegisterSessionResponse registration = fixture.Register();
Assert.Equal(
RendezvousErrorCode.Forbidden,
fixture.Service.Update(
fixture.Principal,
registration.ListingId,
new UpdateSessionRequest
{
LeaseToken = registration.LeaseToken,
BuildVersion = "1.4.3",
DisplayName = "Europa Updated",
Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["mode"] = "co-op",
["map"] = "europa",
},
DedicatedFallback = registrationRequest.DedicatedFallback,
}).Error);
}
[Fact]
public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
{
@@ -11,13 +11,17 @@ internal sealed class SessionLeaseFixture : IDisposable
{
private int _sequence;
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null)
public SessionLeaseFixture(
EphemeralStoreOptions? storeOptions = null,
GamePolicyOptions? policyOptions = null)
{
StoreOptions = storeOptions ?? new EphemeralStoreOptions();
Clock = new();
Store = new(StoreOptions, Clock, Clock);
Capabilities = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]);
GamePolicyRegistry policies = GamePolicyRegistry.Create([
policyOptions ?? ProvisioningTestData.CreatePolicy(),
]);
Service = new(
new PublisherAuthorizationService(policies),
Store,
@@ -134,13 +134,13 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt).Code);
Assert.Equal(StoreResultCode.StaleHost, fixture.Store.CreateJoinAttempt(attempt).Code);
fixture.Store.BindHostPresence(new(
listingCommand.Listing.HostPresenceHandle,
listingCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000),
null));
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
Assert.Equal(StoreResultCode.IncompatibleProtocol, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code);
Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
{
Scope = new(new("other-game"), new("test")),
@@ -19,6 +19,8 @@ public sealed class StoreResultMappingTests
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost,
[StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol,
};
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count);
@@ -29,6 +29,8 @@ TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient
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)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, 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)
@@ -60,12 +62,14 @@ 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.RendezvousConnectionOutcome Outcome {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.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken)
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
CTOR ()
@@ -73,6 +77,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
PROP System.Double JitterRatio {get;set;}
PROP System.TimeSpan MaximumRetryDelay {get;set;}
PROP System.Int32 MaximumSafeRetries {get;set;}
PROP System.TimeSpan RequestTimeout {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
@@ -84,8 +89,55 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
PROP T Value {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory
ENUM None=0
ENUM Directory=1
ENUM Compatibility=2
ENUM Authorization=3
ENUM Capacity=4
ENUM HostPresence=5
ENUM Service=6
ENUM Mediation=7
ENUM NatTraversal=8
ENUM DirectConnection=9
ENUM Lifecycle=10
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory Category {get;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;}
PROP System.TimeSpan Elapsed {get;}
PROP System.Boolean HasDedicatedFallback {get;}
PROP System.Boolean IsSuccess {get;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Kind {get;}
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionPhase Phase {get;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RendezvousErrorCode> ServiceError {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource Source {get;}
METHOD FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket BucketElapsed(System.TimeSpan elapsed)
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome FromServiceError(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.TimeSpan elapsed, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback)
METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource
ENUM RendezvousService=1
ENUM LocalTraversal=2
ENUM RemoteHost=3
ENUM Caller=4
ENUM Lifecycle=5
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionPhase
ENUM Directory=1
ENUM Authorization=2
ENUM Mediation=3
ENUM NatTraversal=4
ENUM DirectConnection=5
ENUM Complete=6
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult
PROP FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse Attempt {get;}
PROP System.Boolean IsCompleted {get;}
PROP System.Boolean IsReadyForTraversal {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult Completed(FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome)
METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult ReadyForTraversal(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt)
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
ENUM Punching=1
ENUM Connecting=2
@@ -98,14 +150,18 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
CTOR ()
PROP System.TimeSpan ConnectionTicketLifetime {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallbackOverride {get;set;}
PROP System.TimeSpan DirectConnectTimeout {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;}
PROP System.TimeSpan PunchTimeout {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 FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator
@@ -127,6 +183,8 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient
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)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult> CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse>> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
CTOR ()
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
@@ -28,6 +28,12 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
ENUM UnderOneSecond=1
ENUM OneToFiveSeconds=2
ENUM FiveToFifteenSeconds=3
ENUM FifteenToThirtySeconds=4
ENUM ThirtySecondsOrMore=5
TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM Connected=1
ENUM Cancelled=2
@@ -38,6 +44,18 @@ TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM HostRejected=7
ENUM TransportFailed=8
ENUM FallbackOffered=9
ENUM DirectoryNotFound=10
ENUM AttemptExpired=11
ENUM Unauthorized=12
ENUM RateLimited=13
ENUM NoHostPresence=14
ENUM ServiceUnavailable=15
ENUM MediatorUnavailable=16
ENUM PunchTimedOut=17
ENUM DirectConnectTimedOut=18
ENUM TransportError=19
ENUM ManagerStopped=20
ENUM Disposed=21
TYPE FinalFactory.Rendezvous.Contracts.ContractJson
PROP System.Text.Json.JsonSerializerOptions Options {get;}
METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options)
@@ -84,6 +102,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize)
METHOD System.Boolean IsReportableConnectionOutcome(FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind outcome)
METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
@@ -234,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
@@ -288,12 +308,14 @@ TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String DiagnosticCode {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket ElapsedBucket {get;set;}
PROP System.Int32 ElapsedMilliseconds {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
CTOR ()
PROP System.Boolean Accepted {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP System.Boolean IsDuplicate {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
CTOR ()
PROP System.Int32 CurrentPlayers {get;set;}
@@ -303,6 +325,7 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListing
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.String DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
@@ -344,6 +367,7 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;}
PROP System.String DisplayName {get;set;}
PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}