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