Compare commits

...

7 Commits

Author SHA1 Message Date
KyuubiYoru 06c4ecf8f3 feat(browser): stream bounded live session updates (#26)
quality-gate / quality (push) Failing after 1m47s
quality-gate / container (push) Has been skipped
2026-07-16 23:25:48 +02:00
KyuubiYoru 95c3a4aed6 docs(operations): record v1 readiness evidence (#23)
quality-gate / quality (push) Failing after 1m29s
quality-gate / container (push) Has been skipped
2026-07-16 22:39:50 +02:00
KyuubiYoru 00d5ff7764 feat(operations): add production readiness gate (#23) 2026-07-16 22:19:51 +02:00
KyuubiYoru 6bad659c12 docs(integration): record Unscouted pilot evidence (#22)
quality-gate / quality (push) Failing after 1m35s
quality-gate / container (push) Has been skipped
2026-07-16 21:53:48 +02:00
KyuubiYoru f368fec6eb feat(deploy): provision Unscouted smoke tenant (#22) 2026-07-16 21:49:17 +02:00
KyuubiYoru 9e863ebf64 docs(integration): verify Godot and Linux SpaceGame pilot (#21)
quality-gate / quality (push) Failing after 1m40s
quality-gate / container (push) Has been skipped
2026-07-16 20:32:53 +02:00
KyuubiYoru ebb5eb617c docs(integration): record SpaceGame pilot checkpoint (#21)
quality-gate / quality (push) Failing after 1m26s
quality-gate / container (push) Has been skipped
2026-07-16 19:18:03 +02:00
58 changed files with 3898 additions and 87 deletions
+10
View File
@@ -113,12 +113,22 @@ signing, staged promotion, rollback, and migration are defined in
[releases and compatibility](docs/releases/README.md). [releases and compatibility](docs/releases/README.md).
The scriptable host/browser/join diagnostic and its stable automation contract are The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md). 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, The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
versioning, and secure rollout seams are in the versioning, and secure rollout seams are in the
[game integration guide](docs/integration/sdk-seams.md). [game integration guide](docs/integration/sdk-seams.md).
The always-on three-party scenarios, optional Linux namespace topology, and The always-on three-party scenarios, optional Linux namespace topology, and
simulation limits are documented in the simulation limits are documented in the
[deterministic topology harness](docs/integration/topology-harness.md). [deterministic topology harness](docs/integration/topology-harness.md).
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 ## Development
+38 -5
View File
@@ -1,5 +1,5 @@
{ {
"AllowedHosts": "localhost;127.0.0.1", "AllowedHosts": "localhost;127.0.0.1;rendezvous",
"Rendezvous": { "Rendezvous": {
"Deployment": { "Deployment": {
"PublicHttpBaseUrl": "https://localhost/", "PublicHttpBaseUrl": "https://localhost/",
@@ -32,6 +32,16 @@
"NotBefore": "2026-01-01T00:00:00Z", "NotBefore": "2026-01-01T00:00:00Z",
"SignUntil": "2100-01-01T00:00:00Z", "SignUntil": "2100-01-01T00:00:00Z",
"VerifyUntil": "2100-01-02T00:00:00Z" "VerifyUntil": "2100-01-02T00:00:00Z"
},
{
"KeyId": "local-smoke-unscouted-1",
"SecretReference": "file:/run/secrets/rendezvous-signing-key",
"CredentialKinds": ["DedicatedPublisher"],
"GameId": "unscouted",
"EnvironmentId": "smoke",
"NotBefore": "2026-01-01T00:00:00Z",
"SignUntil": "2100-01-01T00:00:00Z",
"VerifyUntil": "2100-01-02T00:00:00Z"
} }
], ],
"Games": [ "Games": [
@@ -39,18 +49,41 @@
"GameId": "space-game", "GameId": "space-game",
"EnvironmentId": "smoke", "EnvironmentId": "smoke",
"Enabled": true, "Enabled": true,
"ProtocolVersions": [1, 2],
"Regions": ["local"],
"VisibilityModes": ["Public"],
"PublisherTrustModes": ["ManagedDedicated"],
"MetadataValueMaxBytes": {
"mode": 32
},
"RequiredMetadataKeys": [],
"MetadataMaxBytes": 512,
"MetadataMaxKeys": 1,
"MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 0,
"MaxActiveJoinAttempts": 100,
"FallbackPolicy": "DedicatedEndpointAllowed"
},
{
"GameId": "unscouted",
"EnvironmentId": "smoke",
"Enabled": true,
"ProtocolVersions": [1], "ProtocolVersions": [1],
"Regions": ["local"], "Regions": ["local"],
"VisibilityModes": ["Public"], "VisibilityModes": ["Public"],
"PublisherTrustModes": ["ManagedDedicated"], "PublisherTrustModes": ["ManagedDedicated"],
"MetadataValueMaxBytes": {}, "MetadataValueMaxBytes": {
"RequiredMetadataKeys": [], "mode": 32,
"world": 64,
"mods": 64
},
"RequiredMetadataKeys": ["mode", "world", "mods"],
"MetadataMaxBytes": 512, "MetadataMaxBytes": 512,
"MetadataMaxKeys": 0, "MetadataMaxKeys": 3,
"MaxListingsPerPrincipal": 10, "MaxListingsPerPrincipal": 10,
"MaxAnonymousListingsPerAddress": 0, "MaxAnonymousListingsPerAddress": 0,
"MaxActiveJoinAttempts": 100, "MaxActiveJoinAttempts": 100,
"FallbackPolicy": "Disabled" "FallbackPolicy": "DedicatedEndpointAllowed"
} }
] ]
} }
+233 -1
View File
@@ -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": { "/v1/sessions/{listingId}/join-attempts": {
"get": { "get": {
"tags": [ "tags": [
@@ -2657,7 +2810,8 @@
"BrowseSessionsResponse": { "BrowseSessionsResponse": {
"required": [ "required": [
"contractVersion", "contractVersion",
"items" "items",
"streamCursor"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2676,6 +2830,9 @@
"null", "null",
"string" "string"
] ]
},
"streamCursor": {
"type": "string"
} }
} }
}, },
@@ -3545,6 +3702,54 @@
"type": "string", "type": "string",
"format": "uuid" "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": { "UpdateSessionRequest": {
"required": [ "required": [
"contractVersion", "contractVersion",
@@ -3563,6 +3768,33 @@
"leaseToken": { "leaseToken": {
"type": "string" "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": { "buildVersion": {
"type": "string" "type": "string"
}, },
+5 -3
View File
@@ -42,9 +42,11 @@ test "$RENDEZVOUS_UID" -ne 0
docker compose -f deploy/compose/compose.yaml up --build --detach docker compose -f deploy/compose/compose.yaml up --build --detach
``` ```
`deploy/compose/appsettings.Production.json` is an isolated loopback smoke `deploy/compose/appsettings.Production.json` is a local/private-bridge smoke
profile, not an Internet template: it deliberately opts into private advertised profile, not an Internet template: TCP is published only on host loopback, the
endpoints and has no TLS proxy. Its random key is ignored by Git and must be explicit `rendezvous` host name serves isolated clients on the Compose network,
and the profile deliberately opts into private advertised endpoints without a
TLS proxy. Its random key is ignored by Git and must be
deleted after use. Its deliberately long key window only keeps this disposable deleted after use. Its deliberately long key window only keeps this disposable
local fixture usable; production keys require short, reviewed rotation windows. local fixture usable; production keys require short, reviewed rotation windows.
Production configuration must use its real public names and must leave Production configuration must use its real public names and must leave
+35 -35
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 2, "schemaVersion": 2,
"evidenceVersion": "v2", "evidenceVersion": "v2",
"generatedAt": "2026-07-16T14:10:53.6981858+00:00", "generatedAt": "2026-07-16T20:28:43.2873744+00:00",
"profile": "candidate", "profile": "candidate",
"runtime": { "runtime": {
"framework": ".NET 10.0.9", "framework": ".NET 10.0.9",
@@ -14,14 +14,14 @@
"cpuQuota": "not-enforced", "cpuQuota": "not-enforced",
"memoryLimit": "not-enforced", "memoryLimit": "not-enforced",
"garbageCollector": "workstation", "garbageCollector": "workstation",
"commitSha": "cf14836d48b0b4aaa67f99433f4fba3585bcd2bb", "commitSha": "00d5ff776408e7d80ce6648953e62a7233aca35c",
"treeState": "clean", "treeState": "clean",
"command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh", "command": "RENDEZVOUS_CAPACITY_PROFILE=candidate RENDEZVOUS_CAPACITY_CPUSET=0,1 ./scripts/run-capacity-gate.sh",
"imageDigest": "not-containerized", "imageDigest": "not-containerized",
"workloadSeed": "fixed-sequences-random-identifiers", "workloadSeed": "fixed-sequences-random-identifiers",
"capacityPhaseAverageCpuPercent": 56.37724115383554, "capacityPhaseAverageCpuPercent": 55.52666859166872,
"peakWorkingSetBytes": 169705472, "peakWorkingSetBytes": 176758784,
"managedBytesAfterCleanup": 35615200 "managedBytesAfterCleanup": 35608984
}, },
"targets": { "targets": {
"visibleListings": 25000, "visibleListings": 25000,
@@ -39,10 +39,10 @@
{ {
"operation": "registration-and-presence", "operation": "registration-and-presence",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.003, "p50Milliseconds": 0.0029,
"p95Milliseconds": 0.0046, "p95Milliseconds": 0.0046,
"p99Milliseconds": 0.0054, "p99Milliseconds": 0.0055,
"operationsPerSecond": 282453.96000451926, "operationsPerSecond": 287918.9220315559,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -51,9 +51,9 @@
"operation": "lease-renewal", "operation": "lease-renewal",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0004, "p50Milliseconds": 0.0004,
"p95Milliseconds": 0.0009, "p95Milliseconds": 0.0007,
"p99Milliseconds": 0.0021, "p99Milliseconds": 0.0019,
"operationsPerSecond": 968992.2480620155, "operationsPerSecond": 1076426.264800861,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -61,10 +61,10 @@
{ {
"operation": "visible-session-browse", "operation": "visible-session-browse",
"samples": 250, "samples": 250,
"p50Milliseconds": 0.9046, "p50Milliseconds": 1.0232,
"p95Milliseconds": 3.3704, "p95Milliseconds": 3.6083,
"p99Milliseconds": 3.9471, "p99Milliseconds": 4.2925,
"operationsPerSecond": 695.5799787597697, "operationsPerSecond": 650.0325926341947,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -72,10 +72,10 @@
{ {
"operation": "join-attempt-issuance", "operation": "join-attempt-issuance",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0029, "p50Milliseconds": 0.0028,
"p95Milliseconds": 0.0045, "p95Milliseconds": 0.0042,
"p99Milliseconds": 0.0055, "p99Milliseconds": 0.0052,
"operationsPerSecond": 296428.042092782, "operationsPerSecond": 135253.93927098127,
"minimumOperationsPerSecond": 200, "minimumOperationsPerSecond": 200,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -83,10 +83,10 @@
{ {
"operation": "simultaneous-punch-pairing", "operation": "simultaneous-punch-pairing",
"samples": 1000, "samples": 1000,
"p50Milliseconds": 0.0043, "p50Milliseconds": 0.0039,
"p95Milliseconds": 0.0073, "p95Milliseconds": 0.0069,
"p99Milliseconds": 0.0115, "p99Milliseconds": 0.0087,
"operationsPerSecond": 109212.03516627532, "operationsPerSecond": 110619.46902654869,
"minimumOperationsPerSecond": 2000, "minimumOperationsPerSecond": 2000,
"budgetMilliseconds": 100, "budgetMilliseconds": 100,
"passed": true "passed": true
@@ -94,10 +94,10 @@
{ {
"operation": "principal-revocation", "operation": "principal-revocation",
"samples": 50, "samples": 50,
"p50Milliseconds": 0.518, "p50Milliseconds": 0.495,
"p95Milliseconds": 0.7049, "p95Milliseconds": 0.6508,
"p99Milliseconds": 11.8557, "p99Milliseconds": 11.011,
"operationsPerSecond": 1320.1773262184577, "operationsPerSecond": 1393.258301729591,
"minimumOperationsPerSecond": 50, "minimumOperationsPerSecond": 50,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -108,7 +108,7 @@
"p50Milliseconds": 0.0001, "p50Milliseconds": 0.0001,
"p95Milliseconds": 0.0001, "p95Milliseconds": 0.0001,
"p99Milliseconds": 0.0001, "p99Milliseconds": 0.0001,
"operationsPerSecond": 1438641.9220256077, "operationsPerSecond": 1479289.9408284025,
"minimumOperationsPerSecond": 10000, "minimumOperationsPerSecond": 10000,
"budgetMilliseconds": 1, "budgetMilliseconds": 1,
"passed": true "passed": true
@@ -116,10 +116,10 @@
{ {
"operation": "coincident-listing-attempt-expiry", "operation": "coincident-listing-attempt-expiry",
"samples": 1, "samples": 1,
"p50Milliseconds": 29.6882, "p50Milliseconds": 27.7056,
"p95Milliseconds": 29.6882, "p95Milliseconds": 27.7056,
"p99Milliseconds": 29.6882, "p99Milliseconds": 27.7056,
"operationsPerSecond": 33.682962483916384, "operationsPerSecond": 36.093525543388026,
"minimumOperationsPerSecond": 0, "minimumOperationsPerSecond": 0,
"budgetMilliseconds": 200, "budgetMilliseconds": 200,
"passed": true "passed": true
@@ -134,10 +134,10 @@
"finalReplayMarkers": 0, "finalReplayMarkers": 0,
"expiryChurn": 94906, "expiryChurn": 94906,
"maintenanceSweeps": 36307, "maintenanceSweeps": 36307,
"soakCyclesCompleted": 75126848, "soakCyclesCompleted": 77547145,
"soakDurationSeconds": 300.0000015, "soakDurationSeconds": 300.0000041,
"soakPeakScheduledExpiryEntries": 7, "soakPeakScheduledExpiryEntries": 7,
"soakManagedGrowthBytes": -257288, "soakManagedGrowthBytes": -263432,
"soakHandleGrowth": 2, "soakHandleGrowth": 2,
"restartStartedEmpty": true, "restartStartedEmpty": true,
"overloadWasTyped": true, "overloadWasTyped": true,
+86
View File
@@ -0,0 +1,86 @@
{
"schemaVersion": "1.0",
"recordedAt": "2026-07-16",
"issue": 21,
"consumerIssue": "Kyuubi/SpaceGame#3",
"result": "checkpoint-pass-with-external-gates",
"rendezvousBaseCommit": "ebb5eb617c0bbb170418afab396b68584b7f992e",
"consumerCommit": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
"consumerIssueComment": 11469,
"packages": {
"FinalFactory.Rendezvous.Client": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
},
"FinalFactory.Rendezvous.Contracts": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
},
"LiteNetLib": {
"version": "2.1.4"
}
},
"localRun": {
"processes": ["Rendezvous", "Godot SpaceGame host", "two sequential Godot SpaceGame clients"],
"typedOutcome": "Connected",
"gameAdmission": "Accepted",
"directGameplay": true,
"authenticatedSessions": 2,
"directInputs": 2,
"directSnapshots": 2,
"lifecyclePackets": 4,
"gameplayTransport": "caller-owned-litenetlib",
"rendezvousGameplayPayloadPath": "none",
"hostLeaseRenewed": true,
"reconnected": true,
"deregistered": true
},
"linuxRun": {
"runtime": "Godot 4.7 .NET Linux x86_64",
"freshExport": true,
"sourceDirty": false,
"optimized": true,
"dedicatedHostNamespace": "docker",
"remoteClientNamespace": "docker",
"topology": "private-bridge",
"directGameplay": true,
"fallback": "PunchTimedOut to explicit Docker-gateway endpoint, then Accepted game admission and direct gameplay",
"artifactHashes": "SpaceGame issue #3 comment 11469"
},
"negativePaths": {
"incompatibleProtocol": "proven",
"staleHostPresence": "proven",
"punchTimeout": "proven",
"invalidAdmission": "integration-proven",
"capacity": "regression-tested",
"fallbackConnection": "godot-and-isolated-linux-proven",
"reconnect": "proven"
},
"verification": {
"debugBuild": "passed",
"releaseBuild": "passed",
"debugTests": { "passed": 31, "failed": 0 },
"releaseTests": { "passed": 31, "failed": 0 },
"exportRelease": "optimized-without-debug-symbols",
"format": "passed",
"shellcheck": "passed",
"godotReconnectHarness": "passed",
"godotFallbackHarness": "passed",
"linuxContainerHarness": "passed-clean-source",
"failureMatrix": "passed",
"adversarialReview": "passed-after-fixes"
},
"openGates": [
"public-package-restore",
"representative-external-nat"
],
"relatedSpaceGameGates": [
"production-enet-replacement",
"capacity-profiles-64-and-128",
"sigterm-drain-save"
]
}
+82
View File
@@ -0,0 +1,82 @@
{
"schemaVersion": "1.0",
"recordedAt": "2026-07-16",
"issue": 22,
"consumerIssue": "HeiKyu/Unscouted#459",
"result": "checkpoint-pass-with-external-gates",
"rendezvousConfigurationCommit": "f368fec6eb4344a6042974f58f888cf0f1ac8e8e",
"consumerImplementationCommit": "1e5886aa7f1e44689b4c75e32693eb7b19fd72d7",
"consumerEvidenceCommit": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
"consumerIssueComment": 11499,
"packages": {
"FinalFactory.Rendezvous.Client": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "fb156cf48b49f75c244dd25ea7cc4aa9fc6fab0a878393bb7efd5d9b131d0395"
},
"FinalFactory.Rendezvous.Contracts": {
"version": "1.0.0",
"source": "local-candidate",
"sourceCommit": "07004cd75fe172aa5dfdb3edda22fc280a4c4477",
"sha256": "a82ba986d3905d599096d1d8ce8f32cd4feb104abfca37b0f65e0d2ef3df9a6f"
},
"LiteNetLib": {
"version": "2.1.4"
}
},
"configuration": {
"gameId": "unscouted",
"environmentId": "smoke",
"regionId": "local",
"protocolVersion": 1,
"publisherTrust": "ManagedDedicated",
"fallbackPolicy": "DedicatedEndpointAllowed",
"metadataKeys": ["mode", "world", "mods"],
"metadataMaxKeys": 3,
"metadataMaxBytes": 512
},
"godotRun": {
"runtime": "Godot 4.7 .NET Linux x86_64",
"processes": [
"Rendezvous hardened Compose service",
"Godot Unscouted host",
"Godot incompatible-protocol client",
"Godot direct client",
"Godot fallback client"
],
"gameplayTransport": "unscouted-litenetlib",
"rendezvousGameplayPayloadPath": "none",
"directGameplay": true,
"fallbackGameplay": true,
"authenticatedSessions": 2,
"gameplayExchanges": 2,
"hostLeaseRenewed": true,
"deregistered": true,
"playerIdentityOwner": "unscouted",
"canonicalGameStateOwner": "unscouted"
},
"negativePaths": {
"incompatibleProtocol": "proven-no-compatible-listing",
"wrongGame": "proven-exact-NotFound",
"wrongEnvironment": "proven-exact-NotFound",
"punchTimeout": "proven-typed-failure-then-game-owned-fallback",
"unexpectedMetadata": "consumer-regression-tested"
},
"verification": {
"rendezvousDebugTests": { "passed": 299, "failed": 0 },
"rendezvousReleaseTests": { "passed": 299, "failed": 0 },
"consumerDebugTests": { "passed": 3310, "skipped": 15, "failed": 0 },
"consumerReleaseTests": { "passed": 3310, "skipped": 15, "failed": 0 },
"consumerGdUnitTests": { "passed": 360, "skipped": 0, "failed": 0 },
"consumerExport": "not-applicable-no-export-presets",
"format": "passed",
"shellcheck": "passed",
"godotPilot": "passed",
"adversarialReview": "passed-after-fixes"
},
"openGates": [
"public-package-restore",
"representative-external-nat"
]
}
+124
View File
@@ -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"
}
}
+114
View File
@@ -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.
+5
View File
@@ -70,6 +70,7 @@ thread and do not also call `NetManager.PollEvents()` during that period.
```csharp ```csharp
RendezvousNetListener networkEvents = new(); RendezvousNetListener networkEvents = new();
NetManager gameplayNetwork = networkEvents.CreateManager(); NetManager gameplayNetwork = networkEvents.CreateManager();
gameplayNetwork.ChannelsCount = 3; // set the game's required count before Start
if (!gameplayNetwork.Start(gameplayPort)) if (!gameplayNetwork.Start(gameplayPort))
{ {
throw new InvalidOperationException("Gameplay UDP socket could not start."); throw new InvalidOperationException("Gameplay UDP socket could not start.");
@@ -85,6 +86,10 @@ using RendezvousHostCoordinator host = new(
host.Poll(); // call each game frame while this coordinator owns polling host.Poll(); // call each game frame while this coordinator owns polling
``` ```
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
the game protocol uses additional channels; both peers must configure the same
count. Rendezvous does not choose, remap, or reserve a gameplay channel.
Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous Register normal game callbacks on `networkEvents.GameplayEvents`. Rendezvous
reserves only its authenticated direct requests and forwards other callbacks. reserves only its authenticated direct requests and forwards other callbacks.
The same socket sends host presence, punches through the mediator, establishes The same socket sends host presence, punches through the mediator, establishes
+112
View File
@@ -0,0 +1,112 @@
# SpaceGame consumer pilot
Tracking: Rendezvous #21 and SpaceGame #3.
The current SpaceGame checkpoint proves that the v1 client boundary establishes
authenticated direct LiteNetLib traffic without taking ownership of the game's
protocol, admission, player identity, entity identity, capacity, lifecycle, or
gameplay payloads. Real Godot processes, reconnect, an explicit dedicated
fallback, and a fresh Linux export now pass. The public package restore and a
representative external NAT/CGNAT canary remain required before #21 can close.
## Pinned checkpoint
| Input | Value |
| --- | --- |
| Rendezvous compatibility source | `ebb5eb617c0bbb170418afab396b68584b7f992e` plus the current #21 configuration/evidence changes |
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
| SpaceGame source | `f3f5bc29810c362656cd7143bec1ddc2cfaf9f22` |
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
| LiteNetLib | `2.1.4` |
| HTTP, UDP, ticket contracts | `1` |
| SpaceGame gameplay protocol | `2` |
At the checkpoint date, the Final Factory Gitea NuGet service was reachable but
both `FinalFactory.Rendezvous.*` `1.0.0` registrations returned HTTP 404. The run
therefore restored locally built candidate packages with the hashes recorded in
[`spacegame.json`](../evidence/consumers/spacegame.json). This proves candidate
compatibility, not immutable registry publication. The release package restore
must be repeated from the public feed.
## Proven local path
The SpaceGame host and client each create one caller-owned `NetManager`, set its
three gameplay QoS channels before `Start`, and give the same manager and
`RendezvousNetListener` to the coordinator. Rendezvous authenticates discovery,
join authorization, host presence, mediation, and connection outcome reporting.
After traversal, SpaceGame performs a separate audience-bound admission exchange
on its own reliable command channel. A trusted game-auth boundary mints the
opaque assertion; the player process never receives the signing key.
The authoritative host rejects expired, replayed, incorrectly signed,
wrong-listing, duplicate-player, over-capacity, identity-mismatched,
out-of-sequence, and over-rate traffic. It assigns a canonical game entity ID
only after admission. The player ID, entity ID, listing ID, join-attempt ID, and
LiteNetLib peer ID remain distinct values.
The bounded real-process harnesses observed:
- host publication and lease maintenance;
- browser compatibility filtering and join authorization;
- typed traversal outcome `Connected`;
- successful audience-bound game admission;
- reliable ordered frame-definition and spawn lifecycle records, reliable
ordered input, and sequenced state snapshots on the caller-owned gameplay
socket;
- disconnect and a new authenticated session for the same durable player while
LiteNetLib peers and canonical entity IDs change;
- immediate host lease renewal and successful host deregistration;
- a fresh optimized Linux export running the host and client in distinct
hardened container namespaces; and
- a forced punch timeout that connects the isolated client to an explicitly
advertised, non-loopback Docker-gateway fallback and repeats game admission.
Rendezvous exposes no gameplay relay API; all lifecycle, command, and snapshot
bytes are sent by SpaceGame through its caller-owned `NetManager`. Both Debug
and Release builds passed. Both Debug and Release test runs passed 31 tests with
zero failures. ExportRelease is optimized with debug symbols removed. The
focused formatter, shell checker, fresh-export provenance gate, clean
candidate-package restore, and adversarial branch review also passed.
## Failure evidence
| Path | Evidence | Status |
| --- | --- | --- |
| Incompatible protocol | protocol `999` returns no compatible listing and starts no traversal | Proven |
| Stale/no host presence | typed `NoHostPresence/RendezvousService/HostPresence/Mediation` | Proven |
| Traversal timeout | non-listening mediator produces typed `PunchTimedOut/LocalTraversal/NatTraversal/NatTraversal` | Proven |
| Rejected game admission | invalid signature denies gameplay in the process matrix; wrong audience, expiry, and replay are regression-tested | Proven |
| Capacity and duplicate player | game-owned roster rejects both and publishes current capacity | Regression-tested |
| Configured fallback | typed `PunchTimedOut`, explicit non-loopback endpoint, same game admission, direct gameplay | Proven locally and across Linux namespaces |
| Disconnect | host observes zero active players and final admitted count zero | Proven |
| Reconnect | same durable player enters a second authenticated session with new peer/entity IDs | Proven |
## Rendezvous-side compatibility fixes
The pilot found generic integration gaps and keeps their fixes in this
repository:
- the local production-shaped smoke tenant accepts SpaceGame gameplay protocol
`2` and the bounded `mode` metadata key;
- the Compose smoke tenant explicitly allows its private-network service name
and enables only the dedicated-endpoint fallback policy;
- SDK guidance requires games using multiple LiteNetLib QoS channels to set
`ChannelsCount` before `Start` and states that Rendezvous reserves no gameplay
channel; and
- the local credential helper rejects any signing-key file with group or other
permissions, in addition to its ownership, symlink, and hard-link checks.
Documentation contract tests cover these generic requirements.
## Remaining acceptance gates
Do not mark #21 passed until both remaining external gates have direct evidence:
1. restore the exact immutable `1.0.0` packages from the public Gitea feed; and
2. run representative external NAT/CGNAT canaries and record the network
topology and typed outcome.
SpaceGame #3 remains open independently for the production ENet replacement,
64/128-player profiles, and SIGTERM/drain/save evidence. The consumer pilot
does not claim those broader game-migration gates.
+14 -1
View File
@@ -116,6 +116,9 @@ rm deploy/compose/secrets/signing-key
codes are stable automation contracts. Informational events use stdout and codes are stable automation contracts. Informational events use stdout and
failures use stderr. 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 The deployment smoke performs the full health, publish, join, mediation, direct
traffic, outcome-report, and cleanup flow using bounded waits: 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 such as `host.ready` and apply a deadline. Useful success events are
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`, `host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`, `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 | | 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 external service, mediator, and matching scope variables described in the
[secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke). [secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke).
Run representative external-network tests; loopback success is not NAT coverage. 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.
+99
View File
@@ -0,0 +1,99 @@
# Unscouted consumer pilot
Tracking: Rendezvous #22 and Unscouted #459.
The current checkpoint independently proves that the v1 contracts are not
shaped only around SpaceGame. A real Godot Unscouted host and clients consume
the same Client and Contracts package surface, use one caller-owned LiteNetLib
socket for NAT callbacks and gameplay, perform Unscouted's own keypair
authentication and host admission, exchange gameplay, and exercise a
game-owned fallback. The public package restore and representative external
NAT/CGNAT canary remain required before #22 can close.
## Pinned checkpoint
| Input | Value |
| --- | --- |
| Rendezvous configuration source | `f368fec6eb4344a6042974f58f888cf0f1ac8e8e` |
| Rendezvous package source | `07004cd75fe172aa5dfdb3edda22fc280a4c4477` |
| Unscouted implementation | `1e5886aa7f1e44689b4c75e32693eb7b19fd72d7` |
| Unscouted evidence | `f0574a7de82aadff6495ca5657dfc19cf7c2f67c` |
| Client package | `FinalFactory.Rendezvous.Client` `1.0.0` |
| Contracts package | `FinalFactory.Rendezvous.Contracts` `1.0.0` |
| LiteNetLib | `2.1.4` |
| Godot | `4.7.stable.mono.arch_linux.5b4e0cb0f` |
| Game / environment / region | `unscouted` / `smoke` / `local` |
| Rendezvous and gameplay protocol | `1` |
The exact package hashes are recorded in
[`unscouted.json`](../evidence/consumers/unscouted.json). A clean restore into an
empty package directory using only the consumer's checked-in `NuGet.config`
returns `NU1101` for both packages. The verified local run used those exact
candidate package files from the existing cache. This proves compatibility,
not immutable registry publication.
## Game-neutral service boundary
Rendezvous #22 adds provisioning data, not an Unscouted branch in the server or
SDK. The local production-shaped tenant permits protocol `1`, region `local`,
public managed-dedicated listings, and the three bounded presentation keys
`mode`, `world`, and `mods`. The short-lived credential helper accepts only the
explicitly provisioned `space-game` and `unscouted` scopes and selects a
distinct game-scoped signing-key ID and subject.
The consumer rejects any metadata key outside its three-key presentation
schema and neutralizes control/BBCode characters before display. Rendezvous
never receives Unscouted player keys or resolved identities, colony authority,
simulation or persistence state, fog/interest state, or gameplay packets.
## Proven real Godot path
The normal `NetLaunch` argument path recognizes `--rendezvous-pilot` and opens a
dedicated scene. That scene uses Unscouted's real `LiteNetLibTransport`,
`GameServer`, `GameClient`, `ServerAuthenticator`, and `ClientAuthenticator`.
It is not a copied SDK adapter.
One bounded run against the hardened Compose service started a host plus:
- a protocol-`999` client that found no compatible listing;
- a direct client that received an authorized introduction, completed
same-socket traversal, passed Unscouted keypair admission, and exchanged an
Unscouted gameplay ping/pong; and
- a client pointed at a non-listening mediator that received a typed traversal
failure, applied the fallback decision in Unscouted code, repeated admission,
and exchanged the same gameplay ping/pong through the ordinary game
transport.
The direct client also proved that both a `space-game` join request and a
`production` environment join request return exact `NotFound` results for the
Unscouted listing. The host renewed its lease, admitted two independently
authenticated sessions, completed two gameplay exchanges, and deregistered the
listing on shutdown.
## Verification
- Rendezvous Debug and Release: 299 tests passed in each configuration, zero
failures.
- Unscouted Debug and Release: non-incremental builds passed; 3,310 tests passed
with 15 intentional skips in each configuration.
- Unscouted gdUnit/Godot: 360 tests passed, zero skipped or failed. The harness
fix in Unscouted #461 keeps compilation headless and leaves the open editor's
build tree unchanged.
- The final Godot pilot, ShellCheck, JSON/whitespace checks, formatting gate,
and adversarial branch review passed.
- Export is not applicable because the Unscouted checkout has no
`export_presets.cfg`; both C# configurations and the actual Godot entry point
were exercised.
## Remaining acceptance gates
Do not mark #22 passed until both external gates have direct evidence:
1. publish or expose the exact immutable `1.0.0` packages on the configured
Gitea feed and repeat the empty-cache consumer restore; and
2. run the same Godot host/client path across representative residential,
CGNAT, and IPv6/multi-host networks, recording the topology and typed
direct/fallback outcome.
The loopback run proves the real process, socket, authentication, and gameplay
shape. It does not claim production Internet traversal coverage.
+1 -1
View File
@@ -81,7 +81,7 @@ concurrent build, thermal throttling, or oversubscribed CI host.
The checked-in baseline is The checked-in baseline is
[`candidate-2cpu.json`](../evidence/capacity/v2/candidate-2cpu.json). It was [`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 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 and cleared all active/retained state. The five-minute baseline supersedes any
earlier local probe when its timestamp and target duration differ. earlier local probe when its timestamp and target duration differ.
+217
View File
@@ -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
+309
View File
@@ -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())
+2 -2
View File
@@ -4,13 +4,13 @@
{ {
"name": "SpaceGame", "name": "SpaceGame",
"repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git", "repository": "https://git.finalfactory.de/Kyuubi/SpaceGame.git",
"revision": "77519b0cc418a27f8d408ae2d7b8812fbe087c04", "revision": "f3f5bc29810c362656cd7143bec1ddc2cfaf9f22",
"project": "SpaceGame.csproj" "project": "SpaceGame.csproj"
}, },
{ {
"name": "Unscouted", "name": "Unscouted",
"repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git", "repository": "https://git.finalfactory.de/HeiKyu/Unscouted.git",
"revision": "7807dbee86eb8b98e702f1eb89c88adff728f635", "revision": "f0574a7de82aadff6495ca5657dfc19cf7c2f67c",
"project": "Net.Core/Net.Core.csproj" "project": "Net.Core/Net.Core.csproj"
} }
] ]
+7
View File
@@ -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"
+26 -7
View File
@@ -3,9 +3,25 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}" LOCAL_KEY="${RENDEZVOUS_SMOKE_LOCAL_KEY:-$ROOT/deploy/compose/secrets/signing-key}"
GAME_ID="${RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID:-space-game}"
case "$GAME_ID" in
space-game)
KEY_ID="local-smoke-1"
SUBJECT="local-smoke-host"
;;
unscouted)
KEY_ID="local-smoke-unscouted-1"
SUBJECT="local-smoke-unscouted-host"
;;
*)
printf 'RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted.\n' >&2
exit 2
;;
esac
if (( $# != 0 )); then if (( $# != 0 )); then
printf 'This helper accepts no arguments and mints only the fixed local Compose smoke scope.\n' >&2 printf 'This helper accepts no arguments; select only a provisioned local game through RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID.\n' >&2
exit 2 exit 2
fi fi
@@ -17,7 +33,7 @@ command -v python3 >/dev/null || {
# This is deliberately a local-fixture tool, not a general credential issuer. # This is deliberately a local-fixture tool, not a general credential issuer.
# Python reads the raw key from the protected file; key material never appears in # Python reads the raw key from the protected file; key material never appears in
# a child process argument, environment value, temporary file, or command output. # a child process argument, environment value, temporary file, or command output.
python3 - "$LOCAL_KEY" <<'PY' python3 - "$LOCAL_KEY" "$GAME_ID" "$KEY_ID" "$SUBJECT" <<'PY'
import base64 import base64
import hashlib import hashlib
import hmac import hmac
@@ -29,6 +45,9 @@ import sys
import time import time
key_path = sys.argv[1] key_path = sys.argv[1]
game_id = sys.argv[2]
key_id = sys.argv[3]
subject = sys.argv[4]
try: try:
metadata = os.lstat(key_path) metadata = os.lstat(key_path)
except FileNotFoundError: except FileNotFoundError:
@@ -42,8 +61,8 @@ if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode):
raise SystemExit(f"Local Compose secret directory must be a non-symlink directory: {parent_path}") raise SystemExit(f"Local Compose secret directory must be a non-symlink directory: {parent_path}")
if parent.st_uid != os.geteuid() or parent.st_mode & 0o077: if parent.st_uid != os.geteuid() or parent.st_mode & 0o077:
raise SystemExit(f"Local Compose secret directory must be owned by this user with mode 0700: {parent_path}") raise SystemExit(f"Local Compose secret directory must be owned by this user with mode 0700: {parent_path}")
if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o022 or metadata.st_nlink != 1: if metadata.st_uid != os.geteuid() or metadata.st_mode & 0o077 or metadata.st_nlink != 1:
raise SystemExit(f"Local Compose smoke key must be owned by this user, single-linked, and not group/world writable: {key_path}") raise SystemExit(f"Local Compose smoke key must be owned by this user, single-linked, and private to its owner: {key_path}")
with open(key_path, "rb") as key_file: with open(key_path, "rb") as key_file:
key = key_file.read(33) key = key_file.read(33)
@@ -55,9 +74,9 @@ payload = {
"version": 1, "version": 1,
"issuer": "final-factory-rendezvous-smoke", "issuer": "final-factory-rendezvous-smoke",
"audience": "rendezvous-service", "audience": "rendezvous-service",
"subject": "local-smoke-host", "subject": subject,
"kind": "dedicatedPublisher", "kind": "dedicatedPublisher",
"gameId": "space-game", "gameId": game_id,
"environmentId": "smoke", "environmentId": "smoke",
"regions": ["local"], "regions": ["local"],
"permissions": [], "permissions": [],
@@ -71,7 +90,7 @@ def base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) encoded = base64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
signed = f"rv1.local-smoke-1.{encoded}" signed = f"rv1.{key_id}.{encoded}"
signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest()) signature = base64url(hmac.new(key, signed.encode("ascii"), hashlib.sha256).digest())
print(f"{signed}.{signature}") print(f"{signed}.{signature}")
PY PY
+243
View File
@@ -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"
+2
View File
@@ -47,6 +47,8 @@ for ((index = 0; index < count; index++)); do
cat >"$targets" <<EOF cat >"$targets" <<EOF
<Project> <Project>
<ItemGroup Condition="'\$(MSBuildProjectFullPath)' == '$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.Client" Version="[$version]" />
<PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" /> <PackageReference Include="FinalFactory.Rendezvous.Contracts" Version="[$version]" />
</ItemGroup> </ItemGroup>
@@ -67,12 +67,17 @@ factory does not open a socket, and synchronized events must remain enabled:
```csharp ```csharp
RendezvousNetListener networkEvents = new(); RendezvousNetListener networkEvents = new();
NetManager gameplayNetManager = networkEvents.CreateManager(); NetManager gameplayNetManager = networkEvents.CreateManager();
gameplayNetManager.ChannelsCount = 3; // example: configure the game protocol first
if (!gameplayNetManager.Start(0)) if (!gameplayNetManager.Start(0))
{ {
throw new InvalidOperationException("The gameplay UDP socket could not start."); throw new InvalidOperationException("The gameplay UDP socket could not start.");
} }
``` ```
LiteNetLib defaults to one QoS channel. Set `ChannelsCount` before `Start` when
the game protocol uses more than one; both game processes must agree. Rendezvous
does not reserve or reinterpret any gameplay channel.
The host polls join invitations asynchronously; that method only queues a The host polls join invitations asynchronously; that method only queues a
snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes snapshot and never calls the manager. `Poll()` is the sole SDK path that invokes
LiteNetLib and dispatches its synchronized callbacks. Call it once per game LiteNetLib and dispatches its synchronized callbacks. Call it once per game
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
EnvironmentId environmentId, EnvironmentId environmentId,
uint protocolVersion, uint protocolVersion,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
CancellationToken cancellationToken = default);
} }
public interface IRendezvousJoinClient 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>( internal static HttpRequestMessage JsonRequest<T>(
HttpMethod method, HttpMethod method,
string uri, string uri,
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
{ {
ContractVersion = request.ContractVersion, ContractVersion = request.ContractVersion,
LeaseToken = session.LeaseToken, LeaseToken = session.LeaseToken,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
Visibility = request.Visibility,
BuildVersion = request.BuildVersion, BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName, DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity), 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; using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client; namespace FinalFactory.Rendezvous.Client;
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
cancellationToken); 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 ? $"&regionId={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 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 ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024; public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024; public const int BrowserResponseMaxBytes = 256 * 1024;
public const int SessionStreamEventMaxBytes = 32 * 1024;
public const int UdpDatagramMaxBytes = 1_200; public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024; public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32; public const int MetadataMaxKeys = 32;
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
[JsonRequired] [JsonRequired]
public string LeaseToken { get; set; } = string.Empty; public string LeaseToken { get; set; } = string.Empty;
public RegionId? RegionId { get; set; }
public uint? ProtocolVersion { get; set; }
public ListingVisibility? Visibility { get; set; }
[JsonRequired] [JsonRequired]
public string BuildVersion { get; set; } = string.Empty; public string BuildVersion { get; set; } = string.Empty;
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
public List<SessionListing> Items { get; set; } = []; public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { 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 public sealed class GetSessionResponse
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
internal sealed class SessionBrowserService( internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store, IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors, SessionBrowserCursorCodec cursors,
SessionStreamCursorCodec streamCursors,
SessionChangeJournal changes,
IWallClock clock) IWallClock clock)
{ {
public BrowserServiceResult<BrowseSessionsResponse> Browse( public BrowserServiceResult<BrowseSessionsResponse> Browse(
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
request.PageSize + 1, request.PageSize + 1,
after, after,
request.ExcludeFull); request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings( StoreResult<IReadOnlyList<StoredListing>> found = default!;
query, long streamRevision = 0;
cancellationToken); 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) if (!found.Succeeded || found.Value is null)
{ {
return new(found.Code == StoreResultCode.ServiceUnavailable return new(found.Code == StoreResultCode.ServiceUnavailable
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
string? nextCursor = hasMore string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow) ? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null; : 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 if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes) <= ContractLimits.BrowserResponseMaxBytes)
{ {
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
hasMore = true; 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( 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/EphemeralCursorProtector.cs" />
<Compile Include="Browser/SessionBrowserCursorCodec.cs" /> <Compile Include="Browser/SessionBrowserCursorCodec.cs" />
<Compile Include="Browser/SessionBrowserService.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="ConnectionOutcomes/ConnectionOutcomeService.cs" />
<Compile Include="Deployment/DeploymentOptions.cs" /> <Compile Include="Deployment/DeploymentOptions.cs" />
<Compile Include="Deployment/GracefulDrainService.cs" /> <Compile Include="Deployment/GracefulDrainService.cs" />
@@ -1,4 +1,5 @@
using System.Net; using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse; using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser; using FinalFactory.Rendezvous.Server.Browser;
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
.Produces<ApiError>(StatusCodes.Status429TooManyRequests) .Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable) .Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions"); .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) sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>() .Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest) .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( private static IResult GetSession(
SessionListingId listingId, SessionListingId listingId,
[FromQuery] int contractVersion, [FromQuery] int contractVersion,
@@ -255,6 +255,7 @@ builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10)); options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
SystemRendezvousClock rendezvousClock = new(); SystemRendezvousClock rendezvousClock = new();
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
EphemeralStoreOptions stateOptions = new() EphemeralStoreOptions stateOptions = new()
{ {
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds), GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
@@ -262,8 +263,10 @@ EphemeralStoreOptions stateOptions = new()
InMemoryEphemeralRendezvousStore stateStore = new( InMemoryEphemeralRendezvousStore stateStore = new(
stateOptions, stateOptions,
rendezvousClock, rendezvousClock,
rendezvousClock); rendezvousClock,
sessionChanges);
builder.Services.AddSingleton(stateStore); builder.Services.AddSingleton(stateStore);
builder.Services.AddSingleton(sessionChanges);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore); builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
builder.Services.AddSingleton<IWallClock>(rendezvousClock); builder.Services.AddSingleton<IWallClock>(rendezvousClock);
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock); builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
@@ -297,7 +300,9 @@ else
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>(); builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
} }
StoredListing ownedListing = listing!; 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) if (!authorized.IsAllowed || authorized.Context is null)
{ {
return new(MapAuthorization(authorized.Error)); return new(MapAuthorization(authorized.Error));
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
request.Capacity.CurrentPlayers, request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers, request.Capacity.MaximumPlayers,
request.Metadata, request.Metadata,
request.DedicatedFallback), cancellationToken); request.DedicatedFallback,
request.RegionId,
request.ProtocolVersion,
request.Visibility), cancellationToken);
return updated.Succeeded return updated.Succeeded
? new(RendezvousErrorCode.None, true) ? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError()); : new(updated.Code.ToContractError());
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName) || !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity) || !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata) || !ContractValidation.IsMetadataValid(request.Metadata)
|| request.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 || request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback) && !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest ? RendezvousErrorCode.InvalidRequest
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
int CurrentPlayers, int CurrentPlayers,
int MaximumPlayers, int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata, IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback); NetworkEndpoint? DedicatedFallback,
RegionId? RegionId = null,
uint? ProtocolVersion = null,
ListingVisibility? Visibility = null);
internal sealed record DeleteListingCommand( internal sealed record DeleteListingCommand(
SessionListingId ListingId, SessionListingId ListingId,
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
namespace FinalFactory.Rendezvous.Server.State; namespace FinalFactory.Rendezvous.Server.State;
@@ -8,6 +9,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly object _gate = new(); private readonly object _gate = new();
private readonly EphemeralStoreOptions _options; private readonly EphemeralStoreOptions _options;
private readonly IMonotonicClock _monotonicClock; private readonly IMonotonicClock _monotonicClock;
private readonly SessionChangeJournal? _sessionChanges;
private readonly DateTimeOffset _wallOrigin; private readonly DateTimeOffset _wallOrigin;
private readonly TimeSpan _monotonicOrigin; private readonly TimeSpan _monotonicOrigin;
private readonly Dictionary<SessionListingId, ListingEntry> _listings = []; private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
@@ -45,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public InMemoryEphemeralRendezvousStore( public InMemoryEphemeralRendezvousStore(
EphemeralStoreOptions options, EphemeralStoreOptions options,
IWallClock wallClock, IWallClock wallClock,
IMonotonicClock monotonicClock) IMonotonicClock monotonicClock,
SessionChangeJournal? sessionChanges = null)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(wallClock); ArgumentNullException.ThrowIfNull(wallClock);
@@ -53,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
options.Validate(); options.Validate();
_options = options; _options = options;
_monotonicClock = monotonicClock; _monotonicClock = monotonicClock;
_sessionChanges = sessionChanges;
_wallOrigin = wallClock.UtcNow; _wallOrigin = wallClock.UtcNow;
_monotonicOrigin = monotonicClock.Elapsed; _monotonicOrigin = monotonicClock.Elapsed;
InstanceId = Guid.NewGuid(); InstanceId = Guid.NewGuid();
@@ -225,7 +229,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.IdempotencyLifetime); now + _options.IdempotencyLifetime);
_idempotency.Add(idempotencyKey, idempotency); _idempotency.Add(idempotencyKey, idempotency);
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline); 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); }, cancellationToken);
public StoreResult<StoredListing> RenewLease( public StoreResult<StoredListing> RenewLease(
@@ -277,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers || command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0 || command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers || command.CurrentPlayers > command.MaximumPlayers
|| 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) || !ContractValidation.IsMetadataValid(command.Metadata)
|| command.DedicatedFallback is not null || command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback)) && !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
@@ -302,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.NotFound); return new(StoreResultCode.NotFound);
} }
StoredListing before = Snapshot(entry);
entry.Definition = StoredListing.Freeze(entry.Definition with 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, BuildVersion = command.BuildVersion,
DisplayName = command.DisplayName, DisplayName = command.DisplayName,
CurrentPlayers = command.CurrentPlayers, CurrentPlayers = command.CurrentPlayers,
@@ -312,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
DedicatedFallback = command.DedicatedFallback, DedicatedFallback = command.DedicatedFallback,
}); });
entry.Version++; entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry)); StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken); }, cancellationToken);
public StoreResult<bool> DeleteListing( public StoreResult<bool> DeleteListing(
@@ -382,6 +397,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.CapacityExceeded); return new(StoreResultCode.CapacityExceeded);
} }
StoredListing before = Snapshot(entry);
bool isNewPresence = !_presence.ContainsKey(command.Handle); bool isNewPresence = !_presence.ContainsKey(command.Handle);
PresenceEntry presence = new( PresenceEntry presence = new(
command.PublicEndpoint, command.PublicEndpoint,
@@ -396,7 +412,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
command.Handle, command.Handle,
presence.Deadline); presence.Deadline);
} }
return new(StoreResultCode.Success, Snapshot(entry)); StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken, eagerCleanup: false); }, cancellationToken, eagerCleanup: false);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings( public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
@@ -996,6 +1014,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void ClearActiveState() private void ClearActiveState()
{ {
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
_listings.Clear(); _listings.Clear();
_listingCountsByOwner.Clear(); _listingCountsByOwner.Clear();
_listingExpiries.Clear(); _listingExpiries.Clear();
@@ -1017,6 +1036,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_idempotencyExpiries.Clear(); _idempotencyExpiries.Clear();
_replay.Clear(); _replay.Clear();
_replayExpiries.Clear(); _replayExpiries.Clear();
foreach (StoredListing listing in removedListings)
{
_sessionChanges?.Publish(listing, null);
}
} }
private void RemoveListing(SessionListingId listingId) private void RemoveListing(SessionListingId listingId)
@@ -1026,6 +1049,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return; return;
} }
StoredListing removed = Snapshot(listing);
_leases.Remove(listing.Definition.LeaseId); _leases.Remove(listing.Definition.LeaseId);
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject); DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
_presenceHandles.Remove(listing.Definition.HostPresenceHandle); _presenceHandles.Remove(listing.Definition.HostPresenceHandle);
@@ -1045,6 +1069,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
RemoveOutcome(attemptId); RemoveOutcome(attemptId);
} }
} }
_sessionChanges?.Publish(removed, null);
} }
private void RemoveAttempt(JoinAttemptId attemptId) private void RemoveAttempt(JoinAttemptId attemptId)
@@ -1186,6 +1212,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
} }
else if (_presence.Remove(candidate.Key)) 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++; removed++;
} }
} }
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
{ {
TestClientMode.Host => RunHostAsync(options, output, cancellationToken), TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken), TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken), TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
_ => Task.FromResult(TestClientExitCode.Usage), _ => Task.FromResult(TestClientExitCode.Usage),
}; };
@@ -114,11 +115,12 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
listingId: session.ListingId.ToString(), listingId: session.ListingId.ToString(),
displayName: options.DisplayName); displayName: options.DisplayName);
echo = new DirectEchoProtocol(events.GameplayEvents, host: true); echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
echo.ExchangeCompleted += _ => output.Write( echo.ExchangeCompleted += peer => output.Write(
"host.direct-traffic", "host.direct-traffic",
"verified", "verified",
phase: "direct-traffic", phase: "direct-traffic",
endpointType: "peer-to-peer"); endpointType: "peer-to-peer",
addressFamily: AddressFamilyName(peer.Address));
coordinator = new RendezvousHostCoordinator( coordinator = new RendezvousHostCoordinator(
manager, manager,
events, events,
@@ -485,6 +487,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
"connected", "connected",
phase: "direct-connection", phase: "direct-connection",
endpointType: endpointType, endpointType: endpointType,
addressFamily: AddressFamilyName(peer.Address),
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed)); elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false); await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
echo.BeginJoin(peer); echo.BeginJoin(peer);
@@ -507,7 +510,8 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
"join.direct-traffic", "join.direct-traffic",
"verified", "verified",
phase: "direct-traffic", phase: "direct-traffic",
endpointType: endpointType); endpointType: endpointType,
addressFamily: AddressFamilyName(peer.Address));
peer.Disconnect(); peer.Disconnect();
manager.PollEvents(); manager.PollEvents();
return TestClientExitCode.Success; 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( private static async Task<SessionSelection> SelectListingAsync(
TestClientOptions options, TestClientOptions options,
TestClientOutput output, TestClientOutput output,
@@ -765,6 +941,9 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
return privateAddress ? "private" : "public"; return privateAddress ? "private" : "public";
} }
private static string AddressFamilyName(IPAddress address) =>
address.AddressFamily == AddressFamily.InterNetworkV6 ? "ipv6" : "ipv4";
private static long ToMilliseconds(TimeSpan elapsed) => private static long ToMilliseconds(TimeSpan elapsed) =>
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds)); (long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
@@ -8,6 +8,7 @@ internal enum TestClientMode
{ {
Host, Host,
Browse, Browse,
Watch,
Join, Join,
} }
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
internal bool Script { get; init; } internal bool Script { get; init; }
internal bool Json { get; init; } internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; } internal bool ExitAfterEcho { get; init; }
internal bool ExerciseReconnect { get; init; }
internal bool ExerciseReset { get; init; }
} }
internal sealed class TestClientParseResult internal sealed class TestClientParseResult
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
Usage: Usage:
rendezvous-test-client host [options] rendezvous-test-client host [options]
rendezvous-test-client browse [options] rendezvous-test-client browse [options]
rendezvous-test-client watch [options]
rendezvous-test-client join [options] rendezvous-test-client join [options]
Common options: Common options:
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
--run-seconds NUMBER Stop after 1-86400 seconds --run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange --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: Join options:
--listing UUID Join an exact listing; otherwise browse/select --listing UUID Join an exact listing; otherwise browse/select
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
bool script = false; bool script = false;
bool json = false; bool json = false;
bool exitAfterEcho = false; bool exitAfterEcho = false;
bool exerciseReconnect = false;
bool exerciseReset = false;
HashSet<string> seen = new(StringComparer.Ordinal); HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++) for (int index = 1; index < args.Length; index++)
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
{ {
return TestClientParseResult.Help(); 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)) if (!seen.Add(option))
{ {
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
script |= option == "--script"; script |= option == "--script";
json |= option == "--json"; json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo"; exitAfterEcho |= option == "--exit-after-echo";
exerciseReconnect |= option == "--exercise-reconnect";
exerciseReset |= option == "--exercise-reset";
continue; continue;
} }
if (!option.StartsWith("--", StringComparison.Ordinal) 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."); return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
} }
if (listingId.HasValue && mode != TestClientMode.Join 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 || exitAfterEcho && mode != TestClientMode.Host
|| exerciseReconnect && mode != TestClientMode.Watch
|| exerciseReset && mode != TestClientMode.Watch
|| exerciseReconnect && exerciseReset
|| metadata.Count > 0 && mode != TestClientMode.Host || metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host || dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host || seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
Script = script, Script = script,
Json = json, Json = json,
ExitAfterEcho = exitAfterEcho, ExitAfterEcho = exitAfterEcho,
ExerciseReconnect = exerciseReconnect,
ExerciseReset = exerciseReset,
}); });
} }
@@ -24,6 +24,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
string? displayName = null, string? displayName = null,
string? outcome = null, string? outcome = null,
string? endpointType = null, string? endpointType = null,
string? addressFamily = null,
int? count = null, int? count = null,
long? elapsedMilliseconds = null, long? elapsedMilliseconds = null,
string? message = null) => WriteCore( string? message = null) => WriteCore(
@@ -37,6 +38,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
DisplayName = SafeText(displayName), DisplayName = SafeText(displayName),
Outcome = SafeToken(outcome), Outcome = SafeToken(outcome),
EndpointType = SafeToken(endpointType), EndpointType = SafeToken(endpointType),
AddressFamily = SafeToken(addressFamily),
Count = count, Count = count,
ElapsedMilliseconds = elapsedMilliseconds, ElapsedMilliseconds = elapsedMilliseconds,
Message = SafeText(message), Message = SafeText(message),
@@ -92,6 +94,7 @@ internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter sta
Append(line, "name", item.DisplayName, quote: true); Append(line, "name", item.DisplayName, quote: true);
Append(line, "outcome", item.Outcome); Append(line, "outcome", item.Outcome);
Append(line, "endpoint", item.EndpointType); Append(line, "endpoint", item.EndpointType);
Append(line, "addressFamily", item.AddressFamily);
if (item.Count.HasValue) if (item.Count.HasValue)
{ {
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); 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? DisplayName { get; init; }
public string? Outcome { get; init; } public string? Outcome { get; init; }
public string? EndpointType { get; init; } public string? EndpointType { get; init; }
public string? AddressFamily { get; init; }
public int? Count { get; init; } public int? Count { get; init; }
public long? ElapsedMilliseconds { get; init; } public long? ElapsedMilliseconds { get; init; }
public string? Message { get; init; } public string? Message { get; init; }
@@ -7,18 +7,25 @@ namespace FinalFactory.Rendezvous.Tests.Browser;
internal sealed class SessionBrowserFixture : IDisposable internal sealed class SessionBrowserFixture : IDisposable
{ {
private readonly EphemeralStateFixture _state = new(); private readonly EphemeralStateFixture _state;
public SessionBrowserFixture() public SessionBrowserFixture()
{ {
Changes = new(new SessionChangeJournalOptions());
_state = new(changes: Changes);
Cursors = new(); 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 InMemoryEphemeralRendezvousStore Store => _state.Store;
public ManualRendezvousClock Clock => _state.Clock; public ManualRendezvousClock Clock => _state.Clock;
public SessionBrowserCursorCodec Cursors { get; } public SessionBrowserCursorCodec Cursors { get; }
public SessionStreamCursorCodec StreamCursors { get; }
public SessionChangeJournal Changes { get; }
public SessionBrowserService Browser { get; } public SessionBrowserService Browser { get; }
public SessionStreamService Streams { get; }
public TenantScope Scope => _state.Scope; public TenantScope Scope => _state.Scope;
public StoredListing Add( public StoredListing Add(
@@ -67,5 +74,9 @@ internal sealed class SessionBrowserFixture : IDisposable
PageSize = pageSize, 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); 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] [Fact]
public async Task LeaseMaintainerReportsLeaseLoss() 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) private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{ {
Assert.True(result.IsSuccess, result.Message); Assert.True(result.IsSuccess, result.Message);
@@ -210,7 +281,8 @@ public sealed class RendezvousClientIntegrationTests
{ {
ManualRendezvousClock clock = new(ProvisioningTestData.Now); ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new(); EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new(); EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create( ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(), ProvisioningTestData.CreateOptions(),
@@ -239,7 +311,10 @@ public sealed class RendezvousClientIntegrationTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
WebApplication app = builder.Build(); WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
@@ -17,6 +17,7 @@ public sealed class OpenApiCompatibilityTests
"/v1/operator/principals/revoke", "/v1/operator/principals/revoke",
"/v1/operator/status", "/v1/operator/status",
"/v1/sessions", "/v1/sessions",
"/v1/sessions/stream",
"/v1/sessions/{listingId}", "/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts", "/v1/sessions/{listingId}/join-attempts",
"/v1/sessions/{listingId}/renew", "/v1/sessions/{listingId}/renew",
@@ -68,6 +69,25 @@ public sealed class OpenApiCompatibilityTests
Assert.DoesNotContain(listingProperties, static property => Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase) property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", 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") JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
.GetProperty("properties") .GetProperty("properties")
.GetProperty("dedicatedFallback"); .GetProperty("dedicatedFallback");
@@ -205,7 +225,7 @@ public sealed class OpenApiCompatibilityTests
} }
} }
Assert.Equal(17, overloadContracts); Assert.Equal(18, overloadContracts);
(string Path, string Method)[] bodyOperations = (string Path, string Method)[] bodyOperations =
[ [
("/v1/sessions", "post"), ("/v1/sessions", "post"),
@@ -238,6 +238,10 @@ public sealed class ProductionProcessTests
"--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"), "--Rendezvous:Provisioning:SigningKeys:0:NotBefore", now.AddHours(-1).ToString("O"),
"--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"), "--Rendezvous:Provisioning:SigningKeys:0:SignUntil", now.AddHours(1).ToString("O"),
"--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"), "--Rendezvous:Provisioning:SigningKeys:0:VerifyUntil", now.AddHours(2).ToString("O"),
"--Rendezvous:Provisioning:SigningKeys:1:SecretReference", $"file:{secretPath}",
"--Rendezvous:Provisioning:SigningKeys:1:NotBefore", now.AddHours(-1).ToString("O"),
"--Rendezvous:Provisioning:SigningKeys:1:SignUntil", now.AddHours(1).ToString("O"),
"--Rendezvous:Provisioning:SigningKeys:1:VerifyUntil", now.AddHours(2).ToString("O"),
"--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture), "--Rendezvous:Udp:Port", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
"--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture), "--Rendezvous:Deployment:PublicUdpPort", udpPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
}, },
@@ -40,26 +40,57 @@ public sealed partial class DocumentationContractTests
} }
[Fact] [Fact]
public void LocalCredentialHelperIsFixedScopeAndSmokeDelegatesToIt() public void LocalCredentialHelperWhitelistsProvisionedGameScopesAndSmokeDelegatesToIt()
{ {
string root = FindRepositoryRoot(); string root = FindRepositoryRoot();
string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh")); string helper = File.ReadAllText(Path.Combine(root, "scripts", "mint-local-publisher-credential.sh"));
string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh")); string smoke = File.ReadAllText(Path.Combine(root, "scripts", "smoke-deployment.sh"));
Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal); Assert.Contains("if (( $# != 0 ));", helper, StringComparison.Ordinal);
Assert.Contains("\"gameId\": \"space-game\"", helper, StringComparison.Ordinal); Assert.Contains("space-game)", helper, StringComparison.Ordinal);
Assert.Contains("unscouted)", helper, StringComparison.Ordinal);
Assert.Contains("KEY_ID=\"local-smoke-1\"", helper, StringComparison.Ordinal);
Assert.Contains("KEY_ID=\"local-smoke-unscouted-1\"", helper, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_LOCAL_CREDENTIAL_GAME_ID must be space-game or unscouted", helper, StringComparison.Ordinal);
Assert.Contains("\"gameId\": game_id", helper, StringComparison.Ordinal);
Assert.Contains("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal); Assert.Contains("\"environmentId\": \"smoke\"", helper, StringComparison.Ordinal);
Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal); Assert.Contains("\"regions\": [\"local\"]", helper, StringComparison.Ordinal);
Assert.Contains("now + 600", helper, StringComparison.Ordinal); Assert.Contains("now + 600", helper, StringComparison.Ordinal);
Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal); Assert.Contains("stat.S_ISLNK", helper, StringComparison.Ordinal);
Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal); Assert.Contains("parent.st_mode & 0o077", helper, StringComparison.Ordinal);
Assert.Contains("metadata.st_mode & 0o022", helper, StringComparison.Ordinal); Assert.Contains("metadata.st_mode & 0o077", helper, StringComparison.Ordinal);
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal); Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal); Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal); Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal); Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal);
} }
[Fact]
public void UnscoutedComposeTenantIsGameScopedAndMetadataBounded()
{
string root = FindRepositoryRoot();
using JsonDocument settings = JsonDocument.Parse(File.ReadAllText(
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
JsonElement provisioning = settings.RootElement.GetProperty("Rendezvous").GetProperty("Provisioning");
JsonElement game = provisioning.GetProperty("Games").EnumerateArray().Single(
static item => item.GetProperty("GameId").GetString() == "unscouted");
JsonElement key = provisioning.GetProperty("SigningKeys").EnumerateArray().Single(
static item => item.GetProperty("KeyId").GetString() == "local-smoke-unscouted-1");
Assert.Equal("smoke", game.GetProperty("EnvironmentId").GetString());
Assert.Equal([1], game.GetProperty("ProtocolVersions").EnumerateArray().Select(static value => value.GetInt32()));
Assert.Equal(["mode", "mods", "world"], game.GetProperty("MetadataValueMaxBytes")
.EnumerateObject().Select(static property => property.Name).Order(StringComparer.Ordinal));
Assert.Equal(["mode", "mods", "world"], game.GetProperty("RequiredMetadataKeys")
.EnumerateArray().Select(static value => value.GetString()).Order(StringComparer.Ordinal));
Assert.Equal(3, game.GetProperty("MetadataMaxKeys").GetInt32());
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
Assert.Equal("unscouted", key.GetProperty("GameId").GetString());
Assert.Equal("smoke", key.GetProperty("EnvironmentId").GetString());
Assert.Equal(["DedicatedPublisher"], key.GetProperty("CredentialKinds")
.EnumerateArray().Select(static value => value.GetString()));
}
[Fact] [Fact]
public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates() public void EveryIncidentRunbookHasDetectContainRecoverAndVerifyGates()
{ {
@@ -184,6 +215,79 @@ public sealed partial class DocumentationContractTests
Assert.Equal(httpVersion, udpVersion); Assert.Equal(httpVersion, udpVersion);
Assert.Equal(httpVersion, ticketVersion); Assert.Equal(httpVersion, ticketVersion);
Assert.Contains($"contract version `{httpVersion}`", guide, StringComparison.Ordinal); Assert.Contains($"contract version `{httpVersion}`", guide, StringComparison.Ordinal);
Assert.Contains("gameplayNetwork.ChannelsCount = 3", guide, StringComparison.Ordinal);
Assert.Contains("defaults to one QoS channel", guide, StringComparison.Ordinal);
Assert.Contains("Rendezvous does not choose, remap, or reserve", guide, StringComparison.Ordinal);
}
[Fact]
public void SpaceGamePilotEvidenceSeparatesProvenBehaviorFromOpenGates()
{
string root = FindRepositoryRoot();
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "spacegame-pilot.md"));
string deploymentGuide = File.ReadAllText(Path.Combine(root, "docs", "deployment", "linux.md"));
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
Path.Combine(root, "docs", "evidence", "consumers", "spacegame.json")));
JsonElement record = evidence.RootElement;
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
Assert.Equal("none", record.GetProperty("localRun").GetProperty("rendezvousGameplayPayloadPath").GetString());
Assert.Equal("caller-owned-litenetlib", record.GetProperty("localRun").GetProperty("gameplayTransport").GetString());
Assert.True(record.GetProperty("localRun").GetProperty("directGameplay").GetBoolean());
Assert.True(record.GetProperty("localRun").GetProperty("reconnected").GetBoolean());
Assert.True(record.GetProperty("linuxRun").GetProperty("freshExport").GetBoolean());
Assert.False(record.GetProperty("linuxRun").GetProperty("sourceDirty").GetBoolean());
Assert.Equal(31, record.GetProperty("verification").GetProperty("debugTests").GetProperty("passed").GetInt32());
Assert.Equal(31, record.GetProperty("verification").GetProperty("releaseTests").GetProperty("passed").GetInt32());
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.DoesNotContain("actual-godot-process-integration", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.DoesNotContain("dedicated-fallback-connection", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.Contains("Do not mark #21 passed", guide, StringComparison.Ordinal);
Assert.Contains("Rendezvous reserves no gameplay", guide, StringComparison.Ordinal);
Assert.Contains("private-network service name", guide, StringComparison.Ordinal);
Assert.Contains("local/private-bridge smoke", deploymentGuide, StringComparison.Ordinal);
Assert.Contains("explicit `rendezvous` host name", deploymentGuide, StringComparison.Ordinal);
using JsonDocument composeSettings = JsonDocument.Parse(File.ReadAllText(
Path.Combine(root, "deploy", "compose", "appsettings.Production.json")));
JsonElement compose = composeSettings.RootElement;
Assert.Contains("rendezvous", compose.GetProperty("AllowedHosts").GetString()!.Split(';'));
JsonElement game = compose.GetProperty("Rendezvous").GetProperty("Provisioning").GetProperty("Games")[0];
Assert.Contains(2, game.GetProperty("ProtocolVersions").EnumerateArray().Select(static version => version.GetInt32()));
Assert.Equal("DedicatedEndpointAllowed", game.GetProperty("FallbackPolicy").GetString());
Assert.DoesNotMatch(ReusableCredential(), guide);
}
[Fact]
public void UnscoutedPilotEvidenceProvesAnIndependentGameBoundaryAndKeepsExternalGatesOpen()
{
string root = FindRepositoryRoot();
string guide = File.ReadAllText(Path.Combine(root, "docs", "integration", "unscouted-pilot.md"));
using JsonDocument evidence = JsonDocument.Parse(File.ReadAllText(
Path.Combine(root, "docs", "evidence", "consumers", "unscouted.json")));
JsonElement record = evidence.RootElement;
JsonElement run = record.GetProperty("godotRun");
JsonElement negative = record.GetProperty("negativePaths");
Assert.Equal("checkpoint-pass-with-external-gates", record.GetProperty("result").GetString());
Assert.Equal("unscouted", record.GetProperty("configuration").GetProperty("gameId").GetString());
Assert.Equal(["mode", "world", "mods"], record.GetProperty("configuration").GetProperty("metadataKeys")
.EnumerateArray().Select(static value => value.GetString()));
Assert.Equal("none", run.GetProperty("rendezvousGameplayPayloadPath").GetString());
Assert.Equal("unscouted-litenetlib", run.GetProperty("gameplayTransport").GetString());
Assert.True(run.GetProperty("directGameplay").GetBoolean());
Assert.True(run.GetProperty("fallbackGameplay").GetBoolean());
Assert.Equal(2, run.GetProperty("authenticatedSessions").GetInt32());
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongGame").GetString());
Assert.Equal("proven-exact-NotFound", negative.GetProperty("wrongEnvironment").GetString());
Assert.Equal(3310, record.GetProperty("verification").GetProperty("consumerDebugTests").GetProperty("passed").GetInt32());
Assert.Equal(360, record.GetProperty("verification").GetProperty("consumerGdUnitTests").GetProperty("passed").GetInt32());
Assert.Contains("public-package-restore", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.Contains("representative-external-nat", record.GetProperty("openGates").EnumerateArray().Select(static gate => gate.GetString()));
Assert.Contains("Do not mark #22 passed", guide, StringComparison.Ordinal);
Assert.Contains("not an Unscouted branch", guide, StringComparison.Ordinal);
Assert.DoesNotMatch(ReusableCredential(), guide);
} }
[GeneratedRegex(@"rv1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", RegexOptions.CultureInvariant)] [GeneratedRegex(@"rv1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+", RegexOptions.CultureInvariant)]
@@ -288,7 +288,8 @@ public sealed class JoinAttemptHttpEndpointTests
{ {
ManualRendezvousClock clock = new(ProvisioningTestData.Now); ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new(); EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new(); EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create( ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(), ProvisioningTestData.CreateOptions(),
@@ -318,7 +319,10 @@ public sealed class JoinAttemptHttpEndpointTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>(); builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>(); builder.Services.AddSingleton<JoinAttemptService>();
ConnectionOutcomeMetrics outcomeMetrics = new(); ConnectionOutcomeMetrics outcomeMetrics = new();
@@ -143,6 +143,10 @@ public sealed class ReleaseCompatibilityTests
pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray()); pinnedConsumers.Select(static item => item.GetProperty("name").GetString()!).ToArray());
Assert.All(pinnedConsumers, static item => Assert.All(pinnedConsumers, static item =>
Assert.Matches("^[0-9a-f]{40}$", item.GetProperty("revision").GetString())); 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] [Fact]
@@ -164,6 +168,52 @@ public sealed class ReleaseCompatibilityTests
Assert.Equal(actual, declared); 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) => private static string Property(XDocument document, string name) =>
document.Descendants(name).Single().Value; document.Descendants(name).Single().Value;
@@ -28,7 +28,8 @@ public sealed class SessionHttpEndpointTests
{ {
ManualRendezvousClock clock = new(ProvisioningTestData.Now); ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new(); EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock); SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new(); EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create( ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(), ProvisioningTestData.CreateOptions(),
@@ -57,7 +58,10 @@ public sealed class SessionHttpEndpointTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions)); builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>(); builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>(); builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>(); builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
await using WebApplication app = builder.Build(); await using WebApplication app = builder.Build();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>(); app.UseMiddleware<HttpAbuseProtectionMiddleware>();
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts; using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State; using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State; namespace FinalFactory.Rendezvous.Tests.State;
@@ -24,10 +25,12 @@ internal sealed class EphemeralStateFixture
{ {
private int _sequence; private int _sequence;
public EphemeralStateFixture(EphemeralStoreOptions? options = null) public EphemeralStateFixture(
EphemeralStoreOptions? options = null,
SessionChangeJournal? changes = null)
{ {
Clock = new(); Clock = new();
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock); Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock, changes);
} }
public ManualRendezvousClock Clock { get; } public ManualRendezvousClock Clock { get; }
@@ -59,6 +59,29 @@ public sealed class TestClientCommandTests
Assert.Equal(130, (int)TestClientExitCode.Cancelled); 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] [Fact]
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries() public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
{ {
@@ -178,12 +178,17 @@ public sealed class TestClientProcessIntegrationTests
Assert.Contains( Assert.Contains(
join.JsonEvents(), join.JsonEvents(),
item => item.GetProperty("event").GetString() == "join.connected" 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.punch", "started"), join.DiagnosticText());
Assert.True(join.HasEvent("join.direct-connect", "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.direct-traffic", "verified"), join.DiagnosticText());
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText()); Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.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.punch", "started"), host.DiagnosticText());
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText()); Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
Assert.True(host.HasEvent("host.deregistered", "complete"), 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<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.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.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 TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;} PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {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<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.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.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 TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
EVENT System.EventHandler LeaseLost EVENT System.EventHandler LeaseLost
METHOD System.Threading.Tasks.ValueTask DisposeAsync() METHOD System.Threading.Tasks.ValueTask DisposeAsync()
@@ -28,6 +28,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
PROP System.Int32 ContractVersion {get;set;} PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;} PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {get;set;} PROP System.String NextCursor {get;set;}
PROP System.String StreamCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
ENUM UnderOneSecond=1 ENUM UnderOneSecond=1
ENUM OneToFiveSeconds=2 ENUM OneToFiveSeconds=2
@@ -84,6 +85,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024 FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32 FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000 FIELD System.Int32 SessionCapacityMaxPlayers=10000
FIELD System.Int32 SessionStreamEventMaxBytes=32768
FIELD System.Int32 UdpCapabilityMaxCharacters=192 FIELD System.Int32 UdpCapabilityMaxCharacters=192
FIELD System.Int32 UdpDatagramMaxBytes=1200 FIELD System.Int32 UdpDatagramMaxBytes=1200
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation 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 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_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) 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 TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
ENUM None=0 ENUM None=0
ENUM DatagramTooLarge=1 ENUM DatagramTooLarge=1
@@ -371,3 +385,6 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
PROP System.String DisplayName {get;set;} PROP System.String DisplayName {get;set;}
PROP System.String LeaseToken {get;set;} PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;} PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
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;}