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
@@ -153,5 +153,6 @@ internal sealed class SessionBrowserService(
static item => item.Key,
static item => item.Value,
StringComparer.Ordinal),
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
};
}
@@ -0,0 +1,134 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.ConnectionOutcomes;
internal sealed record ConnectionOutcomeServiceResult(
RendezvousErrorCode Error,
ReportConnectionOutcomeResponse? Value = null)
{
public bool Succeeded => Error == RendezvousErrorCode.None;
}
internal sealed class ConnectionOutcomeMetrics
{
private readonly object _gate = new();
private readonly Dictionary<(ConnectionOutcomeKind, ConnectionElapsedBucket), long> _counts = [];
internal void Record(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
{
lock (_gate)
{
(ConnectionOutcomeKind, ConnectionElapsedBucket) key = (outcome, elapsedBucket);
_counts.TryGetValue(key, out long count);
_counts[key] = count + 1;
}
}
internal long GetCount(ConnectionOutcomeKind outcome, ConnectionElapsedBucket elapsedBucket)
{
lock (_gate)
{
return _counts.GetValueOrDefault((outcome, elapsedBucket));
}
}
}
internal sealed class ConnectionOutcomeService(
IEphemeralRendezvousStore store,
ISessionCapabilityService capabilities,
ConnectionOutcomeMetrics metrics)
{
internal ConnectionOutcomeServiceResult Report(
JoinAttemptId attemptId,
string? clientPunchCapability,
ReportConnectionOutcomeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(
request.ContractVersion);
if (version != RendezvousErrorCode.None)
{
return new(version);
}
if (attemptId.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(clientPunchCapability)
|| !TryNormalizeReport(request, out ConnectionOutcomeKind outcome, out ConnectionElapsedBucket elapsedBucket)
|| !capabilities.TryFingerprint(
clientPunchCapability,
out SecretFingerprint capabilityFingerprint))
{
return new(RendezvousErrorCode.InvalidRequest);
}
StoreResult<StoredConnectionOutcome> reported = store.ReportConnectionOutcome(new(
attemptId,
capabilityFingerprint,
outcome,
elapsedBucket), cancellationToken);
if (!reported.Succeeded)
{
return new(reported.Code.ToContractError());
}
if (!reported.IsIdempotentReplay)
{
metrics.Record(outcome, elapsedBucket);
}
return new(RendezvousErrorCode.None, new ReportConnectionOutcomeResponse
{
Accepted = true,
IsDuplicate = reported.IsIdempotentReplay,
});
}
private static bool TryNormalizeReport(
ReportConnectionOutcomeRequest request,
out ConnectionOutcomeKind outcome,
out ConnectionElapsedBucket elapsedBucket)
{
outcome = request.Outcome switch
{
ConnectionOutcomeKind.TimedOut => ConnectionOutcomeKind.PunchTimedOut,
ConnectionOutcomeKind.StaleHost => ConnectionOutcomeKind.NoHostPresence,
ConnectionOutcomeKind.TransportFailed => ConnectionOutcomeKind.TransportError,
_ => request.Outcome,
};
if (!ContractValidation.IsReportableConnectionOutcome(request.Outcome))
{
elapsedBucket = default;
return false;
}
if (Enum.IsDefined(request.ElapsedBucket))
{
elapsedBucket = request.ElapsedBucket;
return true;
}
#pragma warning disable CS0618 // Frozen v1 compatibility input; never retained at exact precision.
if (request.ElapsedBucket == default && request.ElapsedMilliseconds >= 0)
{
elapsedBucket = BucketElapsedMilliseconds(request.ElapsedMilliseconds);
return true;
}
#pragma warning restore CS0618
elapsedBucket = default;
return false;
}
private static ConnectionElapsedBucket BucketElapsedMilliseconds(int elapsedMilliseconds) =>
elapsedMilliseconds switch
{
< 1_000 => ConnectionElapsedBucket.UnderOneSecond,
< 5_000 => ConnectionElapsedBucket.OneToFiveSeconds,
< 15_000 => ConnectionElapsedBucket.FiveToFifteenSeconds,
< 30_000 => ConnectionElapsedBucket.FifteenToThirtySeconds,
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
};
}
@@ -1,6 +1,7 @@
using System.Net;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions;
@@ -11,8 +12,6 @@ namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints
{
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints)
{
@@ -83,6 +82,7 @@ internal static class ContractEndpoints
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt");
@@ -95,7 +95,10 @@ internal static class ContractEndpoints
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(StatusCodes.Status501NotImplemented)
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("ReportConnectionOutcome");
return endpoints;
@@ -334,16 +337,20 @@ internal static class ContractEndpoints
private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented();
private static IResult NotImplemented() => Results.Json(
new ApiError
{
Code = RendezvousErrorCode.ServiceUnavailable,
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.",
},
ContractJson.Options,
statusCode: NotImplementedStatus);
[FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromBody] ReportConnectionOutcomeRequest request,
[FromServices] ConnectionOutcomeService outcomes,
CancellationToken cancellationToken)
{
ConnectionOutcomeServiceResult result = outcomes.Report(
attemptId,
clientPunchCapability,
request,
cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static bool TryAuthenticatePublisher(
string? authorizationHeader,
@@ -389,9 +396,11 @@ internal static class ContractEndpoints
{
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired => StatusCodes.Status410Gone,
RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict
or RendezvousErrorCode.IncompatibleProtocol
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone,
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable,
@@ -406,6 +415,7 @@ internal static class ContractEndpoints
RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.",
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
RendezvousErrorCode.Expired => "The session lease has expired.",
RendezvousErrorCode.StaleHost => "The session has no fresh host presence.",
RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
@@ -131,6 +131,7 @@ internal sealed class JoinAttemptService(
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(persisted)),
ExpiresAt = persisted.ExpiresAt,
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
});
}
@@ -1,6 +1,7 @@
using System.Net;
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;
@@ -50,6 +51,14 @@ builder.Services.AddOpenApi("v1", static options =>
BearerFormat = "rv1 publisher credential",
Description = "Tenant-scoped publisher credential issued during game provisioning.",
};
const string attemptSchemeName = "JoinAttemptCapability";
document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
Name = "X-Rendezvous-Client-Punch-Capability",
In = ParameterLocation.Header,
Description = "Attempt-scoped client capability returned only to the joining caller.",
};
HashSet<string> securedOperations = new(StringComparer.Ordinal)
{
@@ -59,6 +68,7 @@ builder.Services.AddOpenApi("v1", static options =>
"DeleteSession",
};
OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
foreach (OpenApiPathItem path in document.Paths.Values)
{
if (path.Operations is null)
@@ -75,6 +85,17 @@ builder.Services.AddOpenApi("v1", static options =>
[reference] = [],
});
}
foreach (OpenApiOperation operation in path.Operations.Values.Where(
operation => operation.OperationId is
"CancelJoinAttempt" or "ReportConnectionOutcome"))
{
operation.Security ??= [];
operation.Security.Add(new OpenApiSecurityRequirement
{
[attemptReference] = [],
});
}
}
return Task.CompletedTask;
@@ -125,6 +146,8 @@ else
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
builder.Services.AddSingleton<ConnectionOutcomeService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true));
}
@@ -55,6 +55,11 @@ internal sealed class SessionLeaseService(
}
AuthorizedPublisherContext context = authorized.Context;
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
string requestFingerprint = ComputeRegistrationFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt();
string leaseToken = capabilities.DeriveCapability(
@@ -119,6 +124,7 @@ internal sealed class SessionLeaseService(
CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers,
Metadata = request.Metadata,
DedicatedFallback = request.DedicatedFallback,
LeaseFingerprint = leaseFingerprint,
HostPresenceHandle = presenceHandle,
HostPresenceFingerprint = presenceFingerprint,
@@ -235,10 +241,14 @@ internal sealed class SessionLeaseService(
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
if (!authorized.IsAllowed)
if (!authorized.IsAllowed || authorized.Context is null)
{
return new(MapAuthorization(authorized.Error));
}
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> updated = store.UpdateListing(new(
@@ -250,7 +260,8 @@ internal sealed class SessionLeaseService(
request.DisplayName,
request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers,
request.Metadata), cancellationToken);
request.Metadata,
request.DedicatedFallback), cancellationToken);
return updated.Succeeded
? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError());
@@ -341,6 +352,9 @@ internal sealed class SessionLeaseService(
metadata,
clock.UtcNow);
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
{
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
@@ -359,6 +373,8 @@ internal sealed class SessionLeaseService(
|| !Enum.IsDefined(request.Visibility)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
@@ -375,6 +391,8 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None;
}
@@ -424,6 +442,14 @@ internal sealed class SessionLeaseService(
Metadata = request.Metadata
.OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal),
DedicatedFallback = request.DedicatedFallback is null
? null
: new NetworkEndpoint
{
AddressFamily = request.DedicatedFallback.AddressFamily,
Address = request.DedicatedFallback.Address,
Port = request.DedicatedFallback.Port,
},
};
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded);
@@ -29,6 +29,7 @@ internal sealed record EphemeralStoreOptions
public int MaxListings { get; init; } = 25_000;
public int MaxPresenceBindings { get; init; } = 25_000;
public int MaxJoinAttempts { get; init; } = 10_000;
public int MaxOutcomeReports { get; init; } = 35_000;
public int MaxReplayEntries { get; init; } = 30_000;
public int MaxRevocations { get; init; } = 10_000;
public int MaxIdempotencyEntries { get; init; } = 35_000;
@@ -45,6 +46,7 @@ internal sealed record EphemeralStoreOptions
RequirePositive(MaxListings, nameof(MaxListings));
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
RequirePositive(MaxRevocations, nameof(MaxRevocations));
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
@@ -173,6 +175,7 @@ internal sealed record ListingDefinition
public required int CurrentPlayers { get; init; }
public required int MaximumPlayers { get; init; }
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required SecretFingerprint LeaseFingerprint { get; init; }
public required MediationHandle HostPresenceHandle { get; init; }
public required SecretFingerprint HostPresenceFingerprint { get; init; }
@@ -189,7 +192,17 @@ internal sealed record StoredListing
public static ListingDefinition Freeze(ListingDefinition source) => source with
{
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal),
DedicatedFallback = CopyEndpoint(source.DedicatedFallback),
};
internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null
? null
: new NetworkEndpoint
{
AddressFamily = endpoint.AddressFamily,
Address = endpoint.Address,
Port = endpoint.Port,
};
}
internal sealed record CreateListingCommand(
@@ -214,7 +227,8 @@ internal sealed record UpdateListingCommand(
string DisplayName,
int CurrentPlayers,
int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata);
IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback);
internal sealed record DeleteListingCommand(
SessionListingId ListingId,
@@ -257,6 +271,7 @@ internal sealed record CreateJoinAttemptCommand
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public int ScopeAttemptLimit { get; init; } = int.MaxValue;
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
@@ -280,6 +295,7 @@ internal sealed record StoredJoinAttempt
public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; }
@@ -316,6 +332,16 @@ internal sealed record CancelJoinAttemptCommand(
JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint);
internal sealed record ReportConnectionOutcomeCommand(
JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint,
ConnectionOutcomeKind Outcome,
ConnectionElapsedBucket ElapsedBucket);
internal sealed record StoredConnectionOutcome(
ConnectionOutcomeKind Outcome,
ConnectionElapsedBucket ElapsedBucket);
internal sealed record ConsumeConnectionTicketCommand(
JoinAttemptId AttemptId,
SecretFingerprint ConnectionTicketFingerprint);
@@ -336,6 +362,8 @@ internal enum StoreResultCode
Draining = 6,
ReplayRejected = 7,
ServiceUnavailable = 8,
StaleHost = 9,
IncompatibleProtocol = 10,
}
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false)
@@ -359,6 +387,7 @@ internal interface IEphemeralRendezvousStore
StoreResult<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default);
StoreResult<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default);
@@ -15,6 +15,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly Dictionary<MediationHandle, SessionListingId> _presenceHandles = [];
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
private readonly Dictionary<string, TimeSpan> _replay = new(StringComparer.Ordinal);
@@ -183,7 +184,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers
|| !ContractValidation.IsMetadataValid(command.Metadata))
|| !ContractValidation.IsMetadataValid(command.Metadata)
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
{
throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
}
@@ -213,6 +216,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
CurrentPlayers = command.CurrentPlayers,
MaximumPlayers = command.MaximumPlayers,
Metadata = command.Metadata,
DedicatedFallback = command.DedicatedFallback,
});
entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry));
@@ -362,14 +366,26 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|| listing.Definition.Scope != command.Scope
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
|| listing.Definition.Scope != command.Scope)
{
return new(StoreResultCode.NotFound);
}
if (listing.Definition.ProtocolVersion != command.ProtocolVersion)
{
return new(StoreResultCode.IncompatibleProtocol);
}
if (!_presence.ContainsKey(listing.Definition.HostPresenceHandle))
{
return new(StoreResultCode.StaleHost);
}
command = command with
{
DedicatedFallback = StoredListing.CopyEndpoint(listing.Definition.DedicatedFallback),
};
if (_attempts.Count >= _options.MaxJoinAttempts
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|| _idempotency.Count >= _options.MaxIdempotencyEntries
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
>= command.ScopeAttemptLimit)
@@ -387,6 +403,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.JoinAttemptLifetime,
WallDeadline(now, _options.JoinAttemptLifetime));
_attempts.Add(command.AttemptId, attempt);
_outcomeReports.Add(command.AttemptId, new(
command.ListingId,
command.ClientSubject,
command.ClientCapabilityFingerprint,
now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime));
_attemptHandles.Add(command.MediationHandle, command.AttemptId);
_idempotency.Add(idempotencyKey, new(
command.RequestFingerprint,
@@ -460,6 +481,42 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Success, true);
}, cancellationToken);
public StoreResult<StoredConnectionOutcome> ReportConnectionOutcome(
ReportConnectionOutcomeCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredConnectionOutcome>(_ =>
{
ArgumentNullException.ThrowIfNull(command);
if (command.AttemptId.Value == Guid.Empty
|| !command.ClientCapabilityFingerprint.IsValid
|| !ContractValidation.IsReportableConnectionOutcome(command.Outcome)
|| !Enum.IsDefined(command.ElapsedBucket))
{
throw new ArgumentException("Connection outcome invariants are invalid.", nameof(command));
}
if (!_available)
{
return new(StoreResultCode.ServiceUnavailable);
}
if (!_outcomeReports.TryGetValue(command.AttemptId, out OutcomeReportEntry? entry)
|| entry.ClientCapabilityFingerprint != command.ClientCapabilityFingerprint)
{
return new(StoreResultCode.NotFound);
}
StoredConnectionOutcome reported = new(command.Outcome, command.ElapsedBucket);
if (entry.Outcome is not null)
{
return entry.Outcome == reported
? new(StoreResultCode.Success, entry.Outcome, true)
: new(StoreResultCode.ReplayRejected);
}
entry.Outcome = reported;
return new(StoreResultCode.Success, reported);
}, cancellationToken);
public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now =>
@@ -681,6 +738,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
.Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
.ToArray();
JoinAttemptId[] outcomeReports = _outcomeReports
.Where(item => string.Equals(item.Value.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key)
.ToArray();
foreach (SessionListingId listingId in listings)
{
RemoveListing(listingId);
@@ -690,6 +751,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{
RemoveAttempt(attemptId);
}
foreach (JoinAttemptId attemptId in outcomeReports)
{
_outcomeReports.Remove(attemptId);
}
return new(StoreResultCode.Success, listings.Length + attempts.Length);
}, cancellationToken);
@@ -799,6 +864,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
RemoveAttempt(attemptId);
}
foreach (JoinAttemptId attemptId in _outcomeReports
.Where(item => item.Value.Deadline <= now)
.Select(static item => item.Key)
.ToArray())
{
_outcomeReports.Remove(attemptId);
}
foreach (SessionListingId listingId in _listings
.Where(item => item.Value.LeaseDeadline <= now)
.Select(static item => item.Key)
@@ -815,6 +888,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_presenceHandles.Clear();
_presence.Clear();
_attempts.Clear();
_outcomeReports.Clear();
_attemptHandles.Clear();
_idempotency.Clear();
_replay.Clear();
@@ -837,6 +911,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{
RemoveAttempt(attemptId);
}
foreach (JoinAttemptId attemptId in _outcomeReports
.Where(item => item.Value.ListingId == listingId)
.Select(static item => item.Key)
.ToArray())
{
_outcomeReports.Remove(attemptId);
}
}
private void RemoveAttempt(JoinAttemptId attemptId)
@@ -875,6 +958,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
ExpiresAt = entry.WallExpiresAt,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
HostEndpoint = entry.HostEndpoint,
@@ -914,6 +998,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| listing.CurrentPlayers < 0
|| listing.CurrentPlayers > listing.MaximumPlayers
|| !ContractValidation.IsMetadataValid(listing.Metadata)
|| listing.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback)
|| !listing.LeaseFingerprint.IsValid
|| !listing.HostPresenceFingerprint.IsValid
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
@@ -954,6 +1040,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0)
{
@@ -1031,6 +1119,19 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public bool IsCancelled { get; set; }
}
private sealed class OutcomeReportEntry(
SessionListingId listingId,
string clientSubject,
SecretFingerprint clientCapabilityFingerprint,
TimeSpan deadline)
{
public SessionListingId ListingId { get; } = listingId;
public string ClientSubject { get; } = clientSubject;
public SecretFingerprint ClientCapabilityFingerprint { get; } = clientCapabilityFingerprint;
public TimeSpan Deadline { get; } = deadline;
public StoredConnectionOutcome? Outcome { get; set; }
}
private sealed record IdempotencyEntry(
string RequestFingerprint,
object ResourceId,
@@ -13,6 +13,8 @@ internal static class StoreResultMapping
StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError,