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
+7 -2
View File
@@ -17,7 +17,7 @@ Rendezvous is intended to provide:
- Isolation by game, environment, protocol version, and region. - Isolation by game, environment, protocol version, and region.
- Operational health, metrics, logging, administration, and rate limiting. - Operational health, metrics, logging, administration, and rate limiting.
UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server or a future relay service. UDP hole punching cannot guarantee a direct connection through every network. Symmetric NAT, carrier-grade NAT, restrictive firewalls, and platform policies can prevent it. Consumers must therefore support a defined fallback, such as a dedicated server. The v1 SDK returns an optional game-configured endpoint for an explicit caller decision; it never routes automatically, and v1 does not provide a relay.
## Connection flow ## Connection flow
@@ -75,7 +75,12 @@ The initial service does not provide:
## Project status ## Project status
Rendezvous is currently in its initial design and bootstrap stage. The first implementation should establish the contracts, directory leases, LiteNetLib mediator, client SDK, thin test client, and a three-party integration test before either game depends on it for production connectivity. Rendezvous is under active roadmap development. The versioned contracts,
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, and typed connection outcomes are implemented. The thin test
client, deployment hardening, and production-readiness roadmap remain in progress;
participating games must not treat the current repository as a finished production
service until those gates land.
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md). threat model are indexed in [the architecture documentation](docs/architecture/README.md).
+129 -8
View File
@@ -776,6 +776,16 @@
} }
} }
}, },
"410": {
"description": "Gone",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"429": { "429": {
"description": "Too Many Requests", "description": "Too Many Requests",
"content": { "content": {
@@ -857,7 +867,12 @@
} }
} }
} }
} },
"security": [
{
"JoinAttemptCapability": [ ]
}
]
} }
}, },
"/v1/join-attempts/{attemptId}/outcome": { "/v1/join-attempts/{attemptId}/outcome": {
@@ -874,6 +889,14 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "X-Rendezvous-Client-Punch-Capability",
"in": "header",
"required": true,
"schema": {
"type": "string"
}
} }
], ],
"requestBody": { "requestBody": {
@@ -897,8 +920,38 @@
} }
} }
}, },
"501": { "400": {
"description": "Not Implemented", "description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -907,7 +960,12 @@
} }
} }
} }
} },
"security": [
{
"JoinAttemptCapability": [ ]
}
]
} }
} }
}, },
@@ -1002,6 +1060,15 @@
} }
} }
}, },
"ConnectionElapsedBucket": {
"enum": [
"underOneSecond",
"oneToFiveSeconds",
"fiveToFifteenSeconds",
"fifteenToThirtySeconds",
"thirtySecondsOrMore"
]
},
"ConnectionOutcomeKind": { "ConnectionOutcomeKind": {
"enum": [ "enum": [
"connected", "connected",
@@ -1012,7 +1079,19 @@
"serviceRejected", "serviceRejected",
"hostRejected", "hostRejected",
"transportFailed", "transportFailed",
"fallbackOffered" "fallbackOffered",
"directoryNotFound",
"attemptExpired",
"unauthorized",
"rateLimited",
"noHostPresence",
"serviceUnavailable",
"mediatorUnavailable",
"punchTimedOut",
"directConnectTimedOut",
"transportError",
"managerStopped",
"disposed"
] ]
}, },
"CreateJoinAttemptRequest": { "CreateJoinAttemptRequest": {
@@ -1278,6 +1357,16 @@
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
} }
},
"dedicatedFallback": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NetworkEndpoint"
}
]
} }
} }
}, },
@@ -1388,8 +1477,7 @@
"ReportConnectionOutcomeRequest": { "ReportConnectionOutcomeRequest": {
"required": [ "required": [
"contractVersion", "contractVersion",
"outcome", "outcome"
"elapsedMilliseconds"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1400,6 +1488,9 @@
"outcome": { "outcome": {
"$ref": "#/components/schemas/ConnectionOutcomeKind" "$ref": "#/components/schemas/ConnectionOutcomeKind"
}, },
"elapsedBucket": {
"$ref": "#/components/schemas/ConnectionElapsedBucket"
},
"elapsedMilliseconds": { "elapsedMilliseconds": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
@@ -1415,7 +1506,8 @@
"ReportConnectionOutcomeResponse": { "ReportConnectionOutcomeResponse": {
"required": [ "required": [
"contractVersion", "contractVersion",
"accepted" "accepted",
"isDuplicate"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1425,6 +1517,9 @@
}, },
"accepted": { "accepted": {
"type": "boolean" "type": "boolean"
},
"isDuplicate": {
"type": "boolean"
} }
} }
}, },
@@ -1502,6 +1597,16 @@
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
} }
},
"dedicatedFallback": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NetworkEndpoint"
}
]
} }
} }
}, },
@@ -1541,6 +1646,16 @@
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
} }
},
"dedicatedFallback": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NetworkEndpoint"
}
]
} }
} }
} }
@@ -1551,6 +1666,12 @@
"description": "Tenant-scoped publisher credential issued during game provisioning.", "description": "Tenant-scoped publisher credential issued during game provisioning.",
"scheme": "bearer", "scheme": "bearer",
"bearerFormat": "rv1 publisher credential" "bearerFormat": "rv1 publisher credential"
},
"JoinAttemptCapability": {
"type": "apiKey",
"description": "Attempt-scoped client capability returned only to the joining caller.",
"name": "X-Rendezvous-Client-Punch-Capability",
"in": "header"
} }
} }
}, },
@@ -0,0 +1,117 @@
# ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback
- Status: Accepted
- Date: 2026-07-16
- Tracking: #13
## Context
A connection can stop in the directory, authorization, mediation, NAT traversal,
or direct-connection phase. Those failures have different authorities: an HTTP
response can authoritatively reject a join, the SDK can observe a local timeout,
and only the remote host can reject a direct connection. Treating all of them as
one message or generic timeout would make player guidance, retry policy, tests,
and operational measurements unreliable.
UDP loss, service silence, cancellation, and late LiteNetLib callbacks also make
completion races unavoidable. Games need one terminal result and bounded work,
not a sequence of contradictory callbacks. Direct traversal cannot be guaranteed,
but v1 has no gameplay relay and must not imply otherwise.
## Decision
### Closed typed outcome model
`ConnectionOutcomeKind` is the stable wire-level terminal set: connected,
cancelled, directory not found, attempt expired, incompatible protocol,
unauthorized, rate limited, no host presence, service unavailable or rejected,
mediator unavailable, punch timeout, direct-connect timeout, host rejection,
transport error, manager stopped, and disposed.
The already-frozen v1 members `TimedOut`, `StaleHost`, `TransportFailed`, and
`FallbackOffered` retain their original numeric values for source and wire
compatibility. New SDK code never emits them. The report service accepts them,
normalizes the first three to their precise modern equivalents, and does not let
legacy compatibility weaken the typed coordinator result.
The client adds `RendezvousConnectionOutcomeSource`, failure category, and phase.
These fields preserve authority instead of guessing from text:
- `RendezvousService` is used only for an HTTP decision or bounded service
silence. Its optional `ServiceError` retains the stable service error code.
- `LocalTraversal` reports local punch, direct-connect, and transport
observations.
- `RemoteHost` reports an explicit direct-connection rejection.
- `Caller` and `Lifecycle` distinguish cancellation from manager shutdown or
disposal.
Messages remain diagnostic and are never parsed into outcomes. A successful NAT
introduction is only a transition to direct connection; `Connected` is emitted
only after LiteNetLib reports the authenticated peer connected.
Join issuance is exposed as `RendezvousConnectionStartResult`, containing exactly
one issued attempt or one terminal service outcome. Once an attempt is issued,
the coordinator owns its local terminal outcome. Completion is exactly once;
terminal paths release SDK subscriptions so late introductions, peer callbacks,
network errors, cancellation, and polling are inert.
### Bounded phases and retries
Each HTTP try has a five-second default silence budget, configurable from above
zero through thirty seconds. Only safe operations use the existing bounded retry
policy, honoring caller cancellation and server retry guidance. Exhausting that
budget returns `ServiceUnavailable`; it never waits indefinitely.
Traversal has independent defaults: ten seconds for punch/mediation and five
seconds for the direct connection. Both are configurable up to thirty seconds.
Local budgets, retry schedules, and elapsed duration use monotonic time, so a
wall-clock correction cannot extend them or produce a negative duration. The
signed attempt expiry is converted to an additional monotonic upper bound when
the attempt is received. Punch retries retain
their bounded request count and exponential backoff; crossing a phase deadline
completes exactly once even if a delayed packet later arrives. Tests use an
injected clock and do not depend on wall-clock sleeps.
### Explicit dedicated fallback handoff
A publisher may attach one validated dedicated endpoint to registration or
update only when the tenant's provisioned fallback policy allows it. The server
copies that endpoint into browser and issued-attempt contracts.
The client coordinator defensively copies it into every terminal outcome; a game
may override it locally through `DedicatedFallbackOverride`.
The SDK never opens, dials, reserves, probes, or authenticates the fallback. The
game decides whether the outcome permits fallback, presents any player choice,
and connects through its own gameplay transport and admission rules. Absence of
an endpoint is an honest no-fallback result. Gameplay relay is absent from v1.
### Privacy-safe optional reporting
After an issued attempt completes, the game may explicitly report its outcome
with the short-lived client punch capability. Reporting is authenticated and
idempotent: an exact repeat succeeds as a duplicate, while a conflicting repeat
is rejected. Reports contain only an allowlisted outcome enum and one coarse
elapsed bucket (`<1s`, `15s`, `515s`, `1530s`, or `30s+`). They contain no
diagnostic message, exact duration, endpoint, metadata, player identifier, or
credential.
Frozen v1 DTOs still expose `elapsedMilliseconds` and `diagnosticCode`. They are
deprecated compatibility inputs: the current SDK omits them, the service
immediately buckets legacy elapsed time, and neither exact timing nor diagnostic
text is retained, logged, or used as a metric dimension.
The store retains a bounded capability-fingerprint tombstone long enough to
accept a report after the live attempt expires. Metrics count the first accepted
outcome only and use only outcome plus elapsed bucket as dimensions. Service
issuance failures cannot be reported because no attempt capability was issued.
## Consequences
- Player-facing UI can map stable outcome/category pairs to localized guidance
without exposing diagnostic strings.
- Service rejection, remote-host rejection, and local observation remain
distinguishable for retry and support decisions.
- Games own fallback policy and gameplay admission; Rendezvous does not claim a
guaranteed connection path.
- Outcome additions are contract changes and require OpenAPI, serialization,
public API, fake-clock, late-event, and idempotency coverage.
+1
View File
@@ -12,6 +12,7 @@ decision requires a superseding ADR and corresponding contract/test updates.
- [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md) - [ADR 0007: caller-owned .NET publisher and browser SDK](0007-caller-owned-dotnet-client-sdk.md)
- [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md) - [ADR 0008: scoped join attempts and one-time connection tickets](0008-scoped-join-attempts-and-tickets.md)
- [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md) - [ADR 0009: authenticated bounded LiteNetLib NAT mediator](0009-authenticated-litenet-nat-mediator.md)
- [ADR 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md)
- [Threat model](../security/threat-model.md) - [Threat model](../security/threat-model.md)
- [Security promise and test matrix](../security/control-matrix.md) - [Security promise and test matrix](../security/control-matrix.md)
- [Versioned HTTP and UDP contracts](../contracts/README.md) - [Versioned HTTP and UDP contracts](../contracts/README.md)
+21 -5
View File
@@ -40,9 +40,7 @@ the same value as a required query parameter.
| `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. | | `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. |
The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative
shape reference for parameters, bodies, and responses. Contract-only endpoints shape reference for parameters, bodies, and responses.
return `501` until their behavior is implemented by the subsequent directory,
lease, and join-orchestration issues.
Host polling sends its reusable lease credential in Host polling sends its reusable lease credential in
`X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials `X-Rendezvous-Lease-Token`; it must never be placed in a URL. Lease credentials
@@ -55,6 +53,24 @@ Attempt cancellation sends the short-lived client punch capability in
source only for a process-keyed, short-lived idempotency/abuse scope; this is not source only for a process-keyed, short-lived idempotency/abuse scope; this is not
player authentication and is never returned to callers. player authentication and is never returned to callers.
Outcome reporting uses that same short-lived capability. It accepts only outcomes
for an issued attempt and carries one stable outcome enum plus one coarse elapsed
bucket. Exact duplicate reports are idempotent; conflicting repeats fail. Reports
never carry exact timing, diagnostics, endpoints, metadata, player identifiers,
or credentials.
The frozen v1 .NET request also retains deprecated `elapsedMilliseconds` and
`diagnosticCode` properties for source/wire compatibility. Current clients omit
them. If a legacy client supplies them, the server immediately converts elapsed
milliseconds to the coarse bucket and discards diagnostic text; neither value is
retained or used as a metric dimension.
Registration and update may include one validated `dedicatedFallback`. The
endpoint must be enabled by the tenant's provisioned fallback policy, is visible
browser data, and is copied into subsequently issued attempts.
It is a handoff for caller-owned policy: neither the HTTP service nor the SDK
automatically connects to it. V1 provides no gameplay relay.
## Idempotency, cursors, and retries ## Idempotency, cursors, and retries
Registration and join creation require a caller-generated visible-ASCII Registration and join creation require a caller-generated visible-ASCII
@@ -101,9 +117,9 @@ must not be parsed. Secrets and raw credentials are never echoed.
| 401 | `authenticationRequired` | | 401 | `authenticationRequired` |
| 403 | `forbidden` | | 403 | `forbidden` |
| 404 | `notFound` | | 404 | `notFound` |
| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` | | 409 | `conflict`, `incompatibleProtocol`, `replayRejected` |
| 410 | `expired`, `staleHost` | | 410 | `expired`, `staleHost` |
| 429 | `rateLimited` (with retry guidance when known) | | 429 | `rateLimited`, `capacityExceeded` (with retry guidance when known) |
| 503 | `serviceUnavailable` (with retry guidance when known) | | 503 | `serviceUnavailable` (with retry guidance when known) |
| 500 | `internalError` | | 500 | `internalError` |
@@ -0,0 +1,183 @@
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Client;
public enum RendezvousConnectionOutcomeSource
{
RendezvousService = 1,
LocalTraversal = 2,
RemoteHost = 3,
Caller = 4,
Lifecycle = 5,
}
public enum RendezvousConnectionFailureCategory
{
None = 0,
Directory = 1,
Compatibility = 2,
Authorization = 3,
Capacity = 4,
HostPresence = 5,
Service = 6,
Mediation = 7,
NatTraversal = 8,
DirectConnection = 9,
Lifecycle = 10,
}
public enum RendezvousConnectionPhase
{
Directory = 1,
Authorization = 2,
Mediation = 3,
NatTraversal = 4,
DirectConnection = 5,
Complete = 6,
}
public sealed class RendezvousConnectionOutcome
{
private readonly NetworkEndpoint? _dedicatedFallback;
private RendezvousConnectionOutcome(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
RendezvousErrorCode? serviceError,
NetworkEndpoint? dedicatedFallback,
NetPeer? peer)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
if (dedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
{
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
}
Kind = kind;
Source = source;
Category = category;
Phase = phase;
Elapsed = elapsed;
ServiceError = serviceError;
_dedicatedFallback = RendezvousEndpoint.Copy(dedicatedFallback);
Peer = peer;
}
public ConnectionOutcomeKind Kind { get; }
public RendezvousConnectionOutcomeSource Source { get; }
public RendezvousConnectionFailureCategory Category { get; }
public RendezvousConnectionPhase Phase { get; }
public TimeSpan Elapsed { get; }
public RendezvousErrorCode? ServiceError { get; }
public NetworkEndpoint? DedicatedFallback => RendezvousEndpoint.Copy(_dedicatedFallback);
public NetPeer? Peer { get; }
public bool IsSuccess => Kind == ConnectionOutcomeKind.Connected;
public bool HasDedicatedFallback => _dedicatedFallback is not null;
public static RendezvousConnectionOutcome FromServiceError(
RendezvousErrorCode error,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null)
{
if (error == RendezvousErrorCode.None)
{
throw new ArgumentException("A service failure outcome requires an error.", nameof(error));
}
(ConnectionOutcomeKind kind, RendezvousConnectionFailureCategory category, RendezvousConnectionPhase phase) =
error switch
{
RendezvousErrorCode.NotFound => (
ConnectionOutcomeKind.DirectoryNotFound,
RendezvousConnectionFailureCategory.Directory,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.Expired => (
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.IncompatibleProtocol => (
ConnectionOutcomeKind.IncompatibleProtocol,
RendezvousConnectionFailureCategory.Compatibility,
RendezvousConnectionPhase.Directory),
RendezvousErrorCode.AuthenticationRequired
or RendezvousErrorCode.Forbidden
or RendezvousErrorCode.ReplayRejected => (
ConnectionOutcomeKind.Unauthorized,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.CapacityExceeded => (
ConnectionOutcomeKind.RateLimited,
RendezvousConnectionFailureCategory.Capacity,
RendezvousConnectionPhase.Authorization),
RendezvousErrorCode.StaleHost => (
ConnectionOutcomeKind.NoHostPresence,
RendezvousConnectionFailureCategory.HostPresence,
RendezvousConnectionPhase.Mediation),
RendezvousErrorCode.ServiceUnavailable => (
ConnectionOutcomeKind.ServiceUnavailable,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
_ => (
ConnectionOutcomeKind.ServiceRejected,
RendezvousConnectionFailureCategory.Service,
RendezvousConnectionPhase.Authorization),
};
return new(
kind,
RendezvousConnectionOutcomeSource.RendezvousService,
category,
phase,
elapsed,
error,
dedicatedFallback,
null);
}
public static ConnectionElapsedBucket BucketElapsed(TimeSpan elapsed)
{
if (elapsed < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(elapsed));
}
return elapsed.TotalSeconds switch
{
< 1 => ConnectionElapsedBucket.UnderOneSecond,
< 5 => ConnectionElapsedBucket.OneToFiveSeconds,
< 15 => ConnectionElapsedBucket.FiveToFifteenSeconds,
< 30 => ConnectionElapsedBucket.FifteenToThirtySeconds,
_ => ConnectionElapsedBucket.ThirtySecondsOrMore,
};
}
public override string ToString() =>
$"[RendezvousConnectionOutcome {Kind}; {Source}; credentials redacted]";
internal static RendezvousConnectionOutcome Create(
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
TimeSpan elapsed,
NetworkEndpoint? dedicatedFallback = null,
NetPeer? peer = null) => new(
kind,
source,
category,
phase,
elapsed,
null,
dedicatedFallback,
peer);
}
@@ -0,0 +1,35 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
public sealed class RendezvousConnectionStartResult
{
internal RendezvousConnectionStartResult(
CreateJoinAttemptResponse? attempt,
RendezvousConnectionOutcome? outcome)
{
if ((attempt is null) == (outcome is null))
{
throw new ArgumentException(
"A connection start result requires exactly one attempt or terminal outcome.");
}
Attempt = attempt;
Outcome = outcome;
}
public CreateJoinAttemptResponse? Attempt { get; }
public RendezvousConnectionOutcome? Outcome { get; }
public bool IsReadyForTraversal => Attempt is not null;
public bool IsCompleted => Outcome is not null;
public static RendezvousConnectionStartResult ReadyForTraversal(
CreateJoinAttemptResponse attempt) => new(
attempt ?? throw new ArgumentNullException(nameof(attempt)),
null);
public static RendezvousConnectionStartResult Completed(
RendezvousConnectionOutcome outcome) => new(
null,
outcome ?? throw new ArgumentNullException(nameof(outcome)));
}
@@ -1,3 +1,4 @@
using System.Diagnostics;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client; namespace FinalFactory.Rendezvous.Client;
@@ -39,6 +40,45 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient
cancellationToken); cancellationToken);
} }
public async Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default)
{
if (dedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(dedicatedFallback))
{
throw new ArgumentException("The dedicated fallback endpoint is invalid.", nameof(dedicatedFallback));
}
Stopwatch elapsed = Stopwatch.StartNew();
try
{
RendezvousClientResult<CreateJoinAttemptResponse> result = await CreateAsync(
request,
cancellationToken).ConfigureAwait(false);
elapsed.Stop();
return result.IsSuccess && result.Value is not null
? RendezvousConnectionStartResult.ReadyForTraversal(result.Value)
: RendezvousConnectionStartResult.Completed(
RendezvousConnectionOutcome.FromServiceError(
result.Error,
elapsed.Elapsed,
dedicatedFallback));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
elapsed.Stop();
return RendezvousConnectionStartResult.Completed(
RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization,
elapsed.Elapsed));
}
}
public Task<RendezvousClientResult<bool>> CancelAsync( public Task<RendezvousClientResult<bool>> CancelAsync(
CreateJoinAttemptResponse attempt, CreateJoinAttemptResponse attempt,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -130,6 +170,41 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient
$"Host invitation polling exceeded the configured {maximumPages}-page limit."); $"Host invitation polling exceeded the configured {maximumPages}-page limit.");
} }
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default)
{
if (attempt is null)
{
throw new ArgumentNullException(nameof(attempt));
}
if (outcome is null)
{
throw new ArgumentNullException(nameof(outcome));
}
if (!ContractValidation.IsReportableConnectionOutcome(outcome.Kind))
{
throw new ArgumentException(
"This outcome cannot be reported for an issued join attempt.",
nameof(outcome));
}
ReportConnectionOutcomeRequest body = new()
{
Outcome = outcome.Kind,
ElapsedBucket = RendezvousConnectionOutcome.BucketElapsed(outcome.Elapsed),
};
return _transport.SendSafeAsync<ReportConnectionOutcomeResponse>(
() => HeaderJsonRequest(
HttpMethod.Post,
$"v1/join-attempts/{attempt.AttemptId}/outcome",
ClientPunchCapabilityHeader,
RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)),
body),
cancellationToken);
}
private static HttpRequestMessage HeaderRequest( private static HttpRequestMessage HeaderRequest(
HttpMethod method, HttpMethod method,
string uri, string uri,
@@ -141,6 +216,18 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient
return request; return request;
} }
private static HttpRequestMessage HeaderJsonRequest<T>(
HttpMethod method,
string uri,
string header,
string value,
T body)
{
HttpRequestMessage request = RendezvousHttpTransport.JsonRequest(method, uri, body);
request.Headers.TryAddWithoutValidation(header, value);
return request;
}
private static string RequireHeaderValue(string value, string parameterName) => private static string RequireHeaderValue(string value, string parameterName) =>
!string.IsNullOrWhiteSpace(value) !string.IsNullOrWhiteSpace(value)
? value ? value
+51 -13
View File
@@ -29,6 +29,12 @@ RendezvousClientResult<PublishedSession> registered = await publisher.RegisterAs
DisplayName = "My server", DisplayName = "My server",
Visibility = ListingVisibility.Public, Visibility = ListingVisibility.Public,
Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 },
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.40",
Port = 7777,
},
}, },
publisherCredential, publisherCredential,
cancellationToken); cancellationToken);
@@ -97,14 +103,22 @@ the accepted peer as connected. Register ordinary gameplay callbacks on
`networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct `networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct
requests for ticket validation and forwards every other callback normally. requests for ticket validation and forwards every other callback normally.
The joining game first creates the HTTP attempt, then uses its own already-started The joining game first requests an attempt through the typed start API. It returns
gameplay manager in the same frame loop: exactly one issued attempt or one terminal service outcome, so service authority
is not confused with a later locally observed traversal failure:
```csharp ```csharp
CreateJoinAttemptResponse attempt = (await joins.CreateAsync( RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
createJoinRequest, createJoinRequest,
cancellationToken)).Value cancellationToken: cancellationToken);
?? throw new InvalidOperationException("Join issuance failed."); if (start.Outcome is { } serviceOutcome)
{
ShowConnectionFailure(serviceOutcome.Kind, serviceOutcome.Category);
return;
}
CreateJoinAttemptResponse attempt = start.Attempt
?? throw new InvalidOperationException("The typed start result was invalid.");
using RendezvousClientCoordinator client = new( using RendezvousClientCoordinator client = new(
gameplayNetManager, gameplayNetManager,
networkEvents, networkEvents,
@@ -116,12 +130,35 @@ client.Poll();
``` ```
NAT introduction changes the client state to `Connecting`; it is not success. NAT introduction changes the client state to `Connecting`; it is not success.
Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for Only a `Connected` outcome supplies `Peer`. Completion exposes a stable kind,
local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the source, category, phase, and elapsed duration. The default HTTP silence, punch,
service attempt. Terminal client paths release all event subscriptions. Disposing and direct-connect budgets are five, ten, and five seconds respectively; configure
a coordinator never stops or disposes the caller-owned manager and does not touch them through `RendezvousClientOptions` and `RendezvousCoordinatorOptions` when a
an in-flight peer; call `Cancel()` followed by `Poll()` first when that peer must game has measured reasons to do so. The signed attempt expiry is always the
also be disconnected. absolute upper bound.
Call `Cancel()` and then `Poll()` for local cancellation, or
`CancelAsync(joins, cancellationToken)` to also revoke the service attempt.
Terminal client paths complete exactly once and release all event subscriptions,
so late packets and callbacks are inert. Disposing a coordinator never stops or
disposes the caller-owned manager and does not touch an in-flight peer; call
`Cancel()` followed by `Poll()` first when that peer must also be disconnected.
After terminal completion, reporting is explicit and safe to retry. It sends only
the authenticated outcome enum and a coarse elapsed bucket—never the endpoint,
exact duration, diagnostic text, metadata, or player identity:
```csharp
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
await client.ReportOutcomeAsync(joins, cancellationToken);
```
An optional `DedicatedFallback` is copied from the authoritative listing into the
issued attempt and terminal outcome. A local deployment may replace it with
`RendezvousCoordinatorOptions.DedicatedFallbackOverride`. The SDK only returns
the endpoint; it never connects automatically. The game must explicitly decide
whether to use it and then connect and authenticate through its own gameplay
transport. If the outcome has no fallback, v1 offers no relay.
Lease renewal is explicit and caller-controlled: Lease renewal is explicit and caller-controlled:
@@ -155,5 +192,6 @@ apply its own player identity, capacity, ban, and gameplay admission rules. Revo
the attempt on cancellation and dispose the validator during host shutdown so its the attempt on cancellation and dispose the validator during host shutdown so its
keyed ticket digests are zeroed. keyed ticket digests are zeroed.
See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for
join-capability and connection-ticket security semantics. join-capability and connection-ticket security semantics, and ADR 0010 for typed
outcomes, deadlines, reporting, and caller-owned fallback.
@@ -148,6 +148,11 @@ public interface IRendezvousSessionBrowserClient
public interface IRendezvousJoinClient public interface IRendezvousJoinClient
{ {
Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default);
Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync( Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request, CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
@@ -166,6 +171,11 @@ public interface IRendezvousJoinClient
PublishedSession session, PublishedSession session,
int maximumPages = 100, int maximumPages = 100,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default);
} }
public interface IRendezvousDelay public interface IRendezvousDelay
@@ -176,6 +186,7 @@ public interface IRendezvousDelay
public sealed class RendezvousClientOptions public sealed class RendezvousClientOptions
{ {
public int MaximumSafeRetries { get; set; } = 2; public int MaximumSafeRetries { get; set; } = 2;
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public double JitterRatio { get; set; } = 0.2; public double JitterRatio { get; set; } = 0.2;
@@ -183,6 +194,8 @@ public sealed class RendezvousClientOptions
internal void Validate() internal void Validate()
{ {
if (MaximumSafeRetries is < 0 or > 5 if (MaximumSafeRetries is < 0 or > 5
|| RequestTimeout <= TimeSpan.Zero
|| RequestTimeout > TimeSpan.FromSeconds(30)
|| InitialRetryDelay < TimeSpan.Zero || InitialRetryDelay < TimeSpan.Zero
|| MaximumRetryDelay < InitialRetryDelay || MaximumRetryDelay < InitialRetryDelay
|| MaximumRetryDelay > TimeSpan.FromSeconds(30) || MaximumRetryDelay > TimeSpan.FromSeconds(30)
@@ -198,3 +211,15 @@ internal sealed class SystemRendezvousDelay : IRendezvousDelay
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) => public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) =>
Task.Delay(delay, cancellationToken); Task.Delay(delay, cancellationToken);
} }
internal static class RendezvousEndpoint
{
internal static NetworkEndpoint? Copy(NetworkEndpoint? endpoint) => endpoint is null
? null
: new NetworkEndpoint
{
AddressFamily = endpoint.AddressFamily,
Address = endpoint.Address,
Port = endpoint.Port,
};
}
@@ -24,6 +24,7 @@ internal sealed class RendezvousHttpTransport
_options = new RendezvousClientOptions _options = new RendezvousClientOptions
{ {
MaximumSafeRetries = suppliedOptions.MaximumSafeRetries, MaximumSafeRetries = suppliedOptions.MaximumSafeRetries,
RequestTimeout = suppliedOptions.RequestTimeout,
InitialRetryDelay = suppliedOptions.InitialRetryDelay, InitialRetryDelay = suppliedOptions.InitialRetryDelay,
MaximumRetryDelay = suppliedOptions.MaximumRetryDelay, MaximumRetryDelay = suppliedOptions.MaximumRetryDelay,
JitterRatio = suppliedOptions.JitterRatio, JitterRatio = suppliedOptions.JitterRatio,
@@ -38,11 +39,15 @@ internal sealed class RendezvousHttpTransport
for (int attempt = 0; ; attempt++) for (int attempt = 0; ; attempt++)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
using CancellationTokenSource requestTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(_options.RequestTimeout);
CancellationToken requestCancellation = requestTimeout.Token;
try try
{ {
using HttpRequestMessage request = requestFactory(); using HttpRequestMessage request = requestFactory();
using HttpResponseMessage response = await _httpClient using HttpResponseMessage response = await _httpClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -54,7 +59,7 @@ internal sealed class RendezvousHttpTransport
byte[] payload; byte[] payload;
try try
{ {
payload = await ReadBoundedAsync(response.Content, cancellationToken) payload = await ReadBoundedAsync(response.Content, requestCancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (InvalidDataException) catch (InvalidDataException)
@@ -81,7 +86,7 @@ internal sealed class RendezvousHttpTransport
: RendezvousClientResult.Success(value); : RendezvousClientResult.Success(value);
} }
ApiError error = await ReadErrorAsync(response, cancellationToken).ConfigureAwait(false); ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter); int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code)) if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code))
{ {
@@ -88,6 +88,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
DisplayName = request.DisplayName, DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity), Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata), Metadata = CopyMetadata(request.Metadata),
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
}; };
return _transport.SendSafeAsync<bool>( return _transport.SendSafeAsync<bool>(
() => RendezvousHttpTransport.JsonRequest( () => RendezvousHttpTransport.JsonRequest(
@@ -138,6 +139,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
Visibility = request.Visibility, Visibility = request.Visibility,
Capacity = CopyCapacity(request.Capacity), Capacity = CopyCapacity(request.Capacity),
Metadata = CopyMetadata(request.Metadata), Metadata = CopyMetadata(request.Metadata),
DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback),
}; };
private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new() private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new()
@@ -148,4 +150,5 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) => private static Dictionary<string, string> CopyMetadata(Dictionary<string, string> metadata) =>
new(metadata, StringComparer.Ordinal); new(metadata, StringComparer.Ordinal);
} }
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using LiteNetLib; using LiteNetLib;
@@ -12,12 +13,21 @@ public sealed class RendezvousClientCoordinator : IDisposable
private readonly IPEndPoint _mediator; private readonly IPEndPoint _mediator;
private readonly CreateJoinAttemptResponse _attempt; private readonly CreateJoinAttemptResponse _attempt;
private readonly IRendezvousCoordinatorClock _clock; private readonly IRendezvousCoordinatorClock _clock;
private readonly RendezvousCoordinatorOptions _options;
private readonly RendezvousPunchRetrySchedule _retry; private readonly RendezvousPunchRetrySchedule _retry;
private readonly object _completionGate = new();
private readonly TimeSpan _startedAt;
private readonly TimeSpan _attemptDeadline;
private readonly TimeSpan _punchDeadline;
private readonly NetworkEndpoint? _dedicatedFallback;
private NetPeer? _connectingPeer; private NetPeer? _connectingPeer;
private IPEndPoint? _directEndpoint;
private TimeSpan? _directDeadline;
private RendezvousConnectionOutcome? _outcome;
private bool _cancelRequested; private bool _cancelRequested;
private int _polling; private int _polling;
private bool _subscriptionsReleased; private bool _subscriptionsReleased;
private bool _disposed; private int _disposed;
public RendezvousClientCoordinator( public RendezvousClientCoordinator(
NetManager manager, NetManager manager,
@@ -49,23 +59,31 @@ public sealed class RendezvousClientCoordinator : IDisposable
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_attempt = attempt ?? throw new ArgumentNullException(nameof(attempt)); _attempt = attempt ?? throw new ArgumentNullException(nameof(attempt));
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); _clock = clock ?? throw new ArgumentNullException(nameof(clock));
RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions()) _options = (options ?? new RendezvousCoordinatorOptions())
.CopyAndValidate(); .CopyAndValidate();
_retry = new(validated, _clock); _retry = new(_options, _clock);
RendezvousManagerGuard.Validate(_manager, _networkEvents); RendezvousManagerGuard.Validate(_manager, _networkEvents);
DateTimeOffset startedUtc = _clock.UtcNow;
if (_mediator.Port is < 1 or > 65_535 if (_mediator.Port is < 1 or > 65_535
|| _attempt.AttemptId.Value == Guid.Empty || _attempt.AttemptId.Value == Guid.Empty
|| _attempt.MediationHandle.Value == Guid.Empty || _attempt.MediationHandle.Value == Guid.Empty
|| !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability) || !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability)
|| !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest) || !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest)
|| _attempt.ExpiresAt <= _clock.UtcNow) || _attempt.ExpiresAt <= startedUtc)
{ {
throw new ArgumentException("The client traversal inputs are invalid."); throw new ArgumentException("The client traversal inputs are invalid.");
} }
_startedAt = _clock.Elapsed;
_attemptDeadline = _startedAt + (_attempt.ExpiresAt - startedUtc);
_punchDeadline = Min(_attemptDeadline, _startedAt + _options.PunchTimeout);
_dedicatedFallback = RendezvousEndpoint.Copy(
_options.DedicatedFallbackOverride ?? _attempt.DedicatedFallback);
_networkEvents.RendezvousPeerConnected += OnPeerConnected; _networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected; _networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_networkEvents.RendezvousNetworkError += OnNetworkError;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess; _punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
} }
@@ -73,7 +91,8 @@ public sealed class RendezvousClientCoordinator : IDisposable
public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching; public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching;
public NetPeer? ConnectedPeer { get; private set; } public NetPeer? ConnectedPeer { get; private set; }
public bool IsCompleted => IsTerminal(State); public RendezvousConnectionOutcome? Outcome => Volatile.Read(ref _outcome);
public bool IsCompleted => Outcome is not null;
public void Cancel() => Volatile.Write(ref _cancelRequested, true); public void Cancel() => Volatile.Write(ref _cancelRequested, true);
@@ -91,6 +110,23 @@ public sealed class RendezvousClientCoordinator : IDisposable
return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false); return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false);
} }
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
IRendezvousJoinClient joinClient,
CancellationToken cancellationToken = default)
{
if (joinClient is null)
{
throw new ArgumentNullException(nameof(joinClient));
}
ThrowIfDisposed();
if (Outcome is null)
{
throw new InvalidOperationException("The connection attempt has not completed.");
}
return joinClient.ReportOutcomeAsync(_attempt, Outcome, cancellationToken);
}
public void Poll() public void Poll()
{ {
ThrowIfDisposed(); ThrowIfDisposed();
@@ -109,13 +145,18 @@ public sealed class RendezvousClientCoordinator : IDisposable
if (Volatile.Read(ref _cancelRequested)) if (Volatile.Read(ref _cancelRequested))
{ {
DisconnectPendingPeer(); DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled); Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
return; return;
} }
if (!_manager.IsRunning) if (!_manager.IsRunning)
{ {
Complete(RendezvousConnectionState.ManagerStopped); CompleteManagerStopped();
return; return;
} }
@@ -127,35 +168,67 @@ public sealed class RendezvousClientCoordinator : IDisposable
} }
DateTimeOffset now = _clock.UtcNow; DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (Volatile.Read(ref _cancelRequested)) if (Volatile.Read(ref _cancelRequested))
{ {
DisconnectPendingPeer(); DisconnectPendingPeer();
Complete(RendezvousConnectionState.Cancelled); Complete(
RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
} }
else if (!_manager.IsRunning) else if (!_manager.IsRunning)
{ {
Complete(RendezvousConnectionState.ManagerStopped); CompleteManagerStopped();
} }
else if (now >= _attempt.ExpiresAt) else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline)
{ {
DisconnectPendingPeer(); DisconnectPendingPeer();
Complete(RendezvousConnectionState.TimedOut); Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.AttemptExpired,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization);
} }
else if (State == RendezvousConnectionState.Punching && _retry.IsDue(now)) else if (State == RendezvousConnectionState.Punching)
{ {
if (_retry.IsExhausted) if (elapsed >= _punchDeadline
|| _retry.IsExhausted && _retry.IsDue(elapsed))
{ {
Complete(RendezvousConnectionState.TimedOut); Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
return; return;
} }
_manager.NatPunchModule.SendNatIntroduceRequest( if (_retry.IsDue(elapsed))
_mediator, {
NatPunchRequestTokenCodec.Encode( _manager.NatPunchModule.SendNatIntroduceRequest(
NatPunchPeerRole.Client, _mediator,
_attempt.MediationHandle, NatPunchRequestTokenCodec.Encode(
_attempt.ClientPunchCapability)); NatPunchPeerRole.Client,
_retry.RecordRequest(); _attempt.MediationHandle,
_attempt.ClientPunchCapability));
_retry.RecordRequest();
}
}
else if (State == RendezvousConnectionState.Connecting
&& _directDeadline is TimeSpan directDeadline
&& directDeadline <= elapsed)
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
} }
} }
finally finally
@@ -166,18 +239,22 @@ public sealed class RendezvousClientCoordinator : IDisposable
public void Dispose() public void Dispose()
{ {
if (_disposed) if (Interlocked.Exchange(ref _disposed, 1) != 0)
{ {
return; return;
} }
if (!IsCompleted) if (!IsCompleted)
{ {
Complete(RendezvousConnectionState.Disposed); Complete(
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
CurrentPhase());
} }
ReleaseSubscriptions(); ReleaseSubscriptions();
_disposed = true;
} }
public override string ToString() => public override string ToString() =>
@@ -205,16 +282,25 @@ public sealed class RendezvousClientCoordinator : IDisposable
byte[] connectionData = DirectConnectionRequestCodec.Encode( byte[] connectionData = DirectConnectionRequestCodec.Encode(
introduction.AttemptId, introduction.AttemptId,
introduction.ConnectionTicket); introduction.ConnectionTicket);
_directEndpoint = target;
_connectingPeer = _manager.Connect(target, connectionData); _connectingPeer = _manager.Connect(target, connectionData);
if (_connectingPeer is null if (_connectingPeer is null
|| _connectingPeer.ConnectionState != ConnectionState.Outgoing) || _connectingPeer.ConnectionState != ConnectionState.Outgoing)
{ {
_connectingPeer = null; _connectingPeer = null;
Complete(RendezvousConnectionState.Rejected); Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.TransportError,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
return; return;
} }
State = RendezvousConnectionState.Connecting; State = RendezvousConnectionState.Connecting;
_directDeadline = Min(
_attemptDeadline,
_clock.Elapsed + _options.DirectConnectTimeout);
} }
private void OnPeerConnected(NetPeer peer) private void OnPeerConnected(NetPeer peer)
@@ -225,17 +311,60 @@ public sealed class RendezvousClientCoordinator : IDisposable
return; return;
} }
ConnectedPeer = peer; Complete(
Complete(RendezvousConnectionState.Connected, peer); RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
} }
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo)
{ {
_ = disconnectInfo;
if (State == RendezvousConnectionState.Connecting if (State == RendezvousConnectionState.Connecting
&& ReferenceEquals(peer, _connectingPeer)) && ReferenceEquals(peer, _connectingPeer))
{ {
Complete(RendezvousConnectionState.Rejected); ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
? ConnectionOutcomeKind.DirectConnectTimedOut
: disconnectInfo.Reason == DisconnectReason.ConnectionFailed
? ConnectionOutcomeKind.TransportError
: ConnectionOutcomeKind.HostRejected;
Complete(
kind == ConnectionOutcomeKind.DirectConnectTimedOut
? RendezvousConnectionState.TimedOut
: RendezvousConnectionState.Rejected,
kind,
kind == ConnectionOutcomeKind.HostRejected
? RendezvousConnectionOutcomeSource.RemoteHost
: RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
{
_ = socketError;
if (State == RendezvousConnectionState.Punching && endpoint.Equals(_mediator))
{
Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.MediatorUnavailable,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.Mediation,
RendezvousConnectionPhase.Mediation);
}
else if (State == RendezvousConnectionState.Connecting
&& endpoint.Equals(_directEndpoint))
{
DisconnectPendingPeer();
Complete(
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.TransportError,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
} }
} }
@@ -247,42 +376,85 @@ public sealed class RendezvousClientCoordinator : IDisposable
} }
} }
private void Complete(RendezvousConnectionState terminalState, NetPeer? peer = null) private void Complete(
RendezvousConnectionState terminalState,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null)
{ {
if (IsCompleted) RendezvousConnectionCompletedEventArgs completion;
lock (_completionGate)
{ {
return; if (_outcome is not null)
{
return;
}
RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
kind,
source,
category,
phase,
_clock.Elapsed - _startedAt,
ShouldOfferFallback(kind) ? _dedicatedFallback : null,
peer);
State = terminalState;
if (kind == ConnectionOutcomeKind.Connected)
{
ConnectedPeer = peer;
}
Volatile.Write(ref _outcome, outcome);
ReleaseSubscriptions();
completion = new(terminalState, outcome);
} }
State = terminalState; Completed?.Invoke(this, completion);
ReleaseSubscriptions();
Completed?.Invoke(this, new(terminalState, peer));
} }
private void ReleaseSubscriptions() private void ReleaseSubscriptions()
{ {
if (_subscriptionsReleased) lock (_completionGate)
{ {
return; if (_subscriptionsReleased)
} {
return;
}
_networkEvents.RendezvousPeerConnected -= OnPeerConnected; _networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected; _networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess; _networkEvents.RendezvousNetworkError -= OnNetworkError;
_subscriptionsReleased = true; _punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true;
}
} }
private static bool IsTerminal(RendezvousConnectionState state) => state is private void CompleteManagerStopped() => Complete(
RendezvousConnectionState.Connected RendezvousConnectionState.ManagerStopped,
or RendezvousConnectionState.Cancelled ConnectionOutcomeKind.ManagerStopped,
or RendezvousConnectionState.TimedOut RendezvousConnectionOutcomeSource.Lifecycle,
or RendezvousConnectionState.Rejected RendezvousConnectionFailureCategory.Lifecycle,
or RendezvousConnectionState.ManagerStopped CurrentPhase());
or RendezvousConnectionState.Disposed;
private RendezvousConnectionPhase CurrentPhase() => State switch
{
RendezvousConnectionState.Punching => RendezvousConnectionPhase.NatTraversal,
RendezvousConnectionState.Connecting => RendezvousConnectionPhase.DirectConnection,
_ => RendezvousConnectionPhase.Complete,
};
private static bool ShouldOfferFallback(ConnectionOutcomeKind kind) => kind is not (
ConnectionOutcomeKind.Connected
or ConnectionOutcomeKind.Cancelled
or ConnectionOutcomeKind.Disposed);
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
left <= right ? left : right;
private void ThrowIfDisposed() private void ThrowIfDisposed()
{ {
if (_disposed) if (Volatile.Read(ref _disposed) != 0)
{ {
throw new ObjectDisposedException(nameof(RendezvousClientCoordinator)); throw new ObjectDisposedException(nameof(RendezvousClientCoordinator));
} }
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.Security.Cryptography; using System.Security.Cryptography;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib; using LiteNetLib;
namespace FinalFactory.Rendezvous.Client; namespace FinalFactory.Rendezvous.Client;
@@ -15,12 +17,97 @@ public enum RendezvousConnectionState
Disposed = 8, Disposed = 8,
} }
public sealed class RendezvousConnectionCompletedEventArgs( public sealed class RendezvousConnectionCompletedEventArgs : EventArgs
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{ {
public RendezvousConnectionState State { get; } = state; [Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
public NetPeer? Peer { get; } = peer; public RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
NetPeer? peer)
: this(state, RendezvousCompletionInvariant.FromLegacy(state, peer))
{
}
internal RendezvousConnectionCompletedEventArgs(
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
RendezvousCompletionInvariant.Validate(state, outcome);
State = state;
Outcome = outcome;
}
public RendezvousConnectionState State { get; }
public RendezvousConnectionOutcome Outcome { get; }
public NetPeer? Peer => Outcome.Peer;
}
internal static class RendezvousCompletionInvariant
{
internal static RendezvousConnectionOutcome FromLegacy(
RendezvousConnectionState state,
NetPeer? peer) => state switch
{
RendezvousConnectionState.Connected when peer is not null => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero,
peer: peer),
RendezvousConnectionState.Cancelled => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.TimedOut => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.DirectConnectTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection,
TimeSpan.Zero),
RendezvousConnectionState.Rejected => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.HostRejected,
RendezvousConnectionOutcomeSource.RemoteHost,
RendezvousConnectionFailureCategory.Authorization,
RendezvousConnectionPhase.Authorization,
TimeSpan.Zero),
RendezvousConnectionState.ManagerStopped => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.ManagerStopped,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.Disposed => RendezvousConnectionOutcome.Create(
ConnectionOutcomeKind.Disposed,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Complete,
TimeSpan.Zero),
RendezvousConnectionState.Connected => throw new ArgumentNullException(
nameof(peer),
"A connected completion requires a peer."),
_ => throw new ArgumentOutOfRangeException(
nameof(state),
state,
"A completion event requires a terminal connection state."),
};
internal static void Validate(
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
if (outcome is null)
{
throw new ArgumentNullException(nameof(outcome));
}
if ((state == RendezvousConnectionState.Connected) != outcome.IsSuccess)
{
throw new ArgumentException(
"The connection state and typed outcome contradict each other.",
nameof(outcome));
}
}
} }
public sealed class RendezvousCoordinatorOptions public sealed class RendezvousCoordinatorOptions
@@ -29,8 +116,11 @@ public sealed class RendezvousCoordinatorOptions
public int MaximumAttemptChecksPerPoll { get; set; } = 128; public int MaximumAttemptChecksPerPoll { get; set; } = 128;
public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200);
public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2); public TimeSpan MaximumPunchRetryDelay { get; set; } = TimeSpan.FromSeconds(2);
public TimeSpan PunchTimeout { get; set; } = TimeSpan.FromSeconds(10);
public TimeSpan DirectConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20); public TimeSpan ConnectionTicketLifetime { get; set; } = TimeSpan.FromSeconds(20);
public double JitterRatio { get; set; } = 0.2; public double JitterRatio { get; set; } = 0.2;
public NetworkEndpoint? DedicatedFallbackOverride { get; set; }
internal RendezvousCoordinatorOptions CopyAndValidate() internal RendezvousCoordinatorOptions CopyAndValidate()
{ {
@@ -39,9 +129,15 @@ public sealed class RendezvousCoordinatorOptions
|| InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10) || InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10)
|| MaximumPunchRetryDelay < InitialPunchRetryDelay || MaximumPunchRetryDelay < InitialPunchRetryDelay
|| MaximumPunchRetryDelay > TimeSpan.FromSeconds(10) || MaximumPunchRetryDelay > TimeSpan.FromSeconds(10)
|| PunchTimeout <= TimeSpan.Zero
|| PunchTimeout > TimeSpan.FromSeconds(30)
|| DirectConnectTimeout <= TimeSpan.Zero
|| DirectConnectTimeout > TimeSpan.FromSeconds(30)
|| ConnectionTicketLifetime <= TimeSpan.Zero || ConnectionTicketLifetime <= TimeSpan.Zero
|| ConnectionTicketLifetime > TimeSpan.FromSeconds(20) || ConnectionTicketLifetime > TimeSpan.FromSeconds(20)
|| JitterRatio is < 0 or > 1) || JitterRatio is < 0 or > 1
|| DedicatedFallbackOverride is not null
&& !ContractValidation.IsNetworkEndpointValid(DedicatedFallbackOverride))
{ {
throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions)); throw new ArgumentOutOfRangeException(nameof(RendezvousCoordinatorOptions));
} }
@@ -52,8 +148,11 @@ public sealed class RendezvousCoordinatorOptions
MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll, MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll,
InitialPunchRetryDelay = InitialPunchRetryDelay, InitialPunchRetryDelay = InitialPunchRetryDelay,
MaximumPunchRetryDelay = MaximumPunchRetryDelay, MaximumPunchRetryDelay = MaximumPunchRetryDelay,
PunchTimeout = PunchTimeout,
DirectConnectTimeout = DirectConnectTimeout,
ConnectionTicketLifetime = ConnectionTicketLifetime, ConnectionTicketLifetime = ConnectionTicketLifetime,
JitterRatio = JitterRatio, JitterRatio = JitterRatio,
DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride),
}; };
} }
} }
@@ -61,11 +160,16 @@ public sealed class RendezvousCoordinatorOptions
internal interface IRendezvousCoordinatorClock internal interface IRendezvousCoordinatorClock
{ {
DateTimeOffset UtcNow { get; } DateTimeOffset UtcNow { get; }
TimeSpan Elapsed { get; }
} }
internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock
{ {
private readonly long _origin = Stopwatch.GetTimestamp();
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
public TimeSpan Elapsed => TimeSpan.FromSeconds(
(Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency);
} }
internal static class RendezvousManagerGuard internal static class RendezvousManagerGuard
@@ -95,11 +199,11 @@ internal sealed class RendezvousPunchRetrySchedule(
IRendezvousCoordinatorClock clock) IRendezvousCoordinatorClock clock)
{ {
public int RequestsSent { get; private set; } public int RequestsSent { get; private set; }
public DateTimeOffset NextRequestAt { get; private set; } = DateTimeOffset.MinValue; public TimeSpan NextRequestAt { get; private set; } = TimeSpan.Zero;
public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests; public bool IsExhausted => RequestsSent >= options.MaximumPunchRequests;
public bool IsDue(DateTimeOffset now) => now >= NextRequestAt; public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt;
public void RecordRequest() public void RecordRequest()
{ {
@@ -119,6 +223,6 @@ internal sealed class RendezvousPunchRetrySchedule(
options.MaximumPunchRetryDelay.TotalMilliseconds); options.MaximumPunchRetryDelay.TotalMilliseconds);
} }
NextRequestAt = clock.UtcNow + TimeSpan.FromMilliseconds(milliseconds); NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds);
} }
} }
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using LiteNetLib; using LiteNetLib;
@@ -11,14 +12,37 @@ public enum RendezvousHostState
Disposed = 3, Disposed = 3,
} }
public sealed class RendezvousHostAttemptCompletedEventArgs( public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs
JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer = null) : EventArgs
{ {
public JoinAttemptId AttemptId { get; } = attemptId; [Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")]
public RendezvousConnectionState State { get; } = state; public RendezvousHostAttemptCompletedEventArgs(
public NetPeer? Peer { get; } = peer; JoinAttemptId attemptId,
RendezvousConnectionState state,
NetPeer? peer)
: this(attemptId, state, RendezvousCompletionInvariant.FromLegacy(state, peer))
{
}
internal RendezvousHostAttemptCompletedEventArgs(
JoinAttemptId attemptId,
RendezvousConnectionState state,
RendezvousConnectionOutcome outcome)
{
if (attemptId.Value == Guid.Empty)
{
throw new ArgumentException("The completed attempt ID is invalid.", nameof(attemptId));
}
RendezvousCompletionInvariant.Validate(state, outcome);
AttemptId = attemptId;
State = state;
Outcome = outcome;
}
public JoinAttemptId AttemptId { get; }
public RendezvousConnectionState State { get; }
public RendezvousConnectionOutcome Outcome { get; }
public NetPeer? Peer => Outcome.Peer;
} }
public sealed class RendezvousHostCoordinator : IDisposable public sealed class RendezvousHostCoordinator : IDisposable
@@ -37,6 +61,7 @@ public sealed class RendezvousHostCoordinator : IDisposable
private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = []; private readonly Dictionary<JoinAttemptId, DeferredConnectionRequest> _deferredRequests = [];
private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = []; private readonly Dictionary<JoinAttemptId, DateTimeOffset> _terminalAttempts = [];
private readonly Queue<JoinAttemptId> _attemptSchedule = []; private readonly Queue<JoinAttemptId> _attemptSchedule = [];
private readonly SortedDictionary<long, Queue<HostAttemptDeadline>> _deadlines = [];
private readonly List<JoinAttemptId> _cleanupScratch = []; private readonly List<JoinAttemptId> _cleanupScratch = [];
private HostJoinAttempt[]? _latestSnapshot; private HostJoinAttempt[]? _latestSnapshot;
private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue; private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue;
@@ -90,6 +115,7 @@ public sealed class RendezvousHostCoordinator : IDisposable
_networkEvents.RendezvousConnectionRequest += OnConnectionRequest; _networkEvents.RendezvousConnectionRequest += OnConnectionRequest;
_networkEvents.RendezvousPeerConnected += OnPeerConnected; _networkEvents.RendezvousPeerConnected += OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected; _networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected;
_networkEvents.RendezvousNetworkError += OnNetworkError;
_punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess; _punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess;
} }
@@ -161,7 +187,10 @@ public sealed class RendezvousHostCoordinator : IDisposable
ApplySnapshots(); ApplySnapshots();
if (!_manager.IsRunning) if (!_manager.IsRunning)
{ {
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped); Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return; return;
} }
@@ -174,13 +203,23 @@ public sealed class RendezvousHostCoordinator : IDisposable
} }
DateTimeOffset now = _clock.UtcNow; DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
if (!_manager.IsRunning) if (!_manager.IsRunning)
{ {
Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped); Stop(
RendezvousHostState.ManagerStopped,
RendezvousConnectionState.ManagerStopped,
ConnectionOutcomeKind.ManagerStopped);
return; return;
} }
RefreshPresence(now); RefreshPresence(now);
ProcessDueDeadlines(elapsed);
if (State != RendezvousHostState.Active)
{
return;
}
int checks = Math.Min( int checks = Math.Min(
_attemptSchedule.Count, _attemptSchedule.Count,
_options.MaximumAttemptChecksPerPoll); _options.MaximumAttemptChecksPerPoll);
@@ -192,18 +231,22 @@ public sealed class RendezvousHostCoordinator : IDisposable
continue; continue;
} }
if (now >= attempt.Invitation.ExpiresAt) if (attempt.State != RendezvousConnectionState.Punching)
{ {
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut);
continue; continue;
} }
if (attempt.State == RendezvousConnectionState.Punching if (attempt.Retry.IsDue(elapsed))
&& attempt.Retry.IsDue(now))
{ {
if (attempt.Retry.IsExhausted) if (attempt.Retry.IsExhausted)
{ {
CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut); CompleteAttempt(
attemptId,
RendezvousConnectionState.TimedOut,
ConnectionOutcomeKind.PunchTimedOut,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.NatTraversal,
RendezvousConnectionPhase.NatTraversal);
continue; continue;
} }
@@ -251,9 +294,13 @@ public sealed class RendezvousHostCoordinator : IDisposable
return; return;
} }
Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed); Stop(
RendezvousHostState.Disposed,
RendezvousConnectionState.Disposed,
ConnectionOutcomeKind.Disposed);
Interlocked.Exchange(ref _latestSnapshot, null); Interlocked.Exchange(ref _latestSnapshot, null);
_attemptSchedule.Clear(); _attemptSchedule.Clear();
_deadlines.Clear();
_terminalAttempts.Clear(); _terminalAttempts.Clear();
_cleanupScratch.Clear(); _cleanupScratch.Clear();
_tickets.Dispose(); _tickets.Dispose();
@@ -272,6 +319,7 @@ public sealed class RendezvousHostCoordinator : IDisposable
} }
DateTimeOffset now = _clock.UtcNow; DateTimeOffset now = _clock.UtcNow;
TimeSpan elapsed = _clock.Elapsed;
foreach (HostJoinAttempt invitation in latest) foreach (HostJoinAttempt invitation in latest)
{ {
if (invitation.AttemptId.Value == Guid.Empty if (invitation.AttemptId.Value == Guid.Empty
@@ -289,7 +337,11 @@ public sealed class RendezvousHostCoordinator : IDisposable
{ {
CompleteAttempt( CompleteAttempt(
invitation.AttemptId, invitation.AttemptId,
RendezvousConnectionState.Cancelled); RendezvousConnectionState.Cancelled,
ConnectionOutcomeKind.Cancelled,
RendezvousConnectionOutcomeSource.RendezvousService,
RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization);
} }
_terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt; _terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt;
@@ -303,11 +355,23 @@ public sealed class RendezvousHostCoordinator : IDisposable
continue; continue;
} }
TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now);
TimeSpan punchDeadline = Min(
attemptDeadline,
elapsed + _options.PunchTimeout);
_attempts.Add( _attempts.Add(
invitation.AttemptId, invitation.AttemptId,
new PendingHostAttempt( new PendingHostAttempt(
CopyAttempt(invitation), CopyAttempt(invitation),
new RendezvousPunchRetrySchedule(_options, _clock))); new RendezvousPunchRetrySchedule(_options, _clock),
elapsed,
attemptDeadline,
punchDeadline));
EnqueueDeadline(
new HostAttemptDeadline(
invitation.AttemptId,
RendezvousConnectionState.Punching,
punchDeadline));
_attemptSchedule.Enqueue(invitation.AttemptId); _attemptSchedule.Enqueue(invitation.AttemptId);
} }
} }
@@ -354,6 +418,13 @@ public sealed class RendezvousHostCoordinator : IDisposable
} }
attempt.State = RendezvousConnectionState.Connecting; attempt.State = RendezvousConnectionState.Connecting;
attempt.DirectDeadline = Min(
attempt.AttemptDeadline,
_clock.Elapsed + _options.DirectConnectTimeout);
EnqueueDeadline(new HostAttemptDeadline(
introduction.AttemptId,
RendezvousConnectionState.Connecting,
attempt.DirectDeadline.Value));
if (_deferredRequests.Remove( if (_deferredRequests.Remove(
introduction.AttemptId, introduction.AttemptId,
out DeferredConnectionRequest? deferred)) out DeferredConnectionRequest? deferred))
@@ -410,7 +481,14 @@ public sealed class RendezvousHostCoordinator : IDisposable
{ {
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId)) if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{ {
CompleteAttempt(attemptId, RendezvousConnectionState.Connected, peer); CompleteAttempt(
attemptId,
RendezvousConnectionState.Connected,
ConnectionOutcomeKind.Connected,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.None,
RendezvousConnectionPhase.Complete,
peer);
} }
} }
@@ -419,32 +497,113 @@ public sealed class RendezvousHostCoordinator : IDisposable
_ = disconnectInfo; _ = disconnectInfo;
if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId)) if (_acceptedPeers.TryGetValue(peer, out JoinAttemptId attemptId))
{ {
CompleteAttempt(attemptId, RendezvousConnectionState.Rejected); ConnectionOutcomeKind kind = disconnectInfo.Reason == DisconnectReason.Timeout
? ConnectionOutcomeKind.DirectConnectTimedOut
: ConnectionOutcomeKind.TransportError;
CompleteAttempt(
attemptId,
kind == ConnectionOutcomeKind.DirectConnectTimedOut
? RendezvousConnectionState.TimedOut
: RendezvousConnectionState.Rejected,
kind,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.DirectConnection,
RendezvousConnectionPhase.DirectConnection);
}
}
private void OnNetworkError(IPEndPoint endpoint, SocketError socketError)
{
_ = socketError;
if (!endpoint.Equals(_mediator))
{
return;
}
foreach (JoinAttemptId attemptId in _attempts
.Where(static item => item.Value.State == RendezvousConnectionState.Punching)
.Select(static item => item.Key)
.ToArray())
{
CompleteAttempt(
attemptId,
RendezvousConnectionState.Rejected,
ConnectionOutcomeKind.MediatorUnavailable,
RendezvousConnectionOutcomeSource.LocalTraversal,
RendezvousConnectionFailureCategory.Mediation,
RendezvousConnectionPhase.Mediation);
} }
} }
private void CompleteAttempt( private void CompleteAttempt(
JoinAttemptId attemptId, JoinAttemptId attemptId,
RendezvousConnectionState state, RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer = null) NetPeer? peer = null)
{ {
if (TryCompleteAttempt(
attemptId,
state,
kind,
source,
category,
phase,
peer,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
AttemptCompleted?.Invoke(this, completion!);
}
}
private bool TryCompleteAttempt(
JoinAttemptId attemptId,
RendezvousConnectionState state,
ConnectionOutcomeKind kind,
RendezvousConnectionOutcomeSource source,
RendezvousConnectionFailureCategory category,
RendezvousConnectionPhase phase,
NetPeer? peer,
out RendezvousHostAttemptCompletedEventArgs? completion)
{
completion = null;
if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt)) if (!_attempts.Remove(attemptId, out PendingHostAttempt? attempt))
{ {
return; return false;
} }
if (attempt.AcceptedPeer is not null) if (attempt.AcceptedPeer is not null)
{ {
_acceptedPeers.Remove(attempt.AcceptedPeer); _acceptedPeers.Remove(attempt.AcceptedPeer);
if (kind != ConnectionOutcomeKind.Connected)
{
attempt.AcceptedPeer.Disconnect();
}
} }
_deferredRequests.Remove(attemptId); if (_deferredRequests.Remove(attemptId, out DeferredConnectionRequest? deferred))
{
deferred.Request.RejectForce([]);
}
_tickets.Revoke(attemptId); _tickets.Revoke(attemptId);
_terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt; _terminalAttempts[attemptId] = attempt.Invitation.ExpiresAt;
AttemptCompleted?.Invoke(this, new(attemptId, state, peer)); RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create(
kind,
source,
category,
phase,
_clock.Elapsed - attempt.StartedAt,
peer: peer);
completion = new(attemptId, state, outcome);
return true;
} }
private void Stop(RendezvousHostState hostState, RendezvousConnectionState attemptState) private void Stop(
RendezvousHostState hostState,
RendezvousConnectionState attemptState,
ConnectionOutcomeKind outcomeKind)
{ {
if (State != RendezvousHostState.Active) if (State != RendezvousHostState.Active)
{ {
@@ -452,12 +611,32 @@ public sealed class RendezvousHostCoordinator : IDisposable
} }
State = hostState; State = hostState;
List<RendezvousHostAttemptCompletedEventArgs> completions = [];
foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray()) foreach (JoinAttemptId attemptId in _attempts.Keys.ToArray())
{ {
CompleteAttempt(attemptId, attemptState); RendezvousConnectionPhase phase = _attempts[attemptId].State
== RendezvousConnectionState.Connecting
? RendezvousConnectionPhase.DirectConnection
: RendezvousConnectionPhase.NatTraversal;
if (TryCompleteAttempt(
attemptId,
attemptState,
outcomeKind,
RendezvousConnectionOutcomeSource.Lifecycle,
RendezvousConnectionFailureCategory.Lifecycle,
phase,
null,
out RendezvousHostAttemptCompletedEventArgs? completion))
{
completions.Add(completion!);
}
} }
ReleaseSubscriptions(); ReleaseSubscriptions();
foreach (RendezvousHostAttemptCompletedEventArgs completion in completions)
{
AttemptCompleted?.Invoke(this, completion);
}
} }
private void ReleaseSubscriptions() private void ReleaseSubscriptions()
@@ -470,6 +649,7 @@ public sealed class RendezvousHostCoordinator : IDisposable
_networkEvents.RendezvousConnectionRequest -= OnConnectionRequest; _networkEvents.RendezvousConnectionRequest -= OnConnectionRequest;
_networkEvents.RendezvousPeerConnected -= OnPeerConnected; _networkEvents.RendezvousPeerConnected -= OnPeerConnected;
_networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected; _networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected;
_networkEvents.RendezvousNetworkError -= OnNetworkError;
_punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess; _punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess;
_subscriptionsReleased = true; _subscriptionsReleased = true;
} }
@@ -496,9 +676,78 @@ public sealed class RendezvousHostCoordinator : IDisposable
ExpiresAt = attempt.ExpiresAt, ExpiresAt = attempt.ExpiresAt,
}; };
private static TimeSpan Min(TimeSpan left, TimeSpan right) =>
left <= right ? left : right;
private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) =>
left <= right ? left : right; left <= right ? left : right;
private void EnqueueDeadline(HostAttemptDeadline deadline)
{
if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue<HostAttemptDeadline>? bucket))
{
bucket = new Queue<HostAttemptDeadline>();
_deadlines.Add(deadline.Deadline.Ticks, bucket);
}
bucket.Enqueue(deadline);
}
private void ProcessDueDeadlines(TimeSpan elapsed)
{
while (_deadlines.Count > 0)
{
KeyValuePair<long, Queue<HostAttemptDeadline>> first = _deadlines.First();
if (first.Key > elapsed.Ticks)
{
return;
}
HostAttemptDeadline deadline = first.Value.Dequeue();
if (first.Value.Count == 0)
{
_deadlines.Remove(first.Key);
}
if (!_attempts.TryGetValue(deadline.AttemptId, out PendingHostAttempt? attempt)
|| attempt.State != deadline.ExpectedState
|| (deadline.ExpectedState == RendezvousConnectionState.Punching
? attempt.PunchDeadline
: attempt.DirectDeadline) != deadline.Deadline)
{
continue;
}
bool expired = elapsed >= attempt.AttemptDeadline;
CompleteAttempt(
deadline.AttemptId,
RendezvousConnectionState.TimedOut,
expired
? ConnectionOutcomeKind.AttemptExpired
: deadline.ExpectedState == RendezvousConnectionState.Punching
? ConnectionOutcomeKind.PunchTimedOut
: ConnectionOutcomeKind.DirectConnectTimedOut,
expired
? RendezvousConnectionOutcomeSource.RendezvousService
: RendezvousConnectionOutcomeSource.LocalTraversal,
expired
? RendezvousConnectionFailureCategory.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionFailureCategory.NatTraversal
: RendezvousConnectionFailureCategory.DirectConnection,
expired
? RendezvousConnectionPhase.Authorization
: deadline.ExpectedState == RendezvousConnectionState.Punching
? RendezvousConnectionPhase.NatTraversal
: RendezvousConnectionPhase.DirectConnection);
if (State != RendezvousHostState.Active)
{
return;
}
}
}
private void AcceptAuthorizedRequest( private void AcceptAuthorizedRequest(
JoinAttemptId attemptId, JoinAttemptId attemptId,
PendingHostAttempt attempt, PendingHostAttempt attempt,
@@ -529,14 +778,31 @@ public sealed class RendezvousHostCoordinator : IDisposable
private sealed class PendingHostAttempt( private sealed class PendingHostAttempt(
HostJoinAttempt invitation, HostJoinAttempt invitation,
RendezvousPunchRetrySchedule retry) RendezvousPunchRetrySchedule retry,
TimeSpan startedAt,
TimeSpan attemptDeadline,
TimeSpan punchDeadline)
{ {
internal HostJoinAttempt Invitation { get; } = invitation; internal HostJoinAttempt Invitation { get; } = invitation;
internal RendezvousPunchRetrySchedule Retry { get; } = retry; internal RendezvousPunchRetrySchedule Retry { get; } = retry;
internal TimeSpan StartedAt { get; } = startedAt;
internal TimeSpan AttemptDeadline { get; } = attemptDeadline;
internal TimeSpan PunchDeadline { get; } = punchDeadline;
internal TimeSpan? DirectDeadline { get; set; }
internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching; internal RendezvousConnectionState State { get; set; } = RendezvousConnectionState.Punching;
internal NetPeer? AcceptedPeer { get; set; } internal NetPeer? AcceptedPeer { get; set; }
} }
private sealed class HostAttemptDeadline(
JoinAttemptId attemptId,
RendezvousConnectionState expectedState,
TimeSpan deadline)
{
internal JoinAttemptId AttemptId { get; } = attemptId;
internal RendezvousConnectionState ExpectedState { get; } = expectedState;
internal TimeSpan Deadline { get; } = deadline;
}
private sealed class DeferredConnectionRequest( private sealed class DeferredConnectionRequest(
ConnectionRequest request, ConnectionRequest request,
string connectionTicket) string connectionTicket)
@@ -29,6 +29,7 @@ public sealed class RendezvousNetListener : INetEventListener
internal event Action<NetPeer>? RendezvousPeerConnected; internal event Action<NetPeer>? RendezvousPeerConnected;
internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected; internal event Action<NetPeer, DisconnectInfo>? RendezvousPeerDisconnected;
internal event Action<ConnectionRequest>? RendezvousConnectionRequest; internal event Action<ConnectionRequest>? RendezvousConnectionRequest;
internal event Action<IPEndPoint, SocketError>? RendezvousNetworkError;
internal void ValidateManager(NetManager manager) internal void ValidateManager(NetManager manager)
{ {
@@ -51,8 +52,11 @@ public sealed class RendezvousNetListener : INetEventListener
((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo); ((INetEventListener)GameplayEvents).OnPeerDisconnected(peer, disconnectInfo);
} }
public void OnNetworkError(IPEndPoint endPoint, SocketError socketError) => public void OnNetworkError(IPEndPoint endPoint, SocketError socketError)
{
RendezvousNetworkError?.Invoke(endPoint, socketError);
((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError); ((INetEventListener)GameplayEvents).OnNetworkError(endPoint, socketError);
}
public void OnNetworkReceive( public void OnNetworkReceive(
NetPeer peer, NetPeer peer,
@@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind
HostRejected = 7, HostRejected = 7,
TransportFailed = 8, TransportFailed = 8,
FallbackOffered = 9, FallbackOffered = 9,
DirectoryNotFound = 10,
AttemptExpired = 11,
Unauthorized = 12,
RateLimited = 13,
NoHostPresence = 14,
ServiceUnavailable = 15,
MediatorUnavailable = 16,
PunchTimedOut = 17,
DirectConnectTimedOut = 18,
TransportError = 19,
ManagerStopped = 20,
Disposed = 21,
}
public enum ConnectionElapsedBucket
{
UnderOneSecond = 1,
OneToFiveSeconds = 2,
FiveToFifteenSeconds = 3,
FifteenToThirtySeconds = 4,
ThirtySecondsOrMore = 5,
} }
public enum UdpPresenceMessageType : byte public enum UdpPresenceMessageType : byte
@@ -45,6 +45,23 @@ public static class ContractValidation
public static bool IsDiagnosticCodeValid(string? value) => public static bool IsDiagnosticCodeValid(string? value) =>
value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters); value is null || IsVisibleAsciiWithin(value, ContractLimits.DiagnosticCodeMaxCharacters);
public static bool IsReportableConnectionOutcome(ConnectionOutcomeKind outcome) => outcome is
ConnectionOutcomeKind.Connected
or ConnectionOutcomeKind.Cancelled
or ConnectionOutcomeKind.TimedOut
or ConnectionOutcomeKind.StaleHost
or ConnectionOutcomeKind.TransportFailed
or ConnectionOutcomeKind.FallbackOffered
or ConnectionOutcomeKind.AttemptExpired
or ConnectionOutcomeKind.NoHostPresence
or ConnectionOutcomeKind.MediatorUnavailable
or ConnectionOutcomeKind.PunchTimedOut
or ConnectionOutcomeKind.DirectConnectTimedOut
or ConnectionOutcomeKind.HostRejected
or ConnectionOutcomeKind.TransportError
or ConnectionOutcomeKind.ManagerStopped
or ConnectionOutcomeKind.Disposed;
public static bool IsBuildVersionValid(string? value) => public static bool IsBuildVersionValid(string? value) =>
!string.IsNullOrWhiteSpace(value) !string.IsNullOrWhiteSpace(value)
&& IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); && IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes);
@@ -0,0 +1,33 @@
using System.Text.Json.Serialization;
namespace FinalFactory.Rendezvous.Contracts;
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
public ConnectionElapsedBucket ElapsedBucket { get; set; }
[Obsolete("Use ElapsedBucket. Exact elapsed time is accepted only for v1 compatibility and is not retained.")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ElapsedMilliseconds { get; set; }
[Obsolete("Diagnostic codes are accepted only for v1 compatibility and are not retained.")]
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
[JsonRequired]
public bool IsDuplicate { get; set; }
}
@@ -76,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse
public string? NextCursor { get; set; } public string? NextCursor { get; set; }
} }
public sealed class ReportConnectionOutcomeRequest
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public ConnectionOutcomeKind Outcome { get; set; }
[JsonRequired]
public int ElapsedMilliseconds { get; set; }
public string? DiagnosticCode { get; set; }
}
public sealed class ReportConnectionOutcomeResponse
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public bool Accepted { get; set; }
}
@@ -39,6 +39,8 @@ public sealed class SessionListing
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class RegisterSessionRequest public sealed class RegisterSessionRequest
@@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class RegisterSessionResponse public sealed class RegisterSessionResponse
@@ -147,6 +151,8 @@ public sealed class UpdateSessionRequest
[JsonRequired] [JsonRequired]
public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal); public Dictionary<string, string> Metadata { get; set; } = new(StringComparer.Ordinal);
public NetworkEndpoint? DedicatedFallback { get; set; }
} }
public sealed class DeleteSessionRequest public sealed class DeleteSessionRequest
@@ -25,7 +25,9 @@ public static class ContractJson
options.AllowTrailingCommas = false; options.AllowTrailingCommas = false;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.MaxDepth = 8; // Nine is the minimum that lets ASP.NET generate the nullable fallback
// OpenAPI schema; the 16 KiB HTTP body limit still bounds parser work.
options.MaxDepth = 9;
options.NumberHandling = JsonNumberHandling.Strict; options.NumberHandling = JsonNumberHandling.Strict;
options.PropertyNameCaseInsensitive = false; options.PropertyNameCaseInsensitive = false;
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
@@ -153,5 +153,6 @@ internal sealed class SessionBrowserService(
static item => item.Key, static item => item.Key,
static item => item.Value, static item => item.Value,
StringComparer.Ordinal), 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 System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
@@ -11,8 +12,6 @@ namespace FinalFactory.Rendezvous.Server.Http;
internal static class ContractEndpoints internal static class ContractEndpoints
{ {
private const int NotImplementedStatus = StatusCodes.Status501NotImplemented;
public static IEndpointRouteBuilder MapRendezvousContractEndpoints( public static IEndpointRouteBuilder MapRendezvousContractEndpoints(
this IEndpointRouteBuilder endpoints) this IEndpointRouteBuilder endpoints)
{ {
@@ -83,6 +82,7 @@ internal static class ContractEndpoints
.Produces<ApiError>(StatusCodes.Status400BadRequest) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound) .Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict) .Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status410Gone)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests) .Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("CreateJoinAttempt"); .WithName("CreateJoinAttempt");
@@ -95,7 +95,10 @@ internal static class ContractEndpoints
attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome)
.Accepts<ReportConnectionOutcomeRequest>("application/json") .Accepts<ReportConnectionOutcomeRequest>("application/json")
.Produces<ReportConnectionOutcomeResponse>() .Produces<ReportConnectionOutcomeResponse>()
.Produces<ApiError>(StatusCodes.Status501NotImplemented) .Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status404NotFound)
.Produces<ApiError>(StatusCodes.Status409Conflict)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("ReportConnectionOutcome"); .WithName("ReportConnectionOutcome");
return endpoints; return endpoints;
@@ -334,16 +337,20 @@ internal static class ContractEndpoints
private static IResult ReportConnectionOutcome( private static IResult ReportConnectionOutcome(
JoinAttemptId attemptId, JoinAttemptId attemptId,
[FromBody] ReportConnectionOutcomeRequest request) => NotImplemented(); [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability,
[FromBody] ReportConnectionOutcomeRequest request,
private static IResult NotImplemented() => Results.Json( [FromServices] ConnectionOutcomeService outcomes,
new ApiError CancellationToken cancellationToken)
{ {
Code = RendezvousErrorCode.ServiceUnavailable, ConnectionOutcomeServiceResult result = outcomes.Report(
Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.", attemptId,
}, clientPunchCapability,
ContractJson.Options, request,
statusCode: NotImplementedStatus); cancellationToken);
return result.Succeeded && result.Value is not null
? Results.Ok(result.Value)
: Error(result.Error);
}
private static bool TryAuthenticatePublisher( private static bool TryAuthenticatePublisher(
string? authorizationHeader, string? authorizationHeader,
@@ -389,9 +396,11 @@ internal static class ContractEndpoints
{ {
RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized,
RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden, RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden,
RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound, RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound,
RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, RendezvousErrorCode.Conflict
RendezvousErrorCode.Expired => StatusCodes.Status410Gone, or RendezvousErrorCode.IncompatibleProtocol
or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict,
RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone,
RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded => RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded =>
StatusCodes.Status429TooManyRequests, StatusCodes.Status429TooManyRequests,
RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, 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.NotFound => "The session was not found or is not owned by this publisher.",
RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.", RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.",
RendezvousErrorCode.Expired => "The session lease has expired.", 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.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.",
RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.", RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.",
RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.",
@@ -131,6 +131,7 @@ internal sealed class JoinAttemptService(
ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest(
CreateConnectionTicket(persisted)), CreateConnectionTicket(persisted)),
ExpiresAt = persisted.ExpiresAt, ExpiresAt = persisted.ExpiresAt,
DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback),
}); });
} }
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -50,6 +51,14 @@ builder.Services.AddOpenApi("v1", static options =>
BearerFormat = "rv1 publisher credential", BearerFormat = "rv1 publisher credential",
Description = "Tenant-scoped publisher credential issued during game provisioning.", 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) HashSet<string> securedOperations = new(StringComparer.Ordinal)
{ {
@@ -59,6 +68,7 @@ builder.Services.AddOpenApi("v1", static options =>
"DeleteSession", "DeleteSession",
}; };
OpenApiSecuritySchemeReference reference = new(schemeName, document, null); OpenApiSecuritySchemeReference reference = new(schemeName, document, null);
OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null);
foreach (OpenApiPathItem path in document.Paths.Values) foreach (OpenApiPathItem path in document.Paths.Values)
{ {
if (path.Operations is null) if (path.Operations is null)
@@ -75,6 +85,17 @@ builder.Services.AddOpenApi("v1", static options =>
[reference] = [], [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; return Task.CompletedTask;
@@ -125,6 +146,8 @@ else
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
builder.Services.AddSingleton<ConnectionOutcomeService>();
builder.Services.AddSingleton(new ProvisioningReadiness(true)); builder.Services.AddSingleton(new ProvisioningReadiness(true));
} }
@@ -55,6 +55,11 @@ internal sealed class SessionLeaseService(
} }
AuthorizedPublisherContext context = authorized.Context; AuthorizedPublisherContext context = authorized.Context;
if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
string requestFingerprint = ComputeRegistrationFingerprint(request); string requestFingerprint = ComputeRegistrationFingerprint(request);
string derivationSalt = capabilities.CreateDerivationSalt(); string derivationSalt = capabilities.CreateDerivationSalt();
string leaseToken = capabilities.DeriveCapability( string leaseToken = capabilities.DeriveCapability(
@@ -119,6 +124,7 @@ internal sealed class SessionLeaseService(
CurrentPlayers = request.Capacity.CurrentPlayers, CurrentPlayers = request.Capacity.CurrentPlayers,
MaximumPlayers = request.Capacity.MaximumPlayers, MaximumPlayers = request.Capacity.MaximumPlayers,
Metadata = request.Metadata, Metadata = request.Metadata,
DedicatedFallback = request.DedicatedFallback,
LeaseFingerprint = leaseFingerprint, LeaseFingerprint = leaseFingerprint,
HostPresenceHandle = presenceHandle, HostPresenceHandle = presenceHandle,
HostPresenceFingerprint = presenceFingerprint, HostPresenceFingerprint = presenceFingerprint,
@@ -235,10 +241,14 @@ internal sealed class SessionLeaseService(
StoredListing ownedListing = listing!; StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
if (!authorized.IsAllowed) if (!authorized.IsAllowed || authorized.Context is null)
{ {
return new(MapAuthorization(authorized.Error)); return new(MapAuthorization(authorized.Error));
} }
if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback))
{
return new(RendezvousErrorCode.Forbidden);
}
capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint);
StoreResult<StoredListing> updated = store.UpdateListing(new( StoreResult<StoredListing> updated = store.UpdateListing(new(
@@ -250,7 +260,8 @@ internal sealed class SessionLeaseService(
request.DisplayName, request.DisplayName,
request.Capacity.CurrentPlayers, request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers, request.Capacity.MaximumPlayers,
request.Metadata), cancellationToken); request.Metadata,
request.DedicatedFallback), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError()); : new(updated.Code.ToContractError());
@@ -341,6 +352,9 @@ internal sealed class SessionLeaseService(
metadata, metadata,
clock.UtcNow); clock.UtcNow);
private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) =>
fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed;
private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request) private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request)
{ {
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
@@ -359,6 +373,8 @@ internal sealed class SessionLeaseService(
|| !Enum.IsDefined(request.Visibility) || !Enum.IsDefined(request.Visibility)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None; : RendezvousErrorCode.None;
} }
@@ -375,6 +391,8 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
: RendezvousErrorCode.None; : RendezvousErrorCode.None;
} }
@@ -424,6 +442,14 @@ internal sealed class SessionLeaseService(
Metadata = request.Metadata Metadata = request.Metadata
.OrderBy(static item => item.Key, StringComparer.Ordinal) .OrderBy(static item => item.Key, StringComparer.Ordinal)
.ToDictionary(static item => item.Key, static item => item.Value, 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[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options);
byte[] digest = SHA256.HashData(encoded); byte[] digest = SHA256.HashData(encoded);
@@ -29,6 +29,7 @@ internal sealed record EphemeralStoreOptions
public int MaxListings { get; init; } = 25_000; public int MaxListings { get; init; } = 25_000;
public int MaxPresenceBindings { get; init; } = 25_000; public int MaxPresenceBindings { get; init; } = 25_000;
public int MaxJoinAttempts { get; init; } = 10_000; public int MaxJoinAttempts { get; init; } = 10_000;
public int MaxOutcomeReports { get; init; } = 35_000;
public int MaxReplayEntries { get; init; } = 30_000; public int MaxReplayEntries { get; init; } = 30_000;
public int MaxRevocations { get; init; } = 10_000; public int MaxRevocations { get; init; } = 10_000;
public int MaxIdempotencyEntries { get; init; } = 35_000; public int MaxIdempotencyEntries { get; init; } = 35_000;
@@ -45,6 +46,7 @@ internal sealed record EphemeralStoreOptions
RequirePositive(MaxListings, nameof(MaxListings)); RequirePositive(MaxListings, nameof(MaxListings));
RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings)); RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings));
RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts)); RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts));
RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports));
RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries)); RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries));
RequirePositive(MaxRevocations, nameof(MaxRevocations)); RequirePositive(MaxRevocations, nameof(MaxRevocations));
RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries)); RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries));
@@ -173,6 +175,7 @@ internal sealed record ListingDefinition
public required int CurrentPlayers { get; init; } public required int CurrentPlayers { get; init; }
public required int MaximumPlayers { get; init; } public required int MaximumPlayers { get; init; }
public required IReadOnlyDictionary<string, string> Metadata { get; init; } public required IReadOnlyDictionary<string, string> Metadata { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required SecretFingerprint LeaseFingerprint { get; init; } public required SecretFingerprint LeaseFingerprint { get; init; }
public required MediationHandle HostPresenceHandle { get; init; } public required MediationHandle HostPresenceHandle { get; init; }
public required SecretFingerprint HostPresenceFingerprint { get; init; } public required SecretFingerprint HostPresenceFingerprint { get; init; }
@@ -189,7 +192,17 @@ internal sealed record StoredListing
public static ListingDefinition Freeze(ListingDefinition source) => source with public static ListingDefinition Freeze(ListingDefinition source) => source with
{ {
Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal), 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( internal sealed record CreateListingCommand(
@@ -214,7 +227,8 @@ internal sealed record UpdateListingCommand(
string DisplayName, string DisplayName,
int CurrentPlayers, int CurrentPlayers,
int MaximumPlayers, int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata); IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback);
internal sealed record DeleteListingCommand( internal sealed record DeleteListingCommand(
SessionListingId ListingId, SessionListingId ListingId,
@@ -257,6 +271,7 @@ internal sealed record CreateJoinAttemptCommand
public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; } public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public required string CapabilityDerivationSalt { get; init; } public required string CapabilityDerivationSalt { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public int ScopeAttemptLimit { get; init; } = int.MaxValue; public int ScopeAttemptLimit { get; init; } = int.MaxValue;
public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]"; public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]";
@@ -280,6 +295,7 @@ internal sealed record StoredJoinAttempt
public required SecretFingerprint HostCapabilityFingerprint { get; init; } public required SecretFingerprint HostCapabilityFingerprint { get; init; }
public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; }
public required SecretFingerprint ConnectionTicketFingerprint { get; init; } public required SecretFingerprint ConnectionTicketFingerprint { get; init; }
public NetworkEndpoint? DedicatedFallback { get; init; }
public required DateTimeOffset ExpiresAt { get; init; } public required DateTimeOffset ExpiresAt { get; init; }
public required DateTimeOffset ConnectionTicketExpiresAt { get; init; } public required DateTimeOffset ConnectionTicketExpiresAt { get; init; }
public AttemptEndpointBinding? HostEndpoint { get; init; } public AttemptEndpointBinding? HostEndpoint { get; init; }
@@ -316,6 +332,16 @@ internal sealed record CancelJoinAttemptCommand(
JoinAttemptId AttemptId, JoinAttemptId AttemptId,
SecretFingerprint ClientCapabilityFingerprint); 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( internal sealed record ConsumeConnectionTicketCommand(
JoinAttemptId AttemptId, JoinAttemptId AttemptId,
SecretFingerprint ConnectionTicketFingerprint); SecretFingerprint ConnectionTicketFingerprint);
@@ -336,6 +362,8 @@ internal enum StoreResultCode
Draining = 6, Draining = 6,
ReplayRejected = 7, ReplayRejected = 7,
ServiceUnavailable = 8, ServiceUnavailable = 8,
StaleHost = 9,
IncompatibleProtocol = 10,
} }
internal sealed record StoreResult<T>(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false) 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<StoredJoinAttempt> CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default);
StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default); StoreResult<IReadOnlyList<StoredJoinAttempt>> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default);
StoreResult<bool> CancelJoinAttempt(CancelJoinAttemptCommand command, 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<StoredJoinAttempt> BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default);
StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default); StoreResult<IntroductionEndpoints> ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default);
StoreResult<bool> ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, 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, SessionListingId> _presenceHandles = [];
private readonly Dictionary<MediationHandle, PresenceEntry> _presence = []; private readonly Dictionary<MediationHandle, PresenceEntry> _presence = [];
private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = []; private readonly Dictionary<JoinAttemptId, AttemptEntry> _attempts = [];
private readonly Dictionary<JoinAttemptId, OutcomeReportEntry> _outcomeReports = [];
private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = []; private readonly Dictionary<MediationHandle, JoinAttemptId> _attemptHandles = [];
private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal); private readonly Dictionary<string, IdempotencyEntry> _idempotency = new(StringComparer.Ordinal);
private readonly Dictionary<string, TimeSpan> _replay = 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.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0 || command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers || 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)); throw new ArgumentException("Listing update invariants are invalid.", nameof(command));
} }
@@ -213,6 +216,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
CurrentPlayers = command.CurrentPlayers, CurrentPlayers = command.CurrentPlayers,
MaximumPlayers = command.MaximumPlayers, MaximumPlayers = command.MaximumPlayers,
Metadata = command.Metadata, Metadata = command.Metadata,
DedicatedFallback = command.DedicatedFallback,
}); });
entry.Version++; entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry)); return new(StoreResultCode.Success, Snapshot(entry));
@@ -362,14 +366,26 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing) if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing)
|| listing.Definition.Scope != command.Scope || listing.Definition.Scope != command.Scope)
|| listing.Definition.ProtocolVersion != command.ProtocolVersion
|| !_presence.ContainsKey(listing.Definition.HostPresenceHandle))
{ {
return new(StoreResultCode.NotFound); 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 if (_attempts.Count >= _options.MaxJoinAttempts
|| _outcomeReports.Count >= _options.MaxOutcomeReports
|| _idempotency.Count >= _options.MaxIdempotencyEntries || _idempotency.Count >= _options.MaxIdempotencyEntries
|| _attempts.Values.Count(entry => entry.Command.Scope == command.Scope) || _attempts.Values.Count(entry => entry.Command.Scope == command.Scope)
>= command.ScopeAttemptLimit) >= command.ScopeAttemptLimit)
@@ -387,6 +403,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.JoinAttemptLifetime, now + _options.JoinAttemptLifetime,
WallDeadline(now, _options.JoinAttemptLifetime)); WallDeadline(now, _options.JoinAttemptLifetime));
_attempts.Add(command.AttemptId, attempt); _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); _attemptHandles.Add(command.MediationHandle, command.AttemptId);
_idempotency.Add(idempotencyKey, new( _idempotency.Add(idempotencyKey, new(
command.RequestFingerprint, command.RequestFingerprint,
@@ -460,6 +481,42 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.Success, true); return new(StoreResultCode.Success, true);
}, cancellationToken); }, 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( public StoreResult<StoredJoinAttempt> BindAttemptEndpoint(
BindAttemptEndpointCommand command, BindAttemptEndpointCommand command,
CancellationToken cancellationToken = default) => Atomic<StoredJoinAttempt>(now => 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)) .Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal))
.Select(static item => item.Key) .Select(static item => item.Key)
.ToArray(); .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) foreach (SessionListingId listingId in listings)
{ {
RemoveListing(listingId); RemoveListing(listingId);
@@ -690,6 +751,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{ {
RemoveAttempt(attemptId); RemoveAttempt(attemptId);
} }
foreach (JoinAttemptId attemptId in outcomeReports)
{
_outcomeReports.Remove(attemptId);
}
return new(StoreResultCode.Success, listings.Length + attempts.Length); return new(StoreResultCode.Success, listings.Length + attempts.Length);
}, cancellationToken); }, cancellationToken);
@@ -799,6 +864,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
RemoveAttempt(attemptId); 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 foreach (SessionListingId listingId in _listings
.Where(item => item.Value.LeaseDeadline <= now) .Where(item => item.Value.LeaseDeadline <= now)
.Select(static item => item.Key) .Select(static item => item.Key)
@@ -815,6 +888,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_presenceHandles.Clear(); _presenceHandles.Clear();
_presence.Clear(); _presence.Clear();
_attempts.Clear(); _attempts.Clear();
_outcomeReports.Clear();
_attemptHandles.Clear(); _attemptHandles.Clear();
_idempotency.Clear(); _idempotency.Clear();
_replay.Clear(); _replay.Clear();
@@ -837,6 +911,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
{ {
RemoveAttempt(attemptId); 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) private void RemoveAttempt(JoinAttemptId attemptId)
@@ -875,6 +958,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint, HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint,
ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint, ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint,
ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint, ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint,
DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback),
ExpiresAt = entry.WallExpiresAt, ExpiresAt = entry.WallExpiresAt,
ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default, ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default,
HostEndpoint = entry.HostEndpoint, HostEndpoint = entry.HostEndpoint,
@@ -914,6 +998,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| listing.CurrentPlayers < 0 || listing.CurrentPlayers < 0
|| listing.CurrentPlayers > listing.MaximumPlayers || listing.CurrentPlayers > listing.MaximumPlayers
|| !ContractValidation.IsMetadataValid(listing.Metadata) || !ContractValidation.IsMetadataValid(listing.Metadata)
|| listing.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback)
|| !listing.LeaseFingerprint.IsValid || !listing.LeaseFingerprint.IsValid
|| !listing.HostPresenceFingerprint.IsValid || !listing.HostPresenceFingerprint.IsValid
|| !IsDerivationSaltValid(listing.CapabilityDerivationSalt)) || !IsDerivationSaltValid(listing.CapabilityDerivationSalt))
@@ -954,6 +1040,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| !command.HostCapabilityFingerprint.IsValid || !command.HostCapabilityFingerprint.IsValid
|| !command.ClientCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid
|| !command.ConnectionTicketFingerprint.IsValid || !command.ConnectionTicketFingerprint.IsValid
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)
|| !IsDerivationSaltValid(command.CapabilityDerivationSalt) || !IsDerivationSaltValid(command.CapabilityDerivationSalt)
|| command.ScopeAttemptLimit <= 0) || command.ScopeAttemptLimit <= 0)
{ {
@@ -1031,6 +1119,19 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public bool IsCancelled { get; set; } 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( private sealed record IdempotencyEntry(
string RequestFingerprint, string RequestFingerprint,
object ResourceId, object ResourceId,
@@ -13,6 +13,8 @@ internal static class StoreResultMapping
StoreResultCode.Conflict => RendezvousErrorCode.Conflict, StoreResultCode.Conflict => RendezvousErrorCode.Conflict,
StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded, StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded,
StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected, StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected,
StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost,
StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol,
StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => StoreResultCode.Draining or StoreResultCode.ServiceUnavailable =>
RendezvousErrorCode.ServiceUnavailable, RendezvousErrorCode.ServiceUnavailable,
_ => RendezvousErrorCode.InternalError, _ => RendezvousErrorCode.InternalError,
@@ -102,6 +102,32 @@ public sealed class RendezvousClientBehaviorTests
Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays)); 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] [Fact]
public void SuccessResultRequiresAValue() public void SuccessResultRequiresAValue()
{ {
@@ -339,4 +365,15 @@ public sealed class RendezvousClientBehaviorTests
return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); 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 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] [Fact]
public void NatIntroductionAloneDoesNotCompleteTheClientAttempt() public void NatIntroductionAloneDoesNotCompleteTheClientAttempt()
{ {
@@ -43,6 +92,29 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.False(harness.Coordinator.IsCompleted); 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] [Fact]
public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt() public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt()
{ {
@@ -61,6 +133,7 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State); Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State);
Assert.Equal([RendezvousConnectionState.Cancelled], completions); Assert.Equal([RendezvousConnectionState.Cancelled], completions);
Assert.Equal(ConnectionOutcomeKind.Cancelled, harness.Coordinator.Outcome!.Kind);
} }
[Fact] [Fact]
@@ -85,6 +158,68 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State); Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State);
Assert.Equal(1, completions); 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] [Fact]
@@ -145,6 +280,10 @@ public sealed class RendezvousCoordinatorBehaviorTests
Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State); Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State);
Assert.Equal(1, completions); Assert.Equal(1, completions);
Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Coordinator.Outcome!.Kind);
Assert.Equal(
RendezvousConnectionOutcomeSource.RemoteHost,
client.Coordinator.Outcome.Source);
client.Coordinator.Poll(); client.Coordinator.Poll();
Assert.Equal(1, completions); Assert.Equal(1, completions);
} }
@@ -409,7 +548,7 @@ public sealed class RendezvousCoordinatorBehaviorTests
} }
[Fact] [Fact]
public async Task HostBoundsAttemptExpiryChecksPerPoll() public async Task HostDeadlinesAreNotDelayedByTheBoundedRetryQueue()
{ {
ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero));
RendezvousNetListener networkEvents = new(); RendezvousNetListener networkEvents = new();
@@ -453,9 +592,6 @@ public sealed class RendezvousCoordinatorBehaviorTests
host.Poll(); host.Poll();
Assert.Equal(1, host.PendingAttemptCount);
Assert.Equal([RendezvousConnectionState.TimedOut], completions);
host.Poll();
Assert.Equal(0, host.PendingAttemptCount); Assert.Equal(0, host.PendingAttemptCount);
Assert.Equal( Assert.Equal(
[RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut], [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() private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new()
{ {
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")), AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")),
@@ -548,6 +790,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
{ {
internal IReadOnlyList<HostJoinAttempt> Attempts { get; set; } = attempts; 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( public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request, CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException(); CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -567,6 +814,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
int maximumPages = 100, int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult( CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(Attempts)); RendezvousClientResult.Success(Attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
} }
private sealed class BlockingJoinClient : IRendezvousJoinClient private sealed class BlockingJoinClient : IRendezvousJoinClient
@@ -581,6 +833,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) => internal void Complete(IReadOnlyList<HostJoinAttempt> attempts) =>
_result.SetResult(attempts); _result.SetResult(attempts);
public Task<RendezvousConnectionStartResult> CreateConnectionAttemptAsync(
CreateJoinAttemptRequest request,
NetworkEndpoint? dedicatedFallback = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync( public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request, CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException(); CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -603,6 +860,11 @@ public sealed class RendezvousCoordinatorBehaviorTests
_called.SetResult(true); _called.SetResult(true);
return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken)); 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) : private sealed class ManualCoordinatorClock(DateTimeOffset now) :
@@ -610,7 +872,14 @@ public sealed class RendezvousCoordinatorBehaviorTests
IConnectionTicketClock IConnectionTicketClock
{ {
public DateTimeOffset UtcNow { get; private set; } = now; 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 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( public Task<RendezvousClientResult<CreateJoinAttemptResponse>> CreateAsync(
CreateJoinAttemptRequest request, CreateJoinAttemptRequest request,
CancellationToken cancellationToken = default) => throw new NotSupportedException(); CancellationToken cancellationToken = default) => throw new NotSupportedException();
@@ -261,6 +266,11 @@ public sealed class RendezvousCoordinatorIntegrationTests
int maximumPages = 100, int maximumPages = 100,
CancellationToken cancellationToken = default) => Task.FromResult( CancellationToken cancellationToken = default) => Task.FromResult(
RendezvousClientResult.Success(attempts)); RendezvousClientResult.Success(attempts));
public Task<RendezvousClientResult<ReportConnectionOutcomeResponse>> ReportOutcomeAsync(
CreateJoinAttemptResponse attempt,
RendezvousConnectionOutcome outcome,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
} }
private sealed class FixedCoordinatorClock(DateTimeOffset now) : private sealed class FixedCoordinatorClock(DateTimeOffset now) :
@@ -268,5 +278,6 @@ public sealed class RendezvousCoordinatorIntegrationTests
IConnectionTicketClock IConnectionTicketClock
{ {
public DateTimeOffset UtcNow { get; } = now; 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); 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() private static CreateJoinAttemptResponse CreateAttempt() => new()
{ {
AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")), 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), 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() private static HostJoinAttempt CreateHostAttempt(string id) => new()
{ {
AttemptId = new(Guid.Parse(id)), 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( private sealed record RecordedRequest(
HttpMethod Method, HttpMethod Method,
Uri Uri, 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() public void UnknownEnumNamesAndNumericValuesAreRejected()
{ {
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>( Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}", "{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options)); ContractJson.Options));
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>( Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<ReportConnectionOutcomeRequest>(
"{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}", "{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}",
ContractJson.Options)); ContractJson.Options));
} }
@@ -98,6 +98,7 @@ public sealed class ContractSerializationTests
[Fact] [Fact]
public void SharedCanonicalOptionsCannotBeMutatedByConsumers() public void SharedCanonicalOptionsCannotBeMutatedByConsumers()
{ {
Assert.Equal(9, ContractJson.Options.MaxDepth);
Assert.True(ContractJson.Options.IsReadOnly); Assert.True(ContractJson.Options.IsReadOnly);
Assert.Throws<InvalidOperationException>(() => Assert.Throws<InvalidOperationException>(() =>
ContractJson.Options.WriteIndented = true); ContractJson.Options.WriteIndented = true);
@@ -22,6 +22,7 @@ public sealed class OpenApiCompatibilityTests
"buildVersion", "buildVersion",
"capacity", "capacity",
"contractVersion", "contractVersion",
"dedicatedFallback",
"displayName", "displayName",
"environmentId", "environmentId",
"gameId", "gameId",
@@ -63,14 +64,46 @@ public sealed class OpenApiCompatibilityTests
Assert.Equal(ExpectedListingProperties, listingProperties); Assert.Equal(ExpectedListingProperties, listingProperties);
Assert.DoesNotContain(listingProperties, static property => Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase) property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("endpoint", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", 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") JsonElement publisherBearer = root.GetProperty("components")
.GetProperty("securitySchemes") .GetProperty("securitySchemes")
.GetProperty("PublisherBearer"); .GetProperty("PublisherBearer");
Assert.Equal("http", publisherBearer.GetProperty("type").GetString()); Assert.Equal("http", publisherBearer.GetProperty("type").GetString());
Assert.Equal("bearer", publisherBearer.GetProperty("scheme").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 = (string Path, string Method)[] publisherOperations =
[ [
("/v1/sessions", "post"), ("/v1/sessions", "post"),
@@ -96,6 +129,18 @@ public sealed class OpenApiCompatibilityTests
&& parameter.GetProperty("name").GetString() && parameter.GetProperty("name").GetString()
== "X-Rendezvous-Client-Punch-Capability"); == "X-Rendezvous-Client-Punch-Capability");
Assert.True(cancelCapability.GetProperty("required").GetBoolean()); 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") JsonElement hostPollParameters = root.GetProperty("paths")
.GetProperty("/v1/sessions/{listingId}/join-attempts") .GetProperty("/v1/sessions/{listingId}/join-attempts")
.GetProperty("get") .GetProperty("get")
@@ -3,6 +3,7 @@ using System.Net.Http.Json;
using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Provisioning;
@@ -103,6 +104,139 @@ public sealed class JoinAttemptHttpEndpointTests
Assert.True(cancelledAttempt.IsCancelled); 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) private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{ {
Assert.True(result.IsSuccess, result.Message); Assert.True(result.IsSuccess, result.Message);
@@ -132,18 +266,21 @@ public sealed class JoinAttemptHttpEndpointTests
HttpClient httpClient, HttpClient httpClient,
InMemoryEphemeralRendezvousStore store, InMemoryEphemeralRendezvousStore store,
EphemeralCapabilityIssuer capabilities, EphemeralCapabilityIssuer capabilities,
ConnectionOutcomeMetrics outcomeMetrics,
string publisherCredential) string publisherCredential)
{ {
_application = application; _application = application;
HttpClient = httpClient; HttpClient = httpClient;
Store = store; Store = store;
Capabilities = capabilities; Capabilities = capabilities;
OutcomeMetrics = outcomeMetrics;
PublisherCredential = publisherCredential; PublisherCredential = publisherCredential;
} }
internal HttpClient HttpClient { get; } internal HttpClient HttpClient { get; }
internal InMemoryEphemeralRendezvousStore Store { get; } internal InMemoryEphemeralRendezvousStore Store { get; }
internal EphemeralCapabilityIssuer Capabilities { get; } internal EphemeralCapabilityIssuer Capabilities { get; }
internal ConnectionOutcomeMetrics OutcomeMetrics { get; }
internal string PublisherCredential { get; } internal string PublisherCredential { get; }
internal static async Task<JoinHttpTestHost> StartAsync() internal static async Task<JoinHttpTestHost> StartAsync()
@@ -181,6 +318,9 @@ public sealed class JoinAttemptHttpEndpointTests
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
ConnectionOutcomeMetrics outcomeMetrics = new();
builder.Services.AddSingleton(outcomeMetrics);
builder.Services.AddSingleton<ConnectionOutcomeService>();
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
@@ -193,6 +333,7 @@ public sealed class JoinAttemptHttpEndpointTests
new HttpClient { BaseAddress = new Uri(address) }, new HttpClient { BaseAddress = new Uri(address) },
store, store,
capabilities, capabilities,
outcomeMetrics,
credential); credential);
} }
@@ -1,6 +1,8 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.JoinAttempts; namespace FinalFactory.Rendezvous.Tests.JoinAttempts;
@@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests
using JoinAttemptFixture fixture = new(); using JoinAttemptFixture fixture = new();
(RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false); (RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false);
Assert.Equal( Assert.Equal(
RendezvousErrorCode.NotFound, RendezvousErrorCode.StaleHost,
fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error); fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error);
(RegisterSessionResponse active, _) = fixture.CreateHost(); (RegisterSessionResponse active, _) = fixture.CreateHost();
@@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests
fixture.Service.Create(fixture.ClientSubject, otherTenant).Error); 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] [Fact]
public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging() public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging()
{ {
@@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable
{ {
private int _sequence; private int _sequence;
public JoinAttemptFixture(EphemeralStoreOptions? options = null) public JoinAttemptFixture(
EphemeralStoreOptions? options = null,
GamePolicyOptions? joinPolicy = null)
{ {
Sessions = new(options); Sessions = new(options);
Cursors = new(); 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); Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock);
ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40")); 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.Provisioning;
using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.Sessions;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.Provisioning;
namespace FinalFactory.Rendezvous.Tests.Sessions; namespace FinalFactory.Rendezvous.Tests.Sessions;
@@ -118,6 +119,12 @@ public sealed class SessionLeaseServiceTests
["mode"] = "co-op", ["mode"] = "co-op",
["map"] = "europa", ["map"] = "europa",
}, },
DedicatedFallback = new()
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.92",
Port = 9_062,
},
}); });
StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!; 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(fixture.Scope, stored.Definition.Scope);
Assert.Equal(8, stored.Definition.CurrentPlayers); Assert.Equal(8, stored.Definition.CurrentPlayers);
Assert.Equal(8, stored.Definition.MaximumPlayers); Assert.Equal(8, stored.Definition.MaximumPlayers);
Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address);
Assert.True(fixture.Service.Delete( Assert.True(fixture.Service.Delete(
fixture.Principal, fixture.Principal,
registration.ListingId, registration.ListingId,
@@ -139,6 +147,45 @@ public sealed class SessionLeaseServiceTests
Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); 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] [Fact]
public void AnotherPublisherCannotRenewUpdateOrDeleteListing() public void AnotherPublisherCannotRenewUpdateOrDeleteListing()
{ {
@@ -11,13 +11,17 @@ internal sealed class SessionLeaseFixture : IDisposable
{ {
private int _sequence; private int _sequence;
public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null) public SessionLeaseFixture(
EphemeralStoreOptions? storeOptions = null,
GamePolicyOptions? policyOptions = null)
{ {
StoreOptions = storeOptions ?? new EphemeralStoreOptions(); StoreOptions = storeOptions ?? new EphemeralStoreOptions();
Clock = new(); Clock = new();
Store = new(StoreOptions, Clock, Clock); Store = new(StoreOptions, Clock, Clock);
Capabilities = new(); Capabilities = new();
GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); GamePolicyRegistry policies = GamePolicyRegistry.Create([
policyOptions ?? ProvisioningTestData.CreatePolicy(),
]);
Service = new( Service = new(
new PublisherAuthorizationService(policies), new PublisherAuthorizationService(policies),
Store, Store,
@@ -134,13 +134,13 @@ public sealed class InMemoryEphemeralRendezvousStoreTests
StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!; StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!;
CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing); 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( fixture.Store.BindHostPresence(new(
listingCommand.Listing.HostPresenceHandle, listingCommand.Listing.HostPresenceHandle,
listingCommand.Listing.HostPresenceFingerprint, listingCommand.Listing.HostPresenceFingerprint,
EphemeralStateFixture.PublicEndpoint(40_000), EphemeralStateFixture.PublicEndpoint(40_000),
null)); 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 Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with
{ {
Scope = new(new("other-game"), new("test")), Scope = new(new("other-game"), new("test")),
@@ -19,6 +19,8 @@ public sealed class StoreResultMappingTests
[StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable, [StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected, [StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected,
[StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable, [StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable,
[StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost,
[StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol,
}; };
Assert.Equal(Enum.GetValues<StoreResultCode>().Length, expected.Count); 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<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<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.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 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<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) 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) 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 LiteNetLib.NetPeer ConnectedPeer {get;}
PROP System.Boolean IsCompleted {get;} PROP System.Boolean IsCompleted {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed EVENT System.EventHandler<FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs> Completed
METHOD System.Void Cancel() 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.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 Dispose()
METHOD System.Void Poll() 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() METHOD System.String ToString()
TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
CTOR () CTOR ()
@@ -73,6 +77,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions
PROP System.Double JitterRatio {get;set;} PROP System.Double JitterRatio {get;set;}
PROP System.TimeSpan MaximumRetryDelay {get;set;} PROP System.TimeSpan MaximumRetryDelay {get;set;}
PROP System.Int32 MaximumSafeRetries {get;set;} PROP System.Int32 MaximumSafeRetries {get;set;}
PROP System.TimeSpan RequestTimeout {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult 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> Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable<System.Int32> retryAfterSeconds)
METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value) METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult<T> Success(T value)
@@ -84,8 +89,55 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult<T>
PROP T Value {get;} PROP T Value {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
PROP LiteNetLib.NetPeer Peer {get;} PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {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 TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
ENUM Punching=1 ENUM Punching=1
ENUM Connecting=2 ENUM Connecting=2
@@ -98,14 +150,18 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState
TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions
CTOR () CTOR ()
PROP System.TimeSpan ConnectionTicketLifetime {get;set;} 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.TimeSpan InitialPunchRetryDelay {get;set;}
PROP System.Double JitterRatio {get;set;} PROP System.Double JitterRatio {get;set;}
PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;} PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;}
PROP System.Int32 MaximumPunchRequests {get;set;} PROP System.Int32 MaximumPunchRequests {get;set;}
PROP System.TimeSpan MaximumPunchRetryDelay {get;set;} PROP System.TimeSpan MaximumPunchRetryDelay {get;set;}
PROP System.TimeSpan PunchTimeout {get;set;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs
CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer)
PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;} PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;}
PROP LiteNetLib.NetPeer Peer {get;} PROP LiteNetLib.NetPeer Peer {get;}
PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;}
TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator 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<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<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.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 TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener
CTOR () CTOR ()
PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;} PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;}
@@ -28,6 +28,12 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;} PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {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 TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM Connected=1 ENUM Connected=1
ENUM Cancelled=2 ENUM Cancelled=2
@@ -38,6 +44,18 @@ TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind
ENUM HostRejected=7 ENUM HostRejected=7
ENUM TransportFailed=8 ENUM TransportFailed=8
ENUM FallbackOffered=9 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 TYPE FinalFactory.Rendezvous.Contracts.ContractJson
PROP System.Text.Json.JsonSerializerOptions Options {get;} PROP System.Text.Json.JsonSerializerOptions Options {get;}
METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options) 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 IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint)
METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value) METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value)
METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize) 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 System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes)
METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion) METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion)
TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest
@@ -234,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest
PROP System.String BuildVersion {get;set;} PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {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 DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;}
@@ -288,12 +308,14 @@ TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest
CTOR () CTOR ()
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.String DiagnosticCode {get;set;} PROP System.String DiagnosticCode {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket ElapsedBucket {get;set;}
PROP System.Int32 ElapsedMilliseconds {get;set;} PROP System.Int32 ElapsedMilliseconds {get;set;}
PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;} PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse
CTOR () CTOR ()
PROP System.Boolean Accepted {get;set;} PROP System.Boolean Accepted {get;set;}
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.Boolean IsDuplicate {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity
CTOR () CTOR ()
PROP System.Int32 CurrentPlayers {get;set;} PROP System.Int32 CurrentPlayers {get;set;}
@@ -303,6 +325,7 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListing
PROP System.String BuildVersion {get;set;} PROP System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {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 DisplayName {get;set;}
PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.GameId GameId {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 System.String BuildVersion {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;}
PROP System.Int32 ContractVersion {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 DisplayName {get;set;}
PROP System.String LeaseToken {get;set;} PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;} PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}