From 94aba8a3bb59e16a3e710047dbe8ecddb3bc9296 Mon Sep 17 00:00:00 2001 From: KyuubiYoru Date: Thu, 16 Jul 2026 10:18:41 +0200 Subject: [PATCH] feat(client): standardize connection outcomes (#13) --- README.md | 9 +- docs/api/rendezvous-v1.json | 137 +++++++- ...-typed-connection-outcomes-and-fallback.md | 117 +++++++ docs/architecture/README.md | 1 + docs/contracts/http-v1.md | 26 +- .../RendezvousConnectionOutcome.cs | 183 ++++++++++ .../RendezvousConnectionStartResult.cs | 35 ++ .../JoinAttempts/RendezvousJoinClient.cs | 87 +++++ src/FinalFactory.Rendezvous.Client/README.md | 64 +++- .../RendezvousClientAbstractions.cs | 25 ++ .../RendezvousHttpTransport.cs | 11 +- .../RendezvousPublisherClient.cs | 3 + .../Traversal/RendezvousClientCoordinator.cs | 272 ++++++++++++--- .../RendezvousCoordinatorAbstractions.cs | 122 ++++++- .../Traversal/RendezvousHostCoordinator.cs | 316 ++++++++++++++++-- .../Traversal/RendezvousNetListener.cs | 6 +- .../ContractEnums.cs | 21 ++ .../ContractValidation.cs | 17 + .../Http/ConnectionOutcomeContracts.cs | 33 ++ .../Http/JoinContracts.cs | 23 -- .../Http/SessionContracts.cs | 6 + .../Serialization/ContractJson.cs | 4 +- .../Browser/SessionBrowserService.cs | 1 + .../ConnectionOutcomeService.cs | 134 ++++++++ .../Http/ContractEndpoints.cs | 42 ++- .../JoinAttempts/JoinAttemptService.cs | 1 + src/FinalFactory.Rendezvous.Server/Program.cs | 23 ++ .../Sessions/SessionLeaseService.cs | 30 +- .../State/EphemeralStateContracts.cs | 31 +- .../State/InMemoryEphemeralRendezvousStore.cs | 109 +++++- .../State/StoreResultMapping.cs | 2 + .../Client/RendezvousClientBehaviorTests.cs | 37 ++ .../RendezvousCoordinatorBehaviorTests.cs | 279 +++++++++++++++- .../RendezvousCoordinatorIntegrationTests.cs | 11 + .../Client/RendezvousJoinClientTests.cs | 129 +++++++ .../ConnectionOutcomeServiceTests.cs | 175 ++++++++++ .../Contracts/ContractSerializationTests.cs | 5 +- .../Contracts/OpenApiCompatibilityTests.cs | 47 ++- .../JoinAttemptHttpEndpointTests.cs | 141 ++++++++ .../JoinAttempts/JoinAttemptServiceTests.cs | 39 ++- .../JoinAttempts/JoinAttemptTestData.cs | 8 +- .../Sessions/SessionLeaseServiceTests.cs | 47 +++ .../Sessions/SessionLeaseTestData.cs | 8 +- .../InMemoryEphemeralRendezvousStoreTests.cs | 4 +- .../State/StoreResultMappingTests.cs | 2 + .../Contracts/v1/client-public-api.txt | 58 ++++ .../Contracts/v1/contracts-public-api.txt | 24 ++ 47 files changed, 2727 insertions(+), 178 deletions(-) create mode 100644 docs/architecture/0010-typed-connection-outcomes-and-fallback.md create mode 100644 src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionOutcome.cs create mode 100644 src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionStartResult.cs create mode 100644 src/FinalFactory.Rendezvous.Contracts/Http/ConnectionOutcomeContracts.cs create mode 100644 src/FinalFactory.Rendezvous.Server/ConnectionOutcomes/ConnectionOutcomeService.cs create mode 100644 tests/FinalFactory.Rendezvous.Tests/ConnectionOutcomes/ConnectionOutcomeServiceTests.cs diff --git a/README.md b/README.md index 4305075..41a6f34 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Rendezvous is intended to provide: - Isolation by game, environment, protocol version, and region. - 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 @@ -75,7 +75,12 @@ The initial service does not provide: ## 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 threat model are indexed in [the architecture documentation](docs/architecture/README.md). diff --git a/docs/api/rendezvous-v1.json b/docs/api/rendezvous-v1.json index c667b3c..2b41b93 100644 --- a/docs/api/rendezvous-v1.json +++ b/docs/api/rendezvous-v1.json @@ -776,6 +776,16 @@ } } }, + "410": { + "description": "Gone", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, "429": { "description": "Too Many Requests", "content": { @@ -857,7 +867,12 @@ } } } - } + }, + "security": [ + { + "JoinAttemptCapability": [ ] + } + ] } }, "/v1/join-attempts/{attemptId}/outcome": { @@ -874,6 +889,14 @@ "schema": { "type": "string" } + }, + { + "name": "X-Rendezvous-Client-Punch-Capability", + "in": "header", + "required": true, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -897,8 +920,38 @@ } } }, - "501": { - "description": "Not Implemented", + "400": { + "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": { "application/json": { "schema": { @@ -907,7 +960,12 @@ } } } - } + }, + "security": [ + { + "JoinAttemptCapability": [ ] + } + ] } } }, @@ -1002,6 +1060,15 @@ } } }, + "ConnectionElapsedBucket": { + "enum": [ + "underOneSecond", + "oneToFiveSeconds", + "fiveToFifteenSeconds", + "fifteenToThirtySeconds", + "thirtySecondsOrMore" + ] + }, "ConnectionOutcomeKind": { "enum": [ "connected", @@ -1012,7 +1079,19 @@ "serviceRejected", "hostRejected", "transportFailed", - "fallbackOffered" + "fallbackOffered", + "directoryNotFound", + "attemptExpired", + "unauthorized", + "rateLimited", + "noHostPresence", + "serviceUnavailable", + "mediatorUnavailable", + "punchTimedOut", + "directConnectTimedOut", + "transportError", + "managerStopped", + "disposed" ] }, "CreateJoinAttemptRequest": { @@ -1278,6 +1357,16 @@ "additionalProperties": { "type": "string" } + }, + "dedicatedFallback": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NetworkEndpoint" + } + ] } } }, @@ -1388,8 +1477,7 @@ "ReportConnectionOutcomeRequest": { "required": [ "contractVersion", - "outcome", - "elapsedMilliseconds" + "outcome" ], "type": "object", "properties": { @@ -1400,6 +1488,9 @@ "outcome": { "$ref": "#/components/schemas/ConnectionOutcomeKind" }, + "elapsedBucket": { + "$ref": "#/components/schemas/ConnectionElapsedBucket" + }, "elapsedMilliseconds": { "type": "integer", "format": "int32" @@ -1415,7 +1506,8 @@ "ReportConnectionOutcomeResponse": { "required": [ "contractVersion", - "accepted" + "accepted", + "isDuplicate" ], "type": "object", "properties": { @@ -1425,6 +1517,9 @@ }, "accepted": { "type": "boolean" + }, + "isDuplicate": { + "type": "boolean" } } }, @@ -1502,6 +1597,16 @@ "additionalProperties": { "type": "string" } + }, + "dedicatedFallback": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NetworkEndpoint" + } + ] } } }, @@ -1541,6 +1646,16 @@ "additionalProperties": { "type": "string" } + }, + "dedicatedFallback": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/NetworkEndpoint" + } + ] } } } @@ -1551,6 +1666,12 @@ "description": "Tenant-scoped publisher credential issued during game provisioning.", "scheme": "bearer", "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" } } }, diff --git a/docs/architecture/0010-typed-connection-outcomes-and-fallback.md b/docs/architecture/0010-typed-connection-outcomes-and-fallback.md new file mode 100644 index 0000000..5f34186 --- /dev/null +++ b/docs/architecture/0010-typed-connection-outcomes-and-fallback.md @@ -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`, `1–5s`, `5–15s`, `15–30s`, 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. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c1c84ad..76ea859 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 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 0010: typed connection outcomes, deadlines, and caller-owned fallback](0010-typed-connection-outcomes-and-fallback.md) - [Threat model](../security/threat-model.md) - [Security promise and test matrix](../security/control-matrix.md) - [Versioned HTTP and UDP contracts](../contracts/README.md) diff --git a/docs/contracts/http-v1.md b/docs/contracts/http-v1.md index db97023..8c14365 100644 --- a/docs/contracts/http-v1.md +++ b/docs/contracts/http-v1.md @@ -40,9 +40,7 @@ the same value as a required query parameter. | `GET` | `/health/ready` | Report whether the UDP mediator is bound and ready. | The generated [OpenAPI document](../api/rendezvous-v1.json) is the normative -shape reference for parameters, bodies, and responses. Contract-only endpoints -return `501` until their behavior is implemented by the subsequent directory, -lease, and join-orchestration issues. +shape reference for parameters, bodies, and responses. Host polling sends its reusable lease credential in `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 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 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` | | 403 | `forbidden` | | 404 | `notFound` | -| 409 | `conflict`, `incompatibleProtocol`, `replayRejected`, `capacityExceeded` | +| 409 | `conflict`, `incompatibleProtocol`, `replayRejected` | | 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) | | 500 | `internalError` | diff --git a/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionOutcome.cs b/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionOutcome.cs new file mode 100644 index 0000000..c02d415 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionOutcome.cs @@ -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); + +} diff --git a/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionStartResult.cs b/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionStartResult.cs new file mode 100644 index 0000000..be0f1c8 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Client/ConnectionOutcomes/RendezvousConnectionStartResult.cs @@ -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))); +} diff --git a/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs index 00aded9..992ec51 100644 --- a/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs +++ b/src/FinalFactory.Rendezvous.Client/JoinAttempts/RendezvousJoinClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using FinalFactory.Rendezvous.Contracts; namespace FinalFactory.Rendezvous.Client; @@ -39,6 +40,45 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient cancellationToken); } + public async Task 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 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> CancelAsync( CreateJoinAttemptResponse attempt, CancellationToken cancellationToken = default) @@ -130,6 +170,41 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient $"Host invitation polling exceeded the configured {maximumPages}-page limit."); } + public Task> 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( + () => HeaderJsonRequest( + HttpMethod.Post, + $"v1/join-attempts/{attempt.AttemptId}/outcome", + ClientPunchCapabilityHeader, + RequireHeaderValue(attempt.ClientPunchCapability, nameof(attempt)), + body), + cancellationToken); + } + private static HttpRequestMessage HeaderRequest( HttpMethod method, string uri, @@ -141,6 +216,18 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient return request; } + private static HttpRequestMessage HeaderJsonRequest( + 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) => !string.IsNullOrWhiteSpace(value) ? value diff --git a/src/FinalFactory.Rendezvous.Client/README.md b/src/FinalFactory.Rendezvous.Client/README.md index 0908d48..0910767 100644 --- a/src/FinalFactory.Rendezvous.Client/README.md +++ b/src/FinalFactory.Rendezvous.Client/README.md @@ -29,6 +29,12 @@ RendezvousClientResult registered = await publisher.RegisterAs DisplayName = "My server", Visibility = ListingVisibility.Public, Capacity = new() { CurrentPlayers = 1, MaximumPlayers = 8 }, + DedicatedFallback = new() + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.40", + Port = 7777, + }, }, publisherCredential, cancellationToken); @@ -97,14 +103,22 @@ the accepted peer as connected. Register ordinary gameplay callbacks on `networkEvents.GameplayEvents`; the routing listener reserves Rendezvous direct requests for ticket validation and forwards every other callback normally. -The joining game first creates the HTTP attempt, then uses its own already-started -gameplay manager in the same frame loop: +The joining game first requests an attempt through the typed start API. It returns +exactly one issued attempt or one terminal service outcome, so service authority +is not confused with a later locally observed traversal failure: ```csharp -CreateJoinAttemptResponse attempt = (await joins.CreateAsync( +RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync( createJoinRequest, - cancellationToken)).Value - ?? throw new InvalidOperationException("Join issuance failed."); + cancellationToken: cancellationToken); +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( gameplayNetManager, networkEvents, @@ -116,12 +130,35 @@ client.Poll(); ``` NAT introduction changes the client state to `Connecting`; it is not success. -Only `Connected` supplies `ConnectedPeer`. Call `Cancel()` and then `Poll()` for -local cancellation, or `CancelAsync(joins, cancellationToken)` to also revoke the -service attempt. Terminal client paths release all event subscriptions. 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. +Only a `Connected` outcome supplies `Peer`. Completion exposes a stable kind, +source, category, phase, and elapsed duration. The default HTTP silence, punch, +and direct-connect budgets are five, ten, and five seconds respectively; configure +them through `RendezvousClientOptions` and `RendezvousCoordinatorOptions` when a +game has measured reasons to do so. The signed attempt expiry is always the +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 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: @@ -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 keyed ticket digests are zeroed. -See the repository's ADR 0007 for HTTP ownership/retry semantics and ADR 0008 for -join-capability and connection-ticket security semantics. +See the repository's ADR 0007 for HTTP ownership/retry semantics, ADR 0008 for +join-capability and connection-ticket security semantics, and ADR 0010 for typed +outcomes, deadlines, reporting, and caller-owned fallback. diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs index 9358b7e..7579856 100644 --- a/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs +++ b/src/FinalFactory.Rendezvous.Client/RendezvousClientAbstractions.cs @@ -148,6 +148,11 @@ public interface IRendezvousSessionBrowserClient public interface IRendezvousJoinClient { + Task CreateConnectionAttemptAsync( + CreateJoinAttemptRequest request, + NetworkEndpoint? dedicatedFallback = null, + CancellationToken cancellationToken = default); + Task> CreateAsync( CreateJoinAttemptRequest request, CancellationToken cancellationToken = default); @@ -166,6 +171,11 @@ public interface IRendezvousJoinClient PublishedSession session, int maximumPages = 100, CancellationToken cancellationToken = default); + + Task> ReportOutcomeAsync( + CreateJoinAttemptResponse attempt, + RendezvousConnectionOutcome outcome, + CancellationToken cancellationToken = default); } public interface IRendezvousDelay @@ -176,6 +186,7 @@ public interface IRendezvousDelay public sealed class RendezvousClientOptions { public int MaximumSafeRetries { get; set; } = 2; + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(5); public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); public TimeSpan MaximumRetryDelay { get; set; } = TimeSpan.FromSeconds(2); public double JitterRatio { get; set; } = 0.2; @@ -183,6 +194,8 @@ public sealed class RendezvousClientOptions internal void Validate() { if (MaximumSafeRetries is < 0 or > 5 + || RequestTimeout <= TimeSpan.Zero + || RequestTimeout > TimeSpan.FromSeconds(30) || InitialRetryDelay < TimeSpan.Zero || MaximumRetryDelay < InitialRetryDelay || MaximumRetryDelay > TimeSpan.FromSeconds(30) @@ -198,3 +211,15 @@ internal sealed class SystemRendezvousDelay : IRendezvousDelay public Task DelayAsync(TimeSpan delay, CancellationToken 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, + }; +} diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs b/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs index ffeb9a7..577acf2 100644 --- a/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs +++ b/src/FinalFactory.Rendezvous.Client/RendezvousHttpTransport.cs @@ -24,6 +24,7 @@ internal sealed class RendezvousHttpTransport _options = new RendezvousClientOptions { MaximumSafeRetries = suppliedOptions.MaximumSafeRetries, + RequestTimeout = suppliedOptions.RequestTimeout, InitialRetryDelay = suppliedOptions.InitialRetryDelay, MaximumRetryDelay = suppliedOptions.MaximumRetryDelay, JitterRatio = suppliedOptions.JitterRatio, @@ -38,11 +39,15 @@ internal sealed class RendezvousHttpTransport for (int attempt = 0; ; attempt++) { cancellationToken.ThrowIfCancellationRequested(); + using CancellationTokenSource requestTimeout = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeout.CancelAfter(_options.RequestTimeout); + CancellationToken requestCancellation = requestTimeout.Token; try { using HttpRequestMessage request = requestFactory(); using HttpResponseMessage response = await _httpClient - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestCancellation) .ConfigureAwait(false); if (response.IsSuccessStatusCode) { @@ -54,7 +59,7 @@ internal sealed class RendezvousHttpTransport byte[] payload; try { - payload = await ReadBoundedAsync(response.Content, cancellationToken) + payload = await ReadBoundedAsync(response.Content, requestCancellation) .ConfigureAwait(false); } catch (InvalidDataException) @@ -81,7 +86,7 @@ internal sealed class RendezvousHttpTransport : 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); if (attempt < _options.MaximumSafeRetries && IsTransient(error.Code)) { diff --git a/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs b/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs index 8cab5d7..16c40a0 100644 --- a/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs +++ b/src/FinalFactory.Rendezvous.Client/RendezvousPublisherClient.cs @@ -88,6 +88,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient DisplayName = request.DisplayName, Capacity = CopyCapacity(request.Capacity), Metadata = CopyMetadata(request.Metadata), + DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback), }; return _transport.SendSafeAsync( () => RendezvousHttpTransport.JsonRequest( @@ -138,6 +139,7 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient Visibility = request.Visibility, Capacity = CopyCapacity(request.Capacity), Metadata = CopyMetadata(request.Metadata), + DedicatedFallback = RendezvousEndpoint.Copy(request.DedicatedFallback), }; private static SessionCapacity CopyCapacity(SessionCapacity capacity) => new() @@ -148,4 +150,5 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient private static Dictionary CopyMetadata(Dictionary metadata) => new(metadata, StringComparer.Ordinal); + } diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs index a9348dd..024f300 100644 --- a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousClientCoordinator.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using FinalFactory.Rendezvous.Contracts; using LiteNetLib; @@ -12,12 +13,21 @@ public sealed class RendezvousClientCoordinator : IDisposable private readonly IPEndPoint _mediator; private readonly CreateJoinAttemptResponse _attempt; private readonly IRendezvousCoordinatorClock _clock; + private readonly RendezvousCoordinatorOptions _options; 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 IPEndPoint? _directEndpoint; + private TimeSpan? _directDeadline; + private RendezvousConnectionOutcome? _outcome; private bool _cancelRequested; private int _polling; private bool _subscriptionsReleased; - private bool _disposed; + private int _disposed; public RendezvousClientCoordinator( NetManager manager, @@ -49,23 +59,31 @@ public sealed class RendezvousClientCoordinator : IDisposable _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); _attempt = attempt ?? throw new ArgumentNullException(nameof(attempt)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); - RendezvousCoordinatorOptions validated = (options ?? new RendezvousCoordinatorOptions()) + _options = (options ?? new RendezvousCoordinatorOptions()) .CopyAndValidate(); - _retry = new(validated, _clock); + _retry = new(_options, _clock); RendezvousManagerGuard.Validate(_manager, _networkEvents); + DateTimeOffset startedUtc = _clock.UtcNow; if (_mediator.Port is < 1 or > 65_535 || _attempt.AttemptId.Value == Guid.Empty || _attempt.MediationHandle.Value == Guid.Empty || !ContractValidation.IsCapabilityValid(_attempt.ClientPunchCapability) || !ContractValidation.IsConnectionTicketValid(_attempt.ConnectionTicketDigest) - || _attempt.ExpiresAt <= _clock.UtcNow) + || _attempt.ExpiresAt <= startedUtc) { 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.RendezvousPeerDisconnected += OnPeerDisconnected; + _networkEvents.RendezvousNetworkError += OnNetworkError; _punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess; } @@ -73,7 +91,8 @@ public sealed class RendezvousClientCoordinator : IDisposable public RendezvousConnectionState State { get; private set; } = RendezvousConnectionState.Punching; 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); @@ -91,6 +110,23 @@ public sealed class RendezvousClientCoordinator : IDisposable return await joinClient.CancelAsync(_attempt, cancellationToken).ConfigureAwait(false); } + public Task> 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() { ThrowIfDisposed(); @@ -109,13 +145,18 @@ public sealed class RendezvousClientCoordinator : IDisposable if (Volatile.Read(ref _cancelRequested)) { DisconnectPendingPeer(); - Complete(RendezvousConnectionState.Cancelled); + Complete( + RendezvousConnectionState.Cancelled, + ConnectionOutcomeKind.Cancelled, + RendezvousConnectionOutcomeSource.Caller, + RendezvousConnectionFailureCategory.Lifecycle, + CurrentPhase()); return; } if (!_manager.IsRunning) { - Complete(RendezvousConnectionState.ManagerStopped); + CompleteManagerStopped(); return; } @@ -127,35 +168,67 @@ public sealed class RendezvousClientCoordinator : IDisposable } DateTimeOffset now = _clock.UtcNow; + TimeSpan elapsed = _clock.Elapsed; if (Volatile.Read(ref _cancelRequested)) { DisconnectPendingPeer(); - Complete(RendezvousConnectionState.Cancelled); + Complete( + RendezvousConnectionState.Cancelled, + ConnectionOutcomeKind.Cancelled, + RendezvousConnectionOutcomeSource.Caller, + RendezvousConnectionFailureCategory.Lifecycle, + CurrentPhase()); } else if (!_manager.IsRunning) { - Complete(RendezvousConnectionState.ManagerStopped); + CompleteManagerStopped(); } - else if (now >= _attempt.ExpiresAt) + else if (now >= _attempt.ExpiresAt || elapsed >= _attemptDeadline) { 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; } - _manager.NatPunchModule.SendNatIntroduceRequest( - _mediator, - NatPunchRequestTokenCodec.Encode( - NatPunchPeerRole.Client, - _attempt.MediationHandle, - _attempt.ClientPunchCapability)); - _retry.RecordRequest(); + if (_retry.IsDue(elapsed)) + { + _manager.NatPunchModule.SendNatIntroduceRequest( + _mediator, + NatPunchRequestTokenCodec.Encode( + NatPunchPeerRole.Client, + _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 @@ -166,18 +239,22 @@ public sealed class RendezvousClientCoordinator : IDisposable public void Dispose() { - if (_disposed) + if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } if (!IsCompleted) { - Complete(RendezvousConnectionState.Disposed); + Complete( + RendezvousConnectionState.Disposed, + ConnectionOutcomeKind.Disposed, + RendezvousConnectionOutcomeSource.Lifecycle, + RendezvousConnectionFailureCategory.Lifecycle, + CurrentPhase()); } ReleaseSubscriptions(); - _disposed = true; } public override string ToString() => @@ -205,16 +282,25 @@ public sealed class RendezvousClientCoordinator : IDisposable byte[] connectionData = DirectConnectionRequestCodec.Encode( introduction.AttemptId, introduction.ConnectionTicket); + _directEndpoint = target; _connectingPeer = _manager.Connect(target, connectionData); if (_connectingPeer is null || _connectingPeer.ConnectionState != ConnectionState.Outgoing) { _connectingPeer = null; - Complete(RendezvousConnectionState.Rejected); + Complete( + RendezvousConnectionState.Rejected, + ConnectionOutcomeKind.TransportError, + RendezvousConnectionOutcomeSource.LocalTraversal, + RendezvousConnectionFailureCategory.DirectConnection, + RendezvousConnectionPhase.DirectConnection); return; } State = RendezvousConnectionState.Connecting; + _directDeadline = Min( + _attemptDeadline, + _clock.Elapsed + _options.DirectConnectTimeout); } private void OnPeerConnected(NetPeer peer) @@ -225,17 +311,60 @@ public sealed class RendezvousClientCoordinator : IDisposable return; } - ConnectedPeer = peer; - Complete(RendezvousConnectionState.Connected, peer); + Complete( + RendezvousConnectionState.Connected, + ConnectionOutcomeKind.Connected, + RendezvousConnectionOutcomeSource.LocalTraversal, + RendezvousConnectionFailureCategory.None, + RendezvousConnectionPhase.Complete, + peer); } private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) { - _ = disconnectInfo; if (State == RendezvousConnectionState.Connecting && 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; - ReleaseSubscriptions(); - Completed?.Invoke(this, new(terminalState, peer)); + Completed?.Invoke(this, completion); } private void ReleaseSubscriptions() { - if (_subscriptionsReleased) + lock (_completionGate) { - return; - } + if (_subscriptionsReleased) + { + return; + } - _networkEvents.RendezvousPeerConnected -= OnPeerConnected; - _networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected; - _punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess; - _subscriptionsReleased = true; + _networkEvents.RendezvousPeerConnected -= OnPeerConnected; + _networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected; + _networkEvents.RendezvousNetworkError -= OnNetworkError; + _punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess; + _subscriptionsReleased = true; + } } - private static bool IsTerminal(RendezvousConnectionState state) => state is - RendezvousConnectionState.Connected - or RendezvousConnectionState.Cancelled - or RendezvousConnectionState.TimedOut - or RendezvousConnectionState.Rejected - or RendezvousConnectionState.ManagerStopped - or RendezvousConnectionState.Disposed; + private void CompleteManagerStopped() => Complete( + RendezvousConnectionState.ManagerStopped, + ConnectionOutcomeKind.ManagerStopped, + RendezvousConnectionOutcomeSource.Lifecycle, + RendezvousConnectionFailureCategory.Lifecycle, + CurrentPhase()); + + 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() { - if (_disposed) + if (Volatile.Read(ref _disposed) != 0) { throw new ObjectDisposedException(nameof(RendezvousClientCoordinator)); } diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs index 22221f5..2638021 100644 --- a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousCoordinatorAbstractions.cs @@ -1,4 +1,6 @@ +using System.Diagnostics; using System.Security.Cryptography; +using FinalFactory.Rendezvous.Contracts; using LiteNetLib; namespace FinalFactory.Rendezvous.Client; @@ -15,12 +17,97 @@ public enum RendezvousConnectionState Disposed = 8, } -public sealed class RendezvousConnectionCompletedEventArgs( - RendezvousConnectionState state, - NetPeer? peer = null) : EventArgs +public sealed class RendezvousConnectionCompletedEventArgs : EventArgs { - public RendezvousConnectionState State { get; } = state; - public NetPeer? Peer { get; } = peer; + [Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")] + 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 @@ -29,8 +116,11 @@ public sealed class RendezvousCoordinatorOptions public int MaximumAttemptChecksPerPoll { get; set; } = 128; public TimeSpan InitialPunchRetryDelay { get; set; } = TimeSpan.FromMilliseconds(200); 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 double JitterRatio { get; set; } = 0.2; + public NetworkEndpoint? DedicatedFallbackOverride { get; set; } internal RendezvousCoordinatorOptions CopyAndValidate() { @@ -39,9 +129,15 @@ public sealed class RendezvousCoordinatorOptions || InitialPunchRetryDelay < TimeSpan.FromMilliseconds(10) || MaximumPunchRetryDelay < InitialPunchRetryDelay || MaximumPunchRetryDelay > TimeSpan.FromSeconds(10) + || PunchTimeout <= TimeSpan.Zero + || PunchTimeout > TimeSpan.FromSeconds(30) + || DirectConnectTimeout <= TimeSpan.Zero + || DirectConnectTimeout > TimeSpan.FromSeconds(30) || ConnectionTicketLifetime <= TimeSpan.Zero || 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)); } @@ -52,8 +148,11 @@ public sealed class RendezvousCoordinatorOptions MaximumAttemptChecksPerPoll = MaximumAttemptChecksPerPoll, InitialPunchRetryDelay = InitialPunchRetryDelay, MaximumPunchRetryDelay = MaximumPunchRetryDelay, + PunchTimeout = PunchTimeout, + DirectConnectTimeout = DirectConnectTimeout, ConnectionTicketLifetime = ConnectionTicketLifetime, JitterRatio = JitterRatio, + DedicatedFallbackOverride = RendezvousEndpoint.Copy(DedicatedFallbackOverride), }; } } @@ -61,11 +160,16 @@ public sealed class RendezvousCoordinatorOptions internal interface IRendezvousCoordinatorClock { DateTimeOffset UtcNow { get; } + TimeSpan Elapsed { get; } } internal sealed class SystemRendezvousCoordinatorClock : IRendezvousCoordinatorClock { + private readonly long _origin = Stopwatch.GetTimestamp(); + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; + public TimeSpan Elapsed => TimeSpan.FromSeconds( + (Stopwatch.GetTimestamp() - _origin) / (double)Stopwatch.Frequency); } internal static class RendezvousManagerGuard @@ -95,11 +199,11 @@ internal sealed class RendezvousPunchRetrySchedule( IRendezvousCoordinatorClock clock) { 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 IsDue(DateTimeOffset now) => now >= NextRequestAt; + public bool IsDue(TimeSpan elapsed) => elapsed >= NextRequestAt; public void RecordRequest() { @@ -119,6 +223,6 @@ internal sealed class RendezvousPunchRetrySchedule( options.MaximumPunchRetryDelay.TotalMilliseconds); } - NextRequestAt = clock.UtcNow + TimeSpan.FromMilliseconds(milliseconds); + NextRequestAt = clock.Elapsed + TimeSpan.FromMilliseconds(milliseconds); } } diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs index 83e9fc5..85b8f76 100644 --- a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousHostCoordinator.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using FinalFactory.Rendezvous.Contracts; using LiteNetLib; @@ -11,14 +12,37 @@ public enum RendezvousHostState Disposed = 3, } -public sealed class RendezvousHostAttemptCompletedEventArgs( - JoinAttemptId attemptId, - RendezvousConnectionState state, - NetPeer? peer = null) : EventArgs +public sealed class RendezvousHostAttemptCompletedEventArgs : EventArgs { - public JoinAttemptId AttemptId { get; } = attemptId; - public RendezvousConnectionState State { get; } = state; - public NetPeer? Peer { get; } = peer; + [Obsolete("Completion events now expose a typed Outcome. Construct these arguments only for legacy test doubles.")] + public RendezvousHostAttemptCompletedEventArgs( + 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 @@ -37,6 +61,7 @@ public sealed class RendezvousHostCoordinator : IDisposable private readonly Dictionary _deferredRequests = []; private readonly Dictionary _terminalAttempts = []; private readonly Queue _attemptSchedule = []; + private readonly SortedDictionary> _deadlines = []; private readonly List _cleanupScratch = []; private HostJoinAttempt[]? _latestSnapshot; private DateTimeOffset _nextPresenceAt = DateTimeOffset.MinValue; @@ -90,6 +115,7 @@ public sealed class RendezvousHostCoordinator : IDisposable _networkEvents.RendezvousConnectionRequest += OnConnectionRequest; _networkEvents.RendezvousPeerConnected += OnPeerConnected; _networkEvents.RendezvousPeerDisconnected += OnPeerDisconnected; + _networkEvents.RendezvousNetworkError += OnNetworkError; _punchEvents.NatIntroductionSuccess += OnNatIntroductionSuccess; } @@ -161,7 +187,10 @@ public sealed class RendezvousHostCoordinator : IDisposable ApplySnapshots(); if (!_manager.IsRunning) { - Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped); + Stop( + RendezvousHostState.ManagerStopped, + RendezvousConnectionState.ManagerStopped, + ConnectionOutcomeKind.ManagerStopped); return; } @@ -174,13 +203,23 @@ public sealed class RendezvousHostCoordinator : IDisposable } DateTimeOffset now = _clock.UtcNow; + TimeSpan elapsed = _clock.Elapsed; if (!_manager.IsRunning) { - Stop(RendezvousHostState.ManagerStopped, RendezvousConnectionState.ManagerStopped); + Stop( + RendezvousHostState.ManagerStopped, + RendezvousConnectionState.ManagerStopped, + ConnectionOutcomeKind.ManagerStopped); return; } RefreshPresence(now); + ProcessDueDeadlines(elapsed); + if (State != RendezvousHostState.Active) + { + return; + } + int checks = Math.Min( _attemptSchedule.Count, _options.MaximumAttemptChecksPerPoll); @@ -192,18 +231,22 @@ public sealed class RendezvousHostCoordinator : IDisposable continue; } - if (now >= attempt.Invitation.ExpiresAt) + if (attempt.State != RendezvousConnectionState.Punching) { - CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut); continue; } - if (attempt.State == RendezvousConnectionState.Punching - && attempt.Retry.IsDue(now)) + if (attempt.Retry.IsDue(elapsed)) { if (attempt.Retry.IsExhausted) { - CompleteAttempt(attemptId, RendezvousConnectionState.TimedOut); + CompleteAttempt( + attemptId, + RendezvousConnectionState.TimedOut, + ConnectionOutcomeKind.PunchTimedOut, + RendezvousConnectionOutcomeSource.LocalTraversal, + RendezvousConnectionFailureCategory.NatTraversal, + RendezvousConnectionPhase.NatTraversal); continue; } @@ -251,9 +294,13 @@ public sealed class RendezvousHostCoordinator : IDisposable return; } - Stop(RendezvousHostState.Disposed, RendezvousConnectionState.Disposed); + Stop( + RendezvousHostState.Disposed, + RendezvousConnectionState.Disposed, + ConnectionOutcomeKind.Disposed); Interlocked.Exchange(ref _latestSnapshot, null); _attemptSchedule.Clear(); + _deadlines.Clear(); _terminalAttempts.Clear(); _cleanupScratch.Clear(); _tickets.Dispose(); @@ -272,6 +319,7 @@ public sealed class RendezvousHostCoordinator : IDisposable } DateTimeOffset now = _clock.UtcNow; + TimeSpan elapsed = _clock.Elapsed; foreach (HostJoinAttempt invitation in latest) { if (invitation.AttemptId.Value == Guid.Empty @@ -289,7 +337,11 @@ public sealed class RendezvousHostCoordinator : IDisposable { CompleteAttempt( invitation.AttemptId, - RendezvousConnectionState.Cancelled); + RendezvousConnectionState.Cancelled, + ConnectionOutcomeKind.Cancelled, + RendezvousConnectionOutcomeSource.RendezvousService, + RendezvousConnectionFailureCategory.Lifecycle, + RendezvousConnectionPhase.Authorization); } _terminalAttempts[invitation.AttemptId] = invitation.ExpiresAt; @@ -303,11 +355,23 @@ public sealed class RendezvousHostCoordinator : IDisposable continue; } + TimeSpan attemptDeadline = elapsed + (invitation.ExpiresAt - now); + TimeSpan punchDeadline = Min( + attemptDeadline, + elapsed + _options.PunchTimeout); _attempts.Add( invitation.AttemptId, new PendingHostAttempt( 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); } } @@ -354,6 +418,13 @@ public sealed class RendezvousHostCoordinator : IDisposable } 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( introduction.AttemptId, out DeferredConnectionRequest? deferred)) @@ -410,7 +481,14 @@ public sealed class RendezvousHostCoordinator : IDisposable { 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; 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( JoinAttemptId attemptId, RendezvousConnectionState state, + ConnectionOutcomeKind kind, + RendezvousConnectionOutcomeSource source, + RendezvousConnectionFailureCategory category, + RendezvousConnectionPhase phase, 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)) { - return; + return false; } if (attempt.AcceptedPeer is not null) { _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); _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) { @@ -452,12 +611,32 @@ public sealed class RendezvousHostCoordinator : IDisposable } State = hostState; + List completions = []; 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(); + foreach (RendezvousHostAttemptCompletedEventArgs completion in completions) + { + AttemptCompleted?.Invoke(this, completion); + } } private void ReleaseSubscriptions() @@ -470,6 +649,7 @@ public sealed class RendezvousHostCoordinator : IDisposable _networkEvents.RendezvousConnectionRequest -= OnConnectionRequest; _networkEvents.RendezvousPeerConnected -= OnPeerConnected; _networkEvents.RendezvousPeerDisconnected -= OnPeerDisconnected; + _networkEvents.RendezvousNetworkError -= OnNetworkError; _punchEvents.NatIntroductionSuccess -= OnNatIntroductionSuccess; _subscriptionsReleased = true; } @@ -496,9 +676,78 @@ public sealed class RendezvousHostCoordinator : IDisposable ExpiresAt = attempt.ExpiresAt, }; + private static TimeSpan Min(TimeSpan left, TimeSpan right) => + left <= right ? left : right; + private static DateTimeOffset Min(DateTimeOffset left, DateTimeOffset right) => left <= right ? left : right; + private void EnqueueDeadline(HostAttemptDeadline deadline) + { + if (!_deadlines.TryGetValue(deadline.Deadline.Ticks, out Queue? bucket)) + { + bucket = new Queue(); + _deadlines.Add(deadline.Deadline.Ticks, bucket); + } + + bucket.Enqueue(deadline); + } + + private void ProcessDueDeadlines(TimeSpan elapsed) + { + while (_deadlines.Count > 0) + { + KeyValuePair> 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( JoinAttemptId attemptId, PendingHostAttempt attempt, @@ -529,14 +778,31 @@ public sealed class RendezvousHostCoordinator : IDisposable private sealed class PendingHostAttempt( HostJoinAttempt invitation, - RendezvousPunchRetrySchedule retry) + RendezvousPunchRetrySchedule retry, + TimeSpan startedAt, + TimeSpan attemptDeadline, + TimeSpan punchDeadline) { internal HostJoinAttempt Invitation { get; } = invitation; 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 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( ConnectionRequest request, string connectionTicket) diff --git a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs index 774dda0..efb8586 100644 --- a/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs +++ b/src/FinalFactory.Rendezvous.Client/Traversal/RendezvousNetListener.cs @@ -29,6 +29,7 @@ public sealed class RendezvousNetListener : INetEventListener internal event Action? RendezvousPeerConnected; internal event Action? RendezvousPeerDisconnected; internal event Action? RendezvousConnectionRequest; + internal event Action? RendezvousNetworkError; internal void ValidateManager(NetManager manager) { @@ -51,8 +52,11 @@ public sealed class RendezvousNetListener : INetEventListener ((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); + } public void OnNetworkReceive( NetPeer peer, diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs b/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs index 482009f..a69fc42 100644 --- a/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs +++ b/src/FinalFactory.Rendezvous.Contracts/ContractEnums.cs @@ -49,6 +49,27 @@ public enum ConnectionOutcomeKind HostRejected = 7, TransportFailed = 8, 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 diff --git a/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs index 3dcb126..aa2ed35 100644 --- a/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs +++ b/src/FinalFactory.Rendezvous.Contracts/ContractValidation.cs @@ -45,6 +45,23 @@ public static class ContractValidation public static bool IsDiagnosticCodeValid(string? value) => 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) => !string.IsNullOrWhiteSpace(value) && IsUtf8LengthWithin(value, ContractLimits.BuildVersionMaxBytes); diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/ConnectionOutcomeContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/ConnectionOutcomeContracts.cs new file mode 100644 index 0000000..45b18b8 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Contracts/Http/ConnectionOutcomeContracts.cs @@ -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; } +} diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs index b22f754..31a1a05 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Http/JoinContracts.cs @@ -76,26 +76,3 @@ public sealed class BrowseHostJoinAttemptsResponse 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; } -} diff --git a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs index 49e2148..a200f39 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Http/SessionContracts.cs @@ -39,6 +39,8 @@ public sealed class SessionListing [JsonRequired] public Dictionary Metadata { get; set; } = new(StringComparer.Ordinal); + + public NetworkEndpoint? DedicatedFallback { get; set; } } public sealed class RegisterSessionRequest @@ -75,6 +77,8 @@ public sealed class RegisterSessionRequest [JsonRequired] public Dictionary Metadata { get; set; } = new(StringComparer.Ordinal); + + public NetworkEndpoint? DedicatedFallback { get; set; } } public sealed class RegisterSessionResponse @@ -147,6 +151,8 @@ public sealed class UpdateSessionRequest [JsonRequired] public Dictionary Metadata { get; set; } = new(StringComparer.Ordinal); + + public NetworkEndpoint? DedicatedFallback { get; set; } } public sealed class DeleteSessionRequest diff --git a/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs b/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs index ff719b8..ae2d639 100644 --- a/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs +++ b/src/FinalFactory.Rendezvous.Contracts/Serialization/ContractJson.cs @@ -25,7 +25,9 @@ public static class ContractJson options.AllowTrailingCommas = false; 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.PropertyNameCaseInsensitive = false; options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; diff --git a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs index 08c93aa..aea0deb 100644 --- a/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs +++ b/src/FinalFactory.Rendezvous.Server/Browser/SessionBrowserService.cs @@ -153,5 +153,6 @@ internal sealed class SessionBrowserService( static item => item.Key, static item => item.Value, StringComparer.Ordinal), + DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback), }; } diff --git a/src/FinalFactory.Rendezvous.Server/ConnectionOutcomes/ConnectionOutcomeService.cs b/src/FinalFactory.Rendezvous.Server/ConnectionOutcomes/ConnectionOutcomeService.cs new file mode 100644 index 0000000..1dcf061 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/ConnectionOutcomes/ConnectionOutcomeService.cs @@ -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 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, + }; +} diff --git a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs index fff66d6..2301c58 100644 --- a/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs +++ b/src/FinalFactory.Rendezvous.Server/Http/ContractEndpoints.cs @@ -1,6 +1,7 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; @@ -11,8 +12,6 @@ namespace FinalFactory.Rendezvous.Server.Http; internal static class ContractEndpoints { - private const int NotImplementedStatus = StatusCodes.Status501NotImplemented; - public static IEndpointRouteBuilder MapRendezvousContractEndpoints( this IEndpointRouteBuilder endpoints) { @@ -83,6 +82,7 @@ internal static class ContractEndpoints .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status410Gone) .Produces(StatusCodes.Status429TooManyRequests) .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("CreateJoinAttempt"); @@ -95,7 +95,10 @@ internal static class ContractEndpoints attempts.MapPost("/{attemptId}/outcome", ReportConnectionOutcome) .Accepts("application/json") .Produces() - .Produces(StatusCodes.Status501NotImplemented) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict) + .Produces(StatusCodes.Status503ServiceUnavailable) .WithName("ReportConnectionOutcome"); return endpoints; @@ -334,16 +337,20 @@ internal static class ContractEndpoints private static IResult ReportConnectionOutcome( JoinAttemptId attemptId, - [FromBody] ReportConnectionOutcomeRequest request) => NotImplemented(); - - private static IResult NotImplemented() => Results.Json( - new ApiError - { - Code = RendezvousErrorCode.ServiceUnavailable, - Message = "The v1 contract is reserved; implementation is tracked by subsequent issues.", - }, - ContractJson.Options, - statusCode: NotImplementedStatus); + [FromHeader(Name = "X-Rendezvous-Client-Punch-Capability")] string clientPunchCapability, + [FromBody] ReportConnectionOutcomeRequest request, + [FromServices] ConnectionOutcomeService outcomes, + CancellationToken cancellationToken) + { + ConnectionOutcomeServiceResult result = outcomes.Report( + attemptId, + clientPunchCapability, + request, + cancellationToken); + return result.Succeeded && result.Value is not null + ? Results.Ok(result.Value) + : Error(result.Error); + } private static bool TryAuthenticatePublisher( string? authorizationHeader, @@ -389,9 +396,11 @@ internal static class ContractEndpoints { RendezvousErrorCode.AuthenticationRequired => StatusCodes.Status401Unauthorized, RendezvousErrorCode.Forbidden => StatusCodes.Status403Forbidden, - RendezvousErrorCode.NotFound or RendezvousErrorCode.StaleHost => StatusCodes.Status404NotFound, - RendezvousErrorCode.Conflict or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, - RendezvousErrorCode.Expired => StatusCodes.Status410Gone, + RendezvousErrorCode.NotFound => StatusCodes.Status404NotFound, + RendezvousErrorCode.Conflict + or RendezvousErrorCode.IncompatibleProtocol + or RendezvousErrorCode.ReplayRejected => StatusCodes.Status409Conflict, + RendezvousErrorCode.Expired or RendezvousErrorCode.StaleHost => StatusCodes.Status410Gone, RendezvousErrorCode.RateLimited or RendezvousErrorCode.CapacityExceeded => StatusCodes.Status429TooManyRequests, RendezvousErrorCode.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, @@ -406,6 +415,7 @@ internal static class ContractEndpoints RendezvousErrorCode.NotFound => "The session was not found or is not owned by this publisher.", RendezvousErrorCode.Conflict => "The session changed concurrently; retry with current state.", RendezvousErrorCode.Expired => "The session lease has expired.", + RendezvousErrorCode.StaleHost => "The session has no fresh host presence.", RendezvousErrorCode.IncompatibleProtocol => "The gameplay protocol is not enabled for this game.", RendezvousErrorCode.CapacityExceeded => "The configured session capacity is currently exhausted.", RendezvousErrorCode.ServiceUnavailable => "Session state is temporarily unavailable.", diff --git a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs index 490930c..2951328 100644 --- a/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs +++ b/src/FinalFactory.Rendezvous.Server/JoinAttempts/JoinAttemptService.cs @@ -131,6 +131,7 @@ internal sealed class JoinAttemptService( ConnectionTicketDigest = NatIntroductionTokenCodec.ComputeDigest( CreateConnectionTicket(persisted)), ExpiresAt = persisted.ExpiresAt, + DedicatedFallback = StoredListing.CopyEndpoint(persisted.DedicatedFallback), }); } diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index b88ff2f..2c59f4b 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -1,6 +1,7 @@ using System.Net; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; @@ -50,6 +51,14 @@ builder.Services.AddOpenApi("v1", static options => BearerFormat = "rv1 publisher credential", Description = "Tenant-scoped publisher credential issued during game provisioning.", }; + const string attemptSchemeName = "JoinAttemptCapability"; + document.Components.SecuritySchemes[attemptSchemeName] = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + Name = "X-Rendezvous-Client-Punch-Capability", + In = ParameterLocation.Header, + Description = "Attempt-scoped client capability returned only to the joining caller.", + }; HashSet securedOperations = new(StringComparer.Ordinal) { @@ -59,6 +68,7 @@ builder.Services.AddOpenApi("v1", static options => "DeleteSession", }; OpenApiSecuritySchemeReference reference = new(schemeName, document, null); + OpenApiSecuritySchemeReference attemptReference = new(attemptSchemeName, document, null); foreach (OpenApiPathItem path in document.Paths.Values) { if (path.Operations is null) @@ -75,6 +85,17 @@ builder.Services.AddOpenApi("v1", static options => [reference] = [], }); } + + foreach (OpenApiOperation operation in path.Operations.Values.Where( + operation => operation.OperationId is + "CancelJoinAttempt" or "ReportConnectionOutcome")) + { + operation.Security ??= []; + operation.Security.Add(new OpenApiSecurityRequirement + { + [attemptReference] = [], + }); + } } return Task.CompletedTask; @@ -125,6 +146,8 @@ else builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(new ProvisioningReadiness(true)); } diff --git a/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs index fbfc99d..23addbe 100644 --- a/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs +++ b/src/FinalFactory.Rendezvous.Server/Sessions/SessionLeaseService.cs @@ -55,6 +55,11 @@ internal sealed class SessionLeaseService( } AuthorizedPublisherContext context = authorized.Context; + if (!IsFallbackAllowed(context.Policy, request.DedicatedFallback)) + { + return new(RendezvousErrorCode.Forbidden); + } + string requestFingerprint = ComputeRegistrationFingerprint(request); string derivationSalt = capabilities.CreateDerivationSalt(); string leaseToken = capabilities.DeriveCapability( @@ -119,6 +124,7 @@ internal sealed class SessionLeaseService( CurrentPlayers = request.Capacity.CurrentPlayers, MaximumPlayers = request.Capacity.MaximumPlayers, Metadata = request.Metadata, + DedicatedFallback = request.DedicatedFallback, LeaseFingerprint = leaseFingerprint, HostPresenceHandle = presenceHandle, HostPresenceFingerprint = presenceFingerprint, @@ -235,10 +241,14 @@ internal sealed class SessionLeaseService( StoredListing ownedListing = listing!; PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata); - if (!authorized.IsAllowed) + if (!authorized.IsAllowed || authorized.Context is null) { return new(MapAuthorization(authorized.Error)); } + if (!IsFallbackAllowed(authorized.Context.Policy, request.DedicatedFallback)) + { + return new(RendezvousErrorCode.Forbidden); + } capabilities.TryFingerprint(request.LeaseToken, out SecretFingerprint fingerprint); StoreResult updated = store.UpdateListing(new( @@ -250,7 +260,8 @@ internal sealed class SessionLeaseService( request.DisplayName, request.Capacity.CurrentPlayers, request.Capacity.MaximumPlayers, - request.Metadata), cancellationToken); + request.Metadata, + request.DedicatedFallback), cancellationToken); return updated.Succeeded ? new(RendezvousErrorCode.None, true) : new(updated.Code.ToContractError()); @@ -341,6 +352,9 @@ internal sealed class SessionLeaseService( metadata, clock.UtcNow); + private static bool IsFallbackAllowed(GamePolicy policy, NetworkEndpoint? fallback) => + fallback is null || policy.FallbackPolicy == FallbackPolicyMode.DedicatedEndpointAllowed; + private static RendezvousErrorCode ValidateRegistration(RegisterSessionRequest request) { RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion); @@ -359,6 +373,8 @@ internal sealed class SessionLeaseService( || !Enum.IsDefined(request.Visibility) || !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsMetadataValid(request.Metadata) + || request.DedicatedFallback is not null + && !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback) ? RendezvousErrorCode.InvalidRequest : RendezvousErrorCode.None; } @@ -375,6 +391,8 @@ internal sealed class SessionLeaseService( || !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsMetadataValid(request.Metadata) + || request.DedicatedFallback is not null + && !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback) ? RendezvousErrorCode.InvalidRequest : RendezvousErrorCode.None; } @@ -424,6 +442,14 @@ internal sealed class SessionLeaseService( Metadata = request.Metadata .OrderBy(static item => item.Key, StringComparer.Ordinal) .ToDictionary(static item => item.Key, static item => item.Value, StringComparer.Ordinal), + DedicatedFallback = request.DedicatedFallback is null + ? null + : new NetworkEndpoint + { + AddressFamily = request.DedicatedFallback.AddressFamily, + Address = request.DedicatedFallback.Address, + Port = request.DedicatedFallback.Port, + }, }; byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(canonical, ContractJson.Options); byte[] digest = SHA256.HashData(encoded); diff --git a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs index 0dd4f36..4b8fa7e 100644 --- a/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs +++ b/src/FinalFactory.Rendezvous.Server/State/EphemeralStateContracts.cs @@ -29,6 +29,7 @@ internal sealed record EphemeralStoreOptions public int MaxListings { get; init; } = 25_000; public int MaxPresenceBindings { get; init; } = 25_000; public int MaxJoinAttempts { get; init; } = 10_000; + public int MaxOutcomeReports { get; init; } = 35_000; public int MaxReplayEntries { get; init; } = 30_000; public int MaxRevocations { get; init; } = 10_000; public int MaxIdempotencyEntries { get; init; } = 35_000; @@ -45,6 +46,7 @@ internal sealed record EphemeralStoreOptions RequirePositive(MaxListings, nameof(MaxListings)); RequirePositive(MaxPresenceBindings, nameof(MaxPresenceBindings)); RequirePositive(MaxJoinAttempts, nameof(MaxJoinAttempts)); + RequirePositive(MaxOutcomeReports, nameof(MaxOutcomeReports)); RequirePositive(MaxReplayEntries, nameof(MaxReplayEntries)); RequirePositive(MaxRevocations, nameof(MaxRevocations)); RequirePositive(MaxIdempotencyEntries, nameof(MaxIdempotencyEntries)); @@ -173,6 +175,7 @@ internal sealed record ListingDefinition public required int CurrentPlayers { get; init; } public required int MaximumPlayers { get; init; } public required IReadOnlyDictionary Metadata { get; init; } + public NetworkEndpoint? DedicatedFallback { get; init; } public required SecretFingerprint LeaseFingerprint { get; init; } public required MediationHandle HostPresenceHandle { get; init; } public required SecretFingerprint HostPresenceFingerprint { get; init; } @@ -189,7 +192,17 @@ internal sealed record StoredListing public static ListingDefinition Freeze(ListingDefinition source) => source with { Metadata = source.Metadata.ToFrozenDictionary(StringComparer.Ordinal), + DedicatedFallback = CopyEndpoint(source.DedicatedFallback), }; + + internal static NetworkEndpoint? CopyEndpoint(NetworkEndpoint? endpoint) => endpoint is null + ? null + : new NetworkEndpoint + { + AddressFamily = endpoint.AddressFamily, + Address = endpoint.Address, + Port = endpoint.Port, + }; } internal sealed record CreateListingCommand( @@ -214,7 +227,8 @@ internal sealed record UpdateListingCommand( string DisplayName, int CurrentPlayers, int MaximumPlayers, - IReadOnlyDictionary Metadata); + IReadOnlyDictionary Metadata, + NetworkEndpoint? DedicatedFallback); internal sealed record DeleteListingCommand( SessionListingId ListingId, @@ -257,6 +271,7 @@ internal sealed record CreateJoinAttemptCommand public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ConnectionTicketFingerprint { get; init; } public required string CapabilityDerivationSalt { get; init; } + public NetworkEndpoint? DedicatedFallback { get; init; } public int ScopeAttemptLimit { get; init; } = int.MaxValue; public override string ToString() => "[CreateJoinAttemptCommand: credentials redacted]"; @@ -280,6 +295,7 @@ internal sealed record StoredJoinAttempt public required SecretFingerprint HostCapabilityFingerprint { get; init; } public required SecretFingerprint ClientCapabilityFingerprint { get; init; } public required SecretFingerprint ConnectionTicketFingerprint { get; init; } + public NetworkEndpoint? DedicatedFallback { get; init; } public required DateTimeOffset ExpiresAt { get; init; } public required DateTimeOffset ConnectionTicketExpiresAt { get; init; } public AttemptEndpointBinding? HostEndpoint { get; init; } @@ -316,6 +332,16 @@ internal sealed record CancelJoinAttemptCommand( JoinAttemptId AttemptId, SecretFingerprint ClientCapabilityFingerprint); +internal sealed record ReportConnectionOutcomeCommand( + JoinAttemptId AttemptId, + SecretFingerprint ClientCapabilityFingerprint, + ConnectionOutcomeKind Outcome, + ConnectionElapsedBucket ElapsedBucket); + +internal sealed record StoredConnectionOutcome( + ConnectionOutcomeKind Outcome, + ConnectionElapsedBucket ElapsedBucket); + internal sealed record ConsumeConnectionTicketCommand( JoinAttemptId AttemptId, SecretFingerprint ConnectionTicketFingerprint); @@ -336,6 +362,8 @@ internal enum StoreResultCode Draining = 6, ReplayRejected = 7, ServiceUnavailable = 8, + StaleHost = 9, + IncompatibleProtocol = 10, } internal sealed record StoreResult(StoreResultCode Code, T? Value = default, bool IsIdempotentReplay = false) @@ -359,6 +387,7 @@ internal interface IEphemeralRendezvousStore StoreResult CreateJoinAttempt(CreateJoinAttemptCommand command, CancellationToken cancellationToken = default); StoreResult> BrowseHostJoinAttempts(HostJoinAttemptQuery query, CancellationToken cancellationToken = default); StoreResult CancelJoinAttempt(CancelJoinAttemptCommand command, CancellationToken cancellationToken = default); + StoreResult ReportConnectionOutcome(ReportConnectionOutcomeCommand command, CancellationToken cancellationToken = default); StoreResult BindAttemptEndpoint(BindAttemptEndpointCommand command, CancellationToken cancellationToken = default); StoreResult ConsumeIntroduction(MediationHandle handle, CancellationToken cancellationToken = default); StoreResult ConsumeConnectionTicket(ConsumeConnectionTicketCommand command, CancellationToken cancellationToken = default); diff --git a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs index f909566..060d0e1 100644 --- a/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs +++ b/src/FinalFactory.Rendezvous.Server/State/InMemoryEphemeralRendezvousStore.cs @@ -15,6 +15,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto private readonly Dictionary _presenceHandles = []; private readonly Dictionary _presence = []; private readonly Dictionary _attempts = []; + private readonly Dictionary _outcomeReports = []; private readonly Dictionary _attemptHandles = []; private readonly Dictionary _idempotency = new(StringComparer.Ordinal); private readonly Dictionary _replay = new(StringComparer.Ordinal); @@ -183,7 +184,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers || command.CurrentPlayers < 0 || command.CurrentPlayers > command.MaximumPlayers - || !ContractValidation.IsMetadataValid(command.Metadata)) + || !ContractValidation.IsMetadataValid(command.Metadata) + || command.DedicatedFallback is not null + && !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)) { throw new ArgumentException("Listing update invariants are invalid.", nameof(command)); } @@ -213,6 +216,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto CurrentPlayers = command.CurrentPlayers, MaximumPlayers = command.MaximumPlayers, Metadata = command.Metadata, + DedicatedFallback = command.DedicatedFallback, }); entry.Version++; return new(StoreResultCode.Success, Snapshot(entry)); @@ -362,14 +366,26 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto } if (!_listings.TryGetValue(command.ListingId, out ListingEntry? listing) - || listing.Definition.Scope != command.Scope - || listing.Definition.ProtocolVersion != command.ProtocolVersion - || !_presence.ContainsKey(listing.Definition.HostPresenceHandle)) + || listing.Definition.Scope != command.Scope) { return new(StoreResultCode.NotFound); } + if (listing.Definition.ProtocolVersion != command.ProtocolVersion) + { + return new(StoreResultCode.IncompatibleProtocol); + } + if (!_presence.ContainsKey(listing.Definition.HostPresenceHandle)) + { + return new(StoreResultCode.StaleHost); + } + + command = command with + { + DedicatedFallback = StoredListing.CopyEndpoint(listing.Definition.DedicatedFallback), + }; if (_attempts.Count >= _options.MaxJoinAttempts + || _outcomeReports.Count >= _options.MaxOutcomeReports || _idempotency.Count >= _options.MaxIdempotencyEntries || _attempts.Values.Count(entry => entry.Command.Scope == command.Scope) >= command.ScopeAttemptLimit) @@ -387,6 +403,11 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto now + _options.JoinAttemptLifetime, WallDeadline(now, _options.JoinAttemptLifetime)); _attempts.Add(command.AttemptId, attempt); + _outcomeReports.Add(command.AttemptId, new( + command.ListingId, + command.ClientSubject, + command.ClientCapabilityFingerprint, + now + _options.JoinAttemptLifetime + _options.IdempotencyLifetime)); _attemptHandles.Add(command.MediationHandle, command.AttemptId); _idempotency.Add(idempotencyKey, new( command.RequestFingerprint, @@ -460,6 +481,42 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto return new(StoreResultCode.Success, true); }, cancellationToken); + public StoreResult ReportConnectionOutcome( + ReportConnectionOutcomeCommand command, + CancellationToken cancellationToken = default) => Atomic(_ => + { + 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 BindAttemptEndpoint( BindAttemptEndpointCommand command, CancellationToken cancellationToken = default) => Atomic(now => @@ -681,6 +738,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto .Where(item => string.Equals(item.Value.Command.ClientSubject, subject, StringComparison.Ordinal)) .Select(static item => item.Key) .ToArray(); + JoinAttemptId[] outcomeReports = _outcomeReports + .Where(item => string.Equals(item.Value.ClientSubject, subject, StringComparison.Ordinal)) + .Select(static item => item.Key) + .ToArray(); foreach (SessionListingId listingId in listings) { RemoveListing(listingId); @@ -690,6 +751,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto { RemoveAttempt(attemptId); } + foreach (JoinAttemptId attemptId in outcomeReports) + { + _outcomeReports.Remove(attemptId); + } return new(StoreResultCode.Success, listings.Length + attempts.Length); }, cancellationToken); @@ -799,6 +864,14 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto RemoveAttempt(attemptId); } + foreach (JoinAttemptId attemptId in _outcomeReports + .Where(item => item.Value.Deadline <= now) + .Select(static item => item.Key) + .ToArray()) + { + _outcomeReports.Remove(attemptId); + } + foreach (SessionListingId listingId in _listings .Where(item => item.Value.LeaseDeadline <= now) .Select(static item => item.Key) @@ -815,6 +888,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto _presenceHandles.Clear(); _presence.Clear(); _attempts.Clear(); + _outcomeReports.Clear(); _attemptHandles.Clear(); _idempotency.Clear(); _replay.Clear(); @@ -837,6 +911,15 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto { RemoveAttempt(attemptId); } + + + foreach (JoinAttemptId attemptId in _outcomeReports + .Where(item => item.Value.ListingId == listingId) + .Select(static item => item.Key) + .ToArray()) + { + _outcomeReports.Remove(attemptId); + } } private void RemoveAttempt(JoinAttemptId attemptId) @@ -875,6 +958,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto HostCapabilityFingerprint = entry.Command.HostCapabilityFingerprint, ClientCapabilityFingerprint = entry.Command.ClientCapabilityFingerprint, ConnectionTicketFingerprint = entry.Command.ConnectionTicketFingerprint, + DedicatedFallback = StoredListing.CopyEndpoint(entry.Command.DedicatedFallback), ExpiresAt = entry.WallExpiresAt, ConnectionTicketExpiresAt = entry.TicketWallExpiresAt ?? default, HostEndpoint = entry.HostEndpoint, @@ -914,6 +998,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || listing.CurrentPlayers < 0 || listing.CurrentPlayers > listing.MaximumPlayers || !ContractValidation.IsMetadataValid(listing.Metadata) + || listing.DedicatedFallback is not null + && !ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback) || !listing.LeaseFingerprint.IsValid || !listing.HostPresenceFingerprint.IsValid || !IsDerivationSaltValid(listing.CapabilityDerivationSalt)) @@ -954,6 +1040,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto || !command.HostCapabilityFingerprint.IsValid || !command.ClientCapabilityFingerprint.IsValid || !command.ConnectionTicketFingerprint.IsValid + || command.DedicatedFallback is not null + && !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback) || !IsDerivationSaltValid(command.CapabilityDerivationSalt) || command.ScopeAttemptLimit <= 0) { @@ -1031,6 +1119,19 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto public bool IsCancelled { get; set; } } + private sealed class OutcomeReportEntry( + SessionListingId listingId, + string clientSubject, + SecretFingerprint clientCapabilityFingerprint, + TimeSpan deadline) + { + public SessionListingId ListingId { get; } = listingId; + public string ClientSubject { get; } = clientSubject; + public SecretFingerprint ClientCapabilityFingerprint { get; } = clientCapabilityFingerprint; + public TimeSpan Deadline { get; } = deadline; + public StoredConnectionOutcome? Outcome { get; set; } + } + private sealed record IdempotencyEntry( string RequestFingerprint, object ResourceId, diff --git a/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs b/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs index a9255ae..26b87c5 100644 --- a/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs +++ b/src/FinalFactory.Rendezvous.Server/State/StoreResultMapping.cs @@ -13,6 +13,8 @@ internal static class StoreResultMapping StoreResultCode.Conflict => RendezvousErrorCode.Conflict, StoreResultCode.CapacityExceeded => RendezvousErrorCode.CapacityExceeded, StoreResultCode.ReplayRejected => RendezvousErrorCode.ReplayRejected, + StoreResultCode.StaleHost => RendezvousErrorCode.StaleHost, + StoreResultCode.IncompatibleProtocol => RendezvousErrorCode.IncompatibleProtocol, StoreResultCode.Draining or StoreResultCode.ServiceUnavailable => RendezvousErrorCode.ServiceUnavailable, _ => RendezvousErrorCode.InternalError, diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs index 4fb5310..92aa29a 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousClientBehaviorTests.cs @@ -102,6 +102,32 @@ public sealed class RendezvousClientBehaviorTests Assert.Equal(TimeSpan.FromMilliseconds(200), Assert.Single(delay.Delays)); } + [Fact] + public async Task SilentServiceIsBoundedByTheConfiguredRequestTimeout() + { + using HttpClient httpClient = new(new SilentHandler()) + { + BaseAddress = new("http://rendezvous.test/"), + }; + RendezvousSessionBrowserClient browser = new( + httpClient, + new RendezvousClientOptions + { + MaximumSafeRetries = 0, + RequestTimeout = TimeSpan.FromMilliseconds(20), + JitterRatio = 0, + }); + + RendezvousClientResult result = await browser.BrowseAsync(new() + { + GameId = new("space-game"), + EnvironmentId = new("production"), + ProtocolVersion = 7, + }).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Equal(RendezvousErrorCode.ServiceUnavailable, result.Error); + } + [Fact] public void SuccessResultRequiresAValue() { @@ -339,4 +365,15 @@ public sealed class RendezvousClientBehaviorTests return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } } + + private sealed class SilentHandler : HttpMessageHandler + { + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } } diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs index 77f9e30..240bbad 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorBehaviorTests.cs @@ -7,6 +7,55 @@ namespace FinalFactory.Rendezvous.Tests.Client; public sealed class RendezvousCoordinatorBehaviorTests { + [Fact] + public void LegacyCompletionConstructorsRemainCompatibleWithoutAllowingNonterminalStates() + { +#pragma warning disable CS0618 + RendezvousConnectionCompletedEventArgs client = new( + RendezvousConnectionState.Rejected, + (NetPeer?)null); + RendezvousHostAttemptCompletedEventArgs host = new( + new JoinAttemptId(Guid.NewGuid()), + RendezvousConnectionState.ManagerStopped, + (NetPeer?)null); + Assert.Throws(() => + new RendezvousConnectionCompletedEventArgs( + RendezvousConnectionState.Punching, + (NetPeer?)null)); + Assert.Throws(() => + new RendezvousHostAttemptCompletedEventArgs( + default, + RendezvousConnectionState.Rejected, + (NetPeer?)null)); +#pragma warning restore CS0618 + + Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Outcome.Kind); + Assert.Equal(ConnectionOutcomeKind.ManagerStopped, host.Outcome.Kind); + } + + [Theory] + [InlineData(RendezvousErrorCode.NotFound, ConnectionOutcomeKind.DirectoryNotFound, RendezvousConnectionFailureCategory.Directory)] + [InlineData(RendezvousErrorCode.Expired, ConnectionOutcomeKind.AttemptExpired, RendezvousConnectionFailureCategory.Authorization)] + [InlineData(RendezvousErrorCode.IncompatibleProtocol, ConnectionOutcomeKind.IncompatibleProtocol, RendezvousConnectionFailureCategory.Compatibility)] + [InlineData(RendezvousErrorCode.Forbidden, ConnectionOutcomeKind.Unauthorized, RendezvousConnectionFailureCategory.Authorization)] + [InlineData(RendezvousErrorCode.RateLimited, ConnectionOutcomeKind.RateLimited, RendezvousConnectionFailureCategory.Capacity)] + [InlineData(RendezvousErrorCode.StaleHost, ConnectionOutcomeKind.NoHostPresence, RendezvousConnectionFailureCategory.HostPresence)] + [InlineData(RendezvousErrorCode.ServiceUnavailable, ConnectionOutcomeKind.ServiceUnavailable, RendezvousConnectionFailureCategory.Service)] + public void AuthoritativeServiceErrorsMapToStableConnectionOutcomes( + RendezvousErrorCode error, + ConnectionOutcomeKind expectedKind, + RendezvousConnectionFailureCategory expectedCategory) + { + RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.FromServiceError( + error, + TimeSpan.FromMilliseconds(250)); + + Assert.Equal(expectedKind, outcome.Kind); + Assert.Equal(expectedCategory, outcome.Category); + Assert.Equal(RendezvousConnectionOutcomeSource.RendezvousService, outcome.Source); + Assert.Equal(error, outcome.ServiceError); + } + [Fact] public void NatIntroductionAloneDoesNotCompleteTheClientAttempt() { @@ -43,6 +92,29 @@ public sealed class RendezvousCoordinatorBehaviorTests Assert.False(harness.Coordinator.IsCompleted); } + [Fact] + public void MediatorNetworkErrorProducesOneTypedTerminalOutcome() + { + ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + using ClientHarness harness = new(clock); + int completions = 0; + harness.Coordinator.Completed += (_, _) => completions++; + + harness.NetworkEvents.OnNetworkError( + new IPEndPoint(IPAddress.Loopback, 65_001), + System.Net.Sockets.SocketError.HostUnreachable); + RendezvousConnectionOutcome outcome = Assert.IsType( + harness.Coordinator.Outcome); + harness.NetworkEvents.OnNetworkError( + new IPEndPoint(IPAddress.Loopback, 65_001), + System.Net.Sockets.SocketError.HostUnreachable); + + Assert.Equal(ConnectionOutcomeKind.MediatorUnavailable, outcome.Kind); + Assert.Equal(RendezvousConnectionFailureCategory.Mediation, outcome.Category); + Assert.Equal(1, completions); + Assert.Same(outcome, harness.Coordinator.Outcome); + } + [Fact] public void CancellationCompletesExactlyOnceAndLateCallbacksCannotReopenTheAttempt() { @@ -61,6 +133,7 @@ public sealed class RendezvousCoordinatorBehaviorTests Assert.Equal(RendezvousConnectionState.Cancelled, harness.Coordinator.State); Assert.Equal([RendezvousConnectionState.Cancelled], completions); + Assert.Equal(ConnectionOutcomeKind.Cancelled, harness.Coordinator.Outcome!.Kind); } [Fact] @@ -85,6 +158,68 @@ public sealed class RendezvousCoordinatorBehaviorTests Assert.Equal(RendezvousConnectionState.TimedOut, harness.Coordinator.State); Assert.Equal(1, completions); + Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind); + Assert.Equal( + RendezvousConnectionFailureCategory.NatTraversal, + harness.Coordinator.Outcome.Category); + } + + [Fact] + public void WallClockRollbackCannotExtendTheMonotonicPunchDeadline() + { + ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions + { + PunchTimeout = TimeSpan.FromSeconds(10), + JitterRatio = 0, + }); + clock.AdjustWallClock(TimeSpan.FromHours(-1)); + clock.Advance(TimeSpan.FromSeconds(11)); + + harness.Coordinator.Poll(); + + Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, harness.Coordinator.Outcome!.Kind); + Assert.Equal(TimeSpan.FromSeconds(11), harness.Coordinator.Outcome.Elapsed); + } + + [Fact] + public void DirectConnectTimeoutOffersFallbackWithoutConnectingIt() + { + ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + NetworkEndpoint fallback = new() + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.90", + Port = 9_060, + }; + using ClientHarness harness = new(clock, new RendezvousCoordinatorOptions + { + DirectConnectTimeout = TimeSpan.FromMilliseconds(10), + DedicatedFallbackOverride = fallback, + JitterRatio = 0, + }); + ((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess( + new IPEndPoint(IPAddress.Loopback, 65_000), + NatAddressType.External, + harness.IntroductionToken); + clock.Advance(TimeSpan.FromMilliseconds(10)); + + harness.Coordinator.Poll(); + + RendezvousConnectionOutcome outcome = Assert.IsType( + 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 connectedPeers = []; + harness.Manager.GetConnectedPeers(connectedPeers); + Assert.Empty(connectedPeers); + + ((INatPunchListener)harness.PunchEvents).OnNatIntroductionSuccess( + new IPEndPoint(IPAddress.Loopback, 65_001), + NatAddressType.External, + harness.IntroductionToken); + Assert.Same(outcome, harness.Coordinator.Outcome); } [Fact] @@ -145,6 +280,10 @@ public sealed class RendezvousCoordinatorBehaviorTests Assert.Equal(RendezvousConnectionState.Rejected, client.Coordinator.State); Assert.Equal(1, completions); + Assert.Equal(ConnectionOutcomeKind.HostRejected, client.Coordinator.Outcome!.Kind); + Assert.Equal( + RendezvousConnectionOutcomeSource.RemoteHost, + client.Coordinator.Outcome.Source); client.Coordinator.Poll(); Assert.Equal(1, completions); } @@ -409,7 +548,7 @@ public sealed class RendezvousCoordinatorBehaviorTests } [Fact] - public async Task HostBoundsAttemptExpiryChecksPerPoll() + public async Task HostDeadlinesAreNotDelayedByTheBoundedRetryQueue() { ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); RendezvousNetListener networkEvents = new(); @@ -453,9 +592,6 @@ public sealed class RendezvousCoordinatorBehaviorTests host.Poll(); - Assert.Equal(1, host.PendingAttemptCount); - Assert.Equal([RendezvousConnectionState.TimedOut], completions); - host.Poll(); Assert.Equal(0, host.PendingAttemptCount); Assert.Equal( [RendezvousConnectionState.TimedOut, RendezvousConnectionState.TimedOut], @@ -467,6 +603,112 @@ public sealed class RendezvousCoordinatorBehaviorTests } } + [Fact] + public async Task HostStopPublishesEveryCompletionBeforeReentrantDisposalCanTearDownState() + { + ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + RendezvousNetListener networkEvents = new(); + NetManager manager = networkEvents.CreateManager(); + JoinAttemptId firstId = new(Guid.Parse("00000000-0000-0000-0000-000000000161")); + JoinAttemptId secondId = new(Guid.Parse("00000000-0000-0000-0000-000000000162")); + MutableJoinClient joins = new([ + CreateHostAttempt( + firstId, + new(Guid.Parse("00000000-0000-0000-0000-000000000163")), + NatIntroductionTokenCodec.Encode(firstId, Credential('T')), + clock.UtcNow + TimeSpan.FromSeconds(30)), + CreateHostAttempt( + secondId, + new(Guid.Parse("00000000-0000-0000-0000-000000000164")), + NatIntroductionTokenCodec.Encode(secondId, Credential('U')), + clock.UtcNow + TimeSpan.FromSeconds(30)), + ]); + RendezvousHostCoordinator? host = null; + try + { + Assert.True(manager.Start(0)); + host = new( + manager, + networkEvents, + new IPEndPoint(IPAddress.Loopback, 65_002), + CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)), + joins, + new RendezvousCoordinatorOptions { JitterRatio = 0 }, + clock, + null); + int completions = 0; + host.AttemptCompleted += (_, _) => + { + completions++; + if (completions == 1) + { + host.Dispose(); + } + }; + Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess); + host.Poll(); + manager.Stop(); + + host.Poll(); + + Assert.Equal(2, completions); + Assert.Equal(0, host.PendingAttemptCount); + } + finally + { + host?.Dispose(); + manager.Stop(); + } + } + + [Fact] + public async Task HostPunchTimeoutUsesItsOwnFakeClockBudget() + { + ManualCoordinatorClock clock = new(new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero)); + RendezvousNetListener networkEvents = new(); + NetManager manager = networkEvents.CreateManager(); + JoinAttemptId attemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000161")); + MutableJoinClient joins = new([ + CreateHostAttempt( + attemptId, + new(Guid.Parse("00000000-0000-0000-0000-000000000162")), + NatIntroductionTokenCodec.Encode(attemptId, Credential('T')), + clock.UtcNow + TimeSpan.FromSeconds(30)), + ]); + try + { + Assert.True(manager.Start(0)); + using RendezvousHostCoordinator host = new( + manager, + networkEvents, + new IPEndPoint(IPAddress.Loopback, 65_002), + CreateSession(clock.UtcNow + TimeSpan.FromMinutes(1)), + joins, + new RendezvousCoordinatorOptions + { + MaximumPunchRequests = 20, + PunchTimeout = TimeSpan.FromMilliseconds(10), + JitterRatio = 0, + }, + clock, + null); + RendezvousHostAttemptCompletedEventArgs? completion = null; + host.AttemptCompleted += (_, value) => completion = value; + Assert.True((await host.RefreshJoinAttemptsAsync()).IsSuccess); + host.Poll(); + clock.Advance(TimeSpan.FromMilliseconds(10)); + + host.Poll(); + + Assert.Equal(ConnectionOutcomeKind.PunchTimedOut, completion!.Outcome.Kind); + Assert.Equal(TimeSpan.FromMilliseconds(10), completion.Outcome.Elapsed); + } + finally + { + manager.Stop(); + } + } + private static CreateJoinAttemptResponse CreateAttempt(DateTimeOffset expiresAt) => new() { AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000101")), @@ -548,6 +790,11 @@ public sealed class RendezvousCoordinatorBehaviorTests { internal IReadOnlyList Attempts { get; set; } = attempts; + public Task CreateConnectionAttemptAsync( + CreateJoinAttemptRequest request, + NetworkEndpoint? dedicatedFallback = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> CreateAsync( CreateJoinAttemptRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -567,6 +814,11 @@ public sealed class RendezvousCoordinatorBehaviorTests int maximumPages = 100, CancellationToken cancellationToken = default) => Task.FromResult( RendezvousClientResult.Success(Attempts)); + + public Task> ReportOutcomeAsync( + CreateJoinAttemptResponse attempt, + RendezvousConnectionOutcome outcome, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class BlockingJoinClient : IRendezvousJoinClient @@ -581,6 +833,11 @@ public sealed class RendezvousCoordinatorBehaviorTests internal void Complete(IReadOnlyList attempts) => _result.SetResult(attempts); + public Task CreateConnectionAttemptAsync( + CreateJoinAttemptRequest request, + NetworkEndpoint? dedicatedFallback = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> CreateAsync( CreateJoinAttemptRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -603,6 +860,11 @@ public sealed class RendezvousCoordinatorBehaviorTests _called.SetResult(true); return RendezvousClientResult.Success(await _result.Task.WaitAsync(cancellationToken)); } + + public Task> ReportOutcomeAsync( + CreateJoinAttemptResponse attempt, + RendezvousConnectionOutcome outcome, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class ManualCoordinatorClock(DateTimeOffset now) : @@ -610,7 +872,14 @@ public sealed class RendezvousCoordinatorBehaviorTests IConnectionTicketClock { public DateTimeOffset UtcNow { get; private set; } = now; + public TimeSpan Elapsed { get; private set; } - internal void Advance(TimeSpan amount) => UtcNow += amount; + internal void Advance(TimeSpan amount) + { + UtcNow += amount; + Elapsed += amount; + } + + internal void AdjustWallClock(TimeSpan amount) => UtcNow += amount; } } diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs index efb5ee6..fce5786 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousCoordinatorIntegrationTests.cs @@ -242,6 +242,11 @@ public sealed class RendezvousCoordinatorIntegrationTests private sealed class FakeJoinClient(IReadOnlyList attempts) : IRendezvousJoinClient { + public Task CreateConnectionAttemptAsync( + CreateJoinAttemptRequest request, + NetworkEndpoint? dedicatedFallback = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> CreateAsync( CreateJoinAttemptRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -261,6 +266,11 @@ public sealed class RendezvousCoordinatorIntegrationTests int maximumPages = 100, CancellationToken cancellationToken = default) => Task.FromResult( RendezvousClientResult.Success(attempts)); + + public Task> ReportOutcomeAsync( + CreateJoinAttemptResponse attempt, + RendezvousConnectionOutcome outcome, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); } private sealed class FixedCoordinatorClock(DateTimeOffset now) : @@ -268,5 +278,6 @@ public sealed class RendezvousCoordinatorIntegrationTests IConnectionTicketClock { public DateTimeOffset UtcNow { get; } = now; + public TimeSpan Elapsed => TimeSpan.Zero; } } diff --git a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs index 8900f46..40bd4f4 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Client/RendezvousJoinClientTests.cs @@ -83,6 +83,110 @@ public sealed class RendezvousJoinClientTests Assert.Contains("cursor=next%20page%2Bcursor", handler.Requests[1].Uri.Query, StringComparison.Ordinal); } + [Fact] + public async Task OutcomeReportingUsesTheAttemptCapabilityAndCoarseElapsedBucket() + { + CreateJoinAttemptResponse attempt = CreateAttempt(); + RecordingHandler handler = new(JsonResponse(HttpStatusCode.OK, new ReportConnectionOutcomeResponse + { + Accepted = true, + IsDuplicate = false, + })); + using HttpClient http = new(handler) { BaseAddress = new("http://rendezvous.test/") }; + RendezvousJoinClient client = new(http); + RendezvousConnectionOutcome outcome = RendezvousConnectionOutcome.Create( + ConnectionOutcomeKind.DirectConnectTimedOut, + RendezvousConnectionOutcomeSource.LocalTraversal, + RendezvousConnectionFailureCategory.DirectConnection, + RendezvousConnectionPhase.DirectConnection, + TimeSpan.FromSeconds(6)); + + RendezvousClientResult 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 pending = client.CreateConnectionAttemptAsync( + CreateRequest("cancelled-in-flight"), + cancellationToken: cancellation.Token); + await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + await cancellation.CancelAsync(); + RendezvousConnectionStartResult result = await pending; + + Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind); + Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source); + } + private static CreateJoinAttemptResponse CreateAttempt() => new() { AttemptId = new(Guid.Parse("00000000-0000-0000-0000-000000000202")), @@ -95,6 +199,15 @@ public sealed class RendezvousJoinClientTests ExpiresAt = new DateTimeOffset(2030, 1, 1, 0, 0, 30, TimeSpan.Zero), }; + private static CreateJoinAttemptRequest CreateRequest(string idempotencyKey) => new() + { + IdempotencyKey = idempotencyKey, + GameId = new("space-game"), + EnvironmentId = new("production"), + ListingId = new(Guid.Parse("00000000-0000-0000-0000-000000000230")), + ProtocolVersion = 7, + }; + private static HostJoinAttempt CreateHostAttempt(string id) => new() { AttemptId = new(Guid.Parse(id)), @@ -137,6 +250,22 @@ public sealed class RendezvousJoinClientTests } } + private sealed class CancellingHandler : HttpMessageHandler + { + internal TaskCompletionSource Started { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _ = request; + Started.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The silent request unexpectedly completed."); + } + } + private sealed record RecordedRequest( HttpMethod Method, Uri Uri, diff --git a/tests/FinalFactory.Rendezvous.Tests/ConnectionOutcomes/ConnectionOutcomeServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/ConnectionOutcomes/ConnectionOutcomeServiceTests.cs new file mode 100644 index 0000000..3e23ff5 --- /dev/null +++ b/tests/FinalFactory.Rendezvous.Tests/ConnectionOutcomes/ConnectionOutcomeServiceTests.cs @@ -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)); + } +} diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs index 957dc25..c953c0b 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/ContractSerializationTests.cs @@ -70,10 +70,10 @@ public sealed class ContractSerializationTests public void UnknownEnumNamesAndNumericValuesAreRejected() { Assert.Throws(() => JsonSerializer.Deserialize( - "{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedMilliseconds\":1}", + "{\"contractVersion\":1,\"outcome\":\"futureOutcome\",\"elapsedBucket\":\"underOneSecond\"}", ContractJson.Options)); Assert.Throws(() => JsonSerializer.Deserialize( - "{\"contractVersion\":1,\"outcome\":99,\"elapsedMilliseconds\":1}", + "{\"contractVersion\":1,\"outcome\":99,\"elapsedBucket\":\"underOneSecond\"}", ContractJson.Options)); } @@ -98,6 +98,7 @@ public sealed class ContractSerializationTests [Fact] public void SharedCanonicalOptionsCannotBeMutatedByConsumers() { + Assert.Equal(9, ContractJson.Options.MaxDepth); Assert.True(ContractJson.Options.IsReadOnly); Assert.Throws(() => ContractJson.Options.WriteIndented = true); diff --git a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs index ff3c785..0db83a3 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Contracts/OpenApiCompatibilityTests.cs @@ -22,6 +22,7 @@ public sealed class OpenApiCompatibilityTests "buildVersion", "capacity", "contractVersion", + "dedicatedFallback", "displayName", "environmentId", "gameId", @@ -63,14 +64,46 @@ public sealed class OpenApiCompatibilityTests Assert.Equal(ExpectedListingProperties, listingProperties); Assert.DoesNotContain(listingProperties, static property => property.Contains("token", StringComparison.OrdinalIgnoreCase) - || property.Contains("endpoint", StringComparison.OrdinalIgnoreCase) || property.Contains("playerId", StringComparison.OrdinalIgnoreCase)); + JsonElement dedicatedFallback = schemas.GetProperty("SessionListing") + .GetProperty("properties") + .GetProperty("dedicatedFallback"); + JsonElement fallbackReference = Assert.Single( + dedicatedFallback.GetProperty("oneOf").EnumerateArray(), + static schema => schema.TryGetProperty("$ref", out _)); + Assert.Equal( + "#/components/schemas/NetworkEndpoint", + fallbackReference.GetProperty("$ref").GetString()); + + JsonElement outcomeReportProperties = schemas.GetProperty("ReportConnectionOutcomeRequest") + .GetProperty("properties"); + Assert.True(outcomeReportProperties.TryGetProperty("elapsedBucket", out _)); + Assert.True(outcomeReportProperties.TryGetProperty("elapsedMilliseconds", out _)); + Assert.True(outcomeReportProperties.TryGetProperty("diagnosticCode", out _)); + string[] outcomeNames = schemas.GetProperty("ConnectionOutcomeKind") + .GetProperty("enum") + .EnumerateArray() + .Select(static value => value.GetString()!) + .ToArray(); + Assert.Contains("timedOut", outcomeNames); + Assert.Contains("staleHost", outcomeNames); + Assert.Contains("transportFailed", outcomeNames); + Assert.Contains("punchTimedOut", outcomeNames); + Assert.Contains("directConnectTimedOut", outcomeNames); + Assert.Contains("transportError", outcomeNames); JsonElement publisherBearer = root.GetProperty("components") .GetProperty("securitySchemes") .GetProperty("PublisherBearer"); Assert.Equal("http", publisherBearer.GetProperty("type").GetString()); Assert.Equal("bearer", publisherBearer.GetProperty("scheme").GetString()); + JsonElement attemptCapability = root.GetProperty("components") + .GetProperty("securitySchemes") + .GetProperty("JoinAttemptCapability"); + Assert.Equal("apiKey", attemptCapability.GetProperty("type").GetString()); + Assert.Equal( + "X-Rendezvous-Client-Punch-Capability", + attemptCapability.GetProperty("name").GetString()); (string Path, string Method)[] publisherOperations = [ ("/v1/sessions", "post"), @@ -96,6 +129,18 @@ public sealed class OpenApiCompatibilityTests && parameter.GetProperty("name").GetString() == "X-Rendezvous-Client-Punch-Capability"); Assert.True(cancelCapability.GetProperty("required").GetBoolean()); + foreach ((string operationPath, string method) in new[] + { + ("/v1/join-attempts/{attemptId}", "delete"), + ("/v1/join-attempts/{attemptId}/outcome", "post"), + }) + { + JsonElement security = root.GetProperty("paths") + .GetProperty(operationPath) + .GetProperty(method) + .GetProperty("security"); + Assert.True(security[0].TryGetProperty("JoinAttemptCapability", out _)); + } JsonElement hostPollParameters = root.GetProperty("paths") .GetProperty("/v1/sessions/{listingId}/join-attempts") .GetProperty("get") diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs index 6474e3c..2b50968 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptHttpEndpointTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using FinalFactory.Rendezvous.Client; using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Browser; +using FinalFactory.Rendezvous.Server.ConnectionOutcomes; using FinalFactory.Rendezvous.Server.Http; using FinalFactory.Rendezvous.Server.JoinAttempts; using FinalFactory.Rendezvous.Server.Provisioning; @@ -103,6 +104,139 @@ public sealed class JoinAttemptHttpEndpointTests Assert.True(cancelledAttempt.IsCancelled); } + [Fact] + public async Task OutcomeReportingIsCapabilityAuthenticatedAndIdempotentOverHttp() + { + await using JoinHttpTestHost host = await JoinHttpTestHost.StartAsync(); + RendezvousPublisherClient publisher = new(host.HttpClient); + PublishedSession session = AssertSuccess(await publisher.RegisterAsync( + CreateRegistration(), + host.PublisherCredential)); + Assert.True(host.Capabilities.TryFingerprint( + session.HostPresenceCapability, + out SecretFingerprint presenceFingerprint)); + Assert.True(host.Store.BindHostPresence(new( + session.HostPresenceHandle, + presenceFingerprint, + new(AddressFamilyKind.Ipv4, "203.0.113.80", 41_000), + null)).Succeeded); + using HttpResponseMessage createdResponse = await host.HttpClient.PostAsJsonAsync( + "v1/join-attempts", + new CreateJoinAttemptRequest + { + IdempotencyKey = "outcome-report-1", + GameId = new("space-game"), + EnvironmentId = new("production"), + ListingId = session.ListingId, + ProtocolVersion = 7, + }, + ContractJson.Options); + CreateJoinAttemptResponse created = Assert.IsType( + await createdResponse.Content.ReadFromJsonAsync(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( + await response.Content.ReadFromJsonAsync(ContractJson.Options)); + Assert.Equal(expectedError, error.Code); + } + + private static async Task 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( + await response.Content.ReadFromJsonAsync(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(RendezvousClientResult result) { Assert.True(result.IsSuccess, result.Message); @@ -132,18 +266,21 @@ public sealed class JoinAttemptHttpEndpointTests HttpClient httpClient, InMemoryEphemeralRendezvousStore store, EphemeralCapabilityIssuer capabilities, + ConnectionOutcomeMetrics outcomeMetrics, string publisherCredential) { _application = application; HttpClient = httpClient; Store = store; Capabilities = capabilities; + OutcomeMetrics = outcomeMetrics; PublisherCredential = publisherCredential; } internal HttpClient HttpClient { get; } internal InMemoryEphemeralRendezvousStore Store { get; } internal EphemeralCapabilityIssuer Capabilities { get; } + internal ConnectionOutcomeMetrics OutcomeMetrics { get; } internal string PublisherCredential { get; } internal static async Task StartAsync() @@ -181,6 +318,9 @@ public sealed class JoinAttemptHttpEndpointTests builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + ConnectionOutcomeMetrics outcomeMetrics = new(); + builder.Services.AddSingleton(outcomeMetrics); + builder.Services.AddSingleton(); WebApplication app = builder.Build(); app.UseExceptionHandler(); @@ -193,6 +333,7 @@ public sealed class JoinAttemptHttpEndpointTests new HttpClient { BaseAddress = new Uri(address) }, store, capabilities, + outcomeMetrics, credential); } diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs index 9659d19..2f05dc3 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptServiceTests.cs @@ -1,6 +1,8 @@ using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.JoinAttempts; +using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; namespace FinalFactory.Rendezvous.Tests.JoinAttempts; @@ -64,7 +66,7 @@ public sealed class JoinAttemptServiceTests using JoinAttemptFixture fixture = new(); (RegisterSessionResponse stale, _) = fixture.CreateHost(bindPresence: false); Assert.Equal( - RendezvousErrorCode.NotFound, + RendezvousErrorCode.StaleHost, fixture.Service.Create(fixture.ClientSubject, fixture.Request(stale.ListingId)).Error); (RegisterSessionResponse active, _) = fixture.CreateHost(); @@ -81,6 +83,41 @@ public sealed class JoinAttemptServiceTests fixture.Service.Create(fixture.ClientSubject, otherTenant).Error); } + [Fact] + public void ListingProtocolMismatchRemainsDistinctWhenTheRequestedProtocolIsAllowed() + { + GamePolicyOptions policy = ProvisioningTestData.CreatePolicy(); + policy.ProtocolVersions.Add(8); + using JoinAttemptFixture fixture = new(joinPolicy: policy); + (RegisterSessionResponse active, _) = fixture.CreateHost(); + CreateJoinAttemptRequest request = fixture.Request(active.ListingId); + request.ProtocolVersion = 8; + + Assert.Equal( + RendezvousErrorCode.IncompatibleProtocol, + fixture.Service.Create(fixture.ClientSubject, request).Error); + } + + [Fact] + public void IssuedAttemptCarriesTheHostsDedicatedFallbackCandidate() + { + using JoinAttemptFixture fixture = new(); + RegisterSessionRequest registrationRequest = fixture.Sessions.Request(); + registrationRequest.DedicatedFallback = new() + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.91", + Port = 9_061, + }; + RegisterSessionResponse registration = fixture.Sessions.Register(registrationRequest); + Assert.True(fixture.Sessions.BindPresence(registration).Succeeded); + + CreateJoinAttemptResponse created = fixture.Create(registration.ListingId); + + Assert.Equal("203.0.113.91", created.DedicatedFallback!.Address); + Assert.Equal(9_061, created.DedicatedFallback.Port); + } + [Fact] public void HostPollingAuthenticatesLeaseAndUsesScopeBoundCursorPaging() { diff --git a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs index a1fe8e8..caf4527 100644 --- a/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs +++ b/tests/FinalFactory.Rendezvous.Tests/JoinAttempts/JoinAttemptTestData.cs @@ -12,11 +12,15 @@ internal sealed class JoinAttemptFixture : IDisposable { private int _sequence; - public JoinAttemptFixture(EphemeralStoreOptions? options = null) + public JoinAttemptFixture( + EphemeralStoreOptions? options = null, + GamePolicyOptions? joinPolicy = null) { Sessions = new(options); Cursors = new(); - GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); + GamePolicyRegistry policies = GamePolicyRegistry.Create([ + joinPolicy ?? ProvisioningTestData.CreatePolicy(), + ]); Service = new(policies, Sessions.Store, Sessions.Capabilities, Cursors, Sessions.Clock); ClientSubject = Service.CreateAnonymousClientSubject(IPAddress.Parse("198.51.100.40")); } diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs index 661a2e8..a27a638 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseServiceTests.cs @@ -3,6 +3,7 @@ using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Server.Provisioning; using FinalFactory.Rendezvous.Server.Sessions; using FinalFactory.Rendezvous.Server.State; +using FinalFactory.Rendezvous.Tests.Provisioning; namespace FinalFactory.Rendezvous.Tests.Sessions; @@ -118,6 +119,12 @@ public sealed class SessionLeaseServiceTests ["mode"] = "co-op", ["map"] = "europa", }, + DedicatedFallback = new() + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.92", + Port = 9_062, + }, }); StoredListing stored = fixture.Store.GetListing(registration.ListingId, false).Value!; @@ -128,6 +135,7 @@ public sealed class SessionLeaseServiceTests Assert.Equal(fixture.Scope, stored.Definition.Scope); Assert.Equal(8, stored.Definition.CurrentPlayers); Assert.Equal(8, stored.Definition.MaximumPlayers); + Assert.Equal("203.0.113.92", stored.Definition.DedicatedFallback!.Address); Assert.True(fixture.Service.Delete( fixture.Principal, registration.ListingId, @@ -139,6 +147,45 @@ public sealed class SessionLeaseServiceTests Assert.Equal(StoreResultCode.NotFound, fixture.Store.GetListing(registration.ListingId, false).Code); } + [Fact] + public void DisabledFallbackPolicyRejectsRegistrationAndUpdateEndpoints() + { + GamePolicyOptions policy = ProvisioningTestData.CreatePolicy(); + policy.FallbackPolicy = FallbackPolicyMode.Disabled; + using SessionLeaseFixture fixture = new(policyOptions: policy); + RegisterSessionRequest registrationRequest = fixture.Request(); + registrationRequest.DedicatedFallback = new() + { + AddressFamily = AddressFamilyKind.Ipv4, + Address = "203.0.113.94", + Port = 9_064, + }; + + Assert.Equal( + RendezvousErrorCode.Forbidden, + fixture.Service.Register(fixture.Principal, registrationRequest).Error); + + RegisterSessionResponse registration = fixture.Register(); + Assert.Equal( + RendezvousErrorCode.Forbidden, + fixture.Service.Update( + fixture.Principal, + registration.ListingId, + new UpdateSessionRequest + { + LeaseToken = registration.LeaseToken, + BuildVersion = "1.4.3", + DisplayName = "Europa Updated", + Capacity = new() { CurrentPlayers = 2, MaximumPlayers = 8 }, + Metadata = new Dictionary(StringComparer.Ordinal) + { + ["mode"] = "co-op", + ["map"] = "europa", + }, + DedicatedFallback = registrationRequest.DedicatedFallback, + }).Error); + } + [Fact] public void AnotherPublisherCannotRenewUpdateOrDeleteListing() { diff --git a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs index 5483dc5..ce75740 100644 --- a/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs +++ b/tests/FinalFactory.Rendezvous.Tests/Sessions/SessionLeaseTestData.cs @@ -11,13 +11,17 @@ internal sealed class SessionLeaseFixture : IDisposable { private int _sequence; - public SessionLeaseFixture(EphemeralStoreOptions? storeOptions = null) + public SessionLeaseFixture( + EphemeralStoreOptions? storeOptions = null, + GamePolicyOptions? policyOptions = null) { StoreOptions = storeOptions ?? new EphemeralStoreOptions(); Clock = new(); Store = new(StoreOptions, Clock, Clock); Capabilities = new(); - GamePolicyRegistry policies = GamePolicyRegistry.Create([ProvisioningTestData.CreatePolicy()]); + GamePolicyRegistry policies = GamePolicyRegistry.Create([ + policyOptions ?? ProvisioningTestData.CreatePolicy(), + ]); Service = new( new PublisherAuthorizationService(policies), Store, diff --git a/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs b/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs index e54391d..ec1bb64 100644 --- a/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/State/InMemoryEphemeralRendezvousStoreTests.cs @@ -134,13 +134,13 @@ public sealed class InMemoryEphemeralRendezvousStoreTests StoredListing listing = fixture.Store.CreateListing(listingCommand).Value!; CreateJoinAttemptCommand attempt = fixture.AttemptCommand(listing); - Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt).Code); + Assert.Equal(StoreResultCode.StaleHost, fixture.Store.CreateJoinAttempt(attempt).Code); fixture.Store.BindHostPresence(new( listingCommand.Listing.HostPresenceHandle, listingCommand.Listing.HostPresenceFingerprint, EphemeralStateFixture.PublicEndpoint(40_000), null)); - Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code); + Assert.Equal(StoreResultCode.IncompatibleProtocol, fixture.Store.CreateJoinAttempt(attempt with { ProtocolVersion = 8 }).Code); Assert.Equal(StoreResultCode.NotFound, fixture.Store.CreateJoinAttempt(attempt with { Scope = new(new("other-game"), new("test")), diff --git a/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs b/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs index 395e4fb..f94b889 100644 --- a/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs +++ b/tests/FinalFactory.Rendezvous.Tests/State/StoreResultMappingTests.cs @@ -19,6 +19,8 @@ public sealed class StoreResultMappingTests [StoreResultCode.Draining] = RendezvousErrorCode.ServiceUnavailable, [StoreResultCode.ReplayRejected] = RendezvousErrorCode.ReplayRejected, [StoreResultCode.ServiceUnavailable] = RendezvousErrorCode.ServiceUnavailable, + [StoreResultCode.StaleHost] = RendezvousErrorCode.StaleHost, + [StoreResultCode.IncompatibleProtocol] = RendezvousErrorCode.IncompatibleProtocol, }; Assert.Equal(Enum.GetValues().Length, expected.Count); diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt index dce0b1b..f896627 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/client-public-api.txt @@ -29,6 +29,8 @@ TYPE FinalFactory.Rendezvous.Client.IRendezvousJoinClient METHOD System.Threading.Tasks.Task> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken) TYPE FinalFactory.Rendezvous.Client.IRendezvousPublisherClient METHOD System.Threading.Tasks.Task> DeregisterAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> RegisterAsync(FinalFactory.Rendezvous.Contracts.RegisterSessionRequest request, System.String publisherCredential, System.Threading.CancellationToken cancellationToken) @@ -60,12 +62,14 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientCoordinator CTOR (LiteNetLib.NetManager manager, FinalFactory.Rendezvous.Client.RendezvousNetListener networkEvents, System.Net.IPEndPoint mediator, FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions options) PROP LiteNetLib.NetPeer ConnectedPeer {get;} PROP System.Boolean IsCompleted {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;} PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} EVENT System.EventHandler Completed METHOD System.Void Cancel() METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken) METHOD System.Void Dispose() METHOD System.Void Poll() + METHOD System.Threading.Tasks.Task> ReportOutcomeAsync(FinalFactory.Rendezvous.Client.IRendezvousJoinClient joinClient, System.Threading.CancellationToken cancellationToken) METHOD System.String ToString() TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions CTOR () @@ -73,6 +77,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientOptions PROP System.Double JitterRatio {get;set;} PROP System.TimeSpan MaximumRetryDelay {get;set;} PROP System.Int32 MaximumSafeRetries {get;set;} + PROP System.TimeSpan RequestTimeout {get;set;} TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult Failure(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.String message, System.Nullable retryAfterSeconds) METHOD FinalFactory.Rendezvous.Client.RendezvousClientResult Success(T value) @@ -84,8 +89,55 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousClientResult PROP T Value {get;} TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionCompletedEventArgs CTOR (FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;} PROP LiteNetLib.NetPeer Peer {get;} PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory + ENUM None=0 + ENUM Directory=1 + ENUM Compatibility=2 + ENUM Authorization=3 + ENUM Capacity=4 + ENUM HostPresence=5 + ENUM Service=6 + ENUM Mediation=7 + ENUM NatTraversal=8 + ENUM DirectConnection=9 + ENUM Lifecycle=10 +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionFailureCategory Category {get;} + PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;} + PROP System.TimeSpan Elapsed {get;} + PROP System.Boolean HasDedicatedFallback {get;} + PROP System.Boolean IsSuccess {get;} + PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Kind {get;} + PROP LiteNetLib.NetPeer Peer {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionPhase Phase {get;} + PROP System.Nullable ServiceError {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource Source {get;} + METHOD FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket BucketElapsed(System.TimeSpan elapsed) + METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome FromServiceError(FinalFactory.Rendezvous.Contracts.RendezvousErrorCode error, System.TimeSpan elapsed, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback) + METHOD System.String ToString() +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionOutcomeSource + ENUM RendezvousService=1 + ENUM LocalTraversal=2 + ENUM RemoteHost=3 + ENUM Caller=4 + ENUM Lifecycle=5 +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionPhase + ENUM Directory=1 + ENUM Authorization=2 + ENUM Mediation=3 + ENUM NatTraversal=4 + ENUM DirectConnection=5 + ENUM Complete=6 +TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult + PROP FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse Attempt {get;} + PROP System.Boolean IsCompleted {get;} + PROP System.Boolean IsReadyForTraversal {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;} + METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult Completed(FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome) + METHOD FinalFactory.Rendezvous.Client.RendezvousConnectionStartResult ReadyForTraversal(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt) TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState ENUM Punching=1 ENUM Connecting=2 @@ -98,14 +150,18 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousConnectionState TYPE FinalFactory.Rendezvous.Client.RendezvousCoordinatorOptions CTOR () PROP System.TimeSpan ConnectionTicketLifetime {get;set;} + PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallbackOverride {get;set;} + PROP System.TimeSpan DirectConnectTimeout {get;set;} PROP System.TimeSpan InitialPunchRetryDelay {get;set;} PROP System.Double JitterRatio {get;set;} PROP System.Int32 MaximumAttemptChecksPerPoll {get;set;} PROP System.Int32 MaximumPunchRequests {get;set;} PROP System.TimeSpan MaximumPunchRetryDelay {get;set;} + PROP System.TimeSpan PunchTimeout {get;set;} TYPE FinalFactory.Rendezvous.Client.RendezvousHostAttemptCompletedEventArgs CTOR (FinalFactory.Rendezvous.Contracts.JoinAttemptId attemptId, FinalFactory.Rendezvous.Client.RendezvousConnectionState state, LiteNetLib.NetPeer peer) PROP FinalFactory.Rendezvous.Contracts.JoinAttemptId AttemptId {get;} + PROP FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome Outcome {get;} PROP LiteNetLib.NetPeer Peer {get;} PROP FinalFactory.Rendezvous.Client.RendezvousConnectionState State {get;} TYPE FinalFactory.Rendezvous.Client.RendezvousHostCoordinator @@ -127,6 +183,8 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousJoinClient METHOD System.Threading.Tasks.Task> BrowseForHostAsync(FinalFactory.Rendezvous.Client.PublishedSession session, System.Int32 pageSize, System.String cursor, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> CancelAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, System.Threading.CancellationToken cancellationToken) METHOD System.Threading.Tasks.Task> CreateAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task CreateConnectionAttemptAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest request, FinalFactory.Rendezvous.Contracts.NetworkEndpoint dedicatedFallback, System.Threading.CancellationToken cancellationToken) + METHOD System.Threading.Tasks.Task> ReportOutcomeAsync(FinalFactory.Rendezvous.Contracts.CreateJoinAttemptResponse attempt, FinalFactory.Rendezvous.Client.RendezvousConnectionOutcome outcome, System.Threading.CancellationToken cancellationToken) TYPE FinalFactory.Rendezvous.Client.RendezvousNetListener CTOR () PROP LiteNetLib.EventBasedNetListener GameplayEvents {get;} diff --git a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt index 54bcc48..2936bc8 100644 --- a/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt +++ b/tests/FinalFactory.Rendezvous.Tests/TestData/Contracts/v1/contracts-public-api.txt @@ -28,6 +28,12 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse PROP System.Int32 ContractVersion {get;set;} PROP System.Collections.Generic.List Items {get;set;} PROP System.String NextCursor {get;set;} +TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket + ENUM UnderOneSecond=1 + ENUM OneToFiveSeconds=2 + ENUM FiveToFifteenSeconds=3 + ENUM FifteenToThirtySeconds=4 + ENUM ThirtySecondsOrMore=5 TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind ENUM Connected=1 ENUM Cancelled=2 @@ -38,6 +44,18 @@ TYPE FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind ENUM HostRejected=7 ENUM TransportFailed=8 ENUM FallbackOffered=9 + ENUM DirectoryNotFound=10 + ENUM AttemptExpired=11 + ENUM Unauthorized=12 + ENUM RateLimited=13 + ENUM NoHostPresence=14 + ENUM ServiceUnavailable=15 + ENUM MediatorUnavailable=16 + ENUM PunchTimedOut=17 + ENUM DirectConnectTimedOut=18 + ENUM TransportError=19 + ENUM ManagerStopped=20 + ENUM Disposed=21 TYPE FinalFactory.Rendezvous.Contracts.ContractJson PROP System.Text.Json.JsonSerializerOptions Options {get;} METHOD System.Void Configure(System.Text.Json.JsonSerializerOptions options) @@ -84,6 +102,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractValidation METHOD System.Boolean IsNetworkEndpointValid(FinalFactory.Rendezvous.Contracts.NetworkEndpoint endpoint) METHOD System.Boolean IsOpaqueHttpCredentialValid(System.String value) METHOD System.Boolean IsPageSizeValid(System.Int32 pageSize) + METHOD System.Boolean IsReportableConnectionOutcome(FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind outcome) METHOD System.Boolean IsUtf8LengthWithin(System.String value, System.Int32 maximumBytes) METHOD FinalFactory.Rendezvous.Contracts.RendezvousErrorCode ValidateContractVersion(System.Int32 contractVersion) TYPE FinalFactory.Rendezvous.Contracts.CreateJoinAttemptRequest @@ -234,6 +253,7 @@ TYPE FinalFactory.Rendezvous.Contracts.RegisterSessionRequest PROP System.String BuildVersion {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP System.Int32 ContractVersion {get;set;} + PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;} PROP System.String DisplayName {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} @@ -288,12 +308,14 @@ TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeRequest CTOR () PROP System.Int32 ContractVersion {get;set;} PROP System.String DiagnosticCode {get;set;} + PROP FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket ElapsedBucket {get;set;} PROP System.Int32 ElapsedMilliseconds {get;set;} PROP FinalFactory.Rendezvous.Contracts.ConnectionOutcomeKind Outcome {get;set;} TYPE FinalFactory.Rendezvous.Contracts.ReportConnectionOutcomeResponse CTOR () PROP System.Boolean Accepted {get;set;} PROP System.Int32 ContractVersion {get;set;} + PROP System.Boolean IsDuplicate {get;set;} TYPE FinalFactory.Rendezvous.Contracts.SessionCapacity CTOR () PROP System.Int32 CurrentPlayers {get;set;} @@ -303,6 +325,7 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListing PROP System.String BuildVersion {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP System.Int32 ContractVersion {get;set;} + PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;} PROP System.String DisplayName {get;set;} PROP FinalFactory.Rendezvous.Contracts.EnvironmentId EnvironmentId {get;set;} PROP FinalFactory.Rendezvous.Contracts.GameId GameId {get;set;} @@ -344,6 +367,7 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest PROP System.String BuildVersion {get;set;} PROP FinalFactory.Rendezvous.Contracts.SessionCapacity Capacity {get;set;} PROP System.Int32 ContractVersion {get;set;} + PROP FinalFactory.Rendezvous.Contracts.NetworkEndpoint DedicatedFallback {get;set;} PROP System.String DisplayName {get;set;} PROP System.String LeaseToken {get;set;} PROP System.Collections.Generic.Dictionary Metadata {get;set;}