Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06c4ecf8f3 | |||
| 95c3a4aed6 | |||
| 00d5ff7764 |
@@ -113,6 +113,9 @@ signing, staged promotion, rollback, and migration are defined in
|
||||
[releases and compatibility](docs/releases/README.md).
|
||||
The scriptable host/browser/join diagnostic and its stable automation contract are
|
||||
documented in the [TestClient integration guide](docs/integration/test-client.md).
|
||||
Optional bounded SSE deltas, reconnect/reset semantics, proxy requirements, and
|
||||
polling fallback are documented in
|
||||
[live session-list updates](docs/integration/live-session-updates.md).
|
||||
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
|
||||
versioning, and secure rollout seams are in the
|
||||
[game integration guide](docs/integration/sdk-seams.md).
|
||||
@@ -122,6 +125,9 @@ simulation limits are documented in the
|
||||
The current consumer evidence and remaining external gates are tracked in the
|
||||
[SpaceGame consumer pilot](docs/integration/spacegame-pilot.md) and independent
|
||||
[Unscouted consumer pilot](docs/integration/unscouted-pilot.md).
|
||||
The fail-closed launch decision, redacted evidence matrix, and two-machine
|
||||
external-network procedure are in
|
||||
[production readiness and real-network canary](docs/operations/production-readiness.md).
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
+233
-1
@@ -1177,6 +1177,159 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/sessions/stream": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Sessions"
|
||||
],
|
||||
"operationId": "StreamSessions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "contractVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gameId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "environmentId",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "protocolVersion",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "uint32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "regionId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "excludeFull",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "streamCursor",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Last-Event-ID",
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionStreamEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"Retry-After": {
|
||||
"description": "Whole seconds before the caller should retry (1-60).",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"headers": {
|
||||
"X-Rendezvous-Correlation-ID": {
|
||||
"description": "Safe request correlation identifier generated by the service.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/sessions/{listingId}/join-attempts": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -2657,7 +2810,8 @@
|
||||
"BrowseSessionsResponse": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
"items"
|
||||
"items",
|
||||
"streamCursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2676,6 +2830,9 @@
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"streamCursor": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3545,6 +3702,54 @@
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"SessionStreamEvent": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
"kind",
|
||||
"cursor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"contractVersion": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/components/schemas/SessionStreamEventKind"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string"
|
||||
},
|
||||
"session": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionListing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"listingId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionListingId"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"SessionStreamEventKind": {
|
||||
"enum": [
|
||||
"sessionUpsert",
|
||||
"sessionRemove",
|
||||
"reset",
|
||||
"keepalive"
|
||||
]
|
||||
},
|
||||
"UpdateSessionRequest": {
|
||||
"required": [
|
||||
"contractVersion",
|
||||
@@ -3563,6 +3768,33 @@
|
||||
"leaseToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RegionId"
|
||||
}
|
||||
]
|
||||
},
|
||||
"protocolVersion": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "uint32"
|
||||
},
|
||||
"visibility": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ListingVisibility"
|
||||
}
|
||||
]
|
||||
},
|
||||
"buildVersion": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"evidenceVersion": "v2",
|
||||
"generatedAt": "2026-07-16T14:10:53.6981858+00:00",
|
||||
"generatedAt": "2026-07-16T20:28:43.2873744+00:00",
|
||||
"profile": "candidate",
|
||||
"runtime": {
|
||||
"framework": ".NET 10.0.9",
|
||||
@@ -14,14 +14,14 @@
|
||||
"cpuQuota": "not-enforced",
|
||||
"memoryLimit": "not-enforced",
|
||||
"garbageCollector": "workstation",
|
||||
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb",
|
||||
"commitSha": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||
"treeState": "clean",
|
||||
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
|
||||
"imageDigest": "not-containerized",
|
||||
"workloadSeed": "fixed-sequences-random-identifiers",
|
||||
"capacityPhaseAverageCpuPercent": 56.37724115383554,
|
||||
"peakWorkingSetBytes": 169705472,
|
||||
"managedBytesAfterCleanup": 35615200
|
||||
"capacityPhaseAverageCpuPercent": 55.52666859166872,
|
||||
"peakWorkingSetBytes": 176758784,
|
||||
"managedBytesAfterCleanup": 35608984
|
||||
},
|
||||
"targets": {
|
||||
"visibleListings": 25000,
|
||||
@@ -39,10 +39,10 @@
|
||||
{
|
||||
"operation": "registration-and-presence",
|
||||
"samples": 1000,
|
||||
"p50Milliseconds": 0.003,
|
||||
"p50Milliseconds": 0.0029,
|
||||
"p95Milliseconds": 0.0046,
|
||||
"p99Milliseconds": 0.0054,
|
||||
"operationsPerSecond": 282453.96000451926,
|
||||
"p99Milliseconds": 0.0055,
|
||||
"operationsPerSecond": 287918.9220315559,
|
||||
"minimumOperationsPerSecond": 200,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -51,9 +51,9 @@
|
||||
"operation": "lease-renewal",
|
||||
"samples": 1000,
|
||||
"p50Milliseconds": 0.0004,
|
||||
"p95Milliseconds": 0.0009,
|
||||
"p99Milliseconds": 0.0021,
|
||||
"operationsPerSecond": 968992.2480620155,
|
||||
"p95Milliseconds": 0.0007,
|
||||
"p99Milliseconds": 0.0019,
|
||||
"operationsPerSecond": 1076426.264800861,
|
||||
"minimumOperationsPerSecond": 200,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -61,10 +61,10 @@
|
||||
{
|
||||
"operation": "visible-session-browse",
|
||||
"samples": 250,
|
||||
"p50Milliseconds": 0.9046,
|
||||
"p95Milliseconds": 3.3704,
|
||||
"p99Milliseconds": 3.9471,
|
||||
"operationsPerSecond": 695.5799787597697,
|
||||
"p50Milliseconds": 1.0232,
|
||||
"p95Milliseconds": 3.6083,
|
||||
"p99Milliseconds": 4.2925,
|
||||
"operationsPerSecond": 650.0325926341947,
|
||||
"minimumOperationsPerSecond": 200,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -72,10 +72,10 @@
|
||||
{
|
||||
"operation": "join-attempt-issuance",
|
||||
"samples": 1000,
|
||||
"p50Milliseconds": 0.0029,
|
||||
"p95Milliseconds": 0.0045,
|
||||
"p99Milliseconds": 0.0055,
|
||||
"operationsPerSecond": 296428.042092782,
|
||||
"p50Milliseconds": 0.0028,
|
||||
"p95Milliseconds": 0.0042,
|
||||
"p99Milliseconds": 0.0052,
|
||||
"operationsPerSecond": 135253.93927098127,
|
||||
"minimumOperationsPerSecond": 200,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -83,10 +83,10 @@
|
||||
{
|
||||
"operation": "simultaneous-punch-pairing",
|
||||
"samples": 1000,
|
||||
"p50Milliseconds": 0.0043,
|
||||
"p95Milliseconds": 0.0073,
|
||||
"p99Milliseconds": 0.0115,
|
||||
"operationsPerSecond": 109212.03516627532,
|
||||
"p50Milliseconds": 0.0039,
|
||||
"p95Milliseconds": 0.0069,
|
||||
"p99Milliseconds": 0.0087,
|
||||
"operationsPerSecond": 110619.46902654869,
|
||||
"minimumOperationsPerSecond": 2000,
|
||||
"budgetMilliseconds": 100,
|
||||
"passed": true
|
||||
@@ -94,10 +94,10 @@
|
||||
{
|
||||
"operation": "principal-revocation",
|
||||
"samples": 50,
|
||||
"p50Milliseconds": 0.518,
|
||||
"p95Milliseconds": 0.7049,
|
||||
"p99Milliseconds": 11.8557,
|
||||
"operationsPerSecond": 1320.1773262184577,
|
||||
"p50Milliseconds": 0.495,
|
||||
"p95Milliseconds": 0.6508,
|
||||
"p99Milliseconds": 11.011,
|
||||
"operationsPerSecond": 1393.258301729591,
|
||||
"minimumOperationsPerSecond": 50,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -108,7 +108,7 @@
|
||||
"p50Milliseconds": 0.0001,
|
||||
"p95Milliseconds": 0.0001,
|
||||
"p99Milliseconds": 0.0001,
|
||||
"operationsPerSecond": 1438641.9220256077,
|
||||
"operationsPerSecond": 1479289.9408284025,
|
||||
"minimumOperationsPerSecond": 10000,
|
||||
"budgetMilliseconds": 1,
|
||||
"passed": true
|
||||
@@ -116,10 +116,10 @@
|
||||
{
|
||||
"operation": "coincident-listing-attempt-expiry",
|
||||
"samples": 1,
|
||||
"p50Milliseconds": 29.6882,
|
||||
"p95Milliseconds": 29.6882,
|
||||
"p99Milliseconds": 29.6882,
|
||||
"operationsPerSecond": 33.682962483916384,
|
||||
"p50Milliseconds": 27.7056,
|
||||
"p95Milliseconds": 27.7056,
|
||||
"p99Milliseconds": 27.7056,
|
||||
"operationsPerSecond": 36.093525543388026,
|
||||
"minimumOperationsPerSecond": 0,
|
||||
"budgetMilliseconds": 200,
|
||||
"passed": true
|
||||
@@ -134,10 +134,10 @@
|
||||
"finalReplayMarkers": 0,
|
||||
"expiryChurn": 94906,
|
||||
"maintenanceSweeps": 36307,
|
||||
"soakCyclesCompleted": 75126848,
|
||||
"soakDurationSeconds": 300.0000015,
|
||||
"soakCyclesCompleted": 77547145,
|
||||
"soakDurationSeconds": 300.0000041,
|
||||
"soakPeakScheduledExpiryEntries": 7,
|
||||
"soakManagedGrowthBytes": -257288,
|
||||
"soakManagedGrowthBytes": -263432,
|
||||
"soakHandleGrowth": 2,
|
||||
"restartStartedEmpty": true,
|
||||
"overloadWasTyped": true,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "rendezvous-production-readiness",
|
||||
"evaluatedCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||
"decision": "not-ready",
|
||||
"localGates": [
|
||||
{
|
||||
"id": "immutable-release-artifacts",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"note": "Clean candidate packages and server archive are byte reproducible and fully verified."
|
||||
},
|
||||
{
|
||||
"id": "debug-and-release-verification",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"note": "All 300 tests pass in Debug and Release; the Release build has zero warnings and errors."
|
||||
},
|
||||
{
|
||||
"id": "real-consumer-pilots",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"note": "Pinned real projects restore the candidate and both game launch pilots pass direct traffic."
|
||||
},
|
||||
{
|
||||
"id": "candidate-capacity-resilience",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||
"note": "The clean two-CPU five-minute candidate passes all budgets with zero retained state."
|
||||
},
|
||||
{
|
||||
"id": "production-process-recovery",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"note": "All selected restart, drain, socket release, overload, and recovery tests pass."
|
||||
},
|
||||
{
|
||||
"id": "security-privacy-observability",
|
||||
"status": "pass",
|
||||
"evidenceRef": "docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"note": "The complete security, privacy, health, audit, telemetry, and release suite passes."
|
||||
}
|
||||
],
|
||||
"externalGates": [
|
||||
{
|
||||
"id": "public-package-empty-cache-restore",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "The public registry does not currently resolve version 1.0.0."
|
||||
},
|
||||
{
|
||||
"id": "signed-publication",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/releases/README.md",
|
||||
"note": "Protected release credentials and immutable tag publication are required."
|
||||
},
|
||||
{
|
||||
"id": "source-preserving-udp-ingress",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "The public ingress path needs packet-level source and reply validation."
|
||||
},
|
||||
{
|
||||
"id": "same-lan-direct-canary",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "Requires two independently operated game clients."
|
||||
},
|
||||
{
|
||||
"id": "home-nat-direct-canary",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "Requires distinct residential networks."
|
||||
},
|
||||
{
|
||||
"id": "restrictive-cgnat-typed-failure",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "Requires a known restrictive carrier topology."
|
||||
},
|
||||
{
|
||||
"id": "firewall-blocked-udp-typed-failure",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "Requires an independently controlled firewall rule."
|
||||
},
|
||||
{
|
||||
"id": "ipv6-direct-canary",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/production-readiness.md",
|
||||
"note": "Requires two IPv6-capable external clients and public ingress."
|
||||
},
|
||||
{
|
||||
"id": "public-rate-shaped-capacity",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||
"note": "The full public HTTP and UDP traffic mix has not been measured."
|
||||
},
|
||||
{
|
||||
"id": "one-hour-candidate-endurance",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||
"note": "A production-shaped one-hour candidate run is required."
|
||||
},
|
||||
{
|
||||
"id": "alert-delivery",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||
"note": "A real alert sink must observe trigger and recovery notifications."
|
||||
},
|
||||
{
|
||||
"id": "cold-standby-rollback-drill",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/capacity-and-resilience.md",
|
||||
"note": "The deployment must demonstrate the host-visible recovery objective."
|
||||
},
|
||||
{
|
||||
"id": "documentation-only-runbook-exercise",
|
||||
"status": "pending",
|
||||
"evidenceRef": "docs/operations/incident-runbooks.md",
|
||||
"note": "An independent operator must execute the runbooks using only the docs."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "rendezvous-local-release-candidate",
|
||||
"version": "1.0.0",
|
||||
"sourceCommit": "00d5ff776408e7d80ce6648953e62a7233aca35c",
|
||||
"treeState": "clean",
|
||||
"result": "pass",
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "FinalFactory.Rendezvous.Client.1.0.0.nupkg",
|
||||
"sha256": "f2a4b9727b5faeba284ddcb7fc575c7495f1e29b763faababa7cd71444dc2950"
|
||||
},
|
||||
{
|
||||
"name": "FinalFactory.Rendezvous.Contracts.1.0.0.nupkg",
|
||||
"sha256": "92317f153911ebf7b8ea04cd3206ec2a17f882cb4eddb26aa8ab094ffdb06627"
|
||||
},
|
||||
{
|
||||
"name": "FinalFactory.Rendezvous.Server.1.0.0.linux-x64.tar.gz",
|
||||
"sha256": "0dab8cfc696b4a55d6ffba46286c9e532df2c943ed8fd6347fb528516156b3ba"
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"lockedRestore": "pass",
|
||||
"reportedVulnerabilities": 0,
|
||||
"format": "pass",
|
||||
"releaseBuildWarnings": 0,
|
||||
"releaseBuildErrors": 0,
|
||||
"debugTestsPassed": 300,
|
||||
"debugTestsFailed": 0,
|
||||
"releaseTestsPassed": 300,
|
||||
"releaseTestsFailed": 0,
|
||||
"selectedProductionFaultTestsPassed": 17,
|
||||
"byteReproduciblePackages": "pass",
|
||||
"byteReproducibleServerArchive": "pass",
|
||||
"sbomChecksumsAndProvenance": "pass",
|
||||
"candidateConsumerFixtures": "pass",
|
||||
"realConsumerRestores": "pass"
|
||||
},
|
||||
"consumers": [
|
||||
{
|
||||
"name": "SpaceGame",
|
||||
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||
"candidateRestore": "pass",
|
||||
"directTrafficPilot": "pass"
|
||||
},
|
||||
{
|
||||
"name": "Unscouted",
|
||||
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||
"candidateRestore": "pass",
|
||||
"directTrafficPilot": "pass"
|
||||
}
|
||||
],
|
||||
"limitations": {
|
||||
"publicRegistryRestore": "pending",
|
||||
"signedPublication": "pending",
|
||||
"externalNetworkCanaries": "pending"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
# Live session-list updates
|
||||
|
||||
Tracking: #26
|
||||
|
||||
Live updates are an optional acceleration for an open server browser. The
|
||||
bounded `GET /v1/sessions` snapshot remains the source of truth, and join
|
||||
authorization still revalidates current capacity, presence, policy, and
|
||||
compatibility. A displayed player count is advisory, never an admission promise.
|
||||
|
||||
## Snapshot, stream, reset
|
||||
|
||||
Every `BrowseSessionsResponse` includes `streamCursor` in addition to its normal
|
||||
pagination cursor. Connect to `GET /v1/sessions/stream` with the same game,
|
||||
environment, protocol, optional region, and `excludeFull` filter. Send the most
|
||||
recent stream cursor as `Last-Event-ID`.
|
||||
|
||||
| SSE event | Contract kind | UI action |
|
||||
| --- | --- | --- |
|
||||
| `session_upsert` | `sessionUpsert` | Add or replace the complete public projection by listing ID. |
|
||||
| `session_remove` | `sessionRemove` | Remove the listing ID. |
|
||||
| `reset` | `reset` | Discard local state, fetch a fresh snapshot, then reconnect with its cursor. |
|
||||
| `keepalive` | `keepalive` | Preserve the cursor and connection; do not change UI state. |
|
||||
|
||||
Each SSE `id` equals the opaque cursor inside its JSON event. Cursors are signed,
|
||||
short-lived, monotonically ordered, and bound to the complete filter. A missing,
|
||||
expired, corrupted, foreign, future, or replay-gapped cursor produces `reset`
|
||||
instead of a potentially incomplete view. Do not parse or retain it as a stable
|
||||
identifier.
|
||||
|
||||
Updates cover creation after fresh UDP presence, public-field/capacity changes,
|
||||
presence staleness and recovery, lease expiry, deregistration, operator or
|
||||
principal revocation, and visibility/region/protocol changes. Events contain the
|
||||
same bounded public `SessionListing` as snapshots. They never contain raw peer
|
||||
endpoints, lease tokens, punch capabilities, tickets, publisher subjects, or
|
||||
internal store identifiers.
|
||||
|
||||
## SDK and polling fallback
|
||||
|
||||
```csharp
|
||||
BrowseSessionsRequest filter = new()
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
RegionId = new("eu-central"),
|
||||
ExcludeFull = true,
|
||||
};
|
||||
RendezvousClientResult<BrowseSessionsResponse> snapshot =
|
||||
await browser.BrowseAsync(filter, cancellationToken);
|
||||
|
||||
await foreach (RendezvousClientResult<SessionStreamEvent> update in
|
||||
browser.StreamAsync(filter, snapshot.Value!.StreamCursor, cancellationToken))
|
||||
{
|
||||
if (!update.IsSuccess)
|
||||
{
|
||||
// Switch to bounded polling with jittered backoff.
|
||||
break;
|
||||
}
|
||||
// Apply upsert/remove by listing ID. On reset, discard and browse again.
|
||||
}
|
||||
```
|
||||
|
||||
Cancellation or enumerator disposal closes the response and releases the server
|
||||
subscription. A normal connection-duration close is a reconnect signal: use the
|
||||
last applied event cursor. Repeated failures, unsupported platform HTTP stacks,
|
||||
and restrictive proxies fall back to snapshots with exponential jittered
|
||||
backoff, a capped interval, and `Retry-After`. Never open parallel streams to
|
||||
compensate for a slow UI.
|
||||
|
||||
## TestClient
|
||||
|
||||
```bash
|
||||
dotnet run --project src/FinalFactory.Rendezvous.TestClient \
|
||||
--configuration Release --no-build -- \
|
||||
watch --service https://rendezvous.example.invalid/ \
|
||||
--game space-game --environment production --region eu-central --protocol 7 \
|
||||
--run-seconds 60 --json
|
||||
```
|
||||
|
||||
`watch.snapshot`, `watch.session-upsert`, `watch.session-remove`,
|
||||
`watch.keepalive`, and `watch.reconnect` are stable diagnostics. Add
|
||||
`--exercise-reset --script` to corrupt the snapshot cursor deliberately and
|
||||
verify a typed reset plus snapshot refresh. Use `--exercise-reconnect --script`
|
||||
while producing one update to close the first stream deliberately, reconnect
|
||||
from its prior cursor, and verify that the same ordered event is replayed.
|
||||
Polished list diffing, selection retention, animation, and accessibility remain
|
||||
in each game.
|
||||
|
||||
## Bounds and slow consumers
|
||||
|
||||
The v1 journal retains at most 4,096 public-only changes. It admits at most 256
|
||||
subscribers total and 64 per tenant, reads at most 128 changes per batch,
|
||||
waits a configurable 50 milliseconds after a live change and coalesces the
|
||||
resulting batch to the final change per listing, sends a keepalive every 15
|
||||
seconds, and closes a connection after five minutes. A consumer behind the
|
||||
replay window receives `reset`; it never acquires an unbounded queue.
|
||||
|
||||
Normal optional-work concurrency and per-source/tenant rate controls apply for
|
||||
the stream lifetime. Exhaustion returns typed HTTP `429` before streaming.
|
||||
Shutdown cancels streams; reconnect only after readiness returns and expect a
|
||||
reset after a single-active restart because listings and replay are ephemeral.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
- Disable response buffering (`X-Accel-Buffering: no` is also emitted),
|
||||
compression, transformation, and caching for `text/event-stream`.
|
||||
- Preserve `Last-Event-ID`; set upstream/read timeouts above the 15-second
|
||||
keepalive and around six minutes for the five-minute connection ceiling.
|
||||
- Flush events promptly and use HTTP/2 only when streaming semantics survive.
|
||||
- Preserve the source-IP trust boundary and abuse controls; do not add a bypass.
|
||||
|
||||
Verify the deployed proxy with an idle keepalive, update, reconnect, invalid
|
||||
cursor reset, slow reader, and graceful shutdown. An in-process pass does not
|
||||
prove that a production proxy is non-buffering.
|
||||
@@ -116,6 +116,9 @@ rm deploy/compose/secrets/signing-key
|
||||
codes are stable automation contracts. Informational events use stdout and
|
||||
failures use stderr.
|
||||
|
||||
Successful direct-connection and direct-traffic events include the coarse
|
||||
`addressFamily` value `ipv4` or `ipv6`. They never include the peer address.
|
||||
|
||||
The deployment smoke performs the full health, publish, join, mediation, direct
|
||||
traffic, outcome-report, and cleanup flow using bounded waits:
|
||||
|
||||
@@ -143,7 +146,14 @@ Never use an unbounded sleep to orchestrate processes. Wait for versioned events
|
||||
such as `host.ready` and apply a deadline. Useful success events are
|
||||
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
|
||||
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
|
||||
and `join.outcome-report`.
|
||||
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
|
||||
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
|
||||
|
||||
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
|
||||
`--exercise-reset --script` to prove fail-closed cursor recovery, or
|
||||
`--exercise-reconnect --script` while changing one listing to prove ordered
|
||||
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
|
||||
contract is in [live session-list updates](live-session-updates.md).
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---: | --- |
|
||||
@@ -220,3 +230,6 @@ least-scope publisher credential from the deployment secret boundary and set the
|
||||
external service, mediator, and matching scope variables described in the
|
||||
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
|
||||
Run representative external-network tests; loopback success is not NAT coverage.
|
||||
Use the redacting, bounded
|
||||
[real-network canary procedure](../operations/production-readiness.md) for formal
|
||||
production evidence rather than committing raw TestClient JSON.
|
||||
|
||||
@@ -81,7 +81,7 @@ concurrent build, thermal throttling, or oversubscribed CI host.
|
||||
The checked-in baseline is
|
||||
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was
|
||||
produced on .NET 10.0.9/Linux x64 with CPU affinity restricted to two logical
|
||||
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 162 MiB,
|
||||
CPUs. It filled 25,000 listings and 10,000 attempts, peaked at about 169 MiB,
|
||||
and cleared all active/retained state. The five-minute baseline supersedes any
|
||||
earlier local probe when its timestamp and target duration differ.
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Production-readiness decision and real-network canary
|
||||
|
||||
Tracking: #23
|
||||
|
||||
Rendezvous v1 is **not production-ready** until every required gate in
|
||||
[`production-readiness-v1.json`](../evidence/production-readiness-v1.json) is
|
||||
recorded as `pass`. The machine-checkable decision is intentionally fail-closed:
|
||||
|
||||
```bash
|
||||
./scripts/check-production-readiness.sh
|
||||
```
|
||||
|
||||
Exit `0` means every required gate is present and passing, exit `3` means the
|
||||
record is valid but at least one gate is pending or failed, and exit `2` means
|
||||
the record itself is malformed or contains identifier-, endpoint-, account-, or
|
||||
credential-shaped data. Editing only the top-level decision cannot make the
|
||||
check pass.
|
||||
|
||||
The checked-in record is an index, not a log archive. It contains one
|
||||
repository-relative evidence reference and a short categorical note per gate.
|
||||
Raw packet captures, client event streams, publisher credentials, public or
|
||||
private network endpoints, listing IDs, and player/account identifiers must not
|
||||
be committed.
|
||||
|
||||
## Required decision matrix
|
||||
|
||||
The local matrix covers immutable artifacts, Debug and Release verification,
|
||||
both real game consumers, the candidate capacity/resilience profile,
|
||||
production-process recovery, and the combined security/privacy/observability
|
||||
gate. These may be reproduced by the project team on a clean candidate commit.
|
||||
|
||||
The external matrix remains distinct because a local namespace, loopback,
|
||||
container bridge, or second process on one machine cannot prove it:
|
||||
|
||||
| Gate | Required evidence |
|
||||
| --- | --- |
|
||||
| Public package empty-cache restore | A clean machine restores the exact Client and Contracts version using only the documented public sources. |
|
||||
| Signed publication | The immutable tag publishes packages, image digest, SBOMs, provenance, checksums, and verifiable signatures through the protected release workflow. |
|
||||
| Source-preserving UDP ingress | Packet capture on the service host proves the mediator observes each peer's real source tuple and replies from the advertised public tuple; no UDP proxy rewrites either direction. |
|
||||
| Same-LAN direct canary | Two independently operated game clients establish authenticated direct LiteNetLib traffic. |
|
||||
| Home-NAT direct canary | Host and joiner on distinct residential networks establish authenticated direct LiteNetLib traffic. |
|
||||
| Restrictive/CGNAT and blocked-UDP canaries | Each bounded join exits `12`, records a typed terminal category, and exposes the game-owned fallback policy without hanging or claiming success. |
|
||||
| IPv6 direct canary | Two external IPv6 clients record authenticated direct traffic and an observed `ipv6` peer address family. |
|
||||
| Public rate-shaped capacity | The documented HTTP/UDP workload mix meets its objectives through TLS, Kestrel, JSON, LiteNetLib, kernel sockets, and public ingress. |
|
||||
| One-hour endurance | The immutable production-shaped candidate completes the one-hour profile without a state, handle, memory, readiness, or latency failure. |
|
||||
| Alert delivery | A real alert sink receives both trigger and recovery notifications for the rehearsed outage. |
|
||||
| Cold-standby rollback | Drain, stop, socket release, replacement start, host re-registration, and rollback meet the process and host-visible recovery objectives. |
|
||||
| Documentation-only exercise | An operator who did not author the runbooks completes key rotation/revocation, outage, restart, re-registration, and rollback using only the checked-in documentation. |
|
||||
|
||||
Failure or missing evidence is blocking. It is never converted into an accepted
|
||||
risk by changing the wording of the readiness note.
|
||||
|
||||
When an external gate passes, add a redacted repository JSON attestation and
|
||||
point that gate's `evidenceRef` to it. The checker requires this exact shape and
|
||||
binds the gate to the evaluated candidate commit. `artifactDigest` is the SHA-256
|
||||
of the protected evidence bundle or public release record, not a peer endpoint,
|
||||
listing identifier, account identifier, or credential:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"kind": "rendezvous-external-gate-attestation",
|
||||
"gateId": "replace-with-the-exact-gate-id",
|
||||
"candidateCommit": "replace-with-the-40-character-candidate-commit",
|
||||
"result": "pass",
|
||||
"performedAtUtc": "2026-01-01T00:00:00Z",
|
||||
"artifactDigest": "replace-with-the-64-character-sha256",
|
||||
"evidenceLocation": "protected-operations-record",
|
||||
"reviewerRole": "independent-operator"
|
||||
}
|
||||
```
|
||||
|
||||
Allowed evidence locations are `protected-operations-record` and
|
||||
`public-release-record`. Allowed reviewer roles are `release-operator`,
|
||||
`network-operator`, `security-operator`, and `independent-operator`. The checker
|
||||
rejects a missing file, wrong gate, wrong candidate, malformed digest, naive
|
||||
timestamp, extra fields, or sensitive-data-shaped contents.
|
||||
|
||||
## Prepare one immutable canary build
|
||||
|
||||
Use the exact release candidate on every canary machine. Verify a clean checkout,
|
||||
restore in locked mode, and build the TestClient before changing networks:
|
||||
|
||||
```bash
|
||||
test -z "$(git status --porcelain)"
|
||||
dotnet restore Rendezvous.slnx --locked-mode
|
||||
dotnet build Rendezvous.slnx --configuration Release --no-restore
|
||||
```
|
||||
|
||||
Keep shell tracing disabled. The host receives a short-lived, least-scope
|
||||
publisher credential through `RENDEZVOUS_PUBLISHER_CREDENTIAL`; it must never be
|
||||
put in an argument, coordination file, evidence file, command transcript, or
|
||||
support message. Set the public HTTPS service URL and advertised UDP mediator
|
||||
tuple separately. TestClient rejects credentials embedded in the service URL.
|
||||
|
||||
## Run a success canary across two machines
|
||||
|
||||
On the host machine, choose `same-lan`, `home-nat`, or `ipv6-direct`. The
|
||||
coordination file is mode `0600` and contains only the temporary listing UUID.
|
||||
It is not evidence; transfer it through an approved private channel, then delete
|
||||
both copies.
|
||||
|
||||
```bash
|
||||
set +x
|
||||
export RENDEZVOUS_PUBLISHER_CREDENTIAL='supplied-by-the-approved-secret-boundary'
|
||||
export RENDEZVOUS_CANARY_ROLE=host
|
||||
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||
export RENDEZVOUS_CANARY_COORDINATION_FILE="$HOME/.local/state/rendezvous-canary-listing"
|
||||
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-host.json"
|
||||
./scripts/run-real-network-canary.sh
|
||||
```
|
||||
|
||||
The host prints only that it is ready and waits for the authenticated exchange.
|
||||
On the joiner, read the securely transferred UUID without placing it in shell
|
||||
history and run the matching topology:
|
||||
|
||||
```bash
|
||||
set +x
|
||||
read -r RENDEZVOUS_CANARY_LISTING_ID < "$HOME/.local/state/rendezvous-canary-listing"
|
||||
export RENDEZVOUS_CANARY_LISTING_ID
|
||||
export RENDEZVOUS_CANARY_ROLE=client-success
|
||||
export RENDEZVOUS_CANARY_TOPOLOGY=home-nat
|
||||
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||
export RENDEZVOUS_CANARY_HTTP_URL='https://service.example.invalid/'
|
||||
export RENDEZVOUS_CANARY_UDP_ENDPOINT='203.0.113.10:9050'
|
||||
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/home-nat-client.json"
|
||||
./scripts/run-real-network-canary.sh
|
||||
unset RENDEZVOUS_CANARY_LISTING_ID
|
||||
```
|
||||
|
||||
The host summary requires authenticated direct traffic and deregistration. The
|
||||
client summary requires connection, authenticated direct traffic, accepted
|
||||
outcome reporting, and the declared address family observed on the actual peer.
|
||||
The summaries deliberately contain no network tuple or listing identifier.
|
||||
|
||||
For IPv6, set the topology to `ipv6-direct`, the family to `ipv6`, and use the
|
||||
deployment's bracketed IPv6 mediator form. Record unsupported operating systems,
|
||||
console platforms, VPNs, and address families as untested; an IPv4 pass is not
|
||||
evidence for IPv6 or a platform network policy.
|
||||
|
||||
## Run a bounded failure canary
|
||||
|
||||
Start the host from an independently reachable network as above. On the joiner,
|
||||
apply the reviewed firewall rule that blocks the relevant UDP path, or use the
|
||||
known restrictive carrier network, then set `client-expected-failure` and the
|
||||
matching topology:
|
||||
|
||||
```bash
|
||||
export RENDEZVOUS_CANARY_ROLE=client-expected-failure
|
||||
export RENDEZVOUS_CANARY_TOPOLOGY=firewall-blocked-udp
|
||||
export RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv4
|
||||
export RENDEZVOUS_CANARY_OUTPUT="$PWD/artifacts/canary/firewall-blocked-client.json"
|
||||
./scripts/run-real-network-canary.sh
|
||||
```
|
||||
|
||||
This role passes only when TestClient exits exactly `12`, emits a non-empty typed
|
||||
authorization/traversal outcome, and emits the authoritative fallback category.
|
||||
A timeout without the typed terminal outcome, exit `0`, direct-traffic success,
|
||||
or an unbounded process is a failed canary. Restore the firewall after the drill
|
||||
and verify normal traffic again.
|
||||
|
||||
## Private diagnostics and retention
|
||||
|
||||
The harness creates raw JSON events under a randomly named `0700`-equivalent
|
||||
temporary directory with a process `umask` of `077`. Successful raw events are
|
||||
deleted automatically. On failure they remain in that private directory so the
|
||||
operator can triage locally; do not attach them to an issue before removing
|
||||
listing IDs and reviewing every field. Set `RENDEZVOUS_CANARY_KEEP_RAW=true`
|
||||
only for an approved short-lived diagnostic capture, then delete it manually.
|
||||
|
||||
The sanitized summary contains the commit, clean/dirty tree state, UTC time,
|
||||
role, declared topology, observed address-family gate, aggregate booleans, and
|
||||
the retention policy. Formal evidence requires the default clean-tree check.
|
||||
|
||||
## Public ingress proof
|
||||
|
||||
Success through a public hostname is insufficient proof that UDP source/reply
|
||||
addressing is preserved. During a canary, an authorized operator must capture
|
||||
only packet headers at the service host and verify:
|
||||
|
||||
1. each authenticated contribution reaches the mediator with the external peer
|
||||
source tuple visible to the server;
|
||||
2. introductions are sent from the same advertised public mediator tuple;
|
||||
3. no load balancer, user-space proxy, service mesh, or destination NAT changes
|
||||
the source or reply tuple expected by LiteNetLib; and
|
||||
4. malformed or unauthenticated traffic receives no amplified response.
|
||||
|
||||
Store the approval, capture time window, candidate digest, topology category,
|
||||
and pass/fail result. Do not retain packet payloads or peer tuples in the
|
||||
repository. A failed tuple check blocks release even if one canary happened to
|
||||
connect.
|
||||
|
||||
## Rehearsal and triage
|
||||
|
||||
Run the security, capacity, observability, deployment, rollback, privacy, and
|
||||
incident procedures against the same immutable candidate. The independent
|
||||
operator records which runbook revision they followed, start/end time, observed
|
||||
alerts, recovery time, unexpected decisions, and pass/fail result. Update the
|
||||
documentation and repeat any failed or ambiguous step.
|
||||
|
||||
Before changing the readiness record, reconcile every open roadmap issue as one
|
||||
of: `blocking` with an owner and evidence needed, `accepted-v1` with a bounded
|
||||
documented limitation, or `post-v1` with a filed issue. HA, active-active or
|
||||
multi-region routing, relays, platform authentication, and scale above the
|
||||
single-active v1 envelope are not silently accepted; each needs a traceable
|
||||
post-v1 issue. The current follow-ups are relay decision [#24], HA/multi-region
|
||||
shared state and routing [#28], scale beyond the measured envelope [#29], and
|
||||
platform authentication adapters [#30]. Run the checker after every evidence
|
||||
update. Only its `READY` result may support a production-ready claim.
|
||||
|
||||
[#24]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/24
|
||||
[#28]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/28
|
||||
[#29]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/29
|
||||
[#30]: https://git.finalfactory.de/HeiKyu/Rendezvous/issues/30
|
||||
Executable
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the redacted v1 readiness record and emit the release decision."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
LOCAL_GATES = {
|
||||
"immutable-release-artifacts",
|
||||
"debug-and-release-verification",
|
||||
"real-consumer-pilots",
|
||||
"candidate-capacity-resilience",
|
||||
"production-process-recovery",
|
||||
"security-privacy-observability",
|
||||
}
|
||||
EXTERNAL_GATES = {
|
||||
"public-package-empty-cache-restore",
|
||||
"signed-publication",
|
||||
"source-preserving-udp-ingress",
|
||||
"same-lan-direct-canary",
|
||||
"home-nat-direct-canary",
|
||||
"restrictive-cgnat-typed-failure",
|
||||
"firewall-blocked-udp-typed-failure",
|
||||
"ipv6-direct-canary",
|
||||
"public-rate-shaped-capacity",
|
||||
"one-hour-candidate-endurance",
|
||||
"alert-delivery",
|
||||
"cold-standby-rollback-drill",
|
||||
"documentation-only-runbook-exercise",
|
||||
}
|
||||
STATUSES = {"pass", "pending", "fail"}
|
||||
FORBIDDEN_KEY_PARTS = {
|
||||
"address",
|
||||
"credential",
|
||||
"endpoint",
|
||||
"listingid",
|
||||
"password",
|
||||
"playerid",
|
||||
"secret",
|
||||
"token",
|
||||
"userid",
|
||||
}
|
||||
UUID = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}\b")
|
||||
IPV4 = re.compile(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])")
|
||||
IPV6 = re.compile(
|
||||
r"(?i)(?:\b[0-9a-f]{0,4}:[0-9a-f:]*::[0-9a-f:]*\b|\b(?:[0-9a-f]{1,4}:){4,}[0-9a-f:]{1,39}\b)"
|
||||
)
|
||||
COMMIT = re.compile(r"[0-9a-f]{40}")
|
||||
DIGEST = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
class InvalidRecord(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def reject_sensitive(value: Any, path: str = "$") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
normalized = re.sub(r"[^a-z0-9]", "", key.lower())
|
||||
if any(part in normalized for part in FORBIDDEN_KEY_PARTS):
|
||||
raise InvalidRecord(f"{path}.{key} uses a forbidden sensitive-data key")
|
||||
reject_sensitive(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
reject_sensitive(child, f"{path}[{index}]")
|
||||
elif isinstance(value, str):
|
||||
if UUID.search(value) or IPV4.search(value) or IPV6.search(value) \
|
||||
or "://" in value or "@" in value:
|
||||
raise InvalidRecord(f"{path} contains endpoint, identifier, or account-shaped data")
|
||||
|
||||
|
||||
def evidence_path(repository_root: pathlib.Path, value: str, path: str) -> pathlib.Path:
|
||||
relative = pathlib.PurePosixPath(value)
|
||||
if relative.is_absolute() or ".." in relative.parts or not value:
|
||||
raise InvalidRecord(f"{path} must be a repository-relative reference")
|
||||
candidate = (repository_root / pathlib.Path(*relative.parts)).resolve()
|
||||
if not candidate.is_relative_to(repository_root.resolve()) or not candidate.is_file():
|
||||
raise InvalidRecord(f"{path} does not resolve to a repository evidence file")
|
||||
return candidate
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path, label: str) -> Any:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as source:
|
||||
return json.load(source)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise InvalidRecord(f"{label} is not readable JSON: {error}") from error
|
||||
|
||||
|
||||
def validate_gate_set(
|
||||
items: Any,
|
||||
expected: set[str],
|
||||
path: str,
|
||||
repository_root: pathlib.Path,
|
||||
) -> list[dict[str, str]]:
|
||||
if not isinstance(items, list):
|
||||
raise InvalidRecord(f"{path} must be an array")
|
||||
gates: list[dict[str, str]] = []
|
||||
for index, item in enumerate(items):
|
||||
if not isinstance(item, dict) or set(item) != {"id", "status", "evidenceRef", "note"}:
|
||||
raise InvalidRecord(f"{path}[{index}] has an invalid shape")
|
||||
if not all(isinstance(item[key], str) for key in item):
|
||||
raise InvalidRecord(f"{path}[{index}] fields must be strings")
|
||||
if item["status"] not in STATUSES:
|
||||
raise InvalidRecord(f"{path}[{index}] has an invalid status")
|
||||
evidence_path(repository_root, item["evidenceRef"], f"{path}[{index}].evidenceRef")
|
||||
if len(item["note"]) > 240:
|
||||
raise InvalidRecord(f"{path}[{index}].note is too long")
|
||||
gates.append(item)
|
||||
identifiers = [gate["id"] for gate in gates]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise InvalidRecord(f"{path} contains duplicate gate identifiers")
|
||||
if set(identifiers) != expected:
|
||||
missing = sorted(expected - set(identifiers))
|
||||
extra = sorted(set(identifiers) - expected)
|
||||
raise InvalidRecord(f"{path} gate mismatch; missing={missing}, extra={extra}")
|
||||
return gates
|
||||
|
||||
|
||||
def validate_local_evidence(
|
||||
record: dict[str, Any],
|
||||
gates: list[dict[str, str]],
|
||||
repository_root: pathlib.Path,
|
||||
) -> None:
|
||||
if any(gate["status"] != "pass" for gate in gates):
|
||||
return
|
||||
commit = record["evaluatedCommit"]
|
||||
release_path = evidence_path(
|
||||
repository_root,
|
||||
"docs/evidence/releases/v1.0.0-local-candidate.json",
|
||||
"local release evidence",
|
||||
)
|
||||
release = load_json(release_path, "local release evidence")
|
||||
if not isinstance(release, dict) or release.get("schemaVersion") != 1 \
|
||||
or release.get("kind") != "rendezvous-local-release-candidate" \
|
||||
or release.get("sourceCommit") != commit \
|
||||
or release.get("treeState") != "clean" \
|
||||
or release.get("result") != "pass":
|
||||
raise InvalidRecord("local release evidence is not a passing clean build of evaluatedCommit")
|
||||
verification = release.get("verification")
|
||||
if not isinstance(verification, dict):
|
||||
raise InvalidRecord("local release evidence has no verification object")
|
||||
exact_passes = {
|
||||
"lockedRestore": "pass",
|
||||
"format": "pass",
|
||||
"byteReproduciblePackages": "pass",
|
||||
"byteReproducibleServerArchive": "pass",
|
||||
"sbomChecksumsAndProvenance": "pass",
|
||||
"candidateConsumerFixtures": "pass",
|
||||
"realConsumerRestores": "pass",
|
||||
}
|
||||
if any(verification.get(key) != value for key, value in exact_passes.items()) \
|
||||
or verification.get("reportedVulnerabilities") != 0 \
|
||||
or verification.get("releaseBuildWarnings") != 0 \
|
||||
or verification.get("releaseBuildErrors") != 0 \
|
||||
or verification.get("debugTestsPassed", 0) < 300 \
|
||||
or verification.get("debugTestsFailed") != 0 \
|
||||
or verification.get("releaseTestsPassed", 0) < 300 \
|
||||
or verification.get("releaseTestsFailed") != 0 \
|
||||
or verification.get("selectedProductionFaultTestsPassed", 0) < 17:
|
||||
raise InvalidRecord("local release evidence does not satisfy every required verification")
|
||||
consumers = release.get("consumers")
|
||||
if not isinstance(consumers, list) or {
|
||||
item.get("name") for item in consumers if isinstance(item, dict)
|
||||
} != {"SpaceGame", "Unscouted"} or any(
|
||||
not isinstance(item, dict)
|
||||
or item.get("candidateRestore") != "pass"
|
||||
or item.get("directTrafficPilot") != "pass"
|
||||
for item in consumers
|
||||
):
|
||||
raise InvalidRecord("local release evidence does not prove both required consumers")
|
||||
|
||||
capacity_path = evidence_path(
|
||||
repository_root,
|
||||
"docs/evidence/capacity/v2/candidate-2cpu.json",
|
||||
"candidate capacity evidence",
|
||||
)
|
||||
capacity = load_json(capacity_path, "candidate capacity evidence")
|
||||
runtime = capacity.get("runtime") if isinstance(capacity, dict) else None
|
||||
state = capacity.get("state") if isinstance(capacity, dict) else None
|
||||
if not isinstance(runtime, dict) or not isinstance(state, dict) \
|
||||
or capacity.get("schemaVersion") != 2 \
|
||||
or capacity.get("profile") != "candidate" \
|
||||
or capacity.get("passed") is not True \
|
||||
or capacity.get("failures") != [] \
|
||||
or runtime.get("commitSha") != commit \
|
||||
or runtime.get("treeState") != "clean" \
|
||||
or runtime.get("processorCount") != 2 \
|
||||
or state.get("soakDurationSeconds", 0) < 300 \
|
||||
or state.get("finalListings") != 0 \
|
||||
or state.get("finalAttempts") != 0 \
|
||||
or state.get("finalReplayMarkers") != 0 \
|
||||
or state.get("restartStartedEmpty") is not True \
|
||||
or state.get("overloadWasTyped") is not True \
|
||||
or state.get("recoverySucceeded") is not True:
|
||||
raise InvalidRecord("candidate capacity evidence does not satisfy the clean evaluated commit")
|
||||
|
||||
|
||||
def validate_external_attestations(
|
||||
record: dict[str, Any],
|
||||
gates: list[dict[str, str]],
|
||||
repository_root: pathlib.Path,
|
||||
) -> None:
|
||||
for gate in gates:
|
||||
if gate["status"] != "pass":
|
||||
continue
|
||||
path = evidence_path(repository_root, gate["evidenceRef"], f"{gate['id']} evidence")
|
||||
attestation = load_json(path, f"{gate['id']} evidence")
|
||||
if not isinstance(attestation, dict) or set(attestation) != {
|
||||
"schemaVersion",
|
||||
"kind",
|
||||
"gateId",
|
||||
"candidateCommit",
|
||||
"result",
|
||||
"performedAtUtc",
|
||||
"artifactDigest",
|
||||
"evidenceLocation",
|
||||
"reviewerRole",
|
||||
}:
|
||||
raise InvalidRecord(f"{gate['id']} requires a complete external-gate attestation")
|
||||
reject_sensitive(attestation, f"external evidence {gate['id']}")
|
||||
if attestation["schemaVersion"] != 1 \
|
||||
or attestation["kind"] != "rendezvous-external-gate-attestation" \
|
||||
or attestation["gateId"] != gate["id"] \
|
||||
or attestation["candidateCommit"] != record["evaluatedCommit"] \
|
||||
or attestation["result"] != "pass" \
|
||||
or not isinstance(attestation["artifactDigest"], str) \
|
||||
or not DIGEST.fullmatch(attestation["artifactDigest"]) \
|
||||
or attestation["evidenceLocation"] not in {
|
||||
"protected-operations-record",
|
||||
"public-release-record",
|
||||
} \
|
||||
or attestation["reviewerRole"] not in {
|
||||
"release-operator",
|
||||
"network-operator",
|
||||
"security-operator",
|
||||
"independent-operator",
|
||||
}:
|
||||
raise InvalidRecord(f"{gate['id']} external attestation does not match the candidate gate")
|
||||
try:
|
||||
performed = datetime.fromisoformat(attestation["performedAtUtc"].replace("Z", "+00:00"))
|
||||
except (AttributeError, ValueError) as error:
|
||||
raise InvalidRecord(f"{gate['id']} has an invalid performedAtUtc") from error
|
||||
if performed.tzinfo is None or performed.utcoffset() != timedelta(0):
|
||||
raise InvalidRecord(f"{gate['id']} performedAtUtc must be UTC")
|
||||
|
||||
|
||||
def validate(record: Any, repository_root: pathlib.Path) -> tuple[bool, list[str]]:
|
||||
if not isinstance(record, dict) or set(record) != {
|
||||
"schemaVersion",
|
||||
"kind",
|
||||
"evaluatedCommit",
|
||||
"decision",
|
||||
"localGates",
|
||||
"externalGates",
|
||||
}:
|
||||
raise InvalidRecord("The top-level readiness record shape is invalid")
|
||||
if record["schemaVersion"] != 1 or record["kind"] != "rendezvous-production-readiness":
|
||||
raise InvalidRecord("The readiness schema identity is invalid")
|
||||
if not isinstance(record["evaluatedCommit"], str) or not COMMIT.fullmatch(record["evaluatedCommit"]):
|
||||
raise InvalidRecord("evaluatedCommit must be a full lowercase Git commit")
|
||||
reject_sensitive(record)
|
||||
local_gates = validate_gate_set(
|
||||
record["localGates"], LOCAL_GATES, "$.localGates", repository_root
|
||||
)
|
||||
external_gates = validate_gate_set(
|
||||
record["externalGates"], EXTERNAL_GATES, "$.externalGates", repository_root
|
||||
)
|
||||
validate_local_evidence(record, local_gates, repository_root)
|
||||
validate_external_attestations(record, external_gates, repository_root)
|
||||
gates = local_gates + external_gates
|
||||
blockers = sorted(gate["id"] for gate in gates if gate["status"] != "pass")
|
||||
ready = not blockers
|
||||
expected_decision = "ready" if ready else "not-ready"
|
||||
if record["decision"] != expected_decision:
|
||||
raise InvalidRecord(
|
||||
f"decision must be {expected_decision!r} for the recorded gate statuses"
|
||||
)
|
||||
return ready, blockers
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: check_production_readiness.py RECORD", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as source:
|
||||
record = json.load(source)
|
||||
ready, blockers = validate(record, pathlib.Path(__file__).resolve().parent.parent)
|
||||
except (OSError, json.JSONDecodeError, InvalidRecord) as error:
|
||||
print(f"INVALID: {error}", file=sys.stderr)
|
||||
return 2
|
||||
if not ready:
|
||||
print(f"NOT READY: {len(blockers)} required gate(s) are not passing.")
|
||||
for blocker in blockers:
|
||||
print(f"- {blocker}")
|
||||
return 3
|
||||
print("READY: every required v1 production gate is recorded as passing.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,13 +4,13 @@
|
||||
{
|
||||
"name": "SpaceGame",
|
||||
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
|
||||
"revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04",
|
||||
"revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
|
||||
"project": "SpaceGame.csproj"
|
||||
},
|
||||
{
|
||||
"name": "Unscouted",
|
||||
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
|
||||
"revision": "7807dbee86eb8b98e702f1eb89c88adff728f635",
|
||||
"revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
|
||||
"project": "Net.Core/Net.Core.csproj"
|
||||
}
|
||||
]
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RECORD="${1:-$ROOT/docs/evidence/production-readiness-v1.json}"
|
||||
|
||||
exec python3 "$ROOT/eng/check_production_readiness.py" "$RECORD"
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="$ROOT/src/FinalFactory.Rendezvous.TestClient/FinalFactory.Rendezvous.TestClient.csproj"
|
||||
ROLE="${RENDEZVOUS_CANARY_ROLE:-}"
|
||||
TOPOLOGY="${RENDEZVOUS_CANARY_TOPOLOGY:-}"
|
||||
ADDRESS_FAMILY="${RENDEZVOUS_CANARY_ADDRESS_FAMILY:-ipv4}"
|
||||
SERVICE_URL="${RENDEZVOUS_CANARY_HTTP_URL:-}"
|
||||
MEDIATOR="${RENDEZVOUS_CANARY_UDP_ENDPOINT:-}"
|
||||
GAME_ID="${RENDEZVOUS_CANARY_GAME_ID:-space-game}"
|
||||
ENVIRONMENT_ID="${RENDEZVOUS_CANARY_ENVIRONMENT_ID:-production-canary}"
|
||||
REGION="${RENDEZVOUS_CANARY_REGION:-production-canary}"
|
||||
PROTOCOL_VERSION="${RENDEZVOUS_CANARY_PROTOCOL_VERSION:-1}"
|
||||
TIMEOUT_SECONDS="${RENDEZVOUS_CANARY_TIMEOUT_SECONDS:-60}"
|
||||
RUN_SECONDS="${RENDEZVOUS_CANARY_RUN_SECONDS:-900}"
|
||||
OUTPUT="${RENDEZVOUS_CANARY_OUTPUT:-$ROOT/artifacts/canary/${ROLE:-unknown}-${TOPOLOGY:-unknown}.json}"
|
||||
COORDINATION_FILE="${RENDEZVOUS_CANARY_COORDINATION_FILE:-}"
|
||||
LISTING_ID="${RENDEZVOUS_CANARY_LISTING_ID:-}"
|
||||
REQUIRE_CLEAN="${RENDEZVOUS_CANARY_REQUIRE_CLEAN:-true}"
|
||||
KEEP_RAW="${RENDEZVOUS_CANARY_KEEP_RAW:-false}"
|
||||
UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Set RENDEZVOUS_CANARY_ROLE to host, client-success, or client-expected-failure.' \
|
||||
'Also set RENDEZVOUS_CANARY_TOPOLOGY, RENDEZVOUS_CANARY_HTTP_URL, and' \
|
||||
'RENDEZVOUS_CANARY_UDP_ENDPOINT. See docs/operations/production-readiness.md.' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
for command in date dotnet git jq mktemp tail; do
|
||||
command -v "$command" >/dev/null || {
|
||||
printf 'Missing required command: %s\n' "$command" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
case "$ROLE" in
|
||||
host|client-success|client-expected-failure) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
case "$TOPOLOGY" in
|
||||
same-lan|home-nat|firewall-blocked-udp|restrictive-cgnat|ipv6-direct) ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
case "$ADDRESS_FAMILY" in
|
||||
ipv4|ipv6) ;;
|
||||
*) printf 'RENDEZVOUS_CANARY_ADDRESS_FAMILY must be ipv4 or ipv6.\n' >&2; exit 2 ;;
|
||||
esac
|
||||
if [[ "$TOPOLOGY" == ipv6-direct && "$ADDRESS_FAMILY" != ipv6 ]]; then
|
||||
printf 'The ipv6-direct topology requires RENDEZVOUS_CANARY_ADDRESS_FAMILY=ipv6.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$TOPOLOGY" =~ ^(firewall-blocked-udp|restrictive-cgnat)$ \
|
||||
&& "$ROLE" == client-success ]]; then
|
||||
printf 'Failure topologies must use the client-expected-failure role.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$SERVICE_URL" || -z "$MEDIATOR" ]]; then
|
||||
usage
|
||||
fi
|
||||
if [[ ! "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] \
|
||||
|| (( TIMEOUT_SECONDS < 1 || TIMEOUT_SECONDS > 300 )); then
|
||||
printf 'RENDEZVOUS_CANARY_TIMEOUT_SECONDS must be an integer from 1 through 300.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$RUN_SECONDS" =~ ^[0-9]+$ ]] \
|
||||
|| (( RUN_SECONDS < 60 || RUN_SECONDS > 3600 )); then
|
||||
printf 'RENDEZVOUS_CANARY_RUN_SECONDS must be an integer from 60 through 3600.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$PROTOCOL_VERSION" =~ ^[0-9]+$ ]] || (( PROTOCOL_VERSION < 1 )); then
|
||||
printf 'RENDEZVOUS_CANARY_PROTOCOL_VERSION must be a positive integer.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$REQUIRE_CLEAN" != true && "$REQUIRE_CLEAN" != false ]]; then
|
||||
printf 'RENDEZVOUS_CANARY_REQUIRE_CLEAN must be true or false.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$KEEP_RAW" != true && "$KEEP_RAW" != false ]]; then
|
||||
printf 'RENDEZVOUS_CANARY_KEEP_RAW must be true or false.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
commit="$(git rev-parse HEAD)"
|
||||
tree_state=clean
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
tree_state=dirty
|
||||
fi
|
||||
if [[ "$REQUIRE_CLEAN" == true && "$tree_state" != clean ]]; then
|
||||
printf 'Formal canary evidence requires a clean source tree.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$ROLE" == host ]]; then
|
||||
if [[ -z "$COORDINATION_FILE" ]]; then
|
||||
printf 'The host role requires RENDEZVOUS_CANARY_COORDINATION_FILE.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -e "$COORDINATION_FILE" ]]; then
|
||||
printf 'The host coordination file already exists; remove it explicitly before a new canary.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "${RENDEZVOUS_PUBLISHER_CREDENTIAL:-}" ]]; then
|
||||
printf 'The host role requires RENDEZVOUS_PUBLISHER_CREDENTIAL.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
if [[ ! "$LISTING_ID" =~ $UUID_PATTERN ]]; then
|
||||
printf 'A client role requires a UUID in RENDEZVOUS_CANARY_LISTING_ID.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
umask 077
|
||||
raw_dir="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-canary.XXXXXXXX")"
|
||||
raw_log="$raw_dir/events.jsonl"
|
||||
run_succeeded=false
|
||||
host_pid=''
|
||||
cleanup() {
|
||||
local status="$?"
|
||||
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
|
||||
kill -TERM "$host_pid" 2>/dev/null || true
|
||||
wait "$host_pid" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "$run_succeeded" == true && "$KEEP_RAW" == false ]]; then
|
||||
rm -rf "$raw_dir"
|
||||
else
|
||||
printf 'Private raw canary events retained at %s\n' "$raw_dir" >&2
|
||||
fi
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
common_arguments=(
|
||||
--service "$SERVICE_URL"
|
||||
--mediator "$MEDIATOR"
|
||||
--game "$GAME_ID"
|
||||
--environment "$ENVIRONMENT_ID"
|
||||
--region "$REGION"
|
||||
--protocol "$PROTOCOL_VERSION"
|
||||
--script
|
||||
--json
|
||||
--timeout-seconds "$TIMEOUT_SECONDS"
|
||||
)
|
||||
|
||||
exit_code=0
|
||||
if [[ "$ROLE" == host ]]; then
|
||||
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||
host "${common_arguments[@]}" --exit-after-echo --run-seconds "$RUN_SECONDS" \
|
||||
>"$raw_log" 2>&1 &
|
||||
host_pid="$!"
|
||||
ready=false
|
||||
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
|
||||
if jq -e 'select(.event == "host.ready" and .status == "ready")' "$raw_log" \
|
||||
>/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$host_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
if [[ "$ready" != true ]]; then
|
||||
printf 'The canary host did not become ready within the bounded startup window.\n' >&2
|
||||
kill -TERM "$host_pid" 2>/dev/null || true
|
||||
wait "$host_pid" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
observed_listing="$(jq -r 'select(.event == "host.registered") | .listingId' "$raw_log" | tail -n 1)"
|
||||
if [[ ! "$observed_listing" =~ $UUID_PATTERN ]]; then
|
||||
printf 'The canary host did not produce a valid coordination identifier.\n' >&2
|
||||
kill -TERM "$host_pid" 2>/dev/null || true
|
||||
wait "$host_pid" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
coordination_parent="$(dirname "$COORDINATION_FILE")"
|
||||
mkdir -p "$coordination_parent"
|
||||
coordination_temp="$(mktemp "$COORDINATION_FILE.tmp.XXXXXXXX")"
|
||||
printf '%s\n' "$observed_listing" >"$coordination_temp"
|
||||
mv "$coordination_temp" "$COORDINATION_FILE"
|
||||
printf 'Host ready; securely transfer the private coordination file to the client operator.\n'
|
||||
set +e
|
||||
wait "$host_pid"
|
||||
exit_code="$?"
|
||||
set -e
|
||||
elif [[ "$ROLE" == client-success ]]; then
|
||||
set +e
|
||||
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||
exit_code="$?"
|
||||
set -e
|
||||
else
|
||||
set +e
|
||||
dotnet run --project "$PROJECT" --configuration Release --no-build -- \
|
||||
join "${common_arguments[@]}" --listing "$LISTING_ID" >"$raw_log" 2>&1
|
||||
exit_code="$?"
|
||||
set -e
|
||||
fi
|
||||
|
||||
checks='{}'
|
||||
if [[ "$ROLE" == host ]]; then
|
||||
[[ "$exit_code" -eq 0 ]]
|
||||
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "host.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$raw_log" >/dev/null
|
||||
checks='{"authenticatedDirectTraffic":true,"deregistered":true}'
|
||||
elif [[ "$ROLE" == client-success ]]; then
|
||||
[[ "$exit_code" -eq 0 ]]
|
||||
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.connected" and .status == "connected" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||
jq -e --arg family "$ADDRESS_FAMILY" 'select(.event == "join.direct-traffic" and .status == "verified" and .addressFamily == $family)' "$raw_log" >/dev/null
|
||||
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$raw_log" >/dev/null
|
||||
checks='{"authenticatedDirectTraffic":true,"typedOutcomeReported":true}'
|
||||
else
|
||||
[[ "$exit_code" -eq 12 ]]
|
||||
jq -e 'select((.event == "join.traversal" or .event == "join.authorization") and .status == "failed" and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||
jq -e 'select(.event == "join.fallback" and (.status == "available" or .status == "unavailable") and (.outcome | type == "string") and (.outcome | length > 0))' "$raw_log" >/dev/null
|
||||
checks='{"boundedTypedFailure":true,"fallbackPolicyReported":true}'
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
raw_retention=deleted-after-success
|
||||
if [[ "$KEEP_RAW" == true ]]; then
|
||||
raw_retention=retained-private-on-request
|
||||
fi
|
||||
jq -n \
|
||||
--arg commit "$commit" \
|
||||
--arg treeState "$tree_state" \
|
||||
--arg timestampUtc "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg role "$ROLE" \
|
||||
--arg topology "$TOPOLOGY" \
|
||||
--arg addressFamily "$ADDRESS_FAMILY" \
|
||||
--arg rawEvents "$raw_retention" \
|
||||
--argjson checks "$checks" \
|
||||
'{schemaVersion:1,kind:"rendezvous-real-network-canary",commit:$commit,treeState:$treeState,timestampUtc:$timestampUtc,role:$role,topology:$topology,addressFamily:$addressFamily,result:"pass",checks:$checks,dataRetention:{rawEvents:$rawEvents,identifiers:"not-in-summary",networkEndpoints:"not-in-summary"}}' \
|
||||
>"$OUTPUT"
|
||||
|
||||
run_succeeded=true
|
||||
printf 'Real-network canary passed; sanitized evidence: %s\n' "$OUTPUT"
|
||||
@@ -47,6 +47,8 @@ for ((index = 0; index < count; index++)); do
|
||||
cat >"$targets" <<EOF
|
||||
<Project>
|
||||
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$project'">
|
||||
<PackageReference Remove="FinalFactory.Rendezvous.Client" />
|
||||
<PackageReference Remove="FinalFactory.Rendezvous.Contracts" />
|
||||
<PackageReference Include="FinalFactory.Rendezvous.Client" Version="[$version]" />
|
||||
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
|
||||
EnvironmentId environmentId,
|
||||
uint protocolVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||
BrowseSessionsRequest request,
|
||||
string streamCursor,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IRendezvousJoinClient
|
||||
|
||||
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using CancellationTokenSource requestTimeout =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
requestTimeout.CancelAfter(_options.RequestTimeout);
|
||||
CancellationToken requestCancellation = requestTimeout.Token;
|
||||
using HttpRequestMessage request = requestFactory();
|
||||
HttpResponseMessage? response = null;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
requestCancellation).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
HttpResponseMessage ownedResponse = response;
|
||||
response = null;
|
||||
return RendezvousClientResult.Success(ownedResponse);
|
||||
}
|
||||
|
||||
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
|
||||
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
|
||||
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||
error.Code,
|
||||
error.Message,
|
||||
retryAfter);
|
||||
}
|
||||
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
|
||||
{
|
||||
return RendezvousClientResult.Failure<HttpResponseMessage>(
|
||||
RendezvousErrorCode.ServiceUnavailable,
|
||||
"The Rendezvous event stream could not be opened.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
response?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static HttpRequestMessage JsonRequest<T>(
|
||||
HttpMethod method,
|
||||
string uri,
|
||||
|
||||
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
|
||||
{
|
||||
ContractVersion = request.ContractVersion,
|
||||
LeaseToken = session.LeaseToken,
|
||||
RegionId = request.RegionId,
|
||||
ProtocolVersion = request.ProtocolVersion,
|
||||
Visibility = request.Visibility,
|
||||
BuildVersion = request.BuildVersion,
|
||||
DisplayName = request.DisplayName,
|
||||
Capacity = CopyCapacity(request.Capacity),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Client;
|
||||
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
|
||||
BrowseSessionsRequest request,
|
||||
string streamCursor,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(streamCursor)
|
||||
|| !ContractValidation.IsCursorValid(streamCursor))
|
||||
{
|
||||
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
|
||||
}
|
||||
|
||||
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
|
||||
+ $"&gameId={Escape(request.GameId.Value)}"
|
||||
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
|
||||
+ $"&protocolVersion={request.ProtocolVersion}"
|
||||
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
|
||||
+ (request.RegionId.HasValue ? $"®ionId={Escape(request.RegionId.Value.Value)}" : string.Empty);
|
||||
RendezvousClientResult<HttpResponseMessage> opened = await _transport.OpenStreamAsync(
|
||||
() =>
|
||||
{
|
||||
HttpRequestMessage message = new(HttpMethod.Get, query);
|
||||
message.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
|
||||
message.Headers.TryAddWithoutValidation("Last-Event-ID", streamCursor);
|
||||
return message;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!opened.IsSuccess || opened.Value is null)
|
||||
{
|
||||
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
opened.Error,
|
||||
opened.Message,
|
||||
opened.RetryAfterSeconds);
|
||||
yield break;
|
||||
}
|
||||
|
||||
using HttpResponseMessage response = opened.Value;
|
||||
if (!string.Equals(
|
||||
response.Content.Headers.ContentType?.MediaType,
|
||||
"text/event-stream",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid event-stream content type.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
using Stream source = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
using SseLineReader reader = new(source);
|
||||
while (true)
|
||||
{
|
||||
SseReadResult? read = null;
|
||||
RendezvousClientResult<SessionStreamEvent>? readFailure = null;
|
||||
bool cancelled = false;
|
||||
try
|
||||
{
|
||||
read = await ReadEventAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
cancelled = true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or InvalidDataException)
|
||||
{
|
||||
readFailure = RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid or oversized event stream.");
|
||||
}
|
||||
if (cancelled)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
if (readFailure is not null)
|
||||
{
|
||||
yield return readFailure;
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (read!.EndOfStream)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
yield return read.Result!;
|
||||
if (!read.Result!.IsSuccess)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SseReadResult> ReadEventAsync(
|
||||
SseLineReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? eventName = null;
|
||||
string? id = null;
|
||||
string? data = null;
|
||||
int bytes = 0;
|
||||
while (true)
|
||||
{
|
||||
string? line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
return eventName is null && id is null && data is null
|
||||
? SseReadResult.End
|
||||
: throw new InvalidDataException("The final SSE event was incomplete.");
|
||||
}
|
||||
bytes += Encoding.UTF8.GetByteCount(line) + 1;
|
||||
if (bytes > ContractLimits.SessionStreamEventMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("The SSE event exceeded the contract limit.");
|
||||
}
|
||||
if (line.Length == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||
{
|
||||
eventName = line[7..];
|
||||
}
|
||||
else if (line.StartsWith("id: ", StringComparison.Ordinal))
|
||||
{
|
||||
id = line[4..];
|
||||
}
|
||||
else if (line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
{
|
||||
data = line[6..];
|
||||
}
|
||||
}
|
||||
|
||||
SessionStreamEvent? item = data is null
|
||||
? null
|
||||
: JsonSerializer.Deserialize<SessionStreamEvent>(data, ContractJson.Options);
|
||||
if (item is null
|
||||
|| !string.Equals(item.Cursor, id, StringComparison.Ordinal)
|
||||
|| !string.Equals(eventName, EventName(item.Kind), StringComparison.Ordinal)
|
||||
|| !IsValidShape(item))
|
||||
{
|
||||
return new(false, RendezvousClientResult.Failure<SessionStreamEvent>(
|
||||
RendezvousErrorCode.InternalError,
|
||||
"The service returned an invalid event envelope."));
|
||||
}
|
||||
return new(false, RendezvousClientResult.Success(item));
|
||||
}
|
||||
|
||||
private static string EventName(SessionStreamEventKind kind) => kind switch
|
||||
{
|
||||
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||
SessionStreamEventKind.Reset => "reset",
|
||||
SessionStreamEventKind.Keepalive => "keepalive",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static bool IsValidShape(SessionStreamEvent item) =>
|
||||
item.ContractVersion == ContractLimits.ContractVersion
|
||||
&& !string.IsNullOrWhiteSpace(item.Cursor)
|
||||
&& ContractValidation.IsCursorValid(item.Cursor)
|
||||
&& (item.Kind == SessionStreamEventKind.SessionUpsert
|
||||
&& item.Session is not null
|
||||
&& IsValidListing(item.Session)
|
||||
&& item.ListingId is null
|
||||
|| item.Kind == SessionStreamEventKind.SessionRemove
|
||||
&& item.Session is null
|
||||
&& item.ListingId.HasValue
|
||||
&& item.ListingId.Value.Value != Guid.Empty
|
||||
|| item.Kind is SessionStreamEventKind.Reset or SessionStreamEventKind.Keepalive
|
||||
&& item.Session is null
|
||||
&& item.ListingId is null);
|
||||
|
||||
private static bool IsValidListing(SessionListing listing) =>
|
||||
listing.ContractVersion == ContractLimits.ContractVersion
|
||||
&& listing.ListingId.Value != Guid.Empty
|
||||
&& !string.IsNullOrWhiteSpace(listing.GameId.Value)
|
||||
&& !string.IsNullOrWhiteSpace(listing.EnvironmentId.Value)
|
||||
&& !string.IsNullOrWhiteSpace(listing.RegionId.Value)
|
||||
&& listing.ProtocolVersion != 0
|
||||
&& ContractValidation.IsBuildVersionValid(listing.BuildVersion)
|
||||
&& ContractValidation.IsDisplayNameValid(listing.DisplayName)
|
||||
&& listing.Visibility == ListingVisibility.Public
|
||||
&& Enum.IsDefined(typeof(PublisherTrustMode), listing.PublisherTrustMode)
|
||||
&& ContractValidation.IsCapacityValid(listing.Capacity)
|
||||
&& ContractValidation.IsMetadataValid(listing.Metadata)
|
||||
&& (listing.DedicatedFallback is null
|
||||
|| ContractValidation.IsNetworkEndpointValid(listing.DedicatedFallback));
|
||||
|
||||
private static string Escape(string value) => Uri.EscapeDataString(value ?? string.Empty);
|
||||
|
||||
private sealed class SseReadResult
|
||||
{
|
||||
public SseReadResult(
|
||||
bool endOfStream,
|
||||
RendezvousClientResult<SessionStreamEvent>? result)
|
||||
{
|
||||
EndOfStream = endOfStream;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
public bool EndOfStream { get; }
|
||||
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
|
||||
public static SseReadResult End { get; } = new(true, null);
|
||||
}
|
||||
|
||||
private sealed class SseLineReader(Stream source) : IDisposable
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||
private readonly byte[] _buffer = new byte[4096];
|
||||
private readonly MemoryStream _line = new();
|
||||
private int _offset;
|
||||
private int _count;
|
||||
|
||||
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_offset >= _count)
|
||||
{
|
||||
_count = await source.ReadAsync(
|
||||
_buffer.AsMemory(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
_offset = 0;
|
||||
if (_count == 0)
|
||||
{
|
||||
if (_line.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return TakeLine();
|
||||
}
|
||||
}
|
||||
|
||||
byte value = _buffer[_offset++];
|
||||
if (value == (byte)'\n')
|
||||
{
|
||||
return TakeLine();
|
||||
}
|
||||
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
|
||||
{
|
||||
throw new InvalidDataException("An SSE line exceeded the contract limit.");
|
||||
}
|
||||
_line.WriteByte(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _line.Dispose();
|
||||
|
||||
private string TakeLine()
|
||||
{
|
||||
byte[] bytes = _line.ToArray();
|
||||
_line.SetLength(0);
|
||||
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
|
||||
? bytes.Length - 1
|
||||
: bytes.Length;
|
||||
return Utf8.GetString(bytes, 0, length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ public static class ContractLimits
|
||||
public const int ContractVersion = 1;
|
||||
public const int HttpRequestMaxBytes = 16 * 1024;
|
||||
public const int BrowserResponseMaxBytes = 256 * 1024;
|
||||
public const int SessionStreamEventMaxBytes = 32 * 1024;
|
||||
public const int UdpDatagramMaxBytes = 1_200;
|
||||
public const int MetadataMaxBytes = 4 * 1024;
|
||||
public const int MetadataMaxKeys = 32;
|
||||
|
||||
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
|
||||
[JsonRequired]
|
||||
public string LeaseToken { get; set; } = string.Empty;
|
||||
|
||||
public RegionId? RegionId { get; set; }
|
||||
public uint? ProtocolVersion { get; set; }
|
||||
public ListingVisibility? Visibility { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string BuildVersion { get; set; } = string.Empty;
|
||||
|
||||
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
|
||||
public List<SessionListing> Items { get; set; } = [];
|
||||
|
||||
public string? NextCursor { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string StreamCursor { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum SessionStreamEventKind
|
||||
{
|
||||
SessionUpsert = 1,
|
||||
SessionRemove = 2,
|
||||
Reset = 3,
|
||||
Keepalive = 4,
|
||||
}
|
||||
|
||||
public sealed class SessionStreamEvent
|
||||
{
|
||||
[JsonRequired]
|
||||
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
|
||||
|
||||
[JsonRequired]
|
||||
public SessionStreamEventKind Kind { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public string Cursor { get; set; } = string.Empty;
|
||||
|
||||
public SessionListing? Session { get; set; }
|
||||
public SessionListingId? ListingId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GetSessionResponse
|
||||
|
||||
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
|
||||
internal sealed class SessionBrowserService(
|
||||
IEphemeralRendezvousStore store,
|
||||
SessionBrowserCursorCodec cursors,
|
||||
SessionStreamCursorCodec streamCursors,
|
||||
SessionChangeJournal changes,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<BrowseSessionsResponse> Browse(
|
||||
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
|
||||
request.PageSize + 1,
|
||||
after,
|
||||
request.ExcludeFull);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
|
||||
query,
|
||||
cancellationToken);
|
||||
StoreResult<IReadOnlyList<StoredListing>> found = default!;
|
||||
long streamRevision = 0;
|
||||
bool stableSnapshot = false;
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
long before = changes.CurrentRevision;
|
||||
found = store.BrowseVisibleListings(query, cancellationToken);
|
||||
long afterRevision = changes.CurrentRevision;
|
||||
if (before == afterRevision)
|
||||
{
|
||||
streamRevision = afterRevision;
|
||||
stableSnapshot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!stableSnapshot)
|
||||
{
|
||||
return new(RendezvousErrorCode.ServiceUnavailable);
|
||||
}
|
||||
if (!found.Succeeded || found.Value is null)
|
||||
{
|
||||
return new(found.Code == StoreResultCode.ServiceUnavailable
|
||||
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
|
||||
string? nextCursor = hasMore
|
||||
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
|
||||
: null;
|
||||
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
|
||||
BrowseSessionsResponse response = new()
|
||||
{
|
||||
Items = items,
|
||||
NextCursor = nextCursor,
|
||||
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||
};
|
||||
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
|
||||
<= ContractLimits.BrowserResponseMaxBytes)
|
||||
{
|
||||
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
|
||||
hasMore = true;
|
||||
}
|
||||
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
|
||||
return new(RendezvousErrorCode.None, new BrowseSessionsResponse
|
||||
{
|
||||
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
|
||||
});
|
||||
}
|
||||
|
||||
public BrowserServiceResult<GetSessionResponse> Get(
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record SessionChangeJournalOptions
|
||||
{
|
||||
public int ReplayCapacity { get; init; } = 4096;
|
||||
public int MaximumSubscribers { get; init; } = 256;
|
||||
public int MaximumSubscribersPerTenant { get; init; } = 64;
|
||||
public int MaximumBatchSize { get; init; } = 128;
|
||||
public TimeSpan CoalesceInterval { get; init; } = TimeSpan.FromMilliseconds(50);
|
||||
public TimeSpan KeepaliveInterval { get; init; } = TimeSpan.FromSeconds(15);
|
||||
public TimeSpan MaximumConnectionDuration { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (ReplayCapacity is < 64 or > 65_536
|
||||
|| MaximumSubscribers is < 1 or > 4096
|
||||
|| MaximumSubscribersPerTenant < 1
|
||||
|| MaximumSubscribersPerTenant > MaximumSubscribers
|
||||
|| MaximumBatchSize is < 1 or > 1024
|
||||
|| CoalesceInterval < TimeSpan.Zero
|
||||
|| CoalesceInterval > TimeSpan.FromSeconds(1)
|
||||
|| KeepaliveInterval < TimeSpan.FromSeconds(1)
|
||||
|| KeepaliveInterval > TimeSpan.FromMinutes(1)
|
||||
|| MaximumConnectionDuration < KeepaliveInterval
|
||||
|| MaximumConnectionDuration > TimeSpan.FromMinutes(30))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(SessionChangeJournalOptions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SessionChange(
|
||||
long Revision,
|
||||
SessionListingProjection? Before,
|
||||
SessionListingProjection? After)
|
||||
{
|
||||
public SessionListingId ListingId => (After ?? Before)!.Listing.ListingId;
|
||||
}
|
||||
|
||||
internal sealed record SessionChangeBatch(
|
||||
long CurrentRevision,
|
||||
bool RequiresReset,
|
||||
IReadOnlyList<SessionChange> Changes);
|
||||
|
||||
internal sealed class SessionListingProjection
|
||||
{
|
||||
private SessionListingProjection(SessionListing listing, bool visible)
|
||||
{
|
||||
Listing = listing;
|
||||
Visible = visible;
|
||||
}
|
||||
|
||||
public SessionListing Listing { get; }
|
||||
public bool Visible { get; }
|
||||
|
||||
public static SessionListingProjection From(StoredListing stored) => new(
|
||||
new SessionListing
|
||||
{
|
||||
ListingId = stored.Definition.ListingId,
|
||||
GameId = stored.Definition.Scope.GameId,
|
||||
EnvironmentId = stored.Definition.Scope.EnvironmentId,
|
||||
RegionId = stored.Definition.RegionId,
|
||||
ProtocolVersion = stored.Definition.ProtocolVersion,
|
||||
BuildVersion = stored.Definition.BuildVersion,
|
||||
DisplayName = stored.Definition.DisplayName,
|
||||
Visibility = stored.Definition.Visibility,
|
||||
PublisherTrustMode = stored.Definition.TrustMode,
|
||||
Capacity = new SessionCapacity
|
||||
{
|
||||
CurrentPlayers = stored.Definition.CurrentPlayers,
|
||||
MaximumPlayers = stored.Definition.MaximumPlayers,
|
||||
},
|
||||
Metadata = new Dictionary<string, string>(stored.Definition.Metadata, StringComparer.Ordinal),
|
||||
DedicatedFallback = StoredListing.CopyEndpoint(stored.Definition.DedicatedFallback),
|
||||
},
|
||||
stored.HasFreshPresence && stored.Definition.Visibility == ListingVisibility.Public);
|
||||
|
||||
public bool Matches(VisibleListingQuery query) => Visible
|
||||
&& Listing.GameId == query.Scope.GameId
|
||||
&& Listing.EnvironmentId == query.Scope.EnvironmentId
|
||||
&& Listing.ProtocolVersion == query.ProtocolVersion
|
||||
&& (!query.RegionId.HasValue || Listing.RegionId == query.RegionId.Value)
|
||||
&& (!query.ExcludeFull
|
||||
|| Listing.Capacity.CurrentPlayers < Listing.Capacity.MaximumPlayers);
|
||||
|
||||
public static bool Equivalent(SessionListingProjection? left, SessionListingProjection? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (left is null || right is null || left.Visible != right.Visible)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SessionListing a = left.Listing;
|
||||
SessionListing b = right.Listing;
|
||||
return a.ListingId == b.ListingId
|
||||
&& a.GameId == b.GameId
|
||||
&& a.EnvironmentId == b.EnvironmentId
|
||||
&& a.RegionId == b.RegionId
|
||||
&& a.ProtocolVersion == b.ProtocolVersion
|
||||
&& string.Equals(a.BuildVersion, b.BuildVersion, StringComparison.Ordinal)
|
||||
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
|
||||
&& a.Visibility == b.Visibility
|
||||
&& a.PublisherTrustMode == b.PublisherTrustMode
|
||||
&& a.Capacity.CurrentPlayers == b.Capacity.CurrentPlayers
|
||||
&& a.Capacity.MaximumPlayers == b.Capacity.MaximumPlayers
|
||||
&& a.Metadata.Count == b.Metadata.Count
|
||||
&& a.Metadata.All(item => b.Metadata.TryGetValue(item.Key, out string? value)
|
||||
&& string.Equals(item.Value, value, StringComparison.Ordinal))
|
||||
&& EndpointEquals(a.DedicatedFallback, b.DedicatedFallback);
|
||||
}
|
||||
|
||||
private static bool EndpointEquals(NetworkEndpoint? left, NetworkEndpoint? right) =>
|
||||
left is null && right is null
|
||||
|| left is not null && right is not null
|
||||
&& left.AddressFamily == right.AddressFamily
|
||||
&& string.Equals(left.Address, right.Address, StringComparison.Ordinal)
|
||||
&& left.Port == right.Port;
|
||||
}
|
||||
|
||||
internal sealed class SessionChangeJournal
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly SessionChangeJournalOptions _options;
|
||||
private readonly Queue<SessionChange> _changes = [];
|
||||
private TaskCompletionSource<long> _changed = NewSignal();
|
||||
private long _revision;
|
||||
private int _subscribers;
|
||||
private readonly Dictionary<TenantScope, int> _subscribersByTenant = [];
|
||||
|
||||
public SessionChangeJournal(SessionChangeJournalOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public SessionChangeJournalOptions Options => _options;
|
||||
|
||||
public long CurrentRevision
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Publish(StoredListing? before, StoredListing? after)
|
||||
{
|
||||
SessionListingProjection? previous = before is null ? null : SessionListingProjection.From(before);
|
||||
SessionListingProjection? current = after is null ? null : SessionListingProjection.From(after);
|
||||
if (SessionListingProjection.Equivalent(previous, current)
|
||||
|| previous is { Visible: false } && current is null
|
||||
|| previous is null && current is { Visible: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TaskCompletionSource<long> signal;
|
||||
long revision;
|
||||
lock (_gate)
|
||||
{
|
||||
revision = ++_revision;
|
||||
_changes.Enqueue(new SessionChange(revision, previous, current));
|
||||
while (_changes.Count > _options.ReplayCapacity)
|
||||
{
|
||||
_changes.Dequeue();
|
||||
}
|
||||
signal = _changed;
|
||||
_changed = NewSignal();
|
||||
}
|
||||
signal.TrySetResult(revision);
|
||||
}
|
||||
|
||||
public SessionChangeBatch ReadAfter(long revision)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
long oldest = _changes.TryPeek(out SessionChange? first)
|
||||
? first.Revision
|
||||
: _revision + 1;
|
||||
if (revision < oldest - 1 || revision > _revision)
|
||||
{
|
||||
return new(_revision, true, []);
|
||||
}
|
||||
|
||||
SessionChange[] changes = _changes
|
||||
.Where(change => change.Revision > revision)
|
||||
.Take(_options.MaximumBatchSize)
|
||||
.ToArray();
|
||||
return new(_revision, false, changes);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForChangeAsync(
|
||||
long revision,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Task<long> signal;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_revision > revision)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
signal = _changed.Task;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await signal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySubscribe(TenantScope scope, out IDisposable? lease)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_subscribers >= _options.MaximumSubscribers
|
||||
|| _subscribersByTenant.GetValueOrDefault(scope)
|
||||
>= _options.MaximumSubscribersPerTenant)
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
_subscribers++;
|
||||
_subscribersByTenant[scope] = _subscribersByTenant.GetValueOrDefault(scope) + 1;
|
||||
lease = new Subscription(this, scope);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Release(TenantScope scope)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_subscribers--;
|
||||
int remaining = _subscribersByTenant[scope] - 1;
|
||||
if (remaining == 0)
|
||||
{
|
||||
_subscribersByTenant.Remove(scope);
|
||||
}
|
||||
else
|
||||
{
|
||||
_subscribersByTenant[scope] = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<long> NewSignal() => new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private sealed class Subscription(
|
||||
SessionChangeJournal owner,
|
||||
TenantScope scope) : IDisposable
|
||||
{
|
||||
private SessionChangeJournal? _owner = owner;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(scope);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed class SessionStreamCursorCodec : IDisposable
|
||||
{
|
||||
private const string Prefix = "rvs1";
|
||||
private readonly EphemeralCursorProtector _protector = new();
|
||||
|
||||
public string Encode(VisibleListingQuery query, long revision, DateTimeOffset now)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(revision);
|
||||
SessionStreamCursorPayload payload = new()
|
||||
{
|
||||
GameId = query.Scope.GameId.Value,
|
||||
EnvironmentId = query.Scope.EnvironmentId.Value,
|
||||
ProtocolVersion = query.ProtocolVersion,
|
||||
RegionId = query.RegionId?.Value,
|
||||
ExcludeFull = query.ExcludeFull,
|
||||
Revision = revision,
|
||||
ExpiresAtUnixSeconds = now.AddMinutes(10).ToUnixTimeSeconds(),
|
||||
};
|
||||
byte[] encoded = JsonSerializer.SerializeToUtf8Bytes(payload, ContractJson.Options);
|
||||
try
|
||||
{
|
||||
return _protector.Protect(Prefix, encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecode(
|
||||
string? cursor,
|
||||
VisibleListingQuery query,
|
||||
DateTimeOffset now,
|
||||
out long revision)
|
||||
{
|
||||
revision = 0;
|
||||
if (cursor is null || !_protector.TryUnprotect(Prefix, cursor, out byte[] encodedPayload))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SessionStreamCursorPayload? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<SessionStreamCursorPayload>(
|
||||
encodedPayload,
|
||||
ContractJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
payload = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(encodedPayload);
|
||||
}
|
||||
|
||||
if (payload is null
|
||||
|| payload.Revision < 0
|
||||
|| payload.ExpiresAtUnixSeconds <= now.ToUnixTimeSeconds()
|
||||
|| !string.Equals(payload.GameId, query.Scope.GameId.Value, StringComparison.Ordinal)
|
||||
|| !string.Equals(payload.EnvironmentId, query.Scope.EnvironmentId.Value, StringComparison.Ordinal)
|
||||
|| payload.ProtocolVersion != query.ProtocolVersion
|
||||
|| !string.Equals(payload.RegionId, query.RegionId?.Value, StringComparison.Ordinal)
|
||||
|| payload.ExcludeFull != query.ExcludeFull)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
revision = payload.Revision;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _protector.Dispose();
|
||||
|
||||
public override string ToString() => "[SessionStreamCursorCodec: key and cursors redacted]";
|
||||
}
|
||||
|
||||
internal sealed class SessionStreamCursorPayload
|
||||
{
|
||||
[JsonRequired]
|
||||
public string GameId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string EnvironmentId { get; set; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public uint ProtocolVersion { get; set; }
|
||||
|
||||
public string? RegionId { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public bool ExcludeFull { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public long Revision { get; set; }
|
||||
|
||||
[JsonRequired]
|
||||
public long ExpiresAtUnixSeconds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
internal sealed record SessionStreamReadResult(
|
||||
bool RequiresReset,
|
||||
IReadOnlyList<SessionStreamEvent> Events);
|
||||
|
||||
internal sealed class SessionStreamSubscription : IDisposable
|
||||
{
|
||||
private IDisposable? _lease;
|
||||
|
||||
public SessionStreamSubscription(
|
||||
VisibleListingQuery query,
|
||||
long revision,
|
||||
bool requiresReset,
|
||||
IDisposable lease)
|
||||
{
|
||||
Query = query;
|
||||
Revision = revision;
|
||||
RequiresReset = requiresReset;
|
||||
_lease = lease;
|
||||
}
|
||||
|
||||
public VisibleListingQuery Query { get; }
|
||||
public long Revision { get; set; }
|
||||
public bool RequiresReset { get; set; }
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _lease, null)?.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class SessionStreamService(
|
||||
SessionChangeJournal changes,
|
||||
SessionStreamCursorCodec cursors,
|
||||
IWallClock clock)
|
||||
{
|
||||
public BrowserServiceResult<SessionStreamSubscription> Subscribe(
|
||||
BrowseSessionsRequest request,
|
||||
string? cursor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
RendezvousErrorCode validation = Validate(request);
|
||||
if (validation != RendezvousErrorCode.None)
|
||||
{
|
||||
return new(validation);
|
||||
}
|
||||
VisibleListingQuery query = new(
|
||||
new TenantScope(request.GameId, request.EnvironmentId),
|
||||
request.ProtocolVersion,
|
||||
request.RegionId,
|
||||
ContractLimits.BrowserPageMaxItems,
|
||||
ExcludeFull: request.ExcludeFull);
|
||||
if (!changes.TrySubscribe(query.Scope, out IDisposable? lease) || lease is null)
|
||||
{
|
||||
return new(RendezvousErrorCode.CapacityExceeded);
|
||||
}
|
||||
bool validCursor = cursors.TryDecode(cursor, query, clock.UtcNow, out long revision);
|
||||
if (!validCursor)
|
||||
{
|
||||
revision = changes.CurrentRevision;
|
||||
}
|
||||
return new(RendezvousErrorCode.None, new SessionStreamSubscription(
|
||||
query,
|
||||
revision,
|
||||
requiresReset: !validCursor,
|
||||
lease));
|
||||
}
|
||||
|
||||
public SessionStreamReadResult Read(SessionStreamSubscription subscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscription);
|
||||
if (subscription.RequiresReset)
|
||||
{
|
||||
subscription.RequiresReset = false;
|
||||
return new(true, []);
|
||||
}
|
||||
|
||||
SessionChangeBatch batch = changes.ReadAfter(subscription.Revision);
|
||||
if (batch.RequiresReset)
|
||||
{
|
||||
subscription.Revision = batch.CurrentRevision;
|
||||
return new(true, []);
|
||||
}
|
||||
if (batch.Changes.Count == 0)
|
||||
{
|
||||
return new(false, []);
|
||||
}
|
||||
|
||||
Dictionary<SessionListingId, PendingDelta> coalesced = [];
|
||||
foreach (SessionChange change in batch.Changes)
|
||||
{
|
||||
bool beforeMatches = change.Before?.Matches(subscription.Query) == true;
|
||||
bool afterMatches = change.After?.Matches(subscription.Query) == true;
|
||||
if (!beforeMatches && !afterMatches)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
coalesced[change.ListingId] = afterMatches
|
||||
? new(change.Revision, SessionStreamEventKind.SessionUpsert, change.After!.Listing)
|
||||
: new(change.Revision, SessionStreamEventKind.SessionRemove, null);
|
||||
}
|
||||
|
||||
subscription.Revision = batch.Changes[^1].Revision;
|
||||
SessionStreamEvent[] events = coalesced
|
||||
.OrderBy(static item => item.Value.Revision)
|
||||
.Select(item => ToEvent(item.Key, item.Value, subscription.Query))
|
||||
.ToArray();
|
||||
return new(false, events);
|
||||
}
|
||||
|
||||
public async Task<bool> WaitForChangeAsync(
|
||||
SessionStreamSubscription subscription,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool changed = await changes.WaitForChangeAsync(
|
||||
subscription.Revision,
|
||||
changes.Options.KeepaliveInterval,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (changed && changes.Options.CoalesceInterval > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(changes.Options.CoalesceInterval, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public SessionStreamEvent ResetEvent(SessionStreamSubscription subscription) => new()
|
||||
{
|
||||
Kind = SessionStreamEventKind.Reset,
|
||||
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||
};
|
||||
|
||||
public SessionStreamEvent KeepaliveEvent(SessionStreamSubscription subscription) => new()
|
||||
{
|
||||
Kind = SessionStreamEventKind.Keepalive,
|
||||
Cursor = cursors.Encode(subscription.Query, subscription.Revision, clock.UtcNow),
|
||||
};
|
||||
|
||||
public TimeSpan MaximumConnectionDuration => changes.Options.MaximumConnectionDuration;
|
||||
|
||||
private SessionStreamEvent ToEvent(
|
||||
SessionListingId listingId,
|
||||
PendingDelta delta,
|
||||
VisibleListingQuery query) => new()
|
||||
{
|
||||
Kind = delta.Kind,
|
||||
Cursor = cursors.Encode(query, delta.Revision, clock.UtcNow),
|
||||
Session = delta.Session,
|
||||
ListingId = delta.Kind == SessionStreamEventKind.SessionRemove ? listingId : null,
|
||||
};
|
||||
|
||||
private static RendezvousErrorCode Validate(BrowseSessionsRequest request)
|
||||
{
|
||||
RendezvousErrorCode version = ContractValidation.ValidateContractVersion(request.ContractVersion);
|
||||
if (version != RendezvousErrorCode.None)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
return string.IsNullOrEmpty(request.GameId.Value)
|
||||
|| string.IsNullOrEmpty(request.EnvironmentId.Value)
|
||||
|| request.ProtocolVersion == 0
|
||||
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
: RendezvousErrorCode.None;
|
||||
}
|
||||
|
||||
private sealed record PendingDelta(
|
||||
long Revision,
|
||||
SessionStreamEventKind Kind,
|
||||
SessionListing? Session);
|
||||
}
|
||||
@@ -47,6 +47,9 @@
|
||||
<Compile Include="Browser/EphemeralCursorProtector.cs" />
|
||||
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
|
||||
<Compile Include="Browser/SessionBrowserService.cs" />
|
||||
<Compile Include="Browser/SessionChangeJournal.cs" />
|
||||
<Compile Include="Browser/SessionStreamCursorCodec.cs" />
|
||||
<Compile Include="Browser/SessionStreamService.cs" />
|
||||
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
|
||||
<Compile Include="Deployment/DeploymentOptions.cs" />
|
||||
<Compile Include="Deployment/GracefulDrainService.cs" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/stream", StreamSessions)
|
||||
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("StreamSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> StreamSessions(
|
||||
[FromQuery] int contractVersion,
|
||||
[FromQuery] string gameId,
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromQuery] string? regionId,
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? streamCursor,
|
||||
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
|
||||
[FromServices] SessionStreamService streams,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||
|| regionId is not null && !RegionId.TryParse(regionId, out _))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"StreamSessions",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
|
||||
if (!subscribed.Succeeded || subscribed.Value is null)
|
||||
{
|
||||
return Error(subscribed.Error);
|
||||
}
|
||||
|
||||
using SessionStreamSubscription subscription = subscribed.Value;
|
||||
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
duration.CancelAfter(streams.MaximumConnectionDuration);
|
||||
HttpResponse response = httpContext.Response;
|
||||
response.StatusCode = StatusCodes.Status200OK;
|
||||
response.ContentType = "text/event-stream";
|
||||
response.Headers.CacheControl = "no-cache, no-store";
|
||||
response.Headers["X-Accel-Buffering"] = "no";
|
||||
await response.StartAsync(duration.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
while (!duration.IsCancellationRequested)
|
||||
{
|
||||
SessionStreamReadResult read = streams.Read(subscription);
|
||||
if (read.RequiresReset)
|
||||
{
|
||||
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
|
||||
.ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
if (read.Events.Count > 0)
|
||||
{
|
||||
foreach (SessionStreamEvent item in read.Events)
|
||||
{
|
||||
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
|
||||
}
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
|
||||
.ConfigureAwait(false);
|
||||
if (!changed)
|
||||
{
|
||||
await WriteSseAsync(
|
||||
response,
|
||||
streams.KeepaliveEvent(subscription),
|
||||
duration.Token).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (duration.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
return Results.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSseAsync(
|
||||
HttpResponse response,
|
||||
SessionStreamEvent item,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string eventName = item.Kind switch
|
||||
{
|
||||
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||
SessionStreamEventKind.Reset => "reset",
|
||||
_ => "keepalive",
|
||||
};
|
||||
string data = JsonSerializer.Serialize(item, ContractJson.Options);
|
||||
await response.WriteAsync(
|
||||
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
SessionListingId listingId,
|
||||
[FromQuery] int contractVersion,
|
||||
|
||||
@@ -255,6 +255,7 @@ builder.Services.Configure<HostOptions>(options =>
|
||||
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
|
||||
|
||||
SystemRendezvousClock rendezvousClock = new();
|
||||
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
|
||||
EphemeralStoreOptions stateOptions = new()
|
||||
{
|
||||
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
|
||||
@@ -262,8 +263,10 @@ EphemeralStoreOptions stateOptions = new()
|
||||
InMemoryEphemeralRendezvousStore stateStore = new(
|
||||
stateOptions,
|
||||
rendezvousClock,
|
||||
rendezvousClock);
|
||||
rendezvousClock,
|
||||
sessionChanges);
|
||||
builder.Services.AddSingleton(stateStore);
|
||||
builder.Services.AddSingleton(sessionChanges);
|
||||
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
|
||||
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
|
||||
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
|
||||
@@ -297,7 +300,9 @@ else
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<SessionStreamService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
|
||||
|
||||
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
|
||||
}
|
||||
|
||||
StoredListing ownedListing = listing!;
|
||||
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
|
||||
PublisherAuthorizationResult authorized = authorization.Authorize(
|
||||
principal,
|
||||
ownedListing.Definition.Scope.GameId,
|
||||
ownedListing.Definition.Scope.EnvironmentId,
|
||||
request.RegionId ?? ownedListing.Definition.RegionId,
|
||||
request.ProtocolVersion ?? ownedListing.Definition.ProtocolVersion,
|
||||
request.Visibility ?? ownedListing.Definition.Visibility,
|
||||
request.Metadata,
|
||||
clock.UtcNow);
|
||||
if (!authorized.IsAllowed || authorized.Context is null)
|
||||
{
|
||||
return new(MapAuthorization(authorized.Error));
|
||||
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
|
||||
request.Capacity.CurrentPlayers,
|
||||
request.Capacity.MaximumPlayers,
|
||||
request.Metadata,
|
||||
request.DedicatedFallback), cancellationToken);
|
||||
request.DedicatedFallback,
|
||||
request.RegionId,
|
||||
request.ProtocolVersion,
|
||||
request.Visibility), cancellationToken);
|
||||
return updated.Succeeded
|
||||
? new(RendezvousErrorCode.None, true)
|
||||
: new(updated.Code.ToContractError());
|
||||
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|
||||
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|
||||
|| !ContractValidation.IsCapacityValid(request.Capacity)
|
||||
|| !ContractValidation.IsMetadataValid(request.Metadata)
|
||||
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|
||||
|| request.ProtocolVersion.HasValue && request.ProtocolVersion.Value == 0
|
||||
|| request.Visibility.HasValue && !Enum.IsDefined(request.Visibility.Value)
|
||||
|| request.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
|
||||
? RendezvousErrorCode.InvalidRequest
|
||||
|
||||
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
|
||||
int CurrentPlayers,
|
||||
int MaximumPlayers,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
NetworkEndpoint? DedicatedFallback);
|
||||
NetworkEndpoint? DedicatedFallback,
|
||||
RegionId? RegionId = null,
|
||||
uint? ProtocolVersion = null,
|
||||
ListingVisibility? Visibility = null);
|
||||
|
||||
internal sealed record DeleteListingCommand(
|
||||
SessionListingId ListingId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
@@ -8,6 +9,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
private readonly object _gate = new();
|
||||
private readonly EphemeralStoreOptions _options;
|
||||
private readonly IMonotonicClock _monotonicClock;
|
||||
private readonly SessionChangeJournal? _sessionChanges;
|
||||
private readonly DateTimeOffset _wallOrigin;
|
||||
private readonly TimeSpan _monotonicOrigin;
|
||||
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
|
||||
@@ -45,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
public InMemoryEphemeralRendezvousStore(
|
||||
EphemeralStoreOptions options,
|
||||
IWallClock wallClock,
|
||||
IMonotonicClock monotonicClock)
|
||||
IMonotonicClock monotonicClock,
|
||||
SessionChangeJournal? sessionChanges = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(wallClock);
|
||||
@@ -53,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
options.Validate();
|
||||
_options = options;
|
||||
_monotonicClock = monotonicClock;
|
||||
_sessionChanges = sessionChanges;
|
||||
_wallOrigin = wallClock.UtcNow;
|
||||
_monotonicOrigin = monotonicClock.Elapsed;
|
||||
InstanceId = Guid.NewGuid();
|
||||
@@ -225,7 +229,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
now + _options.IdempotencyLifetime);
|
||||
_idempotency.Add(idempotencyKey, idempotency);
|
||||
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
StoredListing created = Snapshot(entry);
|
||||
_sessionChanges?.Publish(null, created);
|
||||
return new(StoreResultCode.Success, created);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<StoredListing> RenewLease(
|
||||
@@ -277,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|
||||
|| command.CurrentPlayers < 0
|
||||
|| command.CurrentPlayers > command.MaximumPlayers
|
||||
|| command.RegionId.HasValue && string.IsNullOrEmpty(command.RegionId.Value.Value)
|
||||
|| command.ProtocolVersion.HasValue && command.ProtocolVersion.Value == 0
|
||||
|| command.Visibility.HasValue && !Enum.IsDefined(command.Visibility.Value)
|
||||
|| !ContractValidation.IsMetadataValid(command.Metadata)
|
||||
|| command.DedicatedFallback is not null
|
||||
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
|
||||
@@ -302,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.NotFound);
|
||||
}
|
||||
|
||||
StoredListing before = Snapshot(entry);
|
||||
entry.Definition = StoredListing.Freeze(entry.Definition with
|
||||
{
|
||||
RegionId = command.RegionId ?? entry.Definition.RegionId,
|
||||
ProtocolVersion = command.ProtocolVersion ?? entry.Definition.ProtocolVersion,
|
||||
Visibility = command.Visibility ?? entry.Definition.Visibility,
|
||||
BuildVersion = command.BuildVersion,
|
||||
DisplayName = command.DisplayName,
|
||||
CurrentPlayers = command.CurrentPlayers,
|
||||
@@ -312,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
DedicatedFallback = command.DedicatedFallback,
|
||||
});
|
||||
entry.Version++;
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
StoredListing after = Snapshot(entry);
|
||||
_sessionChanges?.Publish(before, after);
|
||||
return new(StoreResultCode.Success, after);
|
||||
}, cancellationToken);
|
||||
|
||||
public StoreResult<bool> DeleteListing(
|
||||
@@ -382,6 +397,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return new(StoreResultCode.CapacityExceeded);
|
||||
}
|
||||
|
||||
StoredListing before = Snapshot(entry);
|
||||
bool isNewPresence = !_presence.ContainsKey(command.Handle);
|
||||
PresenceEntry presence = new(
|
||||
command.PublicEndpoint,
|
||||
@@ -396,7 +412,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
command.Handle,
|
||||
presence.Deadline);
|
||||
}
|
||||
return new(StoreResultCode.Success, Snapshot(entry));
|
||||
StoredListing after = Snapshot(entry);
|
||||
_sessionChanges?.Publish(before, after);
|
||||
return new(StoreResultCode.Success, after);
|
||||
}, cancellationToken, eagerCleanup: false);
|
||||
|
||||
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
|
||||
@@ -996,6 +1014,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
|
||||
private void ClearActiveState()
|
||||
{
|
||||
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
|
||||
_listings.Clear();
|
||||
_listingCountsByOwner.Clear();
|
||||
_listingExpiries.Clear();
|
||||
@@ -1017,6 +1036,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
_idempotencyExpiries.Clear();
|
||||
_replay.Clear();
|
||||
_replayExpiries.Clear();
|
||||
foreach (StoredListing listing in removedListings)
|
||||
{
|
||||
_sessionChanges?.Publish(listing, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveListing(SessionListingId listingId)
|
||||
@@ -1026,6 +1049,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
return;
|
||||
}
|
||||
|
||||
StoredListing removed = Snapshot(listing);
|
||||
_leases.Remove(listing.Definition.LeaseId);
|
||||
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
|
||||
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
|
||||
@@ -1045,6 +1069,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
RemoveOutcome(attemptId);
|
||||
}
|
||||
}
|
||||
|
||||
_sessionChanges?.Publish(removed, null);
|
||||
}
|
||||
|
||||
private void RemoveAttempt(JoinAttemptId attemptId)
|
||||
@@ -1186,6 +1212,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|
||||
}
|
||||
else if (_presence.Remove(candidate.Key))
|
||||
{
|
||||
if (_presenceHandles.TryGetValue(candidate.Key, out SessionListingId listingId)
|
||||
&& _listings.TryGetValue(listingId, out ListingEntry? listing))
|
||||
{
|
||||
StoredListing after = Snapshot(listing);
|
||||
_sessionChanges?.Publish(after with { HasFreshPresence = true }, after);
|
||||
}
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
{
|
||||
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
|
||||
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
|
||||
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
|
||||
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
|
||||
_ => Task.FromResult(TestClientExitCode.Usage),
|
||||
};
|
||||
@@ -114,11 +115,12 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
listingId: session.ListingId.ToString(),
|
||||
displayName: options.DisplayName);
|
||||
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
|
||||
echo.ExchangeCompleted += _ => output.Write(
|
||||
echo.ExchangeCompleted += peer => output.Write(
|
||||
"host.direct-traffic",
|
||||
"verified",
|
||||
phase: "direct-traffic",
|
||||
endpointType: "peer-to-peer");
|
||||
endpointType: "peer-to-peer",
|
||||
addressFamily: AddressFamilyName(peer.Address));
|
||||
coordinator = new RendezvousHostCoordinator(
|
||||
manager,
|
||||
events,
|
||||
@@ -485,6 +487,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
"connected",
|
||||
phase: "direct-connection",
|
||||
endpointType: endpointType,
|
||||
addressFamily: AddressFamilyName(peer.Address),
|
||||
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
|
||||
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
|
||||
echo.BeginJoin(peer);
|
||||
@@ -507,7 +510,8 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
"join.direct-traffic",
|
||||
"verified",
|
||||
phase: "direct-traffic",
|
||||
endpointType: endpointType);
|
||||
endpointType: endpointType,
|
||||
addressFamily: AddressFamilyName(peer.Address));
|
||||
peer.Disconnect();
|
||||
manager.PollEvents();
|
||||
return TestClientExitCode.Success;
|
||||
@@ -550,6 +554,178 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<TestClientExitCode> RunWatchAsync(
|
||||
TestClientOptions options,
|
||||
TestClientOutput output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource watch = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
watch.CancelAfter(options.RunDuration ?? options.OperationTimeout);
|
||||
using HttpClient http = CreateHttpClient(options);
|
||||
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
|
||||
BrowseSessionsRequest request = BrowseRequest(options);
|
||||
RendezvousClientResult<BrowseSessionsResponse> snapshot = await browser.BrowseAsync(
|
||||
request,
|
||||
watch.Token).ConfigureAwait(false);
|
||||
if (!snapshot.IsSuccess || snapshot.Value is null)
|
||||
{
|
||||
WriteServiceFailure(output, "watch.snapshot", "directory", snapshot);
|
||||
return TestClientExitCode.ServiceFailure;
|
||||
}
|
||||
output.Write(
|
||||
"watch.snapshot",
|
||||
"complete",
|
||||
phase: "directory",
|
||||
count: snapshot.Value.Items.Count);
|
||||
string cursor = options.ExerciseReset
|
||||
? CorruptCursor(snapshot.Value.StreamCursor)
|
||||
: snapshot.Value.StreamCursor;
|
||||
output.Write("watch.stream", "started", phase: "live-directory");
|
||||
SessionStreamEvent? expectedReplay = null;
|
||||
bool reconnectExerciseCompleted = false;
|
||||
int emptyConnections = 0;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
string connectionCursor = cursor;
|
||||
bool receivedEvent = false;
|
||||
bool deliberateReconnect = false;
|
||||
await foreach (RendezvousClientResult<SessionStreamEvent> result in browser
|
||||
.StreamAsync(request, cursor, watch.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
WriteServiceFailure(output, "watch.stream", "live-directory", result);
|
||||
return TestClientExitCode.ServiceFailure;
|
||||
}
|
||||
receivedEvent = true;
|
||||
SessionStreamEvent item = result.Value;
|
||||
if (expectedReplay is not null)
|
||||
{
|
||||
if (!SameStreamEvent(expectedReplay, item))
|
||||
{
|
||||
output.WriteError(
|
||||
"watch.reconnect",
|
||||
"failed",
|
||||
"The reconnect did not replay the expected ordered event.",
|
||||
phase: "live-directory");
|
||||
return TestClientExitCode.ServiceFailure;
|
||||
}
|
||||
output.Write("watch.reconnect", "verified", phase: "live-directory");
|
||||
expectedReplay = null;
|
||||
reconnectExerciseCompleted = true;
|
||||
cursor = item.Cursor;
|
||||
if (options.Script)
|
||||
{
|
||||
return TestClientExitCode.Success;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cursor = item.Cursor;
|
||||
}
|
||||
|
||||
switch (item.Kind)
|
||||
{
|
||||
case SessionStreamEventKind.SessionUpsert when item.Session is not null:
|
||||
output.Write(
|
||||
"watch.session-upsert",
|
||||
"available",
|
||||
phase: "live-directory",
|
||||
listingId: item.Session.ListingId.ToString(),
|
||||
displayName: item.Session.DisplayName);
|
||||
break;
|
||||
case SessionStreamEventKind.SessionRemove when item.ListingId.HasValue:
|
||||
output.Write(
|
||||
"watch.session-remove",
|
||||
"removed",
|
||||
phase: "live-directory",
|
||||
listingId: item.ListingId.Value.ToString());
|
||||
break;
|
||||
case SessionStreamEventKind.Reset:
|
||||
output.Write("watch.reset", "required", phase: "live-directory");
|
||||
RendezvousClientResult<BrowseSessionsResponse> refreshed = await browser.BrowseAsync(
|
||||
request,
|
||||
watch.Token).ConfigureAwait(false);
|
||||
if (!refreshed.IsSuccess || refreshed.Value is null)
|
||||
{
|
||||
WriteServiceFailure(output, "watch.snapshot", "directory", refreshed);
|
||||
return TestClientExitCode.ServiceFailure;
|
||||
}
|
||||
output.Write(
|
||||
"watch.snapshot",
|
||||
"refreshed",
|
||||
phase: "directory",
|
||||
count: refreshed.Value.Items.Count);
|
||||
return TestClientExitCode.Success;
|
||||
case SessionStreamEventKind.Keepalive:
|
||||
output.Write("watch.keepalive", "alive", phase: "live-directory");
|
||||
break;
|
||||
}
|
||||
|
||||
bool listingDelta = item.Kind is SessionStreamEventKind.SessionUpsert
|
||||
or SessionStreamEventKind.SessionRemove;
|
||||
if (options.ExerciseReconnect
|
||||
&& !reconnectExerciseCompleted
|
||||
&& listingDelta
|
||||
&& expectedReplay is null)
|
||||
{
|
||||
expectedReplay = item;
|
||||
cursor = connectionCursor;
|
||||
deliberateReconnect = true;
|
||||
output.Write("watch.reconnect", "started", phase: "live-directory");
|
||||
break;
|
||||
}
|
||||
if (options.Script && listingDelta)
|
||||
{
|
||||
return TestClientExitCode.Success;
|
||||
}
|
||||
}
|
||||
|
||||
if (deliberateReconnect)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
emptyConnections = receivedEvent ? 0 : emptyConnections + 1;
|
||||
if (emptyConnections >= 3)
|
||||
{
|
||||
output.WriteError(
|
||||
"watch.reconnect",
|
||||
"failed",
|
||||
"The stream closed repeatedly without an event; use bounded polling fallback.",
|
||||
phase: "live-directory");
|
||||
return TestClientExitCode.ServiceFailure;
|
||||
}
|
||||
output.Write("watch.reconnect", "required", phase: "live-directory");
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), watch.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
output.Write("watch.complete", "complete", phase: "lifecycle");
|
||||
return TestClientExitCode.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameStreamEvent(SessionStreamEvent expected, SessionStreamEvent actual) =>
|
||||
expected.Kind == actual.Kind
|
||||
&& string.Equals(expected.Cursor, actual.Cursor, StringComparison.Ordinal)
|
||||
&& expected.ListingId == actual.ListingId
|
||||
&& expected.Session?.ListingId == actual.Session?.ListingId;
|
||||
|
||||
private static string CorruptCursor(string cursor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return "invalid-stream-cursor";
|
||||
}
|
||||
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||
return cursor[..^1] + replacement;
|
||||
}
|
||||
|
||||
private static async Task<SessionSelection> SelectListingAsync(
|
||||
TestClientOptions options,
|
||||
TestClientOutput output,
|
||||
@@ -765,6 +941,9 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
|
||||
return privateAddress ? "private" : "public";
|
||||
}
|
||||
|
||||
private static string AddressFamilyName(IPAddress address) =>
|
||||
address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
|
||||
|
||||
private static long ToMilliseconds(TimeSpan elapsed) =>
|
||||
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ internal enum TestClientMode
|
||||
{
|
||||
Host,
|
||||
Browse,
|
||||
Watch,
|
||||
Join,
|
||||
}
|
||||
|
||||
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
|
||||
internal bool Script { get; init; }
|
||||
internal bool Json { get; init; }
|
||||
internal bool ExitAfterEcho { get; init; }
|
||||
internal bool ExerciseReconnect { get; init; }
|
||||
internal bool ExerciseReset { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class TestClientParseResult
|
||||
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
|
||||
Usage:
|
||||
rendezvous-test-client host [options]
|
||||
rendezvous-test-client browse [options]
|
||||
rendezvous-test-client watch [options]
|
||||
rendezvous-test-client join [options]
|
||||
|
||||
Common options:
|
||||
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
|
||||
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
|
||||
|
||||
Watch options:
|
||||
--run-seconds NUMBER Stop after 1-86400 seconds
|
||||
--exercise-reconnect Disconnect after an update and verify ordered replay
|
||||
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
|
||||
|
||||
Join options:
|
||||
--listing UUID Join an exact listing; otherwise browse/select
|
||||
|
||||
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
|
||||
bool script = false;
|
||||
bool json = false;
|
||||
bool exitAfterEcho = false;
|
||||
bool exerciseReconnect = false;
|
||||
bool exerciseReset = false;
|
||||
HashSet<string> seen = new(StringComparer.Ordinal);
|
||||
|
||||
for (int index = 1; index < args.Length; index++)
|
||||
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
|
||||
{
|
||||
return TestClientParseResult.Help();
|
||||
}
|
||||
if (option is "--script" or "--json" or "--exit-after-echo")
|
||||
if (option is "--script" or "--json" or "--exit-after-echo"
|
||||
or "--exercise-reconnect" or "--exercise-reset")
|
||||
{
|
||||
if (!seen.Add(option))
|
||||
{
|
||||
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
|
||||
script |= option == "--script";
|
||||
json |= option == "--json";
|
||||
exitAfterEcho |= option == "--exit-after-echo";
|
||||
exerciseReconnect |= option == "--exercise-reconnect";
|
||||
exerciseReset |= option == "--exercise-reset";
|
||||
continue;
|
||||
}
|
||||
if (!option.StartsWith("--", StringComparison.Ordinal)
|
||||
@@ -279,8 +293,11 @@ internal static class TestClientOptionParser
|
||||
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
|
||||
}
|
||||
if (listingId.HasValue && mode != TestClientMode.Join
|
||||
|| runSeconds.HasValue && mode != TestClientMode.Host
|
||||
|| runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch)
|
||||
|| exitAfterEcho && mode != TestClientMode.Host
|
||||
|| exerciseReconnect && mode != TestClientMode.Watch
|
||||
|| exerciseReset && mode != TestClientMode.Watch
|
||||
|| exerciseReconnect && exerciseReset
|
||||
|| metadata.Count > 0 && mode != TestClientMode.Host
|
||||
|| dedicatedFallback is not null && mode != TestClientMode.Host
|
||||
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|
||||
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
|
||||
Script = script,
|
||||
Json = json,
|
||||
ExitAfterEcho = exitAfterEcho,
|
||||
ExerciseReconnect = exerciseReconnect,
|
||||
ExerciseReset = exerciseReset,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
||||
string? displayName = null,
|
||||
string? outcome = null,
|
||||
string? endpointType = null,
|
||||
string? addressFamily = null,
|
||||
int? count = null,
|
||||
long? elapsedMilliseconds = null,
|
||||
string? message = null) => WriteCore(
|
||||
@@ -37,6 +38,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
||||
DisplayName = SafeText(displayName),
|
||||
Outcome = SafeToken(outcome),
|
||||
EndpointType = SafeToken(endpointType),
|
||||
AddressFamily = SafeToken(addressFamily),
|
||||
Count = count,
|
||||
ElapsedMilliseconds = elapsedMilliseconds,
|
||||
Message = SafeText(message),
|
||||
@@ -92,6 +94,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
||||
Append(line, "name", item.DisplayName, quote: true);
|
||||
Append(line, "outcome", item.Outcome);
|
||||
Append(line, "endpoint", item.EndpointType);
|
||||
Append(line, "addressFamily", item.AddressFamily);
|
||||
if (item.Count.HasValue)
|
||||
{
|
||||
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
@@ -174,6 +177,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Outcome { get; init; }
|
||||
public string? EndpointType { get; init; }
|
||||
public string? AddressFamily { get; init; }
|
||||
public int? Count { get; init; }
|
||||
public long? ElapsedMilliseconds { get; init; }
|
||||
public string? Message { get; init; }
|
||||
|
||||
@@ -7,18 +7,25 @@ namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
internal sealed class SessionBrowserFixture : IDisposable
|
||||
{
|
||||
private readonly EphemeralStateFixture _state = new();
|
||||
private readonly EphemeralStateFixture _state;
|
||||
|
||||
public SessionBrowserFixture()
|
||||
{
|
||||
Changes = new(new SessionChangeJournalOptions());
|
||||
_state = new(changes: Changes);
|
||||
Cursors = new();
|
||||
Browser = new(_state.Store, Cursors, _state.Clock);
|
||||
StreamCursors = new();
|
||||
Browser = new(_state.Store, Cursors, StreamCursors, Changes, _state.Clock);
|
||||
Streams = new(Changes, StreamCursors, _state.Clock);
|
||||
}
|
||||
|
||||
public InMemoryEphemeralRendezvousStore Store => _state.Store;
|
||||
public ManualRendezvousClock Clock => _state.Clock;
|
||||
public SessionBrowserCursorCodec Cursors { get; }
|
||||
public SessionStreamCursorCodec StreamCursors { get; }
|
||||
public SessionChangeJournal Changes { get; }
|
||||
public SessionBrowserService Browser { get; }
|
||||
public SessionStreamService Streams { get; }
|
||||
public TenantScope Scope => _state.Scope;
|
||||
|
||||
public StoredListing Add(
|
||||
@@ -67,5 +74,9 @@ internal sealed class SessionBrowserFixture : IDisposable
|
||||
PageSize = pageSize,
|
||||
};
|
||||
|
||||
public void Dispose() => Cursors.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
Cursors.Dispose();
|
||||
StreamCursors.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
using FinalFactory.Rendezvous.Tests.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.Browser;
|
||||
|
||||
public sealed class SessionStreamServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotPlusUpdateMatchesFreshProjection()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing listing = fixture.Add();
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||
using SessionStreamSubscription subscription = AssertSuccess(
|
||||
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||
|
||||
StoreResult<StoredListing> updated = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
displayName: "Updated host",
|
||||
currentPlayers: 4));
|
||||
Assert.True(updated.Succeeded);
|
||||
SessionStreamEvent delta = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, delta.Kind);
|
||||
Assert.Equal("Updated host", delta.Session!.DisplayName);
|
||||
Assert.Equal(4, delta.Session.Capacity.CurrentPlayers);
|
||||
|
||||
BrowseSessionsResponse fresh = AssertSuccess(fixture.Browser.Browse(request));
|
||||
SessionListing expected = Assert.Single(fresh.Items);
|
||||
Assert.Equal(expected.DisplayName, delta.Session.DisplayName);
|
||||
Assert.Equal(expected.Capacity.CurrentPlayers, delta.Session.Capacity.CurrentPlayers);
|
||||
Assert.DoesNotContain("lease", System.Text.Json.JsonSerializer.Serialize(delta, ContractJson.Options), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenceStalenessRecoveryAndRevocationProduceRemoveUpsertRemove()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing listing = fixture.Add();
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||
using SessionStreamSubscription subscription = AssertSuccess(
|
||||
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(21));
|
||||
AssertSuccess(fixture.Browser.Browse(request));
|
||||
SessionStreamEvent stale = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, stale.Kind);
|
||||
Assert.Equal(listing.Definition.ListingId, stale.ListingId);
|
||||
|
||||
StoreResult<StoredListing> rebound = fixture.Store.BindHostPresence(new(
|
||||
listing.Definition.HostPresenceHandle,
|
||||
listing.Definition.HostPresenceFingerprint,
|
||||
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||
null));
|
||||
Assert.True(rebound.Succeeded);
|
||||
Assert.Equal(
|
||||
SessionStreamEventKind.SessionUpsert,
|
||||
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||
|
||||
Assert.True(fixture.Store.RevokeListing(listing.Definition.ListingId).Succeeded);
|
||||
Assert.Equal(
|
||||
SessionStreamEventKind.SessionRemove,
|
||||
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreationAndLeaseExpiryProduceUpsertThenRemove()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||
using SessionStreamSubscription subscription = AssertSuccess(
|
||||
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||
|
||||
StoredListing listing = fixture.Add();
|
||||
SessionStreamEvent created = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, created.Kind);
|
||||
Assert.Equal(listing.Definition.ListingId, created.Session!.ListingId);
|
||||
|
||||
for (int refresh = 0; refresh < 3; refresh++)
|
||||
{
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(19));
|
||||
Assert.True(fixture.Store.BindHostPresence(new(
|
||||
listing.Definition.HostPresenceHandle,
|
||||
listing.Definition.HostPresenceFingerprint,
|
||||
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
|
||||
null)).Succeeded);
|
||||
}
|
||||
fixture.Clock.Advance(TimeSpan.FromSeconds(4));
|
||||
AssertSuccess(fixture.Browser.Browse(request));
|
||||
|
||||
SessionStreamEvent expired = Assert.Single(fixture.Streams.Read(subscription).Events);
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, expired.Kind);
|
||||
Assert.Equal(listing.Definition.ListingId, expired.ListingId);
|
||||
Assert.Equal(
|
||||
StoreResultCode.NotFound,
|
||||
fixture.Store.GetListing(listing.Definition.ListingId, requireFreshPresence: false).Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeProtocolRegionAndFullFiltersNeverLeak()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
request.ExcludeFull = true;
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||
using SessionStreamSubscription subscription = AssertSuccess(
|
||||
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||
|
||||
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
|
||||
fixture.Add(protocolVersion: 99);
|
||||
fixture.Add(regionId: new("other-region"));
|
||||
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
|
||||
Assert.Empty(fixture.Streams.Read(subscription).Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VisibilityCompatibilityAndRegionChangesEnterAndLeaveTheFilter()
|
||||
{
|
||||
using SessionBrowserFixture fixture = new();
|
||||
StoredListing listing = fixture.Add();
|
||||
BrowseSessionsRequest request = fixture.Request();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
|
||||
using SessionStreamSubscription subscription = AssertSuccess(
|
||||
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
|
||||
|
||||
listing = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
listing.Definition.DisplayName,
|
||||
1,
|
||||
visibility: ListingVisibility.Unlisted)).Value!;
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||
listing = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
listing.Definition.DisplayName,
|
||||
1,
|
||||
visibility: ListingVisibility.Public)).Value!;
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||
listing = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
listing.Definition.DisplayName,
|
||||
1,
|
||||
protocolVersion: 99)).Value!;
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||
listing = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
listing.Definition.DisplayName,
|
||||
1,
|
||||
protocolVersion: 7)).Value!;
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
|
||||
listing = fixture.Store.UpdateListing(Update(
|
||||
listing,
|
||||
listing.Definition.DisplayName,
|
||||
1,
|
||||
regionId: new RegionId("other-region"))).Value!;
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplayGapAndForeignCursorForceResetAndSubscriberLimitFailsClosed()
|
||||
{
|
||||
ManualRendezvousClock clock = new();
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||
{
|
||||
ReplayCapacity = 64,
|
||||
MaximumSubscribers = 1,
|
||||
MaximumSubscribersPerTenant = 1,
|
||||
});
|
||||
using SessionStreamCursorCodec cursors = new();
|
||||
SessionStreamService streams = new(changes, cursors, clock);
|
||||
EphemeralStateFixture state = new(changes: changes);
|
||||
StoredListing listing = state.CreateVisibleListing(out _);
|
||||
BrowseSessionsRequest request = new()
|
||||
{
|
||||
GameId = state.Scope.GameId,
|
||||
EnvironmentId = state.Scope.EnvironmentId,
|
||||
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||
};
|
||||
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||
string initial = cursors.Encode(query, changes.CurrentRevision, clock.UtcNow);
|
||||
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(request, initial));
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.CapacityExceeded,
|
||||
streams.Subscribe(request, initial).Error);
|
||||
|
||||
for (int index = 0; index < 65; index++)
|
||||
{
|
||||
listing = state.Store.UpdateListing(Update(
|
||||
listing,
|
||||
displayName: $"Host {index}",
|
||||
currentPlayers: index % 8)).Value!;
|
||||
}
|
||||
Assert.True(streams.Read(subscription).RequiresReset);
|
||||
subscription.Dispose();
|
||||
|
||||
using SessionStreamSubscription foreign = AssertSuccess(streams.Subscribe(
|
||||
request,
|
||||
"not-a-valid-cursor"));
|
||||
Assert.True(streams.Read(foreign).RequiresReset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BurstIsBoundedAndCoalescedWithoutLosingFinalState()
|
||||
{
|
||||
ManualRendezvousClock clock = new();
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||
{
|
||||
ReplayCapacity = 1024,
|
||||
MaximumBatchSize = 128,
|
||||
});
|
||||
using SessionStreamCursorCodec cursors = new();
|
||||
SessionStreamService streams = new(changes, cursors, clock);
|
||||
EphemeralStateFixture state = new(changes: changes);
|
||||
StoredListing listing = state.CreateVisibleListing(out _);
|
||||
BrowseSessionsRequest request = new()
|
||||
{
|
||||
GameId = state.Scope.GameId,
|
||||
EnvironmentId = state.Scope.EnvironmentId,
|
||||
ProtocolVersion = listing.Definition.ProtocolVersion,
|
||||
};
|
||||
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
|
||||
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(
|
||||
request,
|
||||
cursors.Encode(query, changes.CurrentRevision, clock.UtcNow)));
|
||||
|
||||
for (int index = 0; index < 1000; index++)
|
||||
{
|
||||
listing = state.Store.UpdateListing(Update(
|
||||
listing,
|
||||
displayName: $"Host {index}",
|
||||
currentPlayers: index % 8)).Value!;
|
||||
}
|
||||
|
||||
List<SessionStreamEvent> emitted = [];
|
||||
while (subscription.Revision < changes.CurrentRevision)
|
||||
{
|
||||
SessionStreamReadResult read = streams.Read(subscription);
|
||||
Assert.False(read.RequiresReset);
|
||||
emitted.AddRange(read.Events);
|
||||
}
|
||||
|
||||
Assert.Equal(8, emitted.Count);
|
||||
SessionStreamEvent final = emitted[^1];
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, final.Kind);
|
||||
Assert.Equal("Host 999", final.Session!.DisplayName);
|
||||
Assert.Equal(7, final.Session.Capacity.CurrentPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscriberLimitIsEnforcedPerTenantAndReleasedOnDispose()
|
||||
{
|
||||
ManualRendezvousClock clock = new();
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions
|
||||
{
|
||||
MaximumSubscribers = 2,
|
||||
MaximumSubscribersPerTenant = 1,
|
||||
});
|
||||
using SessionStreamCursorCodec cursors = new();
|
||||
SessionStreamService streams = new(changes, cursors, clock);
|
||||
TenantScope firstScope = new(new("first-game"), new("production"));
|
||||
TenantScope secondScope = new(new("second-game"), new("production"));
|
||||
BrowseSessionsRequest firstRequest = Request(firstScope);
|
||||
BrowseSessionsRequest secondRequest = Request(secondScope);
|
||||
string firstCursor = cursors.Encode(
|
||||
new VisibleListingQuery(firstScope, 7, null),
|
||||
changes.CurrentRevision,
|
||||
clock.UtcNow);
|
||||
string secondCursor = cursors.Encode(
|
||||
new VisibleListingQuery(secondScope, 7, null),
|
||||
changes.CurrentRevision,
|
||||
clock.UtcNow);
|
||||
|
||||
SessionStreamSubscription first = AssertSuccess(streams.Subscribe(firstRequest, firstCursor));
|
||||
Assert.Equal(
|
||||
RendezvousErrorCode.CapacityExceeded,
|
||||
streams.Subscribe(firstRequest, firstCursor).Error);
|
||||
using SessionStreamSubscription second = AssertSuccess(
|
||||
streams.Subscribe(secondRequest, secondCursor));
|
||||
first.Dispose();
|
||||
using SessionStreamSubscription replacement = AssertSuccess(
|
||||
streams.Subscribe(firstRequest, firstCursor));
|
||||
}
|
||||
|
||||
private static BrowseSessionsRequest Request(TenantScope scope) => new()
|
||||
{
|
||||
GameId = scope.GameId,
|
||||
EnvironmentId = scope.EnvironmentId,
|
||||
ProtocolVersion = 7,
|
||||
};
|
||||
|
||||
private static UpdateListingCommand Update(
|
||||
StoredListing listing,
|
||||
string displayName,
|
||||
int currentPlayers,
|
||||
RegionId? regionId = null,
|
||||
uint? protocolVersion = null,
|
||||
ListingVisibility? visibility = null) => new(
|
||||
listing.Definition.ListingId,
|
||||
listing.Definition.LeaseId,
|
||||
listing.Definition.LeaseFingerprint,
|
||||
listing.Definition.OwnerSubject,
|
||||
listing.Definition.BuildVersion,
|
||||
displayName,
|
||||
currentPlayers,
|
||||
listing.Definition.MaximumPlayers,
|
||||
listing.Definition.Metadata,
|
||||
listing.Definition.DedicatedFallback,
|
||||
regionId,
|
||||
protocolVersion,
|
||||
visibility);
|
||||
|
||||
private static SessionStreamEventKind SingleKind(
|
||||
SessionBrowserFixture fixture,
|
||||
SessionStreamSubscription subscription) =>
|
||||
Assert.Single(fixture.Streams.Read(subscription).Events).Kind;
|
||||
|
||||
private static T AssertSuccess<T>(BrowserServiceResult<T> result)
|
||||
{
|
||||
Assert.True(result.Succeeded, result.Error.ToString());
|
||||
return Assert.IsType<T>(result.Value);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,88 @@ public sealed class RendezvousClientBehaviorTests
|
||||
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamRejectsMalformedAndOversizedEventEnvelopes()
|
||||
{
|
||||
string[] bodies =
|
||||
[
|
||||
"event: session_upsert\nid: valid-cursor\ndata: {}\n\n",
|
||||
"data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n",
|
||||
];
|
||||
foreach (string body in bodies)
|
||||
{
|
||||
StringContent content = new(body, Encoding.UTF8, "text/event-stream");
|
||||
ScriptedHandler handler = new(Response(HttpStatusCode.OK, content));
|
||||
using HttpClient httpClient = new(handler)
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
}, "valid-stream-cursor")
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync());
|
||||
Assert.False(events.Current.IsSuccess);
|
||||
Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamRequiresANonEmptySnapshotCursor()
|
||||
{
|
||||
using HttpClient httpClient = new(new ScriptedHandler())
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(httpClient);
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (RendezvousClientResult<SessionStreamEvent> _ in browser.StreamAsync(
|
||||
new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
},
|
||||
string.Empty))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout()
|
||||
{
|
||||
using HttpClient httpClient = new(new SilentHandler())
|
||||
{
|
||||
BaseAddress = new("http://rendezvous.test/"),
|
||||
};
|
||||
RendezvousSessionBrowserClient browser = new(
|
||||
httpClient,
|
||||
new RendezvousClientOptions
|
||||
{
|
||||
MaximumSafeRetries = 0,
|
||||
RequestTimeout = TimeSpan.FromMilliseconds(20),
|
||||
});
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(new BrowseSessionsRequest
|
||||
{
|
||||
GameId = new("space-game"),
|
||||
EnvironmentId = new("production"),
|
||||
ProtocolVersion = 7,
|
||||
}, "valid-stream-cursor")
|
||||
.GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
|
||||
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaseMaintainerReportsLeaseLoss()
|
||||
{
|
||||
|
||||
@@ -142,6 +142,77 @@ public sealed class RendezvousClientIntegrationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
|
||||
{
|
||||
await using ClientTestHost host = await ClientTestHost.StartAsync();
|
||||
RendezvousPublisherClient publisher = new(host.HttpClient);
|
||||
RendezvousSessionBrowserClient browser = new(host.HttpClient);
|
||||
PublishedSession session = AssertSuccess(await publisher.RegisterAsync(
|
||||
CreateRegistration(200),
|
||||
host.PublisherCredential));
|
||||
BindPresence(host, session, 41_200);
|
||||
BrowseSessionsRequest request = BrowseRequest();
|
||||
BrowseSessionsResponse snapshot = AssertSuccess(await browser.BrowseAsync(request));
|
||||
Assert.False(string.IsNullOrWhiteSpace(snapshot.StreamCursor));
|
||||
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10));
|
||||
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid = browser
|
||||
.StreamAsync(request, CorruptCursor(snapshot.StreamCursor), timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token))
|
||||
{
|
||||
Assert.True(await invalid.MoveNextAsync());
|
||||
Assert.Equal(SessionStreamEventKind.Reset, AssertSuccess(invalid.Current).Kind);
|
||||
Assert.False(await invalid.MoveNextAsync());
|
||||
}
|
||||
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
|
||||
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token);
|
||||
Task<bool> upsertPending = events.MoveNextAsync().AsTask();
|
||||
Assert.True((await publisher.UpdateAsync(
|
||||
session,
|
||||
new UpdateSessionRequest
|
||||
{
|
||||
BuildVersion = "2.0.0",
|
||||
DisplayName = "Live update",
|
||||
Capacity = new() { CurrentPlayers = 3, MaximumPlayers = 8 },
|
||||
Metadata = new() { ["mode"] = "online-coop" },
|
||||
},
|
||||
host.PublisherCredential,
|
||||
timeout.Token)).IsSuccess);
|
||||
Assert.True(await upsertPending);
|
||||
SessionStreamEvent upsert = AssertSuccess(events.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, upsert.Kind);
|
||||
Assert.Equal("Live update", upsert.Session!.DisplayName);
|
||||
|
||||
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> replay = browser
|
||||
.StreamAsync(request, snapshot.StreamCursor, timeout.Token)
|
||||
.GetAsyncEnumerator(timeout.Token))
|
||||
{
|
||||
Assert.True(await replay.MoveNextAsync());
|
||||
SessionStreamEvent replayed = AssertSuccess(replay.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionUpsert, replayed.Kind);
|
||||
Assert.Equal(upsert.Cursor, replayed.Cursor);
|
||||
Assert.Equal("Live update", replayed.Session!.DisplayName);
|
||||
}
|
||||
|
||||
Task<bool> removePending = events.MoveNextAsync().AsTask();
|
||||
Assert.True((await publisher.DeregisterAsync(
|
||||
session,
|
||||
host.PublisherCredential,
|
||||
timeout.Token)).IsSuccess);
|
||||
Assert.True(await removePending);
|
||||
SessionStreamEvent remove = AssertSuccess(events.Current);
|
||||
Assert.Equal(SessionStreamEventKind.SessionRemove, remove.Kind);
|
||||
Assert.Equal(session.ListingId, remove.ListingId);
|
||||
}
|
||||
|
||||
private static string CorruptCursor(string cursor)
|
||||
{
|
||||
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
|
||||
return cursor[..^1] + replacement;
|
||||
}
|
||||
|
||||
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
|
||||
{
|
||||
Assert.True(result.IsSuccess, result.Message);
|
||||
@@ -210,7 +281,8 @@ public sealed class RendezvousClientIntegrationTests
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
@@ -239,7 +311,10 @@ public sealed class RendezvousClientIntegrationTests
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||
builder.Services.AddSingleton(changes);
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<SessionStreamService>();
|
||||
|
||||
WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class OpenApiCompatibilityTests
|
||||
"/v1/operator/principals/revoke",
|
||||
"/v1/operator/status",
|
||||
"/v1/sessions",
|
||||
"/v1/sessions/stream",
|
||||
"/v1/sessions/{listingId}",
|
||||
"/v1/sessions/{listingId}/join-attempts",
|
||||
"/v1/sessions/{listingId}/renew",
|
||||
@@ -68,6 +69,25 @@ public sealed class OpenApiCompatibilityTests
|
||||
Assert.DoesNotContain(listingProperties, static property =>
|
||||
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
|
||||
JsonElement streamProperties = schemas.GetProperty("SessionStreamEvent")
|
||||
.GetProperty("properties");
|
||||
Assert.True(streamProperties.TryGetProperty("contractVersion", out _));
|
||||
Assert.True(streamProperties.TryGetProperty("kind", out _));
|
||||
Assert.True(streamProperties.TryGetProperty("cursor", out _));
|
||||
Assert.True(streamProperties.TryGetProperty("session", out _));
|
||||
Assert.True(streamProperties.TryGetProperty("listingId", out _));
|
||||
Assert.DoesNotContain(streamProperties.EnumerateObject(), static property =>
|
||||
property.Name.Contains("token", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Name.Contains("capability", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Name.Contains("ticket", StringComparison.OrdinalIgnoreCase)
|
||||
|| property.Name.Contains("endpoint", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(root.GetProperty("paths")
|
||||
.GetProperty("/v1/sessions/stream")
|
||||
.GetProperty("get")
|
||||
.GetProperty("responses")
|
||||
.GetProperty("200")
|
||||
.GetProperty("content")
|
||||
.TryGetProperty("text/event-stream", out _));
|
||||
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
|
||||
.GetProperty("properties")
|
||||
.GetProperty("dedicatedFallback");
|
||||
@@ -205,7 +225,7 @@ public sealed class OpenApiCompatibilityTests
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(17, overloadContracts);
|
||||
Assert.Equal(18, overloadContracts);
|
||||
(string Path, string Method)[] bodyOperations =
|
||||
[
|
||||
("/v1/sessions", "post"),
|
||||
|
||||
@@ -288,7 +288,8 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
@@ -318,7 +319,10 @@ public sealed class JoinAttemptHttpEndpointTests
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||
builder.Services.AddSingleton(changes);
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<SessionStreamService>();
|
||||
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
|
||||
builder.Services.AddSingleton<JoinAttemptService>();
|
||||
ConnectionOutcomeMetrics outcomeMetrics = new();
|
||||
|
||||
@@ -143,6 +143,10 @@ public sealed class ReleaseCompatibilityTests
|
||||
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
|
||||
Assert.All(pinnedConsumers, static item =>
|
||||
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString()));
|
||||
|
||||
string realConsumerGate = File.ReadAllText(Path.Combine(root, "scripts", "verify-real-consumers.sh"));
|
||||
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Client\" />", realConsumerGate, StringComparison.Ordinal);
|
||||
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Contracts\" />", realConsumerGate, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -164,6 +168,52 @@ public sealed class ReleaseCompatibilityTests
|
||||
Assert.Equal(actual, declared);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionReadinessRecordIsFailClosedAndCanaryEvidenceIsRedacted()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
using JsonDocument readiness = JsonDocument.Parse(File.ReadAllText(Path.Combine(
|
||||
root,
|
||||
"docs/evidence/production-readiness-v1.json")));
|
||||
JsonElement document = readiness.RootElement;
|
||||
Assert.Equal(1, document.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal("rendezvous-production-readiness", document.GetProperty("kind").GetString());
|
||||
Assert.Matches("^[0-9a-f]{40}$", document.GetProperty("evaluatedCommit").GetString());
|
||||
|
||||
JsonElement[] local = document.GetProperty("localGates").EnumerateArray().ToArray();
|
||||
JsonElement[] external = document.GetProperty("externalGates").EnumerateArray().ToArray();
|
||||
Assert.Equal(6, local.Length);
|
||||
Assert.Equal(13, external.Length);
|
||||
JsonElement[] gates = local.Concat(external).ToArray();
|
||||
Assert.Equal(gates.Length, gates.Select(static gate => gate.GetProperty("id").GetString()).Distinct().Count());
|
||||
Assert.All(gates, static gate =>
|
||||
{
|
||||
Assert.True(gate.GetProperty("status").GetString() is "pass" or "pending" or "fail");
|
||||
string evidence = Assert.IsType<string>(gate.GetProperty("evidenceRef").GetString());
|
||||
Assert.False(Path.IsPathRooted(evidence));
|
||||
Assert.DoesNotContain("..", evidence, StringComparison.Ordinal);
|
||||
Assert.True(gate.GetProperty("note").GetString()!.Length <= 240);
|
||||
});
|
||||
bool allPass = gates.All(static gate => gate.GetProperty("status").GetString() == "pass");
|
||||
Assert.Equal(allPass ? "ready" : "not-ready", document.GetProperty("decision").GetString());
|
||||
|
||||
string canary = File.ReadAllText(Path.Combine(root, "scripts/run-real-network-canary.sh"));
|
||||
Assert.Contains("umask 077", canary, StringComparison.Ordinal);
|
||||
Assert.Contains("client-expected-failure", canary, StringComparison.Ordinal);
|
||||
Assert.Contains("exit_code\" -eq 12", canary, StringComparison.Ordinal);
|
||||
Assert.Contains(".addressFamily == $family", canary, StringComparison.Ordinal);
|
||||
Assert.Contains("identifiers:\"not-in-summary\"", canary, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("jq -c . \"$raw_log\"", canary, StringComparison.Ordinal);
|
||||
|
||||
string checker = File.ReadAllText(Path.Combine(root, "eng/check_production_readiness.py"));
|
||||
Assert.Contains("return 3", checker, StringComparison.Ordinal);
|
||||
Assert.Contains("FORBIDDEN_KEY_PARTS", checker, StringComparison.Ordinal);
|
||||
Assert.Contains("decision must be", checker, StringComparison.Ordinal);
|
||||
Assert.Contains("candidate capacity evidence", checker, StringComparison.Ordinal);
|
||||
Assert.Contains("rendezvous-external-gate-attestation", checker, StringComparison.Ordinal);
|
||||
Assert.Contains("does not resolve to a repository evidence file", checker, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string Property(XDocument document, string name) =>
|
||||
document.Descendants(name).Single().Value;
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ public sealed class SessionHttpEndpointTests
|
||||
{
|
||||
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
|
||||
EphemeralStoreOptions stateOptions = new();
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
|
||||
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
|
||||
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
|
||||
EphemeralCapabilityIssuer capabilities = new();
|
||||
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
|
||||
ProvisioningTestData.CreateOptions(),
|
||||
@@ -57,7 +58,10 @@ public sealed class SessionHttpEndpointTests
|
||||
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
|
||||
builder.Services.AddSingleton<SessionLeaseService>();
|
||||
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
|
||||
builder.Services.AddSingleton<SessionStreamCursorCodec>();
|
||||
builder.Services.AddSingleton(changes);
|
||||
builder.Services.AddSingleton<SessionBrowserService>();
|
||||
builder.Services.AddSingleton<SessionStreamService>();
|
||||
await using WebApplication app = builder.Build();
|
||||
app.UseExceptionHandler();
|
||||
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
using FinalFactory.Rendezvous.Server.State;
|
||||
|
||||
namespace FinalFactory.Rendezvous.Tests.State;
|
||||
@@ -24,10 +25,12 @@ internal sealed class EphemeralStateFixture
|
||||
{
|
||||
private int _sequence;
|
||||
|
||||
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
|
||||
public EphemeralStateFixture(
|
||||
EphemeralStoreOptions? options = null,
|
||||
SessionChangeJournal? changes = null)
|
||||
{
|
||||
Clock = new();
|
||||
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
|
||||
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock, changes);
|
||||
}
|
||||
|
||||
public ManualRendezvousClock Clock { get; }
|
||||
|
||||
@@ -59,6 +59,29 @@ public sealed class TestClientCommandTests
|
||||
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WatchModeSupportsBoundedRuntimeAndDeliberateRecoveryExercisesOnly()
|
||||
{
|
||||
TestClientParseResult reset = TestClientOptionParser.Parse(
|
||||
["watch", "--run-seconds", "30", "--exercise-reset", "--script", "--json"]);
|
||||
Assert.True(reset.Succeeded, reset.Error);
|
||||
TestClientOptions resetOptions = Assert.IsType<TestClientOptions>(reset.Options);
|
||||
Assert.Equal(TestClientMode.Watch, resetOptions.Mode);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), resetOptions.RunDuration);
|
||||
Assert.True(resetOptions.ExerciseReset);
|
||||
|
||||
TestClientParseResult reconnect = TestClientOptionParser.Parse(
|
||||
["watch", "--exercise-reconnect", "--script"]);
|
||||
Assert.True(reconnect.Succeeded, reconnect.Error);
|
||||
Assert.True(Assert.IsType<TestClientOptions>(reconnect.Options).ExerciseReconnect);
|
||||
|
||||
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reconnect"]).Succeeded);
|
||||
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reset"]).Succeeded);
|
||||
Assert.False(TestClientOptionParser.Parse(
|
||||
["watch", "--exercise-reconnect", "--exercise-reset"]).Succeeded);
|
||||
Assert.False(TestClientOptionParser.Parse(["join", "--run-seconds", "30"]).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
|
||||
{
|
||||
|
||||
@@ -178,12 +178,17 @@ public sealed class TestClientProcessIntegrationTests
|
||||
Assert.Contains(
|
||||
join.JsonEvents(),
|
||||
item => item.GetProperty("event").GetString() == "join.connected"
|
||||
&& item.GetProperty("endpointType").GetString() is "loopback" or "private");
|
||||
&& item.GetProperty("endpointType").GetString() is "loopback" or "private"
|
||||
&& item.GetProperty("addressFamily").GetString() == "ipv4");
|
||||
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
|
||||
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
|
||||
Assert.Contains(
|
||||
host.JsonEvents(),
|
||||
item => item.GetProperty("event").GetString() == "host.direct-traffic"
|
||||
&& item.GetProperty("addressFamily").GetString() == "ipv4");
|
||||
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
|
||||
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
|
||||
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002","streamCursor":"stream-cursor-002"}
|
||||
|
||||
@@ -40,6 +40,7 @@ TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
|
||||
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
|
||||
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
|
||||
@@ -212,6 +213,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
|
||||
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
|
||||
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
|
||||
EVENT System.EventHandler LeaseLost
|
||||
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
|
||||
|
||||
@@ -28,6 +28,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
|
||||
PROP System.String NextCursor {get;set;}
|
||||
PROP System.String StreamCursor {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
|
||||
ENUM UnderOneSecond=1
|
||||
ENUM OneToFiveSeconds=2
|
||||
@@ -84,6 +85,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
|
||||
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
|
||||
FIELD System.Int32 RegionIdMaxCharacters=32
|
||||
FIELD System.Int32 SessionCapacityMaxPlayers=10000
|
||||
FIELD System.Int32 SessionStreamEventMaxBytes=32768
|
||||
FIELD System.Int32 UdpCapabilityMaxCharacters=192
|
||||
FIELD System.Int32 UdpDatagramMaxBytes=1200
|
||||
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
|
||||
@@ -345,6 +347,18 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
|
||||
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
|
||||
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
||||
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
|
||||
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEvent
|
||||
CTOR ()
|
||||
PROP System.Int32 ContractVersion {get;set;}
|
||||
PROP System.String Cursor {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionStreamEventKind Kind {get;set;}
|
||||
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.SessionListingId> ListingId {get;set;}
|
||||
PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
|
||||
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEventKind
|
||||
ENUM SessionUpsert=1
|
||||
ENUM SessionRemove=2
|
||||
ENUM Reset=3
|
||||
ENUM Keepalive=4
|
||||
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
|
||||
ENUM None=0
|
||||
ENUM DatagramTooLarge=1
|
||||
@@ -371,3 +385,6 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
|
||||
PROP System.String DisplayName {get;set;}
|
||||
PROP System.String LeaseToken {get;set;}
|
||||
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
|
||||
PROP System.Nullable<System.UInt32> ProtocolVersion {get;set;}
|
||||
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RegionId> RegionId {get;set;}
|
||||
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.ListingVisibility> Visibility {get;set;}
|
||||
|
||||
Reference in New Issue
Block a user