Compare commits

...
Author SHA1 Message Date
KyuubiYoru dc2de76963 feat(server): answer the root path and probe container health
A bare GET / previously returned an empty 404, which reads as an outage
even while the service is healthy. The root now redirects to
/diagnostics when the dashboard is enabled and otherwise returns a
small JSON status pointing at /health/live. The route is excluded from
the OpenAPI description, so the frozen v1 surface is unchanged.

The chiseled runtime image ships no shell or curl, so the container
healthcheck re-enters the server assembly with --health-probe, which
queries the local liveness endpoint and reports through the exit code.
Dockerfile and compose both wire the probe (30s interval, 3 retries).
2026-08-31 03:55:17 +02:00
KyuubiYoru 13382531ce docs(integration): correct channel claim and flag UDP port collision (#21)
quality-gate / quality (push) Successful in 2m43s
quality-gate / container (push) Successful in 1m43s
SpaceGame's v1.0.0 evaluation (SpaceGame docs/rendezvous-eval.md @ 8eb5607) found the pilot doc asserting ChannelsCount=3 as current SpaceGame fact — their source uses LiteNetLib's single-channel default, and a peer mismatch fails connections silently, so the doc now states the requirement as agreement rather than a count. The compose file also gains a loud warning that host UDP 9050 collides with common game-client defaults during same-machine evaluation.
2026-08-22 23:21:10 +02:00
KyuubiYoru e3b687c903 fix(release): keep publication off GitHub-only actions (#1)
quality-gate / quality (push) Successful in 2m35s
quality-gate / container (push) Successful in 1m38s
immutable-release / release (push) Successful in 6m26s
upload-artifact@v4 speaks the v2 artifacts API, which this Gitea answers
only at its v3-era shape, so the sixth release attempt cleared every gate
and then aborted at "Preserve verified candidate artifacts" with the GHES
compatibility error. Move the step to v3.2.1, the commit the upstream v3
tag resolves to, which keeps name, path, if-no-files-found and
retention-days unchanged. The step also becomes continue-on-error: it is a
pre-publish debugging backstop, and publish-release.sh attaches the same
directory as Gitea release assets, so losing it must never cost a release.

Replace cosign-installer with the direct fetch the scanner already uses.
The action issues no API call on this path, but it is a composite action
resting on envsubst and a resolved runner.arch, neither of which this
runner has exercised. Asked for the version it bootstraps, it downloads
exactly cosign-linux-amd64 from the v3.0.6 release, compares it against
c956e5df..., and exits; that digest matches the release checksums file, so
fetching the asset directly verifies identically with nothing unproven
left before the one-way publication gate. The binary joins the PATH that
publish-release.sh already resolves dotnet through.
2026-08-22 22:10:00 +02:00
KyuubiYoru 747e3bb9c3 test(client): survive a starved stream handshake (#1)
quality-gate / quality (push) Successful in 2m40s
quality-gate / container (push) Successful in 1m51s
immutable-release / release (push) Failing after 5m35s
Opening an SSE stream is a single unretried request bounded by
RendezvousClientOptions.RequestTimeout. On the release runner the suite
shares a builder container with parallel image builds, so the loopback
handshake can lose its whole wall-clock budget to thread-pool starvation;
the client then reports a typed ServiceUnavailable that the test asserted
against as if it were the reset event.

Retry the open from the same replayable cursor, raise the per-request
budget to the contract ceiling, and drop protocol keepalives so a slow
interval between assertions cannot be mistaken for a session event. The
retry only fires on the transport failure the stream endpoint can never
produce as an envelope, is bounded, and yields the final failure verbatim,
so a genuinely broken stream still fails.
2026-08-22 21:42:21 +02:00
KyuubiYoru b235670afd fix(security): pin runtime to .NET 10.0.11 chiseled (#1)
quality-gate / quality (push) Successful in 2m42s
quality-gate / container (push) Successful in 1m55s
immutable-release / release (push) Failing after 5m39s
The 10.0.9 ASP.NET chiseled base shipped Microsoft.NETCore.App 10.0.9,
which the release trivy gate flags with six HIGH advisories:
CVE-2026-47302, CVE-2026-50524, CVE-2026-50528, CVE-2026-50651 and
CVE-2026-57108 (fixed in 10.0.10) plus CVE-2026-62901 (fixed in
10.0.11). Move the runtime stage to 10.0.11-noble-chiseled, pinned by
multi-architecture manifest digest as before, so every finding clears in
one step.

The build stage keeps SDK 10.0.301: the published server is framework
dependent, so the shipped runtime comes from the aspnet base alone and
the discarded builder layer is never scanned.
2026-08-22 21:20:30 +02:00
KyuubiYoru 562b8308b7 fix(release): run trivy from a pinned verified binary (#1)
quality-gate / quality (push) Successful in 2m47s
quality-gate / container (push) Successful in 1m51s
immutable-release / release (push) Failing after 5m40s
trivy-action checks its own repository out of github.com using the runner
token; on this self-hosted Gitea that token is a Gitea token, GitHub answers
"Bad credentials", and both scan steps die before trivy is installed.

Download the v0.69.3 release archive directly, verify it against a sha256
digest pinned inline, and unpack only the binary into .release-work/bin,
which is gitignored and excluded from the Docker build context. The gate
keeps its exact semantics: --exit-code 1, --severity HIGH,CRITICAL, table
output, unfixed vulnerabilities still in scope. The SPDX step writes the
same filename release_artifacts.py normalize-container-sbom consumes, and
TRIVY_CACHE_DIR keeps the vulnerability DB inside the work directory.
2026-08-22 21:06:21 +02:00
KyuubiYoru 4233f12368 fix(release): share sibling paths through the workspace (#1)
quality-gate / quality (push) Successful in 2m42s
quality-gate / container (push) Successful in 2m9s
immutable-release / release (push) Failing after 6m7s
The release job stopped at the bind-source gate. RUNNER_TEMP is
container-internal on this runner and no runner mount exposes it on the Docker
host, so no sibling container could ever share it; only the workspace is
host-mounted.

Move every path shared between the runner's shell steps and its sibling
containers under $GITHUB_WORKSPACE/.release-work: the release directory, the
release builder's HOME and NuGet cache, both candidate image tars, and the
container SPDX inventory. Resolution now maps the workspace alone to its host
path, and each sibling binds that source at $GITHUB_WORKSPACE and works from
there instead of /source, so a shared path is the same string on both sides of
the boundary. publish-release.sh follows with a single bind and requires the
release directory to sit inside the workspace.

Ignore .release-work in Git so the tag gate's cleanliness check stays true while
artifacts accumulate, and in Docker so artifacts written between the two
candidate builds cannot alter the build context the byte-comparison gate
depends on. Skip it in the dependency inventory as well, keeping the restored
package cache out of the license policy scan.
2026-08-22 20:31:18 +02:00
KyuubiYoru c5bc9651e5 docs(release): date the 1.0.0 changelog entry (#1)
quality-gate / quality (push) Successful in 2m39s
immutable-release / release (push) Failing after 1m26s
quality-gate / container (push) Successful in 1m53s
2026-08-22 19:48:01 +02:00
KyuubiYoru d41fa0c7e9 fix(release): repair tag gate and sibling-container publication (#1)
The release workflow could not complete on the self-hosted sibling-container
runner. Four defects are corrected together because three of them share the
same release.yml plumbing.

Exclude the tag under release from the prior-tag probe. check-release-tag.sh
already requires the tag to point at HEAD, so the unfiltered listing always
matched itself and rejected every first release before the initial baseline
branch could run.

Resolve host bind sources once per job. Steps run inside the runner container
while docker run starts siblings on the host daemon, so GITHUB_WORKSPACE and
RUNNER_TEMP are not usable as bind sources. A new step maps both to host paths
through the runner's own mounts and exports them; the build, provenance, smoke,
finalize, and publish steps reuse them, and publish-release.sh receives them by
environment instead of repeating the inspection. Bind destinations stay at the
container-internal paths so runner-side reads keep working unchanged.

Prove the signing material before the first push. Gitea package versions are
immutable, so a missing or undecryptable cosign key must abort ahead of the
require_absent gate rather than after packages and the image are published.

Authenticate the pinned consumer fetches. Both consumer repositories are
private, so anonymous fetches fail; the release token is passed to the
verification step and applied as a URL-scoped extra header through git's
config environment, keeping it out of argv, remotes, and on-disk config.
Anonymous fetch remains the fallback for local runs.
2026-08-22 19:47:57 +02:00
KyuubiYoru 589f802e2e fix(ci): smoke through service namespace (#1)
quality-gate / quality (push) Successful in 2m26s
quality-gate / container (push) Successful in 1m34s
2026-07-17 02:16:21 +02:00
KyuubiYoru 4423503bba fix(ci): reduce readiness probe pressure (#1)
quality-gate / quality (push) Successful in 2m40s
quality-gate / container (push) Failing after 4m34s
2026-07-17 02:06:39 +02:00
KyuubiYoru 74ae126ea7 fix(ci): allow cold container startup (#1)
quality-gate / quality (push) Successful in 2m39s
quality-gate / container (push) Failing after 3m11s
2026-07-17 01:59:30 +02:00
KyuubiYoru a77d4b801c fix(ci): isolate deployment smoke client (#1)
quality-gate / quality (push) Successful in 2m29s
quality-gate / container (push) Failing after 2m23s
2026-07-17 01:53:50 +02:00
KyuubiYoru 769336e424 fix(ci): run smoke on Compose network (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m39s
2026-07-17 01:42:28 +02:00
KyuubiYoru 41be7fbce5 fix(ci): join service to runner network (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m40s
2026-07-17 01:36:47 +02:00
KyuubiYoru 1bca034a52 fix(ci): route smoke through Docker gateway (#1)
quality-gate / quality (push) Successful in 2m37s
quality-gate / container (push) Failing after 1m51s
2026-07-17 01:29:30 +02:00
KyuubiYoru 8d98d888b3 fix(ci): resolve sibling-container bind sources (#1)
quality-gate / quality (push) Successful in 2m36s
quality-gate / container (push) Failing after 1m46s
2026-07-17 01:23:16 +02:00
KyuubiYoru ef07685d22 fix(ci): correct Docker inspect templates (#1)
quality-gate / quality (push) Successful in 2m36s
quality-gate / container (push) Failing after 1m42s
2026-07-17 01:14:44 +02:00
KyuubiYoru f9cb7f47e5 fix(ci): isolate Compose host ports (#1)
quality-gate / quality (push) Successful in 2m46s
quality-gate / container (push) Failing after 1m28s
2026-07-17 01:07:56 +02:00
KyuubiYoru de753b99e6 fix(deploy): validate complete smoke logs (#1)
quality-gate / quality (push) Successful in 2m47s
quality-gate / container (push) Failing after 2m18s
2026-07-17 00:58:28 +02:00
KyuubiYoru c3b3629515 fix(deploy): gate smoke readiness atomically (#1)
quality-gate / quality (push) Failing after 2m14s
quality-gate / container (push) Has been skipped
2026-07-17 00:54:04 +02:00
KyuubiYoru e8c07ee22a fix(deploy): synchronize smoke listing lookup (#1)
quality-gate / quality (push) Failing after 2m14s
quality-gate / container (push) Has been skipped
2026-07-17 00:48:13 +02:00
KyuubiYoru d42e6c99a3 fix(ci): provision TestClient runtime (#1)
quality-gate / quality (push) Failing after 2m18s
quality-gate / container (push) Has been skipped
2026-07-17 00:42:16 +02:00
KyuubiYoru c2f63db3ad test(client): stabilize stream integration gate (#1)
quality-gate / quality (push) Failing after 1m28s
quality-gate / container (push) Has been skipped
2026-07-17 00:34:29 +02:00
KyuubiYoru 99885f8c8c feat(observability): add diagnostic dashboards (#27)
quality-gate / quality (push) Failing after 1m31s
quality-gate / container (push) Has been skipped
2026-07-17 00:22:46 +02:00
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
77 changed files with 6021 additions and 135 deletions
+1
View File
@@ -6,6 +6,7 @@
.agents
**/bin
**/obj
.release-work
TestResults
deploy/compose/secrets
deploy/compose/.smoke.env
+49 -13
View File
@@ -21,7 +21,9 @@ jobs:
- name: Install .NET SDK
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
dotnet-version: |
8.0.128
10.0.301
- name: Restore locked dependencies
run: dotnet restore Rendezvous.slnx --locked-mode
@@ -45,6 +47,12 @@ jobs:
- name: Verify formatting and analyzers
run: dotnet format Rendezvous.slnx --verify-no-changes --no-restore
- name: Test dependency-free diagnostic dashboard
run: ./scripts/test-diagnostic-dashboard.sh
- name: Test observability dashboard provisioning
run: ./scripts/test-observability-assets.sh
- name: Build
run: dotnet build Rendezvous.slnx --configuration Release --no-restore
@@ -121,7 +129,9 @@ jobs:
- name: Install .NET SDK
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
dotnet-version: |
8.0.128
10.0.301
- name: Build deployment diagnostic
run: |
@@ -140,12 +150,29 @@ jobs:
rm -f "$secret"
}
trap cleanup EXIT
umask 077
install -d -m 0700 deploy/compose/secrets
openssl rand -out "$secret" 32
chmod 0600 "$secret"
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
./scripts/mint-local-publisher-credential.sh)"
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
chmod 0444 "$secret"
export RENDEZVOUS_UID=1654
export RENDEZVOUS_GID=1654
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
runner_workspace_source="$(docker inspect "$HOSTNAME" | jq -er \
--arg destination "$GITHUB_WORKSPACE" \
'.[0].Mounts[] | select(.Destination == $destination) | .Source')"
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
export SOURCE_REVISION_ID="$GITHUB_SHA"
smoke_client_image="rendezvous-smoke-client:${GITHUB_SHA}"
docker build --file eng/release-builder.Dockerfile \
--target release-builder \
--tag "$smoke_client_image" .
docker compose -f "$compose_file" build \
--build-arg SOURCE_REVISION_ID="$SOURCE_REVISION_ID"
docker compose -f "$compose_file" up --no-build --detach
@@ -153,19 +180,28 @@ jobs:
test -n "$container_id"
test "$(docker inspect --format '{{.Config.User}}' "$container_id")" = "1654:1654"
test "$(docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' "$container_id")" = "true"
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/app/appsettings.Production.json\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination \"/run/secrets/rendezvous-signing-key\"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
for attempt in {1..100}; do
if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1; then
break
fi
if (( attempt == 100 )); then
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/appsettings.Production.json"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
test "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/run/secrets/rendezvous-signing-key"}}{{.RW}}{{end}}{{end}}' "$container_id")" = "false"
docker run --rm \
--network "container:${container_id}" \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:/source:ro" \
--workdir /source \
"$smoke_client_image" \
bash -lc '
for attempt in {1..180}; do
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
sleep 1
done
exit 1
' || {
docker compose -f "$compose_file" logs rendezvous
exit 1
fi
sleep 0.1
done
./scripts/smoke-deployment.sh
}
docker compose -f "$compose_file" stop --timeout 40 rendezvous
test "$(docker inspect --format '{{.State.Running}}' "$container_id")" = "false"
test "$(docker inspect --format '{{.State.ExitCode}}' "$container_id")" = "0"
+163 -45
View File
@@ -20,10 +20,43 @@ jobs:
with:
fetch-depth: 0
- name: Install pinned .NET SDK
- name: Resolve the host bind source for sibling containers
shell: bash
run: |
set -euo pipefail
# This job's steps run inside the runner container while every
# `docker run` starts a sibling container on the host daemon, so bind
# sources must be host paths. The workspace is the only runner mount
# backed by the host, so every path shared with a sibling lives under
# it and a single bind source is resolved once here. Siblings mount
# that source at $GITHUB_WORKSPACE, which keeps every shared path the
# same string on both sides of the boundary.
echo "RENDEZVOUS_WORK_DIR=$GITHUB_WORKSPACE/.release-work" >>"$GITHUB_ENV"
if ! mounts="$(docker inspect "$HOSTNAME" 2>/dev/null | jq -c '.[0].Mounts')"; then
echo "Runner is not containerized; using the workspace path as its own host path."
echo "RENDEZVOUS_WORKSPACE_SOURCE=$GITHUB_WORKSPACE" >>"$GITHUB_ENV"
exit 0
fi
workspace_source="$(jq -er --arg path "$GITHUB_WORKSPACE" '
[ .[]
| .Destination as $destination
| select($path == $destination
or ($path | startswith($destination + "/"))) ]
| if length == 0 then
error("No runner mount exposes \($path) on the Docker host.")
else
sort_by(.Destination | length) | last
end
| (.Destination | length) as $prefix
| .Source + $path[$prefix:]' <<<"$mounts")"
echo "RENDEZVOUS_WORKSPACE_SOURCE=$workspace_source" >>"$GITHUB_ENV"
- name: Install pinned .NET SDKs
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 10.0.301
dotnet-version: |
8.0.128
10.0.301
- name: Install pinned Buildx and BuildKit
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -34,14 +67,19 @@ jobs:
- name: Validate tag and produce reproducible artifacts
shell: bash
env:
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
./scripts/check-release-tag.sh "$GITHUB_REF_NAME"
previous_tag="$(git tag --merged HEAD^ --list 'v*.*.*' --sort=-version:refname | sed -n '1p')"
# check-release-tag.sh already required this tag to point at HEAD, so
# it must be excluded before asking whether any earlier release exists.
unrelated_tag="$(git tag --list 'v*.*.*' | grep -vFx "$GITHUB_REF_NAME" | sed -n '1p' || true)"
if [[ -n "$previous_tag" ]]; then
./scripts/check-compatibility.sh "$previous_tag"
elif [[ -n "$(git tag --list 'v*.*.*' | sed -n '1p')" ]]; then
elif [[ -n "$unrelated_tag" ]]; then
echo "No prior release tag is an ancestor of $GITHUB_REF_NAME." >&2
exit 1
else
@@ -54,19 +92,19 @@ jobs:
--target release-builder \
--load \
--tag "$release_builder" .
mkdir -p "${RUNNER_TEMP}/release-home" "${RUNNER_TEMP}/nuget"
release_dir="$RENDEZVOUS_WORK_DIR/release/$version"
mkdir -p "$RENDEZVOUS_WORK_DIR/release-home" "$RENDEZVOUS_WORK_DIR/nuget"
docker run --rm \
--user "$(id -u):$(id -g)" \
--env HOME="${RUNNER_TEMP}/release-home" \
--env NUGET_PACKAGES="${RUNNER_TEMP}/nuget" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
--env HOME="$RENDEZVOUS_WORK_DIR/release-home" \
--env NUGET_PACKAGES="$RENDEZVOUS_WORK_DIR/nuget" \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$release_builder" \
./scripts/build-release.sh "$version" "${RUNNER_TEMP}/release/$version"
./scripts/verify-real-consumers.sh "$version" "${RUNNER_TEMP}/release/$version"
./scripts/build-release.sh "$version" "$release_dir"
./scripts/verify-real-consumers.sh "$version" "$release_dir"
echo "RENDEZVOUS_VERSION=$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=${RUNNER_TEMP}/release/$version" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_DIR=$release_dir" >>"$GITHUB_ENV"
echo "RENDEZVOUS_RELEASE_BUILDER=$release_builder" >>"$GITHUB_ENV"
- name: Build exact container candidate
@@ -83,8 +121,10 @@ jobs:
--build-arg SOURCE_REVISION_ID="$GITHUB_SHA"
)
release_tag="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
image_one="${RUNNER_TEMP}/rendezvous-image-1.tar"
image_two="${RUNNER_TEMP}/rendezvous-image-2.tar"
# .release-work is excluded from the build context, so the tar written
# by the first build cannot change the context the second one sees.
image_one="$RENDEZVOUS_WORK_DIR/rendezvous-image-1.tar"
image_two="$RENDEZVOUS_WORK_DIR/rendezvous-image-2.tar"
docker buildx build "${common[@]}" --tag "$release_tag" \
--output "type=docker,dest=$image_one,rewrite-timestamp=true" .
docker buildx build "${common[@]}" --tag "$release_tag" \
@@ -95,9 +135,8 @@ jobs:
buildkit_version="$(docker buildx inspect --bootstrap | sed -n 's/.*BuildKit version: *//p' | sed -n '1p')"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
python3 eng/release_artifacts.py record-container-build \
--provenance "$RENDEZVOUS_RELEASE_DIR/release-provenance.json" \
@@ -116,40 +155,93 @@ jobs:
rm -f "$secret"
}
trap cleanup EXIT
umask 077
install -d -m 0700 deploy/compose/secrets
openssl rand -out "$secret" 32
chmod 0600 "$secret"
publisher_credential="$(RENDEZVOUS_SMOKE_LOCAL_KEY="$secret" \
./scripts/mint-local-publisher-credential.sh)"
export RENDEZVOUS_PUBLISHER_CREDENTIAL="$publisher_credential"
chmod 0444 "$secret"
export RENDEZVOUS_UID=1654
export RENDEZVOUS_GID=1654
port_suffix="$(( ${GITHUB_RUN_ID:-$$} % 10000 ))"
export RENDEZVOUS_HTTP_HOST_PORT="$(( 20000 + port_suffix ))"
export RENDEZVOUS_UDP_HOST_PORT="$(( 40000 + port_suffix ))"
runner_workspace_source="${RENDEZVOUS_WORKSPACE_SOURCE:?host workspace bind source was not resolved}"
export RENDEZVOUS_CONFIG_SOURCE="$runner_workspace_source/deploy/compose/appsettings.Production.json"
export RENDEZVOUS_SECRET_SOURCE="$runner_workspace_source/deploy/compose/secrets/signing-key"
export RENDEZVOUS_IMAGE="git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
docker compose -f deploy/compose/compose.yaml up --detach --no-build
for attempt in {1..100}; do
curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null 2>&1 && break
if (( attempt == 100 )); then
container_id="$(docker compose -f deploy/compose/compose.yaml ps -q rendezvous)"
test -n "$container_id"
docker run --rm \
--network "container:${container_id}" \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
--env RENDEZVOUS_SMOKE_HTTP_URL=http://127.0.0.1:8080/ \
--env RENDEZVOUS_SMOKE_UDP_ENDPOINT=127.0.0.1:9050 \
--env RENDEZVOUS_PUBLISHER_CREDENTIAL \
--volume "$runner_workspace_source:$GITHUB_WORKSPACE:ro" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -lc '
for attempt in {1..180}; do
curl --fail --silent "${RENDEZVOUS_SMOKE_HTTP_URL%/}/health/ready" >/dev/null 2>&1 && exec ./scripts/smoke-deployment.sh
sleep 1
done
exit 1
' || {
docker compose -f deploy/compose/compose.yaml logs rendezvous
exit 1
fi
sleep 0.1
done
./scripts/smoke-deployment.sh
}
- name: Install pinned vulnerability scanner
shell: bash
run: |
set -euo pipefail
# trivy-action checks its own repository out of github.com with the
# runner token, which this Gitea instance cannot mint, so the release
# binary is fetched directly instead. The archive URL and its digest
# are pinned inline: fetching the published checksums file at run time
# would only prove the asset matches whatever the release currently
# serves, which is exactly what pinning has to rule out.
trivy_bin_dir="$RENDEZVOUS_WORK_DIR/bin"
trivy_archive="$RENDEZVOUS_WORK_DIR/trivy_0.69.3_Linux-64bit.tar.gz"
# .release-work is gitignored and excluded from the Docker build
# context, so nothing unpacked here can reach an image layer.
mkdir -p "$trivy_bin_dir" "$RENDEZVOUS_WORK_DIR/trivy-cache"
curl --fail --location --silent --show-error --output "$trivy_archive" \
https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.tar.gz
echo "1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75 $trivy_archive" \
| sha256sum --check --strict -
tar --extract --file "$trivy_archive" --directory "$trivy_bin_dir" trivy
rm -f "$trivy_archive"
chmod +x "$trivy_bin_dir/trivy"
echo "RENDEZVOUS_TRIVY=$trivy_bin_dir/trivy" >>"$GITHUB_ENV"
echo "TRIVY_CACHE_DIR=$RENDEZVOUS_WORK_DIR/trivy-cache" >>"$GITHUB_ENV"
"$trivy_bin_dir/trivy" --version
- name: Scan candidate for high and critical vulnerabilities
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
with:
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
version: v0.69.3
format: table
exit-code: "1"
ignore-unfixed: false
severity: HIGH,CRITICAL
shell: bash
run: |
set -euo pipefail
# Unfixed vulnerabilities stay in scope: the flag that would drop them
# is never passed, so the gate keeps trivy's fail-closed default.
"$RENDEZVOUS_TRIVY" image \
--exit-code 1 \
--severity HIGH,CRITICAL \
--format table \
"git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
- name: Generate container SPDX inventory
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0, post-incident safe SHA
with:
image-ref: git.finalfactory.de/heikyu/rendezvous:${{ env.RENDEZVOUS_VERSION }}
version: v0.69.3
format: spdx-json
output: ${{ env.RENDEZVOUS_RELEASE_DIR }}/FinalFactory.Rendezvous.Container.${{ env.RENDEZVOUS_VERSION }}.spdx.json
shell: bash
run: |
set -euo pipefail
"$RENDEZVOUS_TRIVY" image \
--format spdx-json \
--output "$RENDEZVOUS_RELEASE_DIR/FinalFactory.Rendezvous.Container.$RENDEZVOUS_VERSION.spdx.json" \
"git.finalfactory.de/heikyu/rendezvous:${RENDEZVOUS_VERSION}"
- name: Finalize checksums over the publish-ready candidate
shell: bash
@@ -158,9 +250,8 @@ jobs:
source_date_epoch="$(git show -s --format=%ct HEAD)"
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$GITHUB_WORKSPACE:/source" \
--volume "${RUNNER_TEMP}:${RUNNER_TEMP}" \
--workdir /source \
--volume "$RENDEZVOUS_WORKSPACE_SOURCE:$GITHUB_WORKSPACE" \
--workdir "$GITHUB_WORKSPACE" \
"$RENDEZVOUS_RELEASE_BUILDER" \
bash -c 'python3 eng/release_artifacts.py normalize-container-sbom \
--file "$1/FinalFactory.Rendezvous.Container.$2.spdx.json" \
@@ -171,7 +262,13 @@ jobs:
_ "$RENDEZVOUS_RELEASE_DIR" "$RENDEZVOUS_VERSION" "$GITHUB_SHA" "$source_date_epoch"
- name: Preserve verified candidate artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# upload-artifact@v4 speaks the v2 artifacts API, which this Gitea only
# answers at its v3-era shape, so v4 aborts before uploading anything.
# Keeping the candidate is a pre-publish debugging backstop, not a
# release gate: publish-release.sh attaches the same files as Gitea
# release assets, so a failure here must never block a release.
continue-on-error: true
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
name: rendezvous-${{ env.RENDEZVOUS_VERSION }}
path: ${{ env.RENDEZVOUS_RELEASE_DIR }}
@@ -179,9 +276,29 @@ jobs:
retention-days: 30
- name: Install pinned signing client
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
shell: bash
run: |
set -euo pipefail
# cosign-installer issues no API call on the pinned path, but it is a
# composite action resting on `envsubst` and a resolved runner.arch,
# neither of which this runner has ever exercised. Asked for the same
# version it bootstraps, the action downloads exactly this asset and
# checks it against exactly this digest before declaring itself done,
# so fetching it directly verifies identically with nothing unproven
# left in the path. The digest is pinned inline for the reason the
# scanner's is: a checksums file fetched at run time only proves the
# asset matches whatever the release currently serves.
cosign_bin_dir="$RENDEZVOUS_WORK_DIR/bin"
mkdir -p "$cosign_bin_dir"
curl --fail --location --silent --show-error --output "$cosign_bin_dir/cosign" \
https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64
echo "c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74 $cosign_bin_dir/cosign" \
| sha256sum --check --strict -
chmod +x "$cosign_bin_dir/cosign"
# publish-release.sh resolves cosign through `command -v`, so the
# directory joins the PATH the same way the action would have added it.
echo "$cosign_bin_dir" >>"$GITHUB_PATH"
"$cosign_bin_dir/cosign" version
- name: Publish once, sign, attest, and create release
shell: bash
@@ -190,4 +307,5 @@ jobs:
RENDEZVOUS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
RENDEZVOUS_WORKSPACE_SOURCE: ${{ env.RENDEZVOUS_WORKSPACE_SOURCE }}
run: ./scripts/publish-release.sh "$RENDEZVOUS_VERSION" "$RENDEZVOUS_RELEASE_DIR"
+1
View File
@@ -8,6 +8,7 @@ TestResults/
*.userosscache
deploy/compose/.smoke.env
artifacts/
.release-work/
__pycache__/
*.pyc
deploy/compose/secrets/*
+1 -1
View File
@@ -4,7 +4,7 @@ All notable Rendezvous release changes are recorded here. Versions follow
Semantic Versioning; HTTP, UDP, and connection-ticket format compatibility is
tracked separately and called out for every release.
## 1.0.0 - 2026-07-16
## 1.0.0 - 2026-08-22
### Compatibility
+5 -1
View File
@@ -21,7 +21,7 @@ RUN dotnet publish src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Se
/p:RepositoryCommit="$SOURCE_REVISION_ID" \
/p:SourceRevisionId="$SOURCE_REVISION_ID"
FROM mcr.microsoft.com/dotnet/aspnet:10.0.9-noble-chiseled@sha256:f820c4fbfb8bb204c3bbe05c69d48cd039cd0e67aa8f13ac1cec168819b90643 AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0.11-noble-chiseled@sha256:0839314d08bb65da369135389a5d8291f75ace587fbb0488f469eb92c62eef68 AS runtime
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0 \
@@ -32,4 +32,8 @@ COPY --from=build --chown=1654:1654 /out/ ./
USER 1654:1654
EXPOSE 8080/tcp
EXPOSE 9050/udp
# The chiseled image has no shell or curl; the probe re-enters the server
# assembly in --health-probe mode and reports through the exit code.
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["dotnet", "FinalFactory.Rendezvous.Server.dll", "--health-probe"]
ENTRYPOINT ["dotnet", "FinalFactory.Rendezvous.Server.dll"]
+13 -6
View File
@@ -84,10 +84,10 @@ directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, typed connection outcomes, thin public-SDK diagnostic client,
deterministic NAT topology harness, hostile-input controls,
observability/operator surface, secure single-active Linux deployment, and
numeric capacity/resilience gates, and reproducible signed release pipeline are
implemented. Consumer pilots and final production-readiness gates remain in progress;
participating games must not treat the current repository as a finished production
service until those gates land.
numeric capacity/resilience gates, reproducible signed release pipeline, consumer
pilot integrations, and production-readiness decision framework are implemented.
Deployment-specific external canaries and optional roadmap follow-ups remain;
participating games must not treat a checkout alone as production approval.
The ratified v1 boundaries, trust decisions, privacy rules, safety budgets, and
threat model are indexed in [the architecture documentation](docs/architecture/README.md).
@@ -100,6 +100,8 @@ defined in [hostile-input and overload protection](docs/security/abuse-protectio
Health semantics, bounded telemetry, alerting, audit privacy, and the authenticated
operator controls are defined in the
[observability and operator runbook](docs/operations/observability-and-operator-runbook.md).
The optional public session diagnostic and the private provisioned Grafana stack
are described in [diagnostic dashboards](docs/operations/diagnostic-dashboards.md).
Concrete detect/contain/recover/verify procedures are in the
[incident and change runbooks](docs/operations/incident-runbooks.md).
The pinned non-root container, production topology, graceful drain, Linux
@@ -113,6 +115,9 @@ signing, staged promotion, rollback, and migration are defined in
[releases and compatibility](docs/releases/README.md).
The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md).
Optional bounded SSE deltas, reconnect/reset semantics, proxy requirements, and
polling fallback are documented in
[live session-list updates](docs/integration/live-session-updates.md).
The package, gameplay-socket, host-admission, provisioning, metadata, key rotation,
versioning, and secure rollout seams are in the
[game integration guide](docs/integration/sdk-seams.md).
@@ -129,8 +134,10 @@ external-network procedure are in
## Development
The repository pins .NET SDK 10.0.301. From a clean clone, run the same gates as
CI from the repository root:
The repository pins .NET SDK 10.0.301. The full process-test gate also requires
the .NET 8 runtime because the public TestClient deliberately targets `net8.0`;
CI installs SDK 8.0.128 to supply the pinned 8.0.28 runtime. From a clean clone,
run the same gates as CI from the repository root:
```bash
dotnet restore Rendezvous.slnx --locked-mode
@@ -14,6 +14,29 @@
"ListenAddress": "0.0.0.0",
"Port": 9050
},
"Diagnostics": {
"Enabled": false,
"PollIntervalSeconds": 10,
"MaximumRenderedSessions": 100,
"Scopes": [
{
"GameId": "space-game",
"EnvironmentId": "smoke",
"ProtocolVersions": [1, 2],
"Regions": ["local"]
},
{
"GameId": "unscouted",
"EnvironmentId": "smoke",
"ProtocolVersions": [1],
"Regions": ["local"]
}
]
},
"Metrics": {
"Enabled": false,
"BearerTokenSecretReference": "file:/run/secrets/rendezvous-metrics-token"
},
"AbuseProtection": {
"TrustedProxyAddresses": ["127.0.0.1"],
"OperatorAllowedAddresses": ["127.0.0.1"]
+16 -4
View File
@@ -24,12 +24,24 @@ services:
hard: 4096
stop_grace_period: 40s
restart: unless-stopped
healthcheck:
test: ["CMD", "dotnet", "FinalFactory.Rendezvous.Server.dll", "--health-probe"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: "8080"
Rendezvous__Diagnostics__Enabled: "${RENDEZVOUS_DIAGNOSTICS_ENABLED:-false}"
Rendezvous__Metrics__Enabled: "${RENDEZVOUS_METRICS_ENABLED:-false}"
volumes:
- ./appsettings.Production.json:/app/appsettings.Production.json:ro
- ./secrets/signing-key:/run/secrets/rendezvous-signing-key:ro
- ${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}:/app/appsettings.Production.json:ro
- ${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}:/run/secrets/rendezvous-signing-key:ro
ports:
- "127.0.0.1:8080:8080/tcp"
- "9050:9050/udp"
- "127.0.0.1:${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp"
# 9050 is also a common game-client default UDP port. Evaluating this
# service on the same machine as a running game client can silently
# collide; remap with RENDEZVOUS_UDP_HOST_PORT and keep the advertised
# Rendezvous:Deployment:PublicUdpPort in appsettings matched to it.
- "${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp"
+71
View File
@@ -0,0 +1,71 @@
services:
rendezvous:
environment:
Rendezvous__Metrics__Enabled: "true"
volumes:
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
prometheus:
image: prom/prometheus:v3.13.1@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893
user: "65534:65534"
read_only: true
init: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 512m
cpus: 1.0
restart: unless-stopped
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
volumes:
- ../observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../observability/secrets/rendezvous-metrics-token:/run/secrets/rendezvous-metrics-token:ro
- prometheus-data:/prometheus
depends_on:
- rendezvous
grafana:
image: grafana/grafana:13.1.0@sha256:121a7a9ece6dc10b969f1f96eed64b4f07dfac0d0b8abc070f7cb83bbde86f63
user: "472:472"
read_only: true
init: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 128
mem_limit: 512m
cpus: 1.0
restart: unless-stopped
environment:
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_PLUGINS_PREINSTALL_DISABLED: "true"
GF_PLUGINS_PREINSTALL_AUTO_UPDATE: "false"
GF_SECURITY_ADMIN_USER: "rendezvous-admin"
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana-admin-password
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_SERVER_DOMAIN: localhost
GF_SERVER_ROOT_URL: http://localhost:3000
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=32m,uid=472,gid=472,mode=0700
volumes:
- ../observability/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ../observability/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ../observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- ../observability/secrets/grafana-admin-password:/run/secrets/grafana-admin-password:ro
- grafana-data:/var/lib/grafana
ports:
- "127.0.0.1:3000:3000/tcp"
depends_on:
- prometheus
volumes:
prometheus-data:
grafana-data:
@@ -0,0 +1,292 @@
{
"annotations": {"list": []},
"description": "Privacy-safe operational view of the single-active Rendezvous service. Capacity percentages use the approved 25,000 listing and 10,000 active-attempt launch envelope.",
"editable": false,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"title": "Service",
"description": "Prometheus can authenticate to and scrape the Rendezvous process.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "up{job=\"rendezvous\"}", "legendFormat": "Rendezvous", "range": true, "refId": "A"}]
},
{
"id": 2,
"title": "Store",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "red", "text": "UNAVAILABLE"}, "1": {"color": "green", "text": "AVAILABLE"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_store_available", "legendFormat": "Store", "range": true, "refId": "A"}]
},
{
"id": 3,
"title": "Drain",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
"fieldConfig": {"defaults": {"mappings": [{"options": {"0": {"color": "green", "text": "ACCEPTING"}, "1": {"color": "orange", "text": "DRAINING"}}, "type": "value"}], "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "none", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_store_draining", "legendFormat": "Drain", "range": true, "refId": "A"}]
},
{
"id": 4,
"title": "Listings",
"description": "Percentage of the approved 25,000-listing single-process envelope.",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 12, "y": 0},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_listings / 25000", "legendFormat": "Listings", "range": true, "refId": "A"}]
},
{
"id": 5,
"title": "Join attempts",
"description": "Percentage of the approved 10,000 active-attempt single-process envelope.",
"type": "gauge",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 4, "w": 6, "x": 18, "y": 0},
"fieldConfig": {"defaults": {"max": 100, "min": 0, "unit": "percent", "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 70}, {"color": "red", "value": 90}]}}, "overrides": []},
"options": {"minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto"},
"targets": [{"editorMode": "code", "expr": "100 * rendezvous_store_active_attempts / 10000", "legendFormat": "Attempts", "range": true, "refId": "A"}]
},
{
"id": 6,
"title": "HTTP request rate by operation",
"description": "Registration, renewal, browse, and join issuance appear as bounded operation names.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 4},
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_http_requests_total[5m]))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 7,
"title": "HTTP p95 latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 4},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 200}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_http_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 8,
"title": "HTTP error and shedding rate",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 4},
"fieldConfig": {"defaults": {"unit": "reqps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (status_code) (rate(rendezvous_http_requests_total{status_code=~\"4..|5..\"}[5m]))", "legendFormat": "HTTP {{status_code}}", "range": true, "refId": "A"}]
},
{
"id": 9,
"title": "Browser live-update load",
"description": "Active public SSE clients, tenant scopes, and bounded replay entries.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 12},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rendezvous_browser_sse_subscribers", "legendFormat": "Subscribers", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "rendezvous_browser_sse_tenants", "legendFormat": "Tenant scopes", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "rendezvous_browser_replay_entries", "legendFormat": "Replay entries", "range": true, "refId": "C"}
]
},
{
"id": 10,
"title": "UDP mediation results",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 12},
"fieldConfig": {"defaults": {"unit": "pps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (operation, result) (rate(rendezvous_udp_results_total[5m]))", "legendFormat": "{{operation}} · {{result}}", "range": true, "refId": "A"}]
},
{
"id": 11,
"title": "UDP p95 processing latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 12},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, sum by (le, operation) (rate(rendezvous_udp_duration_milliseconds_bucket[5m])))", "legendFormat": "{{operation}}", "range": true, "refId": "A"}]
},
{
"id": 12,
"title": "UDP ingress and admitted response budget",
"description": "Traffic bytes observed by the process and the conservative maximum response budget admitted for successful introductions; this is not actual egress.",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 19},
"fieldConfig": {"defaults": {"unit": "Bps"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_received_bytes_total[5m]))", "legendFormat": "Ingress · {{operation}}", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "sum by (operation) (rate(rendezvous_udp_response_budget_bytes_total[5m]))", "legendFormat": "Response budget · {{operation}}", "range": true, "refId": "B"}
]
},
{
"id": 13,
"title": "Admission-control drops",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 19},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (transport, partition) (rate(rendezvous_limiter_drops_total[5m]))", "legendFormat": "{{transport}} · {{partition}}", "range": true, "refId": "A"}]
},
{
"id": 14,
"title": "Direct-connect outcomes",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 19},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (outcome, elapsed_bucket) (rate(rendezvous_connection_outcomes_total[5m]))", "legendFormat": "{{outcome}} · {{elapsed_bucket}}", "range": true, "refId": "A"}]
},
{
"id": 15,
"title": "Pairing p95 latency",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 26},
"fieldConfig": {"defaults": {"unit": "ms", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "red", "value": 100}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "histogram_quantile(0.95, rate(rendezvous_pairing_latency_milliseconds_bucket[5m]))", "legendFormat": "Pairing p95", "range": true, "refId": "A"}]
},
{
"id": 16,
"title": "Presence and expiry state",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 26},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rendezvous_store_fresh_presence_bindings", "legendFormat": "Fresh presence", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "rendezvous_store_awaiting_presence_listings", "legendFormat": "Awaiting presence", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "rate(rendezvous_store_expiry_churn_total[5m])", "legendFormat": "Expiry churn/s", "range": true, "refId": "C"}
]
},
{
"id": 17,
"title": "Signing key state",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 26},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_keys", "legendFormat": "{{state}}", "range": true, "refId": "A"}]
},
{
"id": 18,
"title": "Minimum signing window",
"description": "Page below seven days; escalate below 24 hours.",
"type": "stat",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 0, "y": 33},
"fieldConfig": {"defaults": {"unit": "s", "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "orange", "value": 86400}, {"color": "green", "value": 604800}]}}, "overrides": []},
"options": {"colorMode": "background", "graphMode": "area", "justifyMode": "center", "orientation": "auto", "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, "textMode": "auto", "wideLayout": true},
"targets": [{"editorMode": "code", "expr": "rendezvous_signing_key_sign_seconds_remaining", "legendFormat": "Signing window", "range": true, "refId": "A"}]
},
{
"id": 19,
"title": "Operator authentication",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 6, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_operator_authentication_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
},
{
"id": 20,
"title": "Privileged audit outcomes",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 12, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (action, result) (rate(rendezvous_audit_events_total[5m]))", "legendFormat": "{{action}} · {{result}}", "range": true, "refId": "A"}]
},
{
"id": 21,
"title": "Metrics access",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 5, "w": 6, "x": 18, "y": 33},
"fieldConfig": {"defaults": {"unit": "ops"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [{"editorMode": "code", "expr": "sum by (result) (rate(rendezvous_metrics_scrapes_total[5m]))", "legendFormat": "{{result}}", "range": true, "refId": "A"}]
},
{
"id": 22,
"title": "Process memory",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 0, "y": 38},
"fieldConfig": {"defaults": {"unit": "bytes", "custom": {"thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}, {"color": "orange", "value": 1073741824}, {"color": "red", "value": 1610612736}]}}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "process_resident_memory_bytes", "legendFormat": "Resident", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "dotnet_gc_heap_size_bytes", "legendFormat": "Managed heap", "range": true, "refId": "B"}
]
},
{
"id": 23,
"title": "Process CPU and threads",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 8, "y": 38},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": [{"matcher": {"id": "byName", "options": "CPU cores"}, "properties": [{"id": "unit", "value": "cores"}]}]},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "rate(process_cpu_seconds_total[5m])", "legendFormat": "CPU cores", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "process_threads", "legendFormat": "Process threads", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "dotnet_thread_pool_threads", "legendFormat": "Thread-pool threads", "range": true, "refId": "C"}
]
},
{
"id": 24,
"title": "Descriptor and GC pressure",
"type": "timeseries",
"datasource": {"type": "prometheus", "uid": "rendezvous-prometheus"},
"gridPos": {"h": 7, "w": 8, "x": 16, "y": 38},
"fieldConfig": {"defaults": {"unit": "short"}, "overrides": []},
"options": {"legend": {"calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true}, "tooltip": {"mode": "multi", "sort": "desc"}},
"targets": [
{"editorMode": "code", "expr": "process_open_file_descriptors", "legendFormat": "Open descriptors", "range": true, "refId": "A"},
{"editorMode": "code", "expr": "sum(rate(dotnet_gc_collections_total[5m]))", "legendFormat": "GC collections/s", "range": true, "refId": "B"},
{"editorMode": "code", "expr": "dotnet_thread_pool_available_worker_threads", "legendFormat": "Available workers", "range": true, "refId": "C"}
]
}
],
"refresh": "15s",
"schemaVersion": 41,
"tags": ["rendezvous", "operations", "privacy-safe"],
"templating": {"list": []},
"time": {"from": "now-1h", "to": "now"},
"timepicker": {},
"timezone": "browser",
"title": "Rendezvous operational overview",
"uid": "rendezvous-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: Rendezvous
orgId: 1
folder: Rendezvous
type: file
disableDeletion: true
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,17 @@
apiVersion: 1
deleteDatasources:
- name: Rendezvous Prometheus
orgId: 1
datasources:
- name: Rendezvous Prometheus
uid: rendezvous-prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: 15s
@@ -0,0 +1,14 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
service: rendezvous
scrape_configs:
- job_name: rendezvous
scheme: http
metrics_path: /metrics
bearer_token_file: /run/secrets/rendezvous-metrics-token
static_configs:
- targets:
- rendezvous:8080
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+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": {
"get": {
"tags": [
@@ -2657,7 +2810,8 @@
"BrowseSessionsResponse": {
"required": [
"contractVersion",
"items"
"items",
"streamCursor"
],
"type": "object",
"properties": {
@@ -2676,6 +2830,9 @@
"null",
"string"
]
},
"streamCursor": {
"type": "string"
}
}
},
@@ -3545,6 +3702,54 @@
"type": "string",
"format": "uuid"
},
"SessionStreamEvent": {
"required": [
"contractVersion",
"kind",
"cursor"
],
"type": "object",
"properties": {
"contractVersion": {
"type": "integer",
"format": "int32"
},
"kind": {
"$ref": "#/components/schemas/SessionStreamEventKind"
},
"cursor": {
"type": "string"
},
"session": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionListing"
}
]
},
"listingId": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/SessionListingId"
}
]
}
}
},
"SessionStreamEventKind": {
"enum": [
"sessionUpsert",
"sessionRemove",
"reset",
"keepalive"
]
},
"UpdateSessionRequest": {
"required": [
"contractVersion",
@@ -3563,6 +3768,33 @@
"leaseToken": {
"type": "string"
},
"regionId": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/RegionId"
}
]
},
"protocolVersion": {
"type": [
"null",
"integer"
],
"format": "uint32"
},
"visibility": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ListingVisibility"
}
]
},
"buildVersion": {
"type": "string"
},
+2
View File
@@ -29,6 +29,8 @@ engine, transport, or server dependency therefore fails the normal test gate.
## Supported toolchain
- Build SDK: .NET SDK 10.0.301, pinned by `global.json`.
- Verification runtime: .NET SDK 8.0.128 supplies the pinned .NET 8.0.28
runtime used by TestClient process tests and release verification.
- Server runtime: .NET 10 LTS.
- Client/contracts compatibility target: .NET Standard 2.1, consumable by the
.NET 8-or-later runtime used by current Godot 4 C# projects.
+16
View File
@@ -42,6 +42,22 @@ test "$RENDEZVOUS_UID" -ne 0
docker compose -f deploy/compose/compose.yaml up --build --detach
```
The example defaults to host TCP 8080 and UDP 9050. On a shared host, set
`RENDEZVOUS_HTTP_HOST_PORT` and `RENDEZVOUS_UDP_HOST_PORT` before starting
Compose, then point `RENDEZVOUS_SMOKE_HTTP_URL` and
`RENDEZVOUS_SMOKE_UDP_ENDPOINT` at those published ports. The container ports
and production-advertised service ports remain 8080/9050. Sibling-container CI
runners may also set `RENDEZVOUS_CONFIG_SOURCE` and
`RENDEZVOUS_SECRET_SOURCE` to host-visible absolute bind-source paths; local
operators should normally keep the checked-in relative defaults. CI runs its
ephemeral smoke client as a sidecar in the service container's network namespace
to avoid runner-specific bridge and host-routing policy; it does not widen the
default loopback HTTP publication. It mints the disposable publisher credential
while the generated key is still owner-private, then makes the key read-only for
the non-root service container. Automation probes readiness once per second and
allows up to three minutes for a cold, resource-constrained image to become
ready; the protocol smoke retains its separate, stricter scenario timeout.
`deploy/compose/appsettings.Production.json` is a local/private-bridge smoke
profile, not an Internet template: TCP is published only on host loopback, the
explicit `rendezvous` host name serves isolated clients on the Compose network,
+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 -2
View File
@@ -31,8 +31,11 @@ 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
The SpaceGame host and client each create one caller-owned `NetManager`,
configure any additional gameplay QoS channels before `Start` (the pilot
harness used three; current SpaceGame source uses LiteNetLib's default single
channel — both peers must simply agree, because a `ChannelsCount` mismatch
fails the connection silently), 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
+8 -1
View File
@@ -146,7 +146,14 @@ Never use an unbounded sleep to orchestrate processes. Wait for versioned events
such as `host.ready` and apply a deadline. Useful success events are
`host.registered`, `host.ready`, `host.direct-traffic`, `host.deregistered`,
`browse.completed`, `browse.session`, `join.connected`, `join.direct-traffic`,
and `join.outcome-report`.
`join.outcome-report`, `watch.snapshot`, `watch.session-upsert`,
`watch.session-remove`, `watch.reset`, `watch.reconnect`, and `watch.complete`.
For a bounded live-directory diagnostic, use `watch --run-seconds 60`. Add
`--exercise-reset --script` to prove fail-closed cursor recovery, or
`--exercise-reconnect --script` while changing one listing to prove ordered
`Last-Event-ID` replay after a deliberate disconnect. The full event and proxy
contract is in [live session-list updates](live-session-updates.md).
| Exit | Meaning |
| ---: | --- |
+123
View File
@@ -0,0 +1,123 @@
# Diagnostic dashboards
Tracking: #27
Rendezvous provides two deliberately separate, optional views. The public
session diagnostic helps a player or integration operator understand safe
session-list state using only the public browse contract. The private Grafana
dashboard exposes aggregate operational health through authenticated metrics.
Neither view grants operator privileges or exposes player identity, endpoints,
credentials, capabilities, or raw session metadata beyond the explicitly
allowlisted public browse fields.
## Public read-only session diagnostic
The static diagnostic is disabled by default. Enable it only for approved
game/environment scopes and keep the allowlist narrow:
```json
"Diagnostics": {
"Enabled": true,
"PollIntervalSeconds": 10,
"MaximumRenderedSessions": 100,
"Scopes": [
{
"GameId": "space-game",
"EnvironmentId": "smoke",
"ProtocolVersions": [1, 2],
"Regions": ["local"]
}
]
}
```
Open `/diagnostics` on the same origin as Rendezvous. The page cannot choose a
different backend, request private visibility, join a session, or call the
operator surface. It renders a bounded snapshot, then applies ordered SSE
updates. A replay reset, corrupt cursor, incomplete snapshot, transport failure,
or deliberate reconnect returns to a fresh authoritative snapshot and bounded
polling. Apply filter changes explicitly; **Reset filters** restores the
configured defaults, while **Reconnect now** tests recovery without changing
the selection.
The page uses semantic HTML, labelled controls, visible keyboard focus, status
text in addition to color, a reduced-motion mode, and a 320-pixel reflow. It
creates untrusted content with `textContent` only. The endpoint sets a restrictive
same-origin content-security policy, denies framing, disables MIME sniffing and
browser capabilities, and marks every asset/config response `no-store`.
This is a diagnostics convenience, not a game browser, management console, or
availability monitor. Disable it independently by setting `Enabled` to `false`;
all diagnostic paths then return `404` without affecting game traffic, metrics,
or health endpoints.
## Private Prometheus and Grafana view
The observability overlay pins Prometheus 3.13.1 and Grafana 13.1.0 by immutable
multi-platform image digest. Prometheus is not published to the host. Grafana is
bound to host loopback, disables anonymous access and sign-up, and reads its
administrator password from a file. The service and Prometheus share only the
metrics bearer-token file. All three secrets remain ignored by Git.
Create independent random secrets, then start the base service and overlay:
```bash
install -d -m 0700 deploy/compose/secrets deploy/observability/secrets
umask 077
openssl rand -out deploy/compose/secrets/signing-key 32
openssl rand -hex 32 >deploy/observability/secrets/rendezvous-metrics-token
openssl rand -base64 36 >deploy/observability/secrets/grafana-admin-password
export RENDEZVOUS_UID="$(id -u)"
export RENDEZVOUS_GID="$(id -g)"
test "$RENDEZVOUS_UID" -ne 0
docker compose \
-f deploy/compose/compose.yaml \
-f deploy/observability/compose.yaml \
up --build --detach
```
Visit `http://127.0.0.1:3000`, sign in as `rendezvous-admin`, and open the
**Rendezvous operational overview** folder/dashboard. The provisioned panels
cover scrape/store/drain health, listing and join capacity, HTTP volume/errors
and p95, browse/SSE load, lease and join operations, UDP results/latency/bytes,
admission drops, connection outcomes, pairing latency, presence/expiry state,
signing windows, security/audit results, and process/GC/descriptor pressure.
Capacity gauges use the approved single-process envelope of 25,000 listings and
10,000 active attempts, with 70% warning and 90% critical thresholds. The UDP
response series is a conservative admitted maximum, not observed egress.
Validate merged configuration and checked-in dashboard structure before every
rollout:
```bash
RENDEZVOUS_UID="$(id -u)" RENDEZVOUS_GID="$(id -g)" \
docker compose \
-f deploy/compose/compose.yaml \
-f deploy/observability/compose.yaml \
config --quiet
./scripts/test-observability-assets.sh
```
For a real deployment, keep Grafana on a private authenticated management
network instead of host loopback, replace the local admin login with the
organization's supported identity boundary, enforce TLS at the edge, and set
retention to the approved operational period. Do not make Prometheus public.
Provisioning is read-only so local UI edits cannot silently drift from source.
## Verify, rotate, and disable
After startup, verify the dashboard shows `UP`, store `AVAILABLE`, a nonzero
signing window, and changing request/UDP panels during a smoke run. Confirm an
unauthenticated `/metrics` request returns `404`, the bearer-authenticated
collector target is healthy, Prometheus is not bound on a host port, Grafana is
not anonymously accessible, and dashboard query labels contain no identifiers.
Rotate metrics access by writing a new 32-128 character token to the secret file
with private permissions and restarting Rendezvous and Prometheus together.
Rotate the Grafana administrator password through the same protected secret
workflow. Delete both secret files after a disposable local run.
To disable aggregate observability independently, stop/remove the overlay and
set `Rendezvous:Metrics:Enabled` to `false`; `/metrics` returns `404` and the
core service continues. To disable only the public session diagnostic, leave the
overlay running and set `Rendezvous:Diagnostics:Enabled` to `false`.
@@ -2,9 +2,10 @@
This runbook defines the production signals and privileged controls for the
Rendezvous service. The service emits `System.Diagnostics.Metrics` instruments
from the `FinalFactory.Rendezvous` meter and distributed-tracing activities from
`FinalFactory.Rendezvous.Server`. Connect those sources to the deployment's
OpenTelemetry or equivalent collector. Do not add identifiers to metric labels.
from the `FinalFactory.Rendezvous` meter, distributed-tracing activities from
`FinalFactory.Rendezvous.Server`, and an optional bearer-protected Prometheus
endpoint. Connect only a private collector network. Do not add identifiers to
metric labels.
Concrete detect/contain/recover/verify procedures for abuse, key compromise,
targeted revocation, restart, rollback, saturation, privacy incidents, and
@@ -38,6 +39,25 @@ dependency upgrades are in the [incident and change runbooks](incident-runbooks.
| `rendezvous.store.expiry_churn` | Cumulative natural expiry activity | none |
| `rendezvous.store.available` | Store health (`1` available, `0` unavailable) | none |
The Prometheus exporter additionally exposes active/fresh/awaiting store state,
drain state, SSE subscriber/tenant/replay gauges, bounded HTTP and UDP
histograms, UDP ingress and conservative admitted-response budgets, signing-key
state/window gauges, and process/.NET pressure. Its only labels are the fixed
operation, status, result, transport, partition, action, outcome, elapsed-bucket,
key-state, and GC-generation dimensions. Unknown or unsafe values normalize to
`other`; identifiers, metadata, addresses, endpoints, tokens, and capabilities
are never labels.
The exporter is disabled by default. Enabling `Rendezvous:Metrics:Enabled`
requires `BearerTokenSecretReference` to be an external `env:` or absolute
`file:` secret containing 32-128 visible ASCII bytes. Unauthorized requests get
the same `404` as a disabled endpoint, and accepted responses are `no-store`.
Expose `/metrics` only to the private collector network, rotate its token as a
deployment secret, and never place the token in a URL, Compose environment
value, dashboard, log, or issue. The checked-in Prometheus/Grafana provisioning
and its verification procedure are in
[diagnostic dashboards](diagnostic-dashboards.md).
HTTP responses include `X-Rendezvous-Correlation-ID`. It is a generated trace ID
or random value, never a caller-supplied session or player identifier. UDP and
HTTP activities contain operation-level data only. Logs and traces must not add
+3
View File
@@ -1,11 +1,14 @@
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
FROM python:3.12.11-slim-bookworm@sha256:c00fc7b44d844b6da22861ec24af43968a5200eac4ec607b4725d585165d6b49 AS release-python
FROM ghcr.io/jqlang/jq:1.8.1@sha256:95de8f005ca027686a1ca3b0853e2bb219062438015862816159f3f25a4d4230 AS release-jq
FROM mcr.microsoft.com/dotnet/runtime:8.0.28-noble@sha256:19a311642eb7ee9c985bd71b9e5618123879df4e8d948f7388a41b0ee4788e25 AS dotnet-runtime-8
FROM mcr.microsoft.com/dotnet/sdk:10.0.301-noble@sha256:ea8bde36c11b6e7eec2656d0e59101d4462f6bd630730f2c8201ed0572b295d5 AS release-builder
COPY --from=release-python /usr/local/ /usr/local/
COPY --from=release-jq /jq /usr/local/bin/jq
COPY --from=dotnet-runtime-8 /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/ /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.28/
RUN dotnet --version \
&& dotnet --list-runtimes \
&& python3 --version \
&& git --version \
&& tar --version \
+4 -1
View File
@@ -30,7 +30,10 @@ def load_json(path: pathlib.Path):
def dependency_inventory(root: pathlib.Path):
dependencies = {}
for lock_path in sorted(root.glob("**/packages.lock.json")):
if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
if any(
part in {"bin", "obj", "artifacts", ".release-work"}
for part in lock_path.parts
):
continue
lock = load_json(lock_path)
for framework in lock.get("dependencies", {}).values():
+28 -3
View File
@@ -10,6 +10,22 @@ image="$registry/heikyu/rendezvous:$version"
token="${RENDEZVOUS_RELEASE_TOKEN:?RENDEZVOUS_RELEASE_TOKEN is required}"
username="${RENDEZVOUS_RELEASE_USERNAME:?RENDEZVOUS_RELEASE_USERNAME is required}"
release_builder="${RENDEZVOUS_RELEASE_BUILDER:?RENDEZVOUS_RELEASE_BUILDER is required}"
# Sibling-container runners execute this script inside a container while
# `docker run` starts containers on the host daemon, so the bind source must be
# a host path. The workflow resolves the workspace once and exports it; a direct
# host run keeps the local path. Only the workspace is bound, so the release
# directory has to live inside it, and binding it back onto its own path keeps
# every shared path identical on both sides of the boundary.
workspace_source="${RENDEZVOUS_WORKSPACE_SOURCE:-$root}"
[[ -d "$release_dir" ]] || {
echo "Release directory does not exist: $release_dir" >&2
exit 1
}
release_dir="$(cd "$release_dir" && pwd)"
if [[ "$release_dir" != "$root"/* ]]; then
echo "Release directory must live inside the workspace: $release_dir" >&2
exit 1
fi
docker_config="$(mktemp -d)"
curl_config="$(mktemp)"
release_request=""
@@ -33,12 +49,21 @@ for command in cosign curl docker dotnet jq; do
}
done
# Publication is a one-way gate: Gitea package versions are immutable, so the
# signing material must be proven usable before anything is pushed. Never print
# the key or the probe output.
: "${COSIGN_PRIVATE_KEY:?COSIGN_PRIVATE_KEY is required}"
: "${COSIGN_PASSWORD:?COSIGN_PASSWORD is required}"
cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null || {
echo "Configured cosign key could not be loaded; refusing to publish." >&2
exit 1
}
run_release_builder() {
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$root:/source:ro" \
--volume "$release_dir:$release_dir" \
--workdir /source \
--volume "$workspace_source:$root" \
--workdir "$root" \
"$release_builder" "$@"
}
+31 -15
View File
@@ -58,6 +58,14 @@ temp_dir="$(mktemp -d)"
host_log="$temp_dir/host.jsonl"
join_log="$temp_dir/join.jsonl"
host_pid=''
sanitize_log() {
jq -Rrc 'fromjson? | {
event: (.event // "unknown"),
status: (.status // "unknown"),
phase: (.phase // "unknown"),
hasListingId: (((.listingId // "") | length) > 0)
}' "$1"
}
cleanup() {
local status="$?"
if [[ -n "$host_pid" ]] && kill -0 "$host_pid" 2>/dev/null; then
@@ -66,8 +74,8 @@ cleanup() {
fi
if [[ "$status" -ne 0 ]]; then
printf 'Deployment smoke failed; sanitized diagnostic events follow.\n' >&2
[[ -f "$host_log" ]] && jq -c . "$host_log" >&2 || true
[[ -f "$join_log" ]] && jq -c . "$join_log" >&2 || true
[[ -f "$host_log" ]] && sanitize_log "$host_log" >&2 || true
[[ -f "$join_log" ]] && sanitize_log "$join_log" >&2 || true
fi
rm -rf "$temp_dir"
return "$status"
@@ -84,26 +92,24 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
host_pid="$!"
ready=false
listing_id=''
for ((iteration = 0; iteration < TIMEOUT_SECONDS * 4; iteration++)); do
if jq -e 'select(.event == "host.ready")' "$host_log" >/dev/null 2>&1; then
if listing_id="$(jq -er '
select(.event == "host.ready" and .status == "ready")
| .listingId // empty
' "$host_log" 2>/dev/null | tail -n 1)" && [[ -n "$listing_id" ]]; then
ready=true
break
fi
if ! kill -0 "$host_pid" 2>/dev/null; then
printf 'Host diagnostic stopped before it became ready.\n' >&2
jq -c . "$host_log" >&2 || true
sanitize_log "$host_log" >&2 || true
exit 1
fi
sleep 0.25
done
if [[ "$ready" != true ]]; then
printf 'Host diagnostic did not become ready within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
exit 1
fi
listing_id="$(jq -r 'select(.event == "host.registered") | .listingId' "$host_log" | tail -n 1)"
if [[ -z "$listing_id" || "$listing_id" == null ]]; then
printf 'Host diagnostic did not report a listing ID.\n' >&2
printf 'Host diagnostic did not report a ready listing within %s seconds.\n' "$TIMEOUT_SECONDS" >&2
exit 1
fi
@@ -115,9 +121,19 @@ dotnet run --project "$PROJECT" --configuration "$BUILD_CONFIGURATION" --no-buil
wait "$host_pid"
host_pid=''
jq -e 'select(.event == "host.direct-traffic" and .status == "verified")' "$host_log" >/dev/null
jq -e 'select(.event == "host.deregistered" and .status == "complete")' "$host_log" >/dev/null
jq -e 'select(.event == "join.direct-traffic" and .status == "verified")' "$join_log" >/dev/null
jq -e 'select(.event == "join.outcome-report" and .status == "accepted")' "$join_log" >/dev/null
if ! jq -s -e '
any(.[]; .event == "host.direct-traffic" and .status == "verified")
and any(.[]; .event == "host.deregistered" and .status == "complete")
' "$host_log" >/dev/null; then
printf 'Host diagnostic did not complete authenticated traffic and deregistration.\n' >&2
exit 1
fi
if ! jq -s -e '
any(.[]; .event == "join.direct-traffic" and .status == "verified")
and any(.[]; .event == "join.outcome-report" and .status == "accepted")
' "$join_log" >/dev/null; then
printf 'Join diagnostic did not complete authenticated traffic and outcome reporting.\n' >&2
exit 1
fi
printf 'Rendezvous deployment smoke passed: HTTP live/ready and authenticated UDP mediation/direct traffic.\n'
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
node --test tests/Diagnostics/*.test.mjs
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
node --test tests/Observability/observability-assets.test.mjs
+20 -1
View File
@@ -5,6 +5,11 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${1:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
release_dir="${2:?usage: verify-real-consumers.sh VERSION RELEASE_DIRECTORY}"
manifest="$root/eng/consumer-revisions.json"
# The pinned consumers live in private repositories, so release automation must
# authenticate. Keep the token in the environment git reads config from: it must
# never reach argv, the remote URL, or on-disk repository configuration.
token="${RENDEZVOUS_CONSUMER_TOKEN:-${RENDEZVOUS_RELEASE_TOKEN:-}}"
export GIT_TERMINAL_PROMPT=0
work="$(mktemp -d "${TMPDIR:-/tmp}/rendezvous-consumers.XXXXXX")"
cleanup() {
rm -rf "$work"
@@ -18,6 +23,20 @@ for command in dotnet git jq python3; do
}
done
fetch_consumer_revision() {
local checkout="$1"
local repository="$2"
local revision="$3"
if [[ -z "$token" ]]; then
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
return
fi
GIT_CONFIG_COUNT=1 \
GIT_CONFIG_KEY_0="http.${repository}.extraHeader" \
GIT_CONFIG_VALUE_0="Authorization: token ${token}" \
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
}
python3 "$root/eng/release_artifacts.py" consumer-config \
--local-source "$release_dir" \
--output "$work/NuGet.config"
@@ -31,7 +50,7 @@ for ((index = 0; index < count; index++)); do
checkout="$work/$name"
git -c init.defaultBranch=main init --quiet "$checkout"
git -C "$checkout" remote add origin "$repository"
git -C "$checkout" fetch --quiet --depth 1 origin "$revision"
fetch_consumer_revision "$checkout" "$repository" "$revision"
GIT_LFS_SKIP_SMUDGE=1 git -C "$checkout" checkout --quiet --detach FETCH_HEAD
[[ "$(git -C "$checkout" rev-parse HEAD)" == "$revision" ]] || {
echo "$name did not resolve the pinned consumer revision." >&2
@@ -144,6 +144,11 @@ public interface IRendezvousSessionBrowserClient
EnvironmentId environmentId,
uint protocolVersion,
CancellationToken cancellationToken = default);
IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
CancellationToken cancellationToken = default);
}
public interface IRendezvousJoinClient
@@ -114,6 +114,49 @@ internal sealed class RendezvousHttpTransport
}
}
internal async Task<RendezvousClientResult<HttpResponseMessage>> OpenStreamAsync(
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using CancellationTokenSource requestTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(_options.RequestTimeout);
CancellationToken requestCancellation = requestTimeout.Token;
using HttpRequestMessage request = requestFactory();
HttpResponseMessage? response = null;
try
{
response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
requestCancellation).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
HttpResponseMessage ownedResponse = response;
response = null;
return RendezvousClientResult.Success(ownedResponse);
}
ApiError error = await ReadErrorAsync(response, requestCancellation).ConfigureAwait(false);
int? retryAfter = error.RetryAfterSeconds ?? GetRetryAfterSeconds(response.Headers.RetryAfter);
return RendezvousClientResult.Failure<HttpResponseMessage>(
error.Code,
error.Message,
retryAfter);
}
catch (Exception exception) when (IsTransientTransportFailure(exception, cancellationToken))
{
return RendezvousClientResult.Failure<HttpResponseMessage>(
RendezvousErrorCode.ServiceUnavailable,
"The Rendezvous event stream could not be opened.");
}
finally
{
response?.Dispose();
}
}
internal static HttpRequestMessage JsonRequest<T>(
HttpMethod method,
string uri,
@@ -84,6 +84,9 @@ public sealed class RendezvousPublisherClient : IRendezvousPublisherClient
{
ContractVersion = request.ContractVersion,
LeaseToken = session.LeaseToken,
RegionId = request.RegionId,
ProtocolVersion = request.ProtocolVersion,
Visibility = request.Visibility,
BuildVersion = request.BuildVersion,
DisplayName = request.DisplayName,
Capacity = CopyCapacity(request.Capacity),
@@ -1,3 +1,7 @@
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Client;
@@ -106,5 +110,264 @@ public sealed class RendezvousSessionBrowserClient : IRendezvousSessionBrowserCl
cancellationToken);
}
public async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamAsync(
BrowseSessionsRequest request,
string streamCursor,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
if (string.IsNullOrWhiteSpace(streamCursor)
|| !ContractValidation.IsCursorValid(streamCursor))
{
throw new ArgumentException("A valid snapshot stream cursor is required.", nameof(streamCursor));
}
string query = $"v1/sessions/stream?contractVersion={request.ContractVersion}"
+ $"&gameId={Escape(request.GameId.Value)}"
+ $"&environmentId={Escape(request.EnvironmentId.Value)}"
+ $"&protocolVersion={request.ProtocolVersion}"
+ $"&excludeFull={request.ExcludeFull.ToString().ToLowerInvariant()}"
+ (request.RegionId.HasValue ? $"&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 sealed class SseReadResult
{
public SseReadResult(
bool endOfStream,
RendezvousClientResult<SessionStreamEvent>? result)
{
EndOfStream = endOfStream;
Result = result;
}
public bool EndOfStream { get; }
public RendezvousClientResult<SessionStreamEvent>? Result { get; }
public static SseReadResult End { get; } = new(true, null);
}
private sealed class SseLineReader(Stream source) : IDisposable
{
private static readonly UTF8Encoding Utf8 = new(false, true);
private readonly byte[] _buffer = new byte[4096];
private readonly MemoryStream _line = new();
private int _offset;
private int _count;
public async Task<string?> ReadLineAsync(CancellationToken cancellationToken)
{
while (true)
{
if (_offset >= _count)
{
_count = await source.ReadAsync(
_buffer.AsMemory(),
cancellationToken).ConfigureAwait(false);
_offset = 0;
if (_count == 0)
{
if (_line.Length == 0)
{
return null;
}
return TakeLine();
}
}
byte value = _buffer[_offset++];
if (value == (byte)'\n')
{
return TakeLine();
}
if (_line.Length >= ContractLimits.SessionStreamEventMaxBytes)
{
throw new InvalidDataException("An SSE line exceeded the contract limit.");
}
_line.WriteByte(value);
}
}
public void Dispose() => _line.Dispose();
private string TakeLine()
{
byte[] bytes = _line.ToArray();
_line.SetLength(0);
int length = bytes.Length > 0 && bytes[^1] == (byte)'\r'
? bytes.Length - 1
: bytes.Length;
return Utf8.GetString(bytes, 0, length);
}
}
}
@@ -5,6 +5,7 @@ public static class ContractLimits
public const int ContractVersion = 1;
public const int HttpRequestMaxBytes = 16 * 1024;
public const int BrowserResponseMaxBytes = 256 * 1024;
public const int SessionStreamEventMaxBytes = 32 * 1024;
public const int UdpDatagramMaxBytes = 1_200;
public const int MetadataMaxBytes = 4 * 1024;
public const int MetadataMaxKeys = 32;
@@ -140,6 +140,10 @@ public sealed class UpdateSessionRequest
[JsonRequired]
public string LeaseToken { get; set; } = string.Empty;
public RegionId? RegionId { get; set; }
public uint? ProtocolVersion { get; set; }
public ListingVisibility? Visibility { get; set; }
[JsonRequired]
public string BuildVersion { get; set; } = string.Empty;
@@ -194,6 +198,32 @@ public sealed class BrowseSessionsResponse
public List<SessionListing> Items { get; set; } = [];
public string? NextCursor { get; set; }
[JsonRequired]
public string StreamCursor { get; set; } = string.Empty;
}
public enum SessionStreamEventKind
{
SessionUpsert = 1,
SessionRemove = 2,
Reset = 3,
Keepalive = 4,
}
public sealed class SessionStreamEvent
{
[JsonRequired]
public int ContractVersion { get; set; } = ContractLimits.ContractVersion;
[JsonRequired]
public SessionStreamEventKind Kind { get; set; }
[JsonRequired]
public string Cursor { get; set; } = string.Empty;
public SessionListing? Session { get; set; }
public SessionListingId? ListingId { get; set; }
}
public sealed class GetSessionResponse
@@ -12,6 +12,8 @@ internal sealed record BrowserServiceResult<T>(RendezvousErrorCode Error, T? Val
internal sealed class SessionBrowserService(
IEphemeralRendezvousStore store,
SessionBrowserCursorCodec cursors,
SessionStreamCursorCodec streamCursors,
SessionChangeJournal changes,
IWallClock clock)
{
public BrowserServiceResult<BrowseSessionsResponse> Browse(
@@ -45,9 +47,25 @@ internal sealed class SessionBrowserService(
request.PageSize + 1,
after,
request.ExcludeFull);
StoreResult<IReadOnlyList<StoredListing>> found = store.BrowseVisibleListings(
query,
cancellationToken);
StoreResult<IReadOnlyList<StoredListing>> found = default!;
long streamRevision = 0;
bool stableSnapshot = false;
for (int attempt = 0; attempt < 3; attempt++)
{
long before = changes.CurrentRevision;
found = store.BrowseVisibleListings(query, cancellationToken);
long afterRevision = changes.CurrentRevision;
if (before == afterRevision)
{
streamRevision = afterRevision;
stableSnapshot = true;
break;
}
}
if (!stableSnapshot)
{
return new(RendezvousErrorCode.ServiceUnavailable);
}
if (!found.Succeeded || found.Value is null)
{
return new(found.Code == StoreResultCode.ServiceUnavailable
@@ -65,7 +83,12 @@ internal sealed class SessionBrowserService(
string? nextCursor = hasMore
? cursors.Encode(query, items[^1].ListingId, clock.UtcNow)
: null;
BrowseSessionsResponse response = new() { Items = items, NextCursor = nextCursor };
BrowseSessionsResponse response = new()
{
Items = items,
NextCursor = nextCursor,
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
};
if (JsonSerializer.SerializeToUtf8Bytes(response, ContractJson.Options).Length
<= ContractLimits.BrowserResponseMaxBytes)
{
@@ -76,7 +99,10 @@ internal sealed class SessionBrowserService(
hasMore = true;
}
return new(RendezvousErrorCode.None, new BrowseSessionsResponse());
return new(RendezvousErrorCode.None, new BrowseSessionsResponse
{
StreamCursor = streamCursors.Encode(query, streamRevision, clock.UtcNow),
});
}
public BrowserServiceResult<GetSessionResponse> Get(
@@ -0,0 +1,309 @@
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 int ReplayCount
{
get
{
lock (_gate)
{
return _changes.Count;
}
}
}
public int SubscriberCount
{
get
{
lock (_gate)
{
return _subscribers;
}
}
}
public int SubscribedTenantCount
{
get
{
lock (_gate)
{
return _subscribersByTenant.Count;
}
}
}
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);
}
@@ -0,0 +1,615 @@
const CONTRACT_VERSION = 1;
const BROWSER_PAGE_LIMIT = 100;
const REQUEST_TIMEOUT_MS = 10_000;
const STREAM_FAILURE_LIMIT = 3;
export class SessionProjection {
constructor(maximumSessions = 100) {
if (!Number.isInteger(maximumSessions) || maximumSessions < 1 || maximumSessions > 500) {
throw new RangeError("maximumSessions must be between 1 and 500");
}
this.maximumSessions = maximumSessions;
this.entries = new Map();
this.cursor = "";
this.hasMore = false;
}
replace(items, cursor, hasMore = false, updatedAt = Date.now()) {
if (!Array.isArray(items) || items.length > this.maximumSessions) {
return "overflow";
}
const next = new Map();
for (const session of items) {
if (!isPublicSession(session) || next.has(session.listingId)) {
return "invalid";
}
next.set(session.listingId, { session, updatedAt });
}
this.entries = next;
this.cursor = validCursor(cursor) ? cursor : "";
this.hasMore = Boolean(hasMore);
return "applied";
}
apply(event, updatedAt = Date.now()) {
if (!event || event.contractVersion !== CONTRACT_VERSION || !validCursor(event.cursor)) {
return "invalid";
}
if (event.kind === "sessionUpsert" && isPublicSession(event.session) && event.listingId == null) {
if (!this.entries.has(event.session.listingId)
&& this.entries.size >= this.maximumSessions) {
return "overflow";
}
this.entries.set(event.session.listingId, { session: event.session, updatedAt });
this.cursor = event.cursor;
return "applied";
}
if (event.kind === "sessionRemove"
&& typeof event.listingId === "string"
&& event.listingId.length > 0
&& event.session == null) {
this.entries.delete(event.listingId);
this.cursor = event.cursor;
return this.hasMore ? "refresh" : "applied";
}
if (event.kind === "keepalive" && event.session == null && event.listingId == null) {
this.cursor = event.cursor;
return "keepalive";
}
if (event.kind === "reset" && event.session == null && event.listingId == null) {
this.cursor = event.cursor;
return "reset";
}
return "invalid";
}
sessions() {
return [...this.entries.values()]
.sort((left, right) => left.session.listingId.localeCompare(right.session.listingId));
}
}
export function safeText(value, maximumLength = 160) {
const text = typeof value === "string" ? value : String(value ?? "");
const visible = text.replace(/[\u0000-\u001f\u007f]/gu, "");
return visible.length <= maximumLength
? visible
: `${visible.slice(0, Math.max(0, maximumLength - 1))}`;
}
export function buildBrowseUrl(filter) {
validateFilter(filter);
const query = new URLSearchParams({
contractVersion: String(CONTRACT_VERSION),
gameId: filter.gameId,
environmentId: filter.environmentId,
protocolVersion: String(filter.protocolVersion),
regionId: filter.regionId,
pageSize: String(Math.min(BROWSER_PAGE_LIMIT, filter.pageSize ?? BROWSER_PAGE_LIMIT)),
excludeFull: String(Boolean(filter.excludeFull)),
});
return `/v1/sessions?${query}`;
}
export function buildStreamUrl(filter, cursor) {
validateFilter(filter);
if (!validCursor(cursor)) {
throw new TypeError("A bounded stream cursor is required");
}
const query = new URLSearchParams({
contractVersion: String(CONTRACT_VERSION),
gameId: filter.gameId,
environmentId: filter.environmentId,
protocolVersion: String(filter.protocolVersion),
regionId: filter.regionId,
excludeFull: String(Boolean(filter.excludeFull)),
streamCursor: cursor,
});
return `/v1/sessions/stream?${query}`;
}
export function chooseSnapshotTransport(hasMore, pollingOnly) {
if (pollingOnly) {
return "pollingOnly";
}
return hasMore ? "boundedPolling" : "stream";
}
function validCursor(value) {
return typeof value === "string"
&& value.length > 0
&& value.length <= 1024
&& /^[\x21-\x7e]+$/u.test(value);
}
function isPublicSession(session) {
return session != null
&& session.contractVersion === CONTRACT_VERSION
&& typeof session.listingId === "string"
&& session.listingId.length > 0
&& typeof session.gameId === "string"
&& typeof session.environmentId === "string"
&& typeof session.regionId === "string"
&& Number.isInteger(session.protocolVersion)
&& session.protocolVersion > 0
&& typeof session.buildVersion === "string"
&& typeof session.displayName === "string"
&& session.visibility === "public"
&& session.capacity != null
&& Number.isInteger(session.capacity.currentPlayers)
&& Number.isInteger(session.capacity.maximumPlayers)
&& session.capacity.currentPlayers >= 0
&& session.capacity.maximumPlayers >= 1
&& session.capacity.currentPlayers <= session.capacity.maximumPlayers
&& session.metadata != null
&& typeof session.metadata === "object"
&& !Array.isArray(session.metadata);
}
function validateFilter(filter) {
if (!filter
|| typeof filter.gameId !== "string"
|| typeof filter.environmentId !== "string"
|| typeof filter.regionId !== "string"
|| !Number.isInteger(filter.protocolVersion)
|| filter.protocolVersion <= 0) {
throw new TypeError("The selected diagnostic filter is invalid");
}
}
function startDashboard() {
const elements = {
form: document.querySelector("#filters"),
game: document.querySelector("#game-filter"),
environment: document.querySelector("#environment-filter"),
protocol: document.querySelector("#protocol-filter"),
region: document.querySelector("#region-filter"),
capacity: document.querySelector("#capacity-filter"),
error: document.querySelector("#filter-error"),
reconnect: document.querySelector("#reconnect-button"),
reset: document.querySelector("#reset-button"),
poll: document.querySelector("#poll-button"),
streamStatus: document.querySelector("#stream-status"),
transportState: document.querySelector("#transport-state"),
projectionState: document.querySelector("#projection-state"),
lastUpdate: document.querySelector("#last-update"),
sessionCount: document.querySelector("#session-count"),
results: document.querySelector(".results-panel"),
resultsSummary: document.querySelector("#results-summary"),
list: document.querySelector("#session-list"),
empty: document.querySelector("#empty-state"),
};
const state = {
configuration: null,
projection: null,
activeFilter: null,
source: null,
pollingTimer: null,
requestController: null,
streamFailures: 0,
pollingOnly: false,
renderPending: false,
lastSuccess: 0,
};
function setStatus(kind, message, transport = message) {
document.body.dataset.streamState = kind;
elements.streamStatus.textContent = message;
elements.transportState.textContent = transport;
}
function setFormError(message = "") {
elements.error.textContent = message;
elements.error.hidden = message.length === 0;
}
function populate(select, values, previous) {
select.replaceChildren();
for (const value of values) {
const option = document.createElement("option");
option.value = String(value);
option.textContent = safeText(value, 80);
select.append(option);
}
if (values.some((value) => String(value) === previous)) {
select.value = previous;
}
}
function selectedScope() {
return state.configuration?.scopes.find((scope) =>
scope.gameId === elements.game.value
&& scope.environmentId === elements.environment.value) ?? null;
}
function updateEnvironmentOptions() {
const prior = elements.environment.value;
const environments = state.configuration.scopes
.filter((scope) => scope.gameId === elements.game.value)
.map((scope) => scope.environmentId);
populate(elements.environment, environments, prior);
updateScopeOptions();
}
function updateScopeOptions() {
const scope = selectedScope();
populate(elements.protocol, scope?.protocolVersions ?? [], elements.protocol.value);
populate(elements.region, scope?.regions ?? [], elements.region.value);
}
function currentFilter() {
const scope = selectedScope();
const protocolVersion = Number(elements.protocol.value);
if (!scope
|| !scope.protocolVersions.includes(protocolVersion)
|| !scope.regions.includes(elements.region.value)) {
throw new TypeError("Choose one of the configured game, environment, protocol, and region combinations.");
}
return {
gameId: scope.gameId,
environmentId: scope.environmentId,
protocolVersion,
regionId: elements.region.value,
excludeFull: elements.capacity.value === "open",
pageSize: Math.min(BROWSER_PAGE_LIMIT, state.configuration.maximumRenderedSessions),
};
}
function stopLiveWork() {
if (state.source) {
state.source.close();
state.source = null;
}
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
state.pollingTimer = null;
}
if (state.requestController) {
state.requestController.abort();
state.requestController = null;
}
}
function markSuccess(message) {
state.lastSuccess = Date.now();
elements.lastUpdate.dateTime = new Date(state.lastSuccess).toISOString();
elements.lastUpdate.textContent = "Just now";
elements.resultsSummary.textContent = message;
}
function scheduleRender() {
if (state.renderPending) {
return;
}
state.renderPending = true;
requestAnimationFrame(() => {
state.renderPending = false;
renderProjection();
});
}
function appendDetail(list, term, description) {
const dt = document.createElement("dt");
dt.textContent = term;
const dd = document.createElement("dd");
dd.textContent = safeText(description, 160);
list.append(dt, dd);
}
function sessionCard(entry) {
const session = entry.session;
const article = document.createElement("article");
article.className = "session-card";
article.setAttribute("role", "listitem");
article.dataset.updatedAt = String(entry.updatedAt);
const heading = document.createElement("div");
heading.className = "card-heading";
const title = document.createElement("h3");
title.textContent = safeText(session.displayName, 120);
const region = document.createElement("span");
region.className = "region-badge";
region.textContent = safeText(session.regionId, 48);
heading.append(title, region);
const details = document.createElement("dl");
details.className = "session-detail";
appendDetail(details, "Build", session.buildVersion);
appendDetail(details, "Protocol", session.protocolVersion);
appendDetail(details, "Visibility", "Public");
appendDetail(details, "Presence", "Recently verified");
const capacity = document.createElement("div");
capacity.className = "capacity-row";
const capacityCopy = document.createElement("div");
capacityCopy.className = "capacity-copy";
const capacityLabel = document.createElement("span");
capacityLabel.textContent = "Advisory capacity";
const capacityValue = document.createElement("span");
capacityValue.textContent = `${session.capacity.currentPlayers} / ${session.capacity.maximumPlayers}`;
capacityCopy.append(capacityLabel, capacityValue);
const meter = document.createElement("meter");
meter.min = 0;
meter.max = session.capacity.maximumPlayers;
meter.value = session.capacity.currentPlayers;
meter.setAttribute("aria-label", `Advisory capacity ${capacityValue.textContent}`);
capacity.append(capacityCopy, meter);
const metadata = document.createElement("ul");
metadata.className = "metadata-list";
for (const [key, value] of Object.entries(session.metadata).slice(0, 16)) {
const item = document.createElement("li");
item.textContent = `${safeText(key, 48)}: ${safeText(value, 96)}`;
metadata.append(item);
}
if (metadata.childElementCount === 0) {
const item = document.createElement("li");
item.textContent = "No public metadata";
metadata.append(item);
}
const updated = document.createElement("p");
updated.className = "updated-age";
updated.textContent = "Updated just now";
article.append(heading, details, capacity, metadata, updated);
return article;
}
function renderProjection() {
const entries = state.projection?.sessions() ?? [];
const fragment = document.createDocumentFragment();
for (const entry of entries) {
fragment.append(sessionCard(entry));
}
elements.list.replaceChildren(fragment);
elements.sessionCount.textContent = String(entries.length);
elements.empty.hidden = entries.length !== 0;
elements.projectionState.textContent = state.projection?.hasMore
? "Bounded snapshot; polling"
: "Snapshot plus ordered deltas";
elements.results.setAttribute("aria-busy", "false");
}
function updateAges() {
const now = Date.now();
if (state.lastSuccess > 0) {
const age = Math.max(0, Math.floor((now - state.lastSuccess) / 1000));
elements.lastUpdate.textContent = age < 5 ? "Just now" : `${age} seconds ago`;
}
for (const card of elements.list.querySelectorAll(".session-card")) {
const age = Math.max(0, Math.floor((now - Number(card.dataset.updatedAt)) / 1000));
const copy = card.querySelector(".updated-age");
copy.textContent = age < 5 ? "Updated just now" : `Updated ${age} seconds ago`;
}
}
async function readJson(url) {
const controller = new AbortController();
state.requestController = controller;
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
cache: "no-store",
credentials: "same-origin",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`The service returned HTTP ${response.status}.`);
}
return await response.json();
} finally {
clearTimeout(timeout);
if (state.requestController === controller) {
state.requestController = null;
}
}
}
function schedulePolling() {
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
}
state.pollingTimer = setTimeout(
() => fetchSnapshot("poll").catch(showServiceError),
state.configuration.pollIntervalSeconds * 1000,
);
}
function usePolling(message = "Polling fallback active") {
if (state.source) {
state.source.close();
state.source = null;
}
setStatus("polling", message, "Polling fallback");
schedulePolling();
}
function showServiceError(error) {
const message = error?.name === "AbortError"
? "The service did not answer within the request deadline."
: safeText(error?.message || "The service is unavailable.", 200);
setStatus("error", "Service unavailable", "Unavailable");
elements.resultsSummary.textContent = `${message} Retrying with bounded polling.`;
elements.results.setAttribute("aria-busy", "false");
usePolling("Service unavailable; polling retry scheduled");
}
async function fetchSnapshot(reason = "manual") {
if (!state.activeFilter) {
return;
}
if (state.requestController) {
state.requestController.abort();
}
elements.results.setAttribute("aria-busy", "true");
if (reason !== "poll") {
setStatus("reconnecting", "Loading a fresh snapshot", "Snapshot request");
}
const response = await readJson(buildBrowseUrl(state.activeFilter));
if (response.contractVersion !== CONTRACT_VERSION
|| !Array.isArray(response.items)
|| !validCursor(response.streamCursor)) {
throw new Error("The service returned an invalid browser snapshot.");
}
const outcome = state.projection.replace(
response.items,
response.streamCursor,
Boolean(response.nextCursor),
);
if (outcome !== "applied") {
throw new Error("The bounded browser snapshot could not be applied safely.");
}
scheduleRender();
markSuccess(`${response.items.length} public session${response.items.length === 1 ? "" : "s"} in the fresh snapshot.`);
const transport = chooseSnapshotTransport(Boolean(response.nextCursor), state.pollingOnly);
if (transport !== "stream") {
if (transport === "pollingOnly") {
usePolling("Polling only selected");
return;
}
usePolling("More sessions exist than this bounded view; polling keeps it authoritative");
return;
}
connectStream(response.streamCursor);
}
function handleStreamEvent(serialized, expectedKind) {
let event;
try {
event = JSON.parse(serialized);
} catch {
usePolling("Invalid stream data; polling fallback active");
return;
}
if (event.kind !== expectedKind) {
usePolling("Unexpected stream event; polling fallback active");
return;
}
const outcome = state.projection.apply(event);
if (outcome === "invalid" || outcome === "overflow" || outcome === "refresh") {
fetchSnapshot("stream-recovery").catch(showServiceError);
return;
}
if (outcome === "reset") {
setStatus("reset", "Cursor reset; refreshing snapshot", "Cursor reset");
fetchSnapshot("reset").catch(showServiceError);
return;
}
if (outcome === "keepalive") {
markSuccess("Live connection healthy; no listing changes.");
return;
}
state.streamFailures = 0;
scheduleRender();
markSuccess(expectedKind === "sessionUpsert"
? "Applied a live session add or update."
: "Applied a live session removal.");
}
function connectStream(cursor, corrupt = false) {
if (state.pollingTimer) {
clearTimeout(state.pollingTimer);
state.pollingTimer = null;
}
if (state.source) {
state.source.close();
}
const selectedCursor = corrupt
? `${cursor.slice(0, -1)}${cursor.endsWith("a") ? "b" : "a"}`
: cursor;
setStatus(corrupt ? "reset" : "reconnecting",
corrupt ? "Testing cursor reset" : "Connecting live updates",
corrupt ? "Cursor reset test" : "SSE reconnecting");
const source = new EventSource(buildStreamUrl(state.activeFilter, selectedCursor));
state.source = source;
source.addEventListener("open", () => {
if (state.source !== source) {
return;
}
state.streamFailures = 0;
setStatus("connected", "Live updates connected", "SSE live");
});
for (const [name, kind] of [
["session_upsert", "sessionUpsert"],
["session_remove", "sessionRemove"],
["reset", "reset"],
["keepalive", "keepalive"],
]) {
source.addEventListener(name, (message) => {
if (state.source === source) {
handleStreamEvent(message.data, kind);
}
});
}
source.addEventListener("error", () => {
if (state.source !== source) {
return;
}
state.streamFailures += 1;
if (state.streamFailures >= STREAM_FAILURE_LIMIT || source.readyState === EventSource.CLOSED) {
usePolling("Live stream unavailable; polling fallback active");
} else {
setStatus("reconnecting", "Live stream interrupted; reconnecting", "SSE reconnecting");
}
});
}
async function loadConfiguration() {
const configuration = await readJson("/diagnostics/config.json");
if (configuration.contractVersion !== CONTRACT_VERSION
|| !Array.isArray(configuration.scopes)
|| configuration.scopes.length < 1
|| !Number.isInteger(configuration.pollIntervalSeconds)
|| !Number.isInteger(configuration.maximumRenderedSessions)) {
throw new Error("The diagnostic configuration is invalid.");
}
state.configuration = configuration;
state.projection = new SessionProjection(configuration.maximumRenderedSessions);
populate(elements.game, [...new Set(configuration.scopes.map((scope) => scope.gameId))], "");
updateEnvironmentOptions();
state.activeFilter = currentFilter();
await fetchSnapshot("startup");
}
elements.game.addEventListener("change", updateEnvironmentOptions);
elements.environment.addEventListener("change", updateScopeOptions);
elements.form.addEventListener("submit", (event) => {
event.preventDefault();
try {
setFormError();
stopLiveWork();
state.pollingOnly = false;
state.activeFilter = currentFilter();
state.projection = new SessionProjection(state.configuration.maximumRenderedSessions);
fetchSnapshot("filter").catch(showServiceError);
} catch (error) {
setFormError(safeText(error.message, 200));
}
});
elements.reconnect.addEventListener("click", () => {
if (state.projection?.cursor) {
state.pollingOnly = false;
connectStream(state.projection.cursor);
}
});
elements.reset.addEventListener("click", () => {
if (state.projection?.cursor) {
state.pollingOnly = false;
connectStream(state.projection.cursor, true);
}
});
elements.poll.addEventListener("click", () => {
state.pollingOnly = true;
usePolling("Polling only selected deliberately");
fetchSnapshot("poll").catch(showServiceError);
});
window.addEventListener("pagehide", stopLiveWork, { once: true });
setInterval(updateAges, 5000);
loadConfiguration().catch(showServiceError);
}
if (typeof document !== "undefined") {
startDashboard();
}
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Session diagnostics — Rendezvous</title>
<link rel="stylesheet" href="/diagnostics/styles.css">
<script type="module" src="/diagnostics/app.mjs"></script>
</head>
<body>
<a class="skip-link" href="#main-content">Skip to session results</a>
<header class="site-header">
<div>
<p class="eyebrow">Final Factory infrastructure</p>
<h1>Rendezvous session diagnostics</h1>
<p class="lede">A read-only view of public session discovery. Capacity is advisory; joining always revalidates current state.</p>
</div>
<div class="connection-state" aria-live="polite" aria-atomic="true">
<span class="status-dot" aria-hidden="true"></span>
<span id="stream-status">Loading configuration</span>
</div>
</header>
<main id="main-content" tabindex="-1">
<section class="filter-panel" aria-labelledby="filter-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Public browser scope</p>
<h2 id="filter-heading">Choose a configured session view</h2>
</div>
<p class="boundary-note">This page has no join, publish, or operator authority.</p>
</div>
<form id="filters" novalidate>
<div class="filter-grid">
<label>
<span>Game</span>
<select id="game-filter" name="game" required></select>
</label>
<label>
<span>Environment</span>
<select id="environment-filter" name="environment" required></select>
</label>
<label>
<span>Protocol</span>
<select id="protocol-filter" name="protocol" required></select>
</label>
<label>
<span>Region</span>
<select id="region-filter" name="region" required></select>
</label>
<label>
<span>Availability</span>
<select id="capacity-filter" name="capacity">
<option value="open">Open slots only</option>
<option value="all">Include full sessions</option>
</select>
</label>
<label>
<span>Visibility</span>
<select id="visibility-filter" name="visibility" disabled>
<option value="public">Public only</option>
</select>
</label>
</div>
<p id="filter-error" class="form-error" role="alert" hidden></p>
<div class="button-row">
<button class="button primary" type="submit">Apply filters</button>
<button class="button" id="reconnect-button" type="button">Reconnect now</button>
<button class="button" id="reset-button" type="button">Test reset recovery</button>
<button class="button" id="poll-button" type="button">Use polling only</button>
</div>
</form>
</section>
<section class="summary-panel" aria-labelledby="summary-heading">
<h2 id="summary-heading" class="visually-hidden">Current diagnostic state</h2>
<dl class="summary-grid">
<div>
<dt>Sessions shown</dt>
<dd id="session-count">0</dd>
</div>
<div>
<dt>Transport</dt>
<dd id="transport-state">Starting</dd>
</div>
<div>
<dt>Last successful update</dt>
<dd><time id="last-update">Not yet</time></dd>
</div>
<div>
<dt>Projection</dt>
<dd id="projection-state">Waiting for snapshot</dd>
</div>
</dl>
</section>
<section class="results-panel" aria-labelledby="results-heading" aria-busy="true">
<div class="section-heading results-heading">
<div>
<p class="eyebrow">Bounded public projection</p>
<h2 id="results-heading">Available sessions</h2>
</div>
<p id="results-summary" role="status" aria-live="polite">Loading a fresh snapshot…</p>
</div>
<div id="session-list" class="session-grid" role="list"></div>
<div id="empty-state" class="empty-state" hidden>
<h3>No compatible public sessions</h3>
<p>The service answered successfully, but this configured filter currently has no listings.</p>
</div>
</section>
</main>
<footer>
<p>Read-only diagnostics · no credentials stored · no gameplay or administration</p>
</footer>
<noscript>This diagnostic requires JavaScript to consume the public snapshot and event stream.</noscript>
</body>
</html>
@@ -0,0 +1,505 @@
:root {
color-scheme: dark;
--bg: #071019;
--surface: #101c28;
--surface-raised: #172737;
--border: #375066;
--text: #f2f7fb;
--muted: #b6c8d6;
--accent: #55d7b5;
--accent-strong: #7ce9cc;
--accent-ink: #04231c;
--warning: #ffd479;
--danger: #ff9f9f;
--focus: #ffd479;
--radius: 0.75rem;
--space-1: 0.5rem;
--space-2: 0.75rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
--space-6: 3rem;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 100%;
line-height: 1.55;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
background: var(--bg);
}
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 10% -10%, rgb(36 103 104 / 35%), transparent 32rem),
linear-gradient(180deg, #09141f 0%, var(--bg) 50%);
}
button,
select {
font: inherit;
}
button,
select,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 0.2rem solid var(--focus);
outline-offset: 0.2rem;
}
.skip-link {
position: fixed;
z-index: 10;
top: var(--space-2);
left: var(--space-2);
padding: var(--space-2) var(--space-3);
color: #071019;
background: var(--focus);
border-radius: 0.4rem;
font-weight: 800;
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}
.site-header,
main,
footer {
width: min(80rem, calc(100% - 2rem));
margin-inline: auto;
}
.site-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: var(--space-5);
padding-block: var(--space-6) var(--space-5);
}
h1,
h2,
h3,
p {
margin-top: 0;
}
h1 {
max-width: 18ch;
margin-bottom: var(--space-2);
font-size: clamp(2rem, 6vw, 4.25rem);
line-height: 1.02;
letter-spacing: -0.045em;
}
h2 {
margin-bottom: var(--space-1);
font-size: clamp(1.35rem, 3vw, 2rem);
line-height: 1.2;
}
h3 {
margin-bottom: var(--space-1);
font-size: 1.1rem;
}
.eyebrow {
margin-bottom: var(--space-1);
color: var(--accent-strong);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.lede {
max-width: 65ch;
margin-bottom: 0;
color: var(--muted);
font-size: 1.05rem;
}
.connection-state {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
border-radius: 999px;
background: rgb(16 28 40 / 88%);
color: var(--muted);
font-weight: 700;
white-space: nowrap;
}
.status-dot {
width: 0.7rem;
height: 0.7rem;
border: 2px solid currentColor;
border-radius: 50%;
background: currentColor;
}
body[data-stream-state="connected"] .connection-state {
color: var(--accent-strong);
}
body[data-stream-state="reconnecting"] .connection-state,
body[data-stream-state="polling"] .connection-state,
body[data-stream-state="reset"] .connection-state {
color: var(--warning);
}
body[data-stream-state="error"] .connection-state {
color: var(--danger);
}
main {
display: grid;
gap: var(--space-4);
}
.filter-panel,
.summary-panel,
.results-panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgb(16 28 40 / 94%);
box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%);
}
.filter-panel,
.results-panel {
padding: clamp(1rem, 4vw, 2rem);
}
.section-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.boundary-note,
#results-summary {
max-width: 34rem;
margin-bottom: 0;
color: var(--muted);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.9rem;
font-weight: 750;
}
select {
width: 100%;
min-height: 2.75rem;
padding: 0.6rem 2.4rem 0.6rem 0.75rem;
border: 1px solid #587189;
border-radius: 0.45rem;
color: var(--text);
background: #0b1722;
}
select:disabled {
color: #9fb0bd;
border-style: dashed;
opacity: 1;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-4);
}
.button {
min-height: 2.75rem;
padding: 0.65rem 1rem;
border: 1px solid #6a8298;
border-radius: 0.45rem;
color: var(--text);
background: var(--surface-raised);
cursor: pointer;
font-weight: 800;
}
.button:hover {
border-color: var(--accent-strong);
background: #21384a;
}
.button:active {
transform: translateY(1px);
}
.button.primary {
color: var(--accent-ink);
border-color: var(--accent);
background: var(--accent);
}
.button.primary:hover {
background: var(--accent-strong);
}
.form-error {
margin: var(--space-3) 0 0;
color: var(--danger);
font-weight: 750;
}
.summary-panel {
padding: 0;
overflow: hidden;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.summary-grid > div {
min-width: 0;
padding: var(--space-3) var(--space-4);
border-right: 1px solid var(--border);
}
.summary-grid > div:last-child {
border-right: 0;
}
.summary-grid dt {
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.summary-grid dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
font-size: 1.05rem;
font-weight: 800;
}
.results-heading {
align-items: end;
}
.session-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
gap: var(--space-3);
}
.session-card {
min-width: 0;
padding: var(--space-3);
border: 1px solid #496177;
border-radius: 0.6rem;
background: #0b1722;
}
.card-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: var(--space-2);
}
.card-heading h3 {
overflow-wrap: anywhere;
}
.region-badge {
flex: 0 0 auto;
padding: 0.15rem 0.5rem;
border: 1px solid #597187;
border-radius: 999px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 750;
}
.session-detail {
display: grid;
grid-template-columns: minmax(5rem, auto) 1fr;
gap: 0.25rem var(--space-2);
margin: var(--space-3) 0 0;
font-size: 0.9rem;
}
.session-detail dt {
color: var(--muted);
}
.session-detail dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.capacity-row {
margin-top: var(--space-3);
}
.capacity-copy {
display: flex;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
}
meter {
width: 100%;
height: 0.65rem;
accent-color: var(--accent);
}
.metadata-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
}
.metadata-list li {
max-width: 100%;
padding: 0.2rem 0.45rem;
overflow-wrap: anywhere;
border-radius: 0.3rem;
color: #d6e4ed;
background: #1b2b39;
font-size: 0.78rem;
}
.updated-age {
margin: var(--space-3) 0 0;
color: var(--muted);
font-size: 0.78rem;
}
.empty-state {
padding: var(--space-6) var(--space-3);
text-align: center;
border: 1px dashed #587189;
border-radius: 0.6rem;
color: var(--muted);
}
.empty-state h3 {
color: var(--text);
}
footer {
padding-block: var(--space-5);
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
[hidden] {
display: none !important;
}
@media (max-width: 56rem) {
.site-header,
.section-heading {
align-items: start;
flex-direction: column;
}
.filter-grid,
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-grid > div:nth-child(2) {
border-right: 0;
}
.summary-grid > div:nth-child(-n + 2) {
border-bottom: 1px solid var(--border);
}
}
@media (max-width: 36rem) {
.site-header,
main,
footer {
width: min(100% - 1rem, 80rem);
}
.site-header {
padding-block: var(--space-5) var(--space-4);
}
.filter-grid,
.summary-grid {
grid-template-columns: 1fr;
}
.summary-grid > div {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.summary-grid > div:last-child {
border-bottom: 0;
}
.button {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,111 @@
using System.Reflection;
using FinalFactory.Rendezvous.Contracts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Diagnostics;
internal static class DiagnosticDashboardEndpoints
{
private const string ContentSecurityPolicy =
"default-src 'none'; base-uri 'none'; connect-src 'self'; "
+ "font-src 'self'; form-action 'none'; frame-ancestors 'none'; "
+ "img-src 'self'; manifest-src 'none'; object-src 'none'; "
+ "script-src 'self'; style-src 'self'";
private static readonly byte[] Index = ReadAsset("index.html");
private static readonly byte[] Script = ReadAsset("app.mjs");
private static readonly byte[] Styles = ReadAsset("styles.css");
public static IEndpointRouteBuilder MapDiagnosticDashboardEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder dashboard = endpoints.MapGroup("/diagnostics")
.ExcludeFromDescription();
dashboard.MapGet("", ServeIndex);
dashboard.MapGet("/app.mjs", ServeScript);
dashboard.MapGet("/styles.css", ServeStyles);
dashboard.MapGet("/config.json", ServeConfiguration);
return endpoints;
}
private static IResult ServeIndex(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Index, "text/html; charset=utf-8");
private static IResult ServeScript(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Script, "text/javascript; charset=utf-8");
private static IResult ServeStyles(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured) =>
ServeAsset(context, configured.Value, Styles, "text/css; charset=utf-8");
private static IResult ServeConfiguration(
HttpContext context,
[FromServices] IOptions<DiagnosticDashboardOptions> configured)
{
DiagnosticDashboardOptions options = configured.Value;
if (!options.Enabled)
{
return Results.NotFound();
}
ApplySecurityHeaders(context.Response);
return Results.Json(new
{
contractVersion = ContractLimits.ContractVersion,
pollIntervalSeconds = options.PollIntervalSeconds,
maximumRenderedSessions = options.MaximumRenderedSessions,
scopes = options.Scopes.Select(static scope => new
{
gameId = scope.GameId,
environmentId = scope.EnvironmentId,
protocolVersions = scope.ProtocolVersions,
regions = scope.Regions,
visibility = "public",
}),
});
}
private static IResult ServeAsset(
HttpContext context,
DiagnosticDashboardOptions options,
byte[] content,
string contentType)
{
if (!options.Enabled)
{
return Results.NotFound();
}
ApplySecurityHeaders(context.Response);
return Results.Bytes(content, contentType);
}
private static void ApplySecurityHeaders(HttpResponse response)
{
response.Headers.CacheControl = "no-store";
response.Headers["Content-Security-Policy"] = ContentSecurityPolicy;
response.Headers["X-Content-Type-Options"] = "nosniff";
response.Headers["X-Frame-Options"] = "DENY";
response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
response.Headers["Cross-Origin-Resource-Policy"] = "same-origin";
response.Headers["Permissions-Policy"] =
"camera=(), geolocation=(), microphone=(), payment=(), usb=()";
response.Headers["Referrer-Policy"] = "no-referrer";
}
private static byte[] ReadAsset(string fileName)
{
Assembly assembly = typeof(DiagnosticDashboardEndpoints).Assembly;
string resourceName = $"FinalFactory.Rendezvous.Server.Diagnostics.Assets.{fileName}";
using Stream source = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Missing embedded dashboard asset {fileName}.");
using MemoryStream destination = new();
source.CopyTo(destination);
return destination.ToArray();
}
}
@@ -0,0 +1,81 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.Server.Diagnostics;
internal sealed record DiagnosticDashboardOptions
{
public const string SectionName = "Rendezvous:Diagnostics";
public bool Enabled { get; init; }
public int PollIntervalSeconds { get; init; } = 10;
public int MaximumRenderedSessions { get; init; } = 100;
public DiagnosticDashboardScope[] Scopes { get; init; } = [];
public IReadOnlyList<string> Validate()
{
List<string> errors = [];
if (PollIntervalSeconds is < 5 or > 60)
{
errors.Add($"{SectionName}:PollIntervalSeconds must be between 5 and 60.");
}
if (MaximumRenderedSessions is < 10 or > 500)
{
errors.Add($"{SectionName}:MaximumRenderedSessions must be between 10 and 500.");
}
if (!Enabled)
{
return errors;
}
if (Scopes is null || Scopes.Length is < 1 or > 32)
{
errors.Add($"{SectionName}:Scopes must contain between 1 and 32 allow-listed scopes when enabled.");
return errors;
}
HashSet<string> identities = new(StringComparer.Ordinal);
foreach (DiagnosticDashboardScope? scope in Scopes)
{
if (scope is null)
{
errors.Add($"{SectionName}:Scopes cannot contain null entries.");
continue;
}
string gameId = scope.GameId ?? string.Empty;
string environmentId = scope.EnvironmentId ?? string.Empty;
uint[] protocolVersions = scope.ProtocolVersions ?? [];
string[] regions = scope.Regions ?? [];
if (!GameId.TryParse(gameId, out _)
|| !EnvironmentId.TryParse(environmentId, out _))
{
errors.Add($"{SectionName}:Scopes contains an invalid game or environment identifier.");
}
if (protocolVersions.Length is < 1 or > 16
|| protocolVersions.Any(static version => version == 0)
|| protocolVersions.Distinct().Count() != protocolVersions.Length)
{
errors.Add($"{SectionName}:Scopes protocol versions must contain 1-16 unique positive values.");
}
if (regions.Length is < 1 or > 16
|| regions.Any(static region => !RegionId.TryParse(region ?? string.Empty, out _))
|| regions.Distinct(StringComparer.Ordinal).Count() != regions.Length)
{
errors.Add($"{SectionName}:Scopes regions must contain 1-16 unique valid identifiers.");
}
string identity = $"{gameId}\n{environmentId}";
if (!identities.Add(identity))
{
errors.Add($"{SectionName}:Scopes contains a duplicate game/environment pair.");
}
}
return errors;
}
}
internal sealed record DiagnosticDashboardScope
{
public string GameId { get; init; } = string.Empty;
public string EnvironmentId { get; init; } = string.Empty;
public uint[] ProtocolVersions { get; init; } = [];
public string[] Regions { get; init; } = [];
}
@@ -47,16 +47,24 @@
<Compile Include="Browser/EphemeralCursorProtector.cs" />
<Compile Include="Browser/SessionBrowserCursorCodec.cs" />
<Compile Include="Browser/SessionBrowserService.cs" />
<Compile Include="Browser/SessionChangeJournal.cs" />
<Compile Include="Browser/SessionStreamCursorCodec.cs" />
<Compile Include="Browser/SessionStreamService.cs" />
<Compile Include="ConnectionOutcomes/ConnectionOutcomeService.cs" />
<Compile Include="Deployment/DeploymentOptions.cs" />
<Compile Include="Deployment/GracefulDrainService.cs" />
<Compile Include="Diagnostics/DiagnosticDashboardEndpoints.cs" />
<Compile Include="Diagnostics/DiagnosticDashboardOptions.cs" />
<Compile Include="Http/ContractEndpoints.cs" />
<Compile Include="Http/RendezvousExceptionHandler.cs" />
<Compile Include="Http/RootEndpoints.cs" />
<Compile Include="JoinAttempts/JoinAttemptCursorCodec.cs" />
<Compile Include="JoinAttempts/JoinAttemptService.cs" />
<Compile Include="Observability/AuditOptions.cs" />
<Compile Include="Observability/AuditTrail.cs" />
<Compile Include="Observability/HealthEndpoints.cs" />
<Compile Include="Observability/HealthProbe.cs" />
<Compile Include="Observability/PrometheusMetricsEndpoint.cs" />
<Compile Include="Observability/RendezvousReadiness.cs" />
<Compile Include="Observability/RendezvousTelemetry.cs" />
<Compile Include="Observability/TelemetryMiddleware.cs" />
@@ -85,4 +93,15 @@
<Compile Include="Transport/UdpMediatorOptions.cs" />
<Compile Include="Transport/UdpMediatorService.cs" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Diagnostics/Assets/app.mjs">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.app.mjs</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Diagnostics/Assets/index.html">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.index.html</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Diagnostics/Assets/styles.css">
<LogicalName>FinalFactory.Rendezvous.Server.Diagnostics.Assets.styles.css</LogicalName>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser;
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("BrowseSessions");
sessions.MapGet("/stream", StreamSessions)
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
.Produces<ApiError>(StatusCodes.Status400BadRequest)
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
.WithName("StreamSessions");
sessions.MapGet("/{listingId}", GetSession)
.Produces<GetSessionResponse>()
.Produces<ApiError>(StatusCodes.Status400BadRequest)
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
}
}
private static async Task<IResult> StreamSessions(
[FromQuery] int contractVersion,
[FromQuery] string gameId,
[FromQuery] string environmentId,
[FromQuery] uint protocolVersion,
[FromQuery] string? regionId,
[FromQuery] bool? excludeFull,
[FromQuery] string? streamCursor,
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
[FromServices] SessionStreamService streams,
[FromServices] AbuseProtectionService abuseProtection,
HttpContext httpContext,
CancellationToken cancellationToken)
{
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|| regionId is not null && !RegionId.TryParse(regionId, out _))
{
return Error(RendezvousErrorCode.InvalidRequest);
}
if (!TryAcquireIdentity(
abuseProtection,
httpContext,
"StreamSessions",
Tenant(parsedGameId, parsedEnvironmentId),
null,
null,
out AbuseProtectionService.AbuseLease? abuseLease))
{
return RateLimited(httpContext);
}
using (abuseLease)
{
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
{
ContractVersion = contractVersion,
GameId = parsedGameId,
EnvironmentId = parsedEnvironmentId,
ProtocolVersion = protocolVersion,
RegionId = regionId is null ? null : new RegionId(regionId),
ExcludeFull = excludeFull ?? false,
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
if (!subscribed.Succeeded || subscribed.Value is null)
{
return Error(subscribed.Error);
}
using SessionStreamSubscription subscription = subscribed.Value;
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
duration.CancelAfter(streams.MaximumConnectionDuration);
HttpResponse response = httpContext.Response;
response.StatusCode = StatusCodes.Status200OK;
response.ContentType = "text/event-stream";
response.Headers.CacheControl = "no-cache, no-store";
response.Headers["X-Accel-Buffering"] = "no";
await response.StartAsync(duration.Token).ConfigureAwait(false);
try
{
while (!duration.IsCancellationRequested)
{
SessionStreamReadResult read = streams.Read(subscription);
if (read.RequiresReset)
{
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
.ConfigureAwait(false);
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
break;
}
if (read.Events.Count > 0)
{
foreach (SessionStreamEvent item in read.Events)
{
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
}
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
continue;
}
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
.ConfigureAwait(false);
if (!changed)
{
await WriteSseAsync(
response,
streams.KeepaliveEvent(subscription),
duration.Token).ConfigureAwait(false);
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException) when (duration.IsCancellationRequested)
{
}
return Results.Empty;
}
}
private static async Task WriteSseAsync(
HttpResponse response,
SessionStreamEvent item,
CancellationToken cancellationToken)
{
string eventName = item.Kind switch
{
SessionStreamEventKind.SessionUpsert => "session_upsert",
SessionStreamEventKind.SessionRemove => "session_remove",
SessionStreamEventKind.Reset => "reset",
_ => "keepalive",
};
string data = JsonSerializer.Serialize(item, ContractJson.Options);
await response.WriteAsync(
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
cancellationToken).ConfigureAwait(false);
}
private static IResult GetSession(
SessionListingId listingId,
[FromQuery] int contractVersion,
@@ -0,0 +1,27 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Server.Http;
internal static class RootEndpoints
{
public static IEndpointRouteBuilder MapRootEndpoint(
this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/", ServeRoot).ExcludeFromDescription();
return endpoints;
}
private static IResult ServeRoot(
[FromServices] IOptions<DiagnosticDashboardOptions> diagnostics) =>
diagnostics.Value.Enabled
? Results.Redirect("/diagnostics")
: Results.Json(new
{
contractVersion = ContractLimits.ContractVersion,
service = "rendezvous",
health = "/health/live",
});
}
@@ -0,0 +1,34 @@
namespace FinalFactory.Rendezvous.Server.Observability;
/// <summary>
/// Container healthcheck entry mode (<c>--health-probe</c>). The chiseled
/// runtime image ships no shell or curl, so the probe re-enters the server
/// assembly, queries the local liveness endpoint, and reports via exit code.
/// </summary>
internal static class HealthProbe
{
public const string Argument = "--health-probe";
public static async Task<int> RunAsync()
{
string configuredPorts =
Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS") ?? "8080";
string port = configuredPorts
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault() ?? "8080";
using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(4) };
try
{
using HttpResponseMessage response = await client.GetAsync(
new Uri($"http://127.0.0.1:{port}/health/live"));
return response.IsSuccessStatusCode ? 0 : 1;
}
catch (Exception exception)
when (exception is HttpRequestException
or TaskCanceledException
or UriFormatException)
{
return 1;
}
}
}
@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using System.Text;
using FinalFactory.Rendezvous.Server.Provisioning;
namespace FinalFactory.Rendezvous.Server.Observability;
internal sealed record PrometheusMetricsOptions
{
public const string SectionName = "Rendezvous:Metrics";
public bool Enabled { get; init; }
public string BearerTokenSecretReference { get; init; } = string.Empty;
public IReadOnlyList<string> Validate()
{
if (!Enabled)
{
return [];
}
string reference = BearerTokenSecretReference ?? string.Empty;
bool environmentReference = reference.StartsWith("env:", StringComparison.Ordinal)
&& reference.Length > "env:".Length;
bool absoluteFileReference = reference.StartsWith("file:", StringComparison.Ordinal)
&& Path.IsPathFullyQualified(reference["file:".Length..]);
return reference.Length is < 5 or > 512
|| !(environmentReference || absoluteFileReference)
? [$"{SectionName}:BearerTokenSecretReference must be a bounded env: or absolute file: secret reference when metrics are enabled."]
: [];
}
}
internal sealed class MetricsAccessCredential : IDisposable
{
private byte[]? _token;
private MetricsAccessCredential(byte[] token) => _token = token;
public static bool TryCreate(
PrometheusMetricsOptions options,
ISecretProvider secrets,
out MetricsAccessCredential? credential)
{
credential = null;
if (!options.Enabled
|| !secrets.TryGetSecret(options.BearerTokenSecretReference, out SecretMaterial? material)
|| material is null)
{
return false;
}
using (material)
{
byte[] bytes = material.CopyBytes();
int length = bytes.Length;
while (length > 0 && bytes[length - 1] is (byte)'\r' or (byte)'\n')
{
length--;
}
bool valid = length is >= 32 and <= 128
&& bytes.AsSpan(0, length).IndexOfAnyExceptInRange((byte)0x21, (byte)0x7e) < 0;
if (!valid)
{
CryptographicOperations.ZeroMemory(bytes);
return false;
}
byte[] token = bytes.AsSpan(0, length).ToArray();
CryptographicOperations.ZeroMemory(bytes);
credential = new(token);
return true;
}
}
public bool Authorizes(HttpRequest request)
{
byte[]? expected = _token;
string authorization = request.Headers.Authorization.ToString();
const string prefix = "Bearer ";
if (expected is null
|| !authorization.StartsWith(prefix, StringComparison.Ordinal)
|| authorization.Length - prefix.Length is < 32 or > 128)
{
return false;
}
byte[] supplied = Encoding.UTF8.GetBytes(authorization[prefix.Length..]);
try
{
return supplied.Length == expected.Length
&& CryptographicOperations.FixedTimeEquals(supplied, expected);
}
finally
{
CryptographicOperations.ZeroMemory(supplied);
}
}
public void Dispose()
{
byte[]? token = Interlocked.Exchange(ref _token, null);
if (token is not null)
{
CryptographicOperations.ZeroMemory(token);
}
}
public override string ToString() => "[MetricsAccessCredential: REDACTED]";
}
internal static class PrometheusMetricsEndpoint
{
public static IEndpointRouteBuilder MapPrometheusMetricsEndpoint(
this IEndpointRouteBuilder endpoints,
PrometheusMetricsOptions options,
MetricsAccessCredential? credential)
{
endpoints.MapGet("/metrics", (HttpContext context, RendezvousTelemetry telemetry) =>
Export(context, telemetry, options, credential))
.WithName("MetricsScrape")
.ExcludeFromDescription();
return endpoints;
}
private static IResult Export(
HttpContext context,
RendezvousTelemetry telemetry,
PrometheusMetricsOptions options,
MetricsAccessCredential? credential)
{
if (!options.Enabled)
{
return Results.NotFound();
}
if (credential is null || !credential.Authorizes(context.Request))
{
telemetry.RecordMetricsScrape("rejected");
return Results.NotFound();
}
telemetry.RecordMetricsScrape("accepted");
context.Response.Headers.CacheControl = "no-store";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.Text(
telemetry.RenderPrometheus(),
"text/plain; version=0.0.4; charset=utf-8",
Encoding.UTF8);
}
}
@@ -1,5 +1,10 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Text;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Server.Observability;
@@ -10,6 +15,10 @@ internal sealed class RendezvousTelemetry : IDisposable
public const string ActivitySourceName = "FinalFactory.Rendezvous.Server";
private readonly InMemoryEphemeralRendezvousStore _store;
private readonly SessionChangeJournal? _sessionChanges;
private readonly ProvisioningRuntime? _provisioning;
private readonly ConcurrentDictionary<MetricSeriesKey, long> _prometheusCounters = new();
private readonly ConcurrentDictionary<MetricSeriesKey, PrometheusHistogram> _prometheusHistograms = new();
private readonly Meter _meter = new(MeterName, "1.0.0");
private readonly ActivitySource _activities = new(ActivitySourceName, "1.0.0");
private readonly Counter<long> _httpRequests;
@@ -22,9 +31,14 @@ internal sealed class RendezvousTelemetry : IDisposable
private readonly Counter<long> _operatorAuthentication;
private readonly Histogram<double> _pairingLatency;
public RendezvousTelemetry(InMemoryEphemeralRendezvousStore store)
public RendezvousTelemetry(
InMemoryEphemeralRendezvousStore store,
SessionChangeJournal? sessionChanges = null,
ProvisioningRuntime? provisioning = null)
{
_store = store;
_sessionChanges = sessionChanges;
_provisioning = provisioning;
_httpRequests = _meter.CreateCounter<long>("rendezvous.http.requests");
_httpDuration = _meter.CreateHistogram<double>(
"rendezvous.http.duration",
@@ -75,9 +89,21 @@ internal sealed class RendezvousTelemetry : IDisposable
};
_httpRequests.Add(1, tags);
_httpDuration.Record(elapsedMilliseconds, tags);
IncrementPrometheus(
"rendezvous_http_requests_total",
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
ObservePrometheus(
"rendezvous_http_duration_milliseconds",
elapsedMilliseconds,
Labels(("operation", operation), ("status_code", statusCode.ToString(CultureInfo.InvariantCulture))));
}
public void RecordUdp(string operation, string result, double elapsedMilliseconds)
public void RecordUdp(
string operation,
string result,
double elapsedMilliseconds,
int receivedBytes = 0,
int responseBudgetBytes = 0)
{
TagList tags = new()
{
@@ -86,41 +112,315 @@ internal sealed class RendezvousTelemetry : IDisposable
};
_udpResults.Add(1, tags);
_udpDuration.Record(elapsedMilliseconds, tags);
IncrementPrometheus(
"rendezvous_udp_results_total",
Labels(("operation", operation), ("result", result)));
ObservePrometheus(
"rendezvous_udp_duration_milliseconds",
elapsedMilliseconds,
Labels(("operation", operation), ("result", result)));
AddPrometheus(
"rendezvous_udp_received_bytes_total",
Math.Max(0, receivedBytes),
Labels(("operation", operation)));
AddPrometheus(
"rendezvous_udp_response_budget_bytes_total",
Math.Max(0, responseBudgetBytes),
Labels(("operation", operation)));
}
public void RecordLimiterDrop(string transport, string partition) =>
public void RecordLimiterDrop(string transport, string partition)
{
_limiterDrops.Add(1, new TagList
{
{ "transport", transport },
{ "partition", partition },
});
IncrementPrometheus(
"rendezvous_limiter_drops_total",
Labels(("transport", transport), ("partition", partition)));
}
public void RecordAudit(string action, string result) =>
public void RecordAudit(string action, string result)
{
_auditEvents.Add(1, new TagList
{
{ "action", action },
{ "result", result },
});
IncrementPrometheus(
"rendezvous_audit_events_total",
Labels(("action", action), ("result", result)));
}
public void RecordConnectionOutcome(string outcome, string elapsedBucket) =>
public void RecordConnectionOutcome(string outcome, string elapsedBucket)
{
_connectionOutcomes.Add(1, new TagList
{
{ "outcome", outcome },
{ "elapsed_bucket", elapsedBucket },
});
IncrementPrometheus(
"rendezvous_connection_outcomes_total",
Labels(("outcome", outcome), ("elapsed_bucket", elapsedBucket)));
}
public void RecordOperatorAuthentication(string result) =>
public void RecordOperatorAuthentication(string result)
{
_operatorAuthentication.Add(1, new TagList
{
{ "result", result },
});
IncrementPrometheus(
"rendezvous_operator_authentication_total",
Labels(("result", result)));
}
public void RecordPairingLatency(double elapsedMilliseconds) =>
public void RecordPairingLatency(double elapsedMilliseconds)
{
_pairingLatency.Record(elapsedMilliseconds);
ObservePrometheus("rendezvous_pairing_latency_milliseconds", elapsedMilliseconds, string.Empty);
}
public void RecordMetricsScrape(string result) => IncrementPrometheus(
"rendezvous_metrics_scrapes_total",
Labels(("result", result)));
public string RenderPrometheus()
{
StringBuilder output = new(16 * 1024);
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, long>> family in _prometheusCounters
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
{
output.Append("# TYPE ").Append(family.Key).Append(" counter\n");
foreach (KeyValuePair<MetricSeriesKey, long> series in family)
{
AppendValue(output, series.Key.Name, series.Key.Labels, series.Value);
}
}
foreach (IGrouping<string, KeyValuePair<MetricSeriesKey, PrometheusHistogram>> family in
_prometheusHistograms
.OrderBy(static item => item.Key.Name, StringComparer.Ordinal)
.ThenBy(static item => item.Key.Labels, StringComparer.Ordinal)
.GroupBy(static item => item.Key.Name, StringComparer.Ordinal))
{
output.Append("# TYPE ").Append(family.Key).Append(" histogram\n");
foreach (KeyValuePair<MetricSeriesKey, PrometheusHistogram> series in family)
{
series.Value.Append(output, series.Key.Name, series.Key.Labels);
}
}
EphemeralStoreSnapshot store = _store.GetMetricsSnapshot();
AppendGauge(output, "rendezvous_store_active_listings", store.ActiveListings);
AppendGauge(output, "rendezvous_store_fresh_presence_bindings", store.FreshPresenceBindings);
AppendGauge(
output,
"rendezvous_store_awaiting_presence_listings",
Math.Max(0, store.ActiveListings - store.FreshPresenceBindings));
AppendGauge(output, "rendezvous_store_active_leases", store.ActiveListings);
AppendGauge(output, "rendezvous_store_active_attempts", store.ActiveJoinAttempts);
AppendGauge(output, "rendezvous_queue_depth", store.ActiveJoinAttempts);
AppendGauge(output, "rendezvous_store_replay_markers", store.ReplayMarkers);
AppendGauge(output, "rendezvous_store_available", store.IsAvailable ? 1 : 0);
AppendGauge(output, "rendezvous_store_draining", store.IsDraining ? 1 : 0);
AppendCounter(output, "rendezvous_store_expiry_churn_total", store.ExpiryChurn);
if (_sessionChanges is not null)
{
AppendGauge(output, "rendezvous_browser_sse_subscribers", _sessionChanges.SubscriberCount);
AppendGauge(output, "rendezvous_browser_sse_tenants", _sessionChanges.SubscribedTenantCount);
AppendGauge(output, "rendezvous_browser_replay_entries", _sessionChanges.ReplayCount);
}
AppendProcessMetrics(output);
AppendSigningKeyMetrics(output);
return output.ToString();
}
private void AppendSigningKeyMetrics(StringBuilder output)
{
if (_provisioning is null)
{
return;
}
DateTimeOffset now = DateTimeOffset.UtcNow;
SigningKeyStatus[] statuses = _provisioning.SigningKeys.GetStatuses(now).ToArray();
output.Append("# TYPE rendezvous_signing_keys gauge\n");
foreach (IGrouping<string, SigningKeyStatus> state in statuses.GroupBy(
static status => status.Status,
StringComparer.Ordinal))
{
AppendValue(
output,
"rendezvous_signing_keys",
Labels(("state", state.Key)),
state.Count());
}
double seconds = statuses
.Where(static status => status.Status == "signing")
.Select(status => Math.Max(0, (status.SignUntil - now).TotalSeconds))
.DefaultIfEmpty(0)
.Min();
AppendGauge(output, "rendezvous_signing_key_sign_seconds_remaining", seconds);
}
private static void AppendProcessMetrics(StringBuilder output)
{
using Process process = Process.GetCurrentProcess();
process.Refresh();
AppendCounter(output, "process_cpu_seconds_total", process.TotalProcessorTime.TotalSeconds);
AppendGauge(output, "process_resident_memory_bytes", process.WorkingSet64);
AppendGauge(output, "process_virtual_memory_bytes", process.VirtualMemorySize64);
AppendGauge(output, "process_threads", process.Threads.Count);
try
{
AppendGauge(
output,
"process_open_file_descriptors",
Directory.EnumerateFileSystemEntries("/proc/self/fd").Count());
}
catch (Exception exception) when (exception is IOException
or UnauthorizedAccessException)
{
}
AppendGauge(output, "dotnet_gc_heap_size_bytes", GC.GetTotalMemory(forceFullCollection: false));
output.Append("# TYPE dotnet_gc_collections_total counter\n");
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "0")), GC.CollectionCount(0));
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "1")), GC.CollectionCount(1));
AppendValue(output, "dotnet_gc_collections_total", Labels(("generation", "2")), GC.CollectionCount(2));
AppendGauge(output, "dotnet_thread_pool_threads", ThreadPool.ThreadCount);
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionThreads);
AppendGauge(output, "dotnet_thread_pool_available_worker_threads", workerThreads);
AppendGauge(output, "dotnet_thread_pool_available_completion_threads", completionThreads);
}
private void IncrementPrometheus(string name, string labels) =>
_prometheusCounters.AddOrUpdate(new(name, labels), 1, static (_, current) => current + 1);
private void AddPrometheus(string name, long value, string labels)
{
if (value <= 0)
{
return;
}
_prometheusCounters.AddOrUpdate(new(name, labels), value, (_, current) => current + value);
}
private void ObservePrometheus(string name, double value, string labels) =>
_prometheusHistograms.GetOrAdd(new(name, labels), static _ => new()).Observe(value);
private static string Labels(params (string Name, string Value)[] labels)
{
if (labels.Length == 0)
{
return string.Empty;
}
return "{" + string.Join(',', labels.Select(static label =>
$"{label.Name}=\"{EscapeLabel(NormalizeLabel(label.Value))}\"")) + "}";
}
private static string NormalizeLabel(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > 64)
{
return "other";
}
return value.All(static character => char.IsAsciiLetterOrDigit(character)
|| character is '-' or '_' or '.')
? value
: "other";
}
private static string EscapeLabel(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
private static void AppendCounter(
StringBuilder output,
string name,
double value,
string labels = "")
{
output.Append("# TYPE ").Append(name).Append(" counter\n");
AppendValue(output, name, labels, value);
}
private static void AppendGauge(
StringBuilder output,
string name,
double value,
string labels = "")
{
output.Append("# TYPE ").Append(name).Append(" gauge\n");
AppendValue(output, name, labels, value);
}
private static void AppendValue(StringBuilder output, string name, string labels, double value) =>
output.Append(name)
.Append(labels)
.Append(' ')
.Append(value.ToString("R", CultureInfo.InvariantCulture))
.Append('\n');
public void Dispose()
{
_activities.Dispose();
_meter.Dispose();
}
private readonly record struct MetricSeriesKey(string Name, string Labels);
private sealed class PrometheusHistogram
{
private static readonly double[] Bounds = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000];
private readonly object _gate = new();
private readonly long[] _buckets = new long[Bounds.Length];
private long _count;
private double _sum;
public void Observe(double value)
{
if (!double.IsFinite(value) || value < 0)
{
return;
}
lock (_gate)
{
_count++;
_sum += value;
for (int index = 0; index < Bounds.Length; index++)
{
if (value <= Bounds[index])
{
_buckets[index]++;
}
}
}
}
public void Append(StringBuilder output, string name, string labels)
{
lock (_gate)
{
for (int index = 0; index < Bounds.Length; index++)
{
AppendValue(
output,
name + "_bucket",
AddLabel(labels, "le", Bounds[index].ToString("R", CultureInfo.InvariantCulture)),
_buckets[index]);
}
AppendValue(output, name + "_bucket", AddLabel(labels, "le", "+Inf"), _count);
AppendValue(output, name + "_sum", labels, _sum);
AppendValue(output, name + "_count", labels, _count);
}
}
private static string AddLabel(string labels, string name, string value) =>
string.IsNullOrEmpty(labels)
? $"{{{name}=\"{value}\"}}"
: labels[..^1] + $",{name}=\"{value}\"}}";
}
}
+47 -1
View File
@@ -4,6 +4,7 @@ using FinalFactory.Rendezvous.Server.Abuse;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.ConnectionOutcomes;
using FinalFactory.Rendezvous.Server.Deployment;
using FinalFactory.Rendezvous.Server.Diagnostics;
using FinalFactory.Rendezvous.Server.Http;
using FinalFactory.Rendezvous.Server.JoinAttempts;
using FinalFactory.Rendezvous.Server.Observability;
@@ -15,6 +16,11 @@ using FinalFactory.Rendezvous.Server.Transport;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.OpenApi;
if (args.Contains(HealthProbe.Argument, StringComparer.Ordinal))
{
return await HealthProbe.RunAsync();
}
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Logging.AddFilter(
"Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware",
@@ -251,10 +257,42 @@ if (!builder.Environment.IsDevelopment() && !isOpenApiGeneration)
}
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(deploymentOptions));
DiagnosticDashboardOptions diagnosticDashboardOptions = builder.Configuration
.GetSection(DiagnosticDashboardOptions.SectionName)
.Get<DiagnosticDashboardOptions>() ?? new DiagnosticDashboardOptions();
IReadOnlyList<string> diagnosticErrors = diagnosticDashboardOptions.Validate();
if (diagnosticErrors.Count > 0)
{
throw new DeploymentConfigurationException(diagnosticErrors);
}
builder.Services.AddSingleton(
Microsoft.Extensions.Options.Options.Create(diagnosticDashboardOptions));
PrometheusMetricsOptions metricsOptions = builder.Configuration
.GetSection(PrometheusMetricsOptions.SectionName)
.Get<PrometheusMetricsOptions>() ?? new PrometheusMetricsOptions();
IReadOnlyList<string> metricsErrors = metricsOptions.Validate();
if (metricsErrors.Count > 0)
{
throw new DeploymentConfigurationException(metricsErrors);
}
builder.Services.AddSingleton(Microsoft.Extensions.Options.Options.Create(metricsOptions));
MetricsAccessCredential? metricsCredential = null;
if (metricsOptions.Enabled && !isOpenApiGeneration)
{
EnvironmentSecretProvider metricsSecrets = new();
if (!MetricsAccessCredential.TryCreate(metricsOptions, metricsSecrets, out metricsCredential)
|| metricsCredential is null)
{
throw new DeploymentConfigurationException(
[$"{PrometheusMetricsOptions.SectionName}:BearerTokenSecretReference did not resolve to 32-128 visible ASCII bytes."]);
}
builder.Services.AddSingleton(metricsCredential);
}
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds + 10));
SystemRendezvousClock rendezvousClock = new();
SessionChangeJournal sessionChanges = new(new SessionChangeJournalOptions());
EphemeralStoreOptions stateOptions = new()
{
GracefulDrainLifetime = TimeSpan.FromSeconds(deploymentOptions.DrainDeadlineSeconds),
@@ -262,8 +300,10 @@ EphemeralStoreOptions stateOptions = new()
InMemoryEphemeralRendezvousStore stateStore = new(
stateOptions,
rendezvousClock,
rendezvousClock);
rendezvousClock,
sessionChanges);
builder.Services.AddSingleton(stateStore);
builder.Services.AddSingleton(sessionChanges);
builder.Services.AddSingleton<IEphemeralRendezvousStore>(stateStore);
builder.Services.AddSingleton<IWallClock>(rendezvousClock);
builder.Services.AddSingleton<IMonotonicClock>(rendezvousClock);
@@ -297,7 +337,9 @@ else
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
builder.Services.AddSingleton<ConnectionOutcomeMetrics>();
@@ -341,11 +383,15 @@ app.UseMiddleware<TelemetryMiddleware>();
app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
app.MapOpenApi();
app.MapRootEndpoint();
app.MapRendezvousContractEndpoints();
app.MapOperatorEndpoints();
app.MapRendezvousHealthEndpoints();
app.MapDiagnosticDashboardEndpoints();
app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential);
await app.RunAsync();
return 0;
/// <summary>
/// Entry point marker used by integration-test hosts.
@@ -240,7 +240,15 @@ internal sealed class SessionLeaseService(
}
StoredListing ownedListing = listing!;
PublisherAuthorizationResult authorized = AuthorizeExisting(principal, ownedListing, request.Metadata);
PublisherAuthorizationResult authorized = authorization.Authorize(
principal,
ownedListing.Definition.Scope.GameId,
ownedListing.Definition.Scope.EnvironmentId,
request.RegionId ?? ownedListing.Definition.RegionId,
request.ProtocolVersion ?? ownedListing.Definition.ProtocolVersion,
request.Visibility ?? ownedListing.Definition.Visibility,
request.Metadata,
clock.UtcNow);
if (!authorized.IsAllowed || authorized.Context is null)
{
return new(MapAuthorization(authorized.Error));
@@ -261,7 +269,10 @@ internal sealed class SessionLeaseService(
request.Capacity.CurrentPlayers,
request.Capacity.MaximumPlayers,
request.Metadata,
request.DedicatedFallback), cancellationToken);
request.DedicatedFallback,
request.RegionId,
request.ProtocolVersion,
request.Visibility), cancellationToken);
return updated.Succeeded
? new(RendezvousErrorCode.None, true)
: new(updated.Code.ToContractError());
@@ -391,6 +402,9 @@ internal sealed class SessionLeaseService(
|| !ContractValidation.IsDisplayNameValid(request.DisplayName)
|| !ContractValidation.IsCapacityValid(request.Capacity)
|| !ContractValidation.IsMetadataValid(request.Metadata)
|| request.RegionId.HasValue && string.IsNullOrEmpty(request.RegionId.Value.Value)
|| request.ProtocolVersion.HasValue && request.ProtocolVersion.Value == 0
|| request.Visibility.HasValue && !Enum.IsDefined(request.Visibility.Value)
|| request.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(request.DedicatedFallback)
? RendezvousErrorCode.InvalidRequest
@@ -228,7 +228,10 @@ internal sealed record UpdateListingCommand(
int CurrentPlayers,
int MaximumPlayers,
IReadOnlyDictionary<string, string> Metadata,
NetworkEndpoint? DedicatedFallback);
NetworkEndpoint? DedicatedFallback,
RegionId? RegionId = null,
uint? ProtocolVersion = null,
ListingVisibility? Visibility = null);
internal sealed record DeleteListingCommand(
SessionListingId ListingId,
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
namespace FinalFactory.Rendezvous.Server.State;
@@ -8,6 +9,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private readonly object _gate = new();
private readonly EphemeralStoreOptions _options;
private readonly IMonotonicClock _monotonicClock;
private readonly SessionChangeJournal? _sessionChanges;
private readonly DateTimeOffset _wallOrigin;
private readonly TimeSpan _monotonicOrigin;
private readonly Dictionary<SessionListingId, ListingEntry> _listings = [];
@@ -45,7 +47,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
public InMemoryEphemeralRendezvousStore(
EphemeralStoreOptions options,
IWallClock wallClock,
IMonotonicClock monotonicClock)
IMonotonicClock monotonicClock,
SessionChangeJournal? sessionChanges = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(wallClock);
@@ -53,6 +56,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
options.Validate();
_options = options;
_monotonicClock = monotonicClock;
_sessionChanges = sessionChanges;
_wallOrigin = wallClock.UtcNow;
_monotonicOrigin = monotonicClock.Elapsed;
InstanceId = Guid.NewGuid();
@@ -225,7 +229,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
now + _options.IdempotencyLifetime);
_idempotency.Add(idempotencyKey, idempotency);
EnqueueDeadline(_idempotencyExpiries, idempotencyKey, idempotency.Deadline);
return new(StoreResultCode.Success, Snapshot(entry));
StoredListing created = Snapshot(entry);
_sessionChanges?.Publish(null, created);
return new(StoreResultCode.Success, created);
}, cancellationToken);
public StoreResult<StoredListing> RenewLease(
@@ -277,6 +283,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
|| command.MaximumPlayers is <= 0 or > ContractLimits.SessionCapacityMaxPlayers
|| command.CurrentPlayers < 0
|| command.CurrentPlayers > command.MaximumPlayers
|| command.RegionId.HasValue && string.IsNullOrEmpty(command.RegionId.Value.Value)
|| command.ProtocolVersion.HasValue && command.ProtocolVersion.Value == 0
|| command.Visibility.HasValue && !Enum.IsDefined(command.Visibility.Value)
|| !ContractValidation.IsMetadataValid(command.Metadata)
|| command.DedicatedFallback is not null
&& !ContractValidation.IsNetworkEndpointValid(command.DedicatedFallback))
@@ -302,8 +311,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.NotFound);
}
StoredListing before = Snapshot(entry);
entry.Definition = StoredListing.Freeze(entry.Definition with
{
RegionId = command.RegionId ?? entry.Definition.RegionId,
ProtocolVersion = command.ProtocolVersion ?? entry.Definition.ProtocolVersion,
Visibility = command.Visibility ?? entry.Definition.Visibility,
BuildVersion = command.BuildVersion,
DisplayName = command.DisplayName,
CurrentPlayers = command.CurrentPlayers,
@@ -312,7 +325,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
DedicatedFallback = command.DedicatedFallback,
});
entry.Version++;
return new(StoreResultCode.Success, Snapshot(entry));
StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken);
public StoreResult<bool> DeleteListing(
@@ -382,6 +397,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return new(StoreResultCode.CapacityExceeded);
}
StoredListing before = Snapshot(entry);
bool isNewPresence = !_presence.ContainsKey(command.Handle);
PresenceEntry presence = new(
command.PublicEndpoint,
@@ -396,7 +412,9 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
command.Handle,
presence.Deadline);
}
return new(StoreResultCode.Success, Snapshot(entry));
StoredListing after = Snapshot(entry);
_sessionChanges?.Publish(before, after);
return new(StoreResultCode.Success, after);
}, cancellationToken, eagerCleanup: false);
public StoreResult<IReadOnlyList<StoredListing>> BrowseVisibleListings(
@@ -996,6 +1014,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
private void ClearActiveState()
{
StoredListing[] removedListings = _listings.Values.Select(Snapshot).ToArray();
_listings.Clear();
_listingCountsByOwner.Clear();
_listingExpiries.Clear();
@@ -1017,6 +1036,10 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
_idempotencyExpiries.Clear();
_replay.Clear();
_replayExpiries.Clear();
foreach (StoredListing listing in removedListings)
{
_sessionChanges?.Publish(listing, null);
}
}
private void RemoveListing(SessionListingId listingId)
@@ -1026,6 +1049,7 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
return;
}
StoredListing removed = Snapshot(listing);
_leases.Remove(listing.Definition.LeaseId);
DecrementCount(_listingCountsByOwner, listing.Definition.OwnerSubject);
_presenceHandles.Remove(listing.Definition.HostPresenceHandle);
@@ -1045,6 +1069,8 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
RemoveOutcome(attemptId);
}
}
_sessionChanges?.Publish(removed, null);
}
private void RemoveAttempt(JoinAttemptId attemptId)
@@ -1186,6 +1212,12 @@ internal sealed class InMemoryEphemeralRendezvousStore : IEphemeralRendezvousSto
}
else if (_presence.Remove(candidate.Key))
{
if (_presenceHandles.TryGetValue(candidate.Key, out SessionListingId listingId)
&& _listings.TryGetValue(listingId, out ListingEntry? listing))
{
StoredListing after = Snapshot(listing);
_sessionChanges?.Publish(after with { HasFreshPresence = true }, after);
}
removed++;
}
}
@@ -82,7 +82,9 @@ internal sealed class NatMediationProcessor(
telemetry?.RecordUdp(
"frozen",
result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
Stopwatch.GetElapsedTime(started).TotalMilliseconds,
encoded.Length,
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
return result;
}
@@ -157,7 +159,7 @@ internal sealed class NatMediationProcessor(
observedPublicEndpoint,
token,
introductionSink,
cancellationToken);
cancellationToken: cancellationToken);
}
internal NatMediationResult ProcessRequestAfterIngress(
@@ -165,6 +167,7 @@ internal sealed class NatMediationProcessor(
IPEndPoint observedPublicEndpoint,
string token,
INatIntroductionSink introductionSink,
int receivedBytes = 0,
CancellationToken cancellationToken = default)
{
long started = Stopwatch.GetTimestamp();
@@ -178,7 +181,9 @@ internal sealed class NatMediationProcessor(
telemetry?.RecordUdp(
"litenet",
result.ToString(),
Stopwatch.GetElapsedTime(started).TotalMilliseconds);
Stopwatch.GetElapsedTime(started).TotalMilliseconds,
receivedBytes,
result == NatMediationResult.Introduced ? 2 * ContractLimits.UdpDatagramMaxBytes : 0);
return result;
}
@@ -201,7 +201,7 @@ internal sealed class UdpMediatorService : BackgroundService
&& token is not null)
{
_ = processor.ProcessRequestAfterIngress(
claimedLocalEndpoint, endPoint, token, sink);
claimedLocalEndpoint, endPoint, token, sink, length);
}
// Every inbound packet is consumed here. NatPunchModule is used only for outbound introductions.
@@ -21,6 +21,7 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
{
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
TestClientMode.Watch => RunWatchAsync(options, output, cancellationToken),
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
_ => Task.FromResult(TestClientExitCode.Usage),
};
@@ -553,6 +554,178 @@ internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
}
}
private static async Task<TestClientExitCode> RunWatchAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
using CancellationTokenSource watch = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
watch.CancelAfter(options.RunDuration ?? options.OperationTimeout);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
BrowseSessionsRequest request = BrowseRequest(options);
RendezvousClientResult<BrowseSessionsResponse> snapshot = await browser.BrowseAsync(
request,
watch.Token).ConfigureAwait(false);
if (!snapshot.IsSuccess || snapshot.Value is null)
{
WriteServiceFailure(output, "watch.snapshot", "directory", snapshot);
return TestClientExitCode.ServiceFailure;
}
output.Write(
"watch.snapshot",
"complete",
phase: "directory",
count: snapshot.Value.Items.Count);
string cursor = options.ExerciseReset
? CorruptCursor(snapshot.Value.StreamCursor)
: snapshot.Value.StreamCursor;
output.Write("watch.stream", "started", phase: "live-directory");
SessionStreamEvent? expectedReplay = null;
bool reconnectExerciseCompleted = false;
int emptyConnections = 0;
try
{
while (true)
{
string connectionCursor = cursor;
bool receivedEvent = false;
bool deliberateReconnect = false;
await foreach (RendezvousClientResult<SessionStreamEvent> result in browser
.StreamAsync(request, cursor, watch.Token)
.ConfigureAwait(false))
{
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "watch.stream", "live-directory", result);
return TestClientExitCode.ServiceFailure;
}
receivedEvent = true;
SessionStreamEvent item = result.Value;
if (expectedReplay is not null)
{
if (!SameStreamEvent(expectedReplay, item))
{
output.WriteError(
"watch.reconnect",
"failed",
"The reconnect did not replay the expected ordered event.",
phase: "live-directory");
return TestClientExitCode.ServiceFailure;
}
output.Write("watch.reconnect", "verified", phase: "live-directory");
expectedReplay = null;
reconnectExerciseCompleted = true;
cursor = item.Cursor;
if (options.Script)
{
return TestClientExitCode.Success;
}
}
else
{
cursor = item.Cursor;
}
switch (item.Kind)
{
case SessionStreamEventKind.SessionUpsert when item.Session is not null:
output.Write(
"watch.session-upsert",
"available",
phase: "live-directory",
listingId: item.Session.ListingId.ToString(),
displayName: item.Session.DisplayName);
break;
case SessionStreamEventKind.SessionRemove when item.ListingId.HasValue:
output.Write(
"watch.session-remove",
"removed",
phase: "live-directory",
listingId: item.ListingId.Value.ToString());
break;
case SessionStreamEventKind.Reset:
output.Write("watch.reset", "required", phase: "live-directory");
RendezvousClientResult<BrowseSessionsResponse> refreshed = await browser.BrowseAsync(
request,
watch.Token).ConfigureAwait(false);
if (!refreshed.IsSuccess || refreshed.Value is null)
{
WriteServiceFailure(output, "watch.snapshot", "directory", refreshed);
return TestClientExitCode.ServiceFailure;
}
output.Write(
"watch.snapshot",
"refreshed",
phase: "directory",
count: refreshed.Value.Items.Count);
return TestClientExitCode.Success;
case SessionStreamEventKind.Keepalive:
output.Write("watch.keepalive", "alive", phase: "live-directory");
break;
}
bool listingDelta = item.Kind is SessionStreamEventKind.SessionUpsert
or SessionStreamEventKind.SessionRemove;
if (options.ExerciseReconnect
&& !reconnectExerciseCompleted
&& listingDelta
&& expectedReplay is null)
{
expectedReplay = item;
cursor = connectionCursor;
deliberateReconnect = true;
output.Write("watch.reconnect", "started", phase: "live-directory");
break;
}
if (options.Script && listingDelta)
{
return TestClientExitCode.Success;
}
}
if (deliberateReconnect)
{
continue;
}
emptyConnections = receivedEvent ? 0 : emptyConnections + 1;
if (emptyConnections >= 3)
{
output.WriteError(
"watch.reconnect",
"failed",
"The stream closed repeatedly without an event; use bounded polling fallback.",
phase: "live-directory");
return TestClientExitCode.ServiceFailure;
}
output.Write("watch.reconnect", "required", phase: "live-directory");
await Task.Delay(TimeSpan.FromMilliseconds(250), watch.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.Write("watch.complete", "complete", phase: "lifecycle");
return TestClientExitCode.Success;
}
}
private static bool SameStreamEvent(SessionStreamEvent expected, SessionStreamEvent actual) =>
expected.Kind == actual.Kind
&& string.Equals(expected.Cursor, actual.Cursor, StringComparison.Ordinal)
&& expected.ListingId == actual.ListingId
&& expected.Session?.ListingId == actual.Session?.ListingId;
private static string CorruptCursor(string cursor)
{
if (string.IsNullOrEmpty(cursor))
{
return "invalid-stream-cursor";
}
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
return cursor[..^1] + replacement;
}
private static async Task<SessionSelection> SelectListingAsync(
TestClientOptions options,
TestClientOutput output,
@@ -8,6 +8,7 @@ internal enum TestClientMode
{
Host,
Browse,
Watch,
Join,
}
@@ -34,6 +35,8 @@ internal sealed class TestClientOptions
internal bool Script { get; init; }
internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; }
internal bool ExerciseReconnect { get; init; }
internal bool ExerciseReset { get; init; }
}
internal sealed class TestClientParseResult
@@ -63,6 +66,7 @@ internal static class TestClientOptionParser
Usage:
rendezvous-test-client host [options]
rendezvous-test-client browse [options]
rendezvous-test-client watch [options]
rendezvous-test-client join [options]
Common options:
@@ -88,6 +92,11 @@ internal static class TestClientOptionParser
--run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
Watch options:
--run-seconds NUMBER Stop after 1-86400 seconds
--exercise-reconnect Disconnect after an update and verify ordered replay
--exercise-reset Corrupt the snapshot cursor and verify reset/refresh
Join options:
--listing UUID Join an exact listing; otherwise browse/select
@@ -129,6 +138,8 @@ internal static class TestClientOptionParser
bool script = false;
bool json = false;
bool exitAfterEcho = false;
bool exerciseReconnect = false;
bool exerciseReset = false;
HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++)
@@ -138,7 +149,8 @@ internal static class TestClientOptionParser
{
return TestClientParseResult.Help();
}
if (option is "--script" or "--json" or "--exit-after-echo")
if (option is "--script" or "--json" or "--exit-after-echo"
or "--exercise-reconnect" or "--exercise-reset")
{
if (!seen.Add(option))
{
@@ -147,6 +159,8 @@ internal static class TestClientOptionParser
script |= option == "--script";
json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo";
exerciseReconnect |= option == "--exercise-reconnect";
exerciseReset |= option == "--exercise-reset";
continue;
}
if (!option.StartsWith("--", StringComparison.Ordinal)
@@ -279,8 +293,11 @@ internal static class TestClientOptionParser
return TestClientParseResult.Failure("One or more game, environment, region, build, or display values violate v1 limits.");
}
if (listingId.HasValue && mode != TestClientMode.Join
|| runSeconds.HasValue && mode != TestClientMode.Host
|| runSeconds.HasValue && mode is not (TestClientMode.Host or TestClientMode.Watch)
|| exitAfterEcho && mode != TestClientMode.Host
|| exerciseReconnect && mode != TestClientMode.Watch
|| exerciseReset && mode != TestClientMode.Watch
|| exerciseReconnect && exerciseReset
|| metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
@@ -316,6 +333,8 @@ internal static class TestClientOptionParser
Script = script,
Json = json,
ExitAfterEcho = exitAfterEcho,
ExerciseReconnect = exerciseReconnect,
ExerciseReset = exerciseReset,
});
}
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import {
SessionProjection,
buildBrowseUrl,
buildStreamUrl,
chooseSnapshotTransport,
safeText,
} from "../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs";
const cursor = "rvs1.test-cursor";
function session(id, name = `Host ${id}`) {
return {
contractVersion: 1,
listingId: `00000000-0000-0000-0000-${String(id).padStart(12, "0")}`,
gameId: "space-game",
environmentId: "smoke",
regionId: "local",
protocolVersion: 1,
buildVersion: "1.0.0",
displayName: name,
visibility: "public",
publisherTrustMode: "managedDedicated",
capacity: { currentPlayers: 1, maximumPlayers: 8 },
metadata: { mode: "online-coop" },
};
}
function event(kind, values = {}) {
return {
contractVersion: 1,
kind,
cursor: values.cursor ?? cursor,
session: values.session ?? null,
listingId: values.listingId ?? null,
};
}
test("snapshot and ordered deltas match the final public projection", () => {
const projection = new SessionProjection(10);
assert.equal(projection.replace([session(1)], cursor), "applied");
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-2`,
session: session(2),
})), "applied");
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-3`,
session: session(1, "Updated host"),
})), "applied");
assert.equal(projection.apply(event("sessionRemove", {
cursor: `${cursor}-4`,
listingId: session(2).listingId,
})), "applied");
assert.deepEqual(
projection.sessions().map((entry) => entry.session.displayName),
["Updated host"],
);
assert.equal(projection.cursor, `${cursor}-4`);
});
test("reset, malformed events, and a partial snapshot fail closed", () => {
const projection = new SessionProjection(2);
assert.equal(projection.replace([session(1)], cursor, true), "applied");
assert.equal(projection.apply(event("sessionRemove", {
listingId: session(1).listingId,
})), "refresh");
assert.equal(projection.apply(event("reset")), "reset");
assert.equal(projection.apply({ kind: "sessionUpsert" }), "invalid");
assert.equal(projection.replace([session(1), session(2), session(3)], cursor), "overflow");
});
test("bursts coalesce by listing identity and memory stays bounded", () => {
const projection = new SessionProjection(2);
assert.equal(projection.replace([], cursor), "applied");
for (let index = 0; index < 1_000; index += 1) {
assert.equal(projection.apply(event("sessionUpsert", {
cursor: `${cursor}-${index}`,
session: session(1, `Host ${index}`),
})), "applied");
}
assert.equal(projection.sessions().length, 1);
assert.equal(projection.sessions()[0].session.displayName, "Host 999");
assert.equal(projection.apply(event("sessionUpsert", { session: session(2) })), "applied");
assert.equal(projection.apply(event("sessionUpsert", { session: session(3) })), "overflow");
assert.equal(projection.sessions().length, 2);
});
test("requests are fixed same-origin paths with an exact configured filter", () => {
const filter = {
gameId: "space-game",
environmentId: "smoke",
protocolVersion: 1,
regionId: "local",
excludeFull: true,
};
const browse = buildBrowseUrl(filter);
const stream = buildStreamUrl(filter, cursor);
assert.match(browse, /^\/v1\/sessions\?/u);
assert.match(stream, /^\/v1\/sessions\/stream\?/u);
assert.match(browse, /gameId=space-game/u);
assert.match(browse, /environmentId=smoke/u);
assert.match(stream, /streamCursor=rvs1.test-cursor/u);
assert.doesNotMatch(browse, /https?:/u);
assert.doesNotMatch(stream, /https?:/u);
});
test("snapshot transport preserves deliberate polling and bounds partial snapshots", () => {
assert.equal(chooseSnapshotTransport(false, false), "stream");
assert.equal(chooseSnapshotTransport(true, false), "boundedPolling");
assert.equal(chooseSnapshotTransport(false, true), "pollingOnly");
assert.equal(chooseSnapshotTransport(true, true), "pollingOnly");
});
test("hostile display data remains literal text and no unsafe DOM sink exists", async () => {
const payload = `<img src=x onerror=alert(1)><style>body{display:none}</style><a href=//evil>go</a>`;
assert.equal(safeText(payload, 200), payload);
const source = await readFile(new URL(
"../../src/FinalFactory.Rendezvous.Server/Diagnostics/Assets/app.mjs",
import.meta.url,
), "utf8");
for (const forbidden of [
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"document.write",
"eval(",
"new Function",
"window.location",
]) {
assert.equal(source.includes(forbidden), false, `unsafe browser sink: ${forbidden}`);
}
});
@@ -7,18 +7,25 @@ namespace FinalFactory.Rendezvous.Tests.Browser;
internal sealed class SessionBrowserFixture : IDisposable
{
private readonly EphemeralStateFixture _state = new();
private readonly EphemeralStateFixture _state;
public SessionBrowserFixture()
{
Changes = new(new SessionChangeJournalOptions());
_state = new(changes: Changes);
Cursors = new();
Browser = new(_state.Store, Cursors, _state.Clock);
StreamCursors = new();
Browser = new(_state.Store, Cursors, StreamCursors, Changes, _state.Clock);
Streams = new(Changes, StreamCursors, _state.Clock);
}
public InMemoryEphemeralRendezvousStore Store => _state.Store;
public ManualRendezvousClock Clock => _state.Clock;
public SessionBrowserCursorCodec Cursors { get; }
public SessionStreamCursorCodec StreamCursors { get; }
public SessionChangeJournal Changes { get; }
public SessionBrowserService Browser { get; }
public SessionStreamService Streams { get; }
public TenantScope Scope => _state.Scope;
public StoredListing Add(
@@ -67,5 +74,9 @@ internal sealed class SessionBrowserFixture : IDisposable
PageSize = pageSize,
};
public void Dispose() => Cursors.Dispose();
public void Dispose()
{
Cursors.Dispose();
StreamCursors.Dispose();
}
}
@@ -0,0 +1,325 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.State;
namespace FinalFactory.Rendezvous.Tests.Browser;
public sealed class SessionStreamServiceTests
{
[Fact]
public void SnapshotPlusUpdateMatchesFreshProjection()
{
using SessionBrowserFixture fixture = new();
StoredListing listing = fixture.Add();
BrowseSessionsRequest request = fixture.Request();
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
using SessionStreamSubscription subscription = AssertSuccess(
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
StoreResult<StoredListing> updated = fixture.Store.UpdateListing(Update(
listing,
displayName: "Updated host",
currentPlayers: 4));
Assert.True(updated.Succeeded);
SessionStreamEvent delta = Assert.Single(fixture.Streams.Read(subscription).Events);
Assert.Equal(SessionStreamEventKind.SessionUpsert, delta.Kind);
Assert.Equal("Updated host", delta.Session!.DisplayName);
Assert.Equal(4, delta.Session.Capacity.CurrentPlayers);
BrowseSessionsResponse fresh = AssertSuccess(fixture.Browser.Browse(request));
SessionListing expected = Assert.Single(fresh.Items);
Assert.Equal(expected.DisplayName, delta.Session.DisplayName);
Assert.Equal(expected.Capacity.CurrentPlayers, delta.Session.Capacity.CurrentPlayers);
Assert.DoesNotContain("lease", System.Text.Json.JsonSerializer.Serialize(delta, ContractJson.Options), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PresenceStalenessRecoveryAndRevocationProduceRemoveUpsertRemove()
{
using SessionBrowserFixture fixture = new();
StoredListing listing = fixture.Add();
BrowseSessionsRequest request = fixture.Request();
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
using SessionStreamSubscription subscription = AssertSuccess(
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
fixture.Clock.Advance(TimeSpan.FromSeconds(21));
AssertSuccess(fixture.Browser.Browse(request));
SessionStreamEvent stale = Assert.Single(fixture.Streams.Read(subscription).Events);
Assert.Equal(SessionStreamEventKind.SessionRemove, stale.Kind);
Assert.Equal(listing.Definition.ListingId, stale.ListingId);
StoreResult<StoredListing> rebound = fixture.Store.BindHostPresence(new(
listing.Definition.HostPresenceHandle,
listing.Definition.HostPresenceFingerprint,
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
null));
Assert.True(rebound.Succeeded);
Assert.Equal(
SessionStreamEventKind.SessionUpsert,
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
Assert.True(fixture.Store.RevokeListing(listing.Definition.ListingId).Succeeded);
Assert.Equal(
SessionStreamEventKind.SessionRemove,
Assert.Single(fixture.Streams.Read(subscription).Events).Kind);
}
[Fact]
public void CreationAndLeaseExpiryProduceUpsertThenRemove()
{
using SessionBrowserFixture fixture = new();
BrowseSessionsRequest request = fixture.Request();
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
using SessionStreamSubscription subscription = AssertSuccess(
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
StoredListing listing = fixture.Add();
SessionStreamEvent created = Assert.Single(fixture.Streams.Read(subscription).Events);
Assert.Equal(SessionStreamEventKind.SessionUpsert, created.Kind);
Assert.Equal(listing.Definition.ListingId, created.Session!.ListingId);
for (int refresh = 0; refresh < 3; refresh++)
{
fixture.Clock.Advance(TimeSpan.FromSeconds(19));
Assert.True(fixture.Store.BindHostPresence(new(
listing.Definition.HostPresenceHandle,
listing.Definition.HostPresenceFingerprint,
new ObservedEndpoint(AddressFamilyKind.Ipv4, "203.0.113.20", 40_020),
null)).Succeeded);
}
fixture.Clock.Advance(TimeSpan.FromSeconds(4));
AssertSuccess(fixture.Browser.Browse(request));
SessionStreamEvent expired = Assert.Single(fixture.Streams.Read(subscription).Events);
Assert.Equal(SessionStreamEventKind.SessionRemove, expired.Kind);
Assert.Equal(listing.Definition.ListingId, expired.ListingId);
Assert.Equal(
StoreResultCode.NotFound,
fixture.Store.GetListing(listing.Definition.ListingId, requireFreshPresence: false).Code);
}
[Fact]
public void ScopeProtocolRegionAndFullFiltersNeverLeak()
{
using SessionBrowserFixture fixture = new();
BrowseSessionsRequest request = fixture.Request();
request.ExcludeFull = true;
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
using SessionStreamSubscription subscription = AssertSuccess(
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
fixture.Add(scope: new(new("other-game"), fixture.Scope.EnvironmentId));
fixture.Add(protocolVersion: 99);
fixture.Add(regionId: new("other-region"));
fixture.Add(currentPlayers: 8, maximumPlayers: 8);
Assert.Empty(fixture.Streams.Read(subscription).Events);
}
[Fact]
public void VisibilityCompatibilityAndRegionChangesEnterAndLeaveTheFilter()
{
using SessionBrowserFixture fixture = new();
StoredListing listing = fixture.Add();
BrowseSessionsRequest request = fixture.Request();
BrowseSessionsResponse snapshot = AssertSuccess(fixture.Browser.Browse(request));
using SessionStreamSubscription subscription = AssertSuccess(
fixture.Streams.Subscribe(request, snapshot.StreamCursor));
listing = fixture.Store.UpdateListing(Update(
listing,
listing.Definition.DisplayName,
1,
visibility: ListingVisibility.Unlisted)).Value!;
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
listing = fixture.Store.UpdateListing(Update(
listing,
listing.Definition.DisplayName,
1,
visibility: ListingVisibility.Public)).Value!;
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
listing = fixture.Store.UpdateListing(Update(
listing,
listing.Definition.DisplayName,
1,
protocolVersion: 99)).Value!;
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
listing = fixture.Store.UpdateListing(Update(
listing,
listing.Definition.DisplayName,
1,
protocolVersion: 7)).Value!;
Assert.Equal(SessionStreamEventKind.SessionUpsert, SingleKind(fixture, subscription));
listing = fixture.Store.UpdateListing(Update(
listing,
listing.Definition.DisplayName,
1,
regionId: new RegionId("other-region"))).Value!;
Assert.Equal(SessionStreamEventKind.SessionRemove, SingleKind(fixture, subscription));
}
[Fact]
public void ReplayGapAndForeignCursorForceResetAndSubscriberLimitFailsClosed()
{
ManualRendezvousClock clock = new();
SessionChangeJournal changes = new(new SessionChangeJournalOptions
{
ReplayCapacity = 64,
MaximumSubscribers = 1,
MaximumSubscribersPerTenant = 1,
});
using SessionStreamCursorCodec cursors = new();
SessionStreamService streams = new(changes, cursors, clock);
EphemeralStateFixture state = new(changes: changes);
StoredListing listing = state.CreateVisibleListing(out _);
BrowseSessionsRequest request = new()
{
GameId = state.Scope.GameId,
EnvironmentId = state.Scope.EnvironmentId,
ProtocolVersion = listing.Definition.ProtocolVersion,
};
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
string initial = cursors.Encode(query, changes.CurrentRevision, clock.UtcNow);
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(request, initial));
Assert.Equal(
RendezvousErrorCode.CapacityExceeded,
streams.Subscribe(request, initial).Error);
for (int index = 0; index < 65; index++)
{
listing = state.Store.UpdateListing(Update(
listing,
displayName: $"Host {index}",
currentPlayers: index % 8)).Value!;
}
Assert.True(streams.Read(subscription).RequiresReset);
subscription.Dispose();
using SessionStreamSubscription foreign = AssertSuccess(streams.Subscribe(
request,
"not-a-valid-cursor"));
Assert.True(streams.Read(foreign).RequiresReset);
}
[Fact]
public void BurstIsBoundedAndCoalescedWithoutLosingFinalState()
{
ManualRendezvousClock clock = new();
SessionChangeJournal changes = new(new SessionChangeJournalOptions
{
ReplayCapacity = 1024,
MaximumBatchSize = 128,
});
using SessionStreamCursorCodec cursors = new();
SessionStreamService streams = new(changes, cursors, clock);
EphemeralStateFixture state = new(changes: changes);
StoredListing listing = state.CreateVisibleListing(out _);
BrowseSessionsRequest request = new()
{
GameId = state.Scope.GameId,
EnvironmentId = state.Scope.EnvironmentId,
ProtocolVersion = listing.Definition.ProtocolVersion,
};
VisibleListingQuery query = new(state.Scope, listing.Definition.ProtocolVersion, null);
using SessionStreamSubscription subscription = AssertSuccess(streams.Subscribe(
request,
cursors.Encode(query, changes.CurrentRevision, clock.UtcNow)));
for (int index = 0; index < 1000; index++)
{
listing = state.Store.UpdateListing(Update(
listing,
displayName: $"Host {index}",
currentPlayers: index % 8)).Value!;
}
List<SessionStreamEvent> emitted = [];
while (subscription.Revision < changes.CurrentRevision)
{
SessionStreamReadResult read = streams.Read(subscription);
Assert.False(read.RequiresReset);
emitted.AddRange(read.Events);
}
Assert.Equal(8, emitted.Count);
SessionStreamEvent final = emitted[^1];
Assert.Equal(SessionStreamEventKind.SessionUpsert, final.Kind);
Assert.Equal("Host 999", final.Session!.DisplayName);
Assert.Equal(7, final.Session.Capacity.CurrentPlayers);
}
[Fact]
public void SubscriberLimitIsEnforcedPerTenantAndReleasedOnDispose()
{
ManualRendezvousClock clock = new();
SessionChangeJournal changes = new(new SessionChangeJournalOptions
{
MaximumSubscribers = 2,
MaximumSubscribersPerTenant = 1,
});
using SessionStreamCursorCodec cursors = new();
SessionStreamService streams = new(changes, cursors, clock);
TenantScope firstScope = new(new("first-game"), new("production"));
TenantScope secondScope = new(new("second-game"), new("production"));
BrowseSessionsRequest firstRequest = Request(firstScope);
BrowseSessionsRequest secondRequest = Request(secondScope);
string firstCursor = cursors.Encode(
new VisibleListingQuery(firstScope, 7, null),
changes.CurrentRevision,
clock.UtcNow);
string secondCursor = cursors.Encode(
new VisibleListingQuery(secondScope, 7, null),
changes.CurrentRevision,
clock.UtcNow);
SessionStreamSubscription first = AssertSuccess(streams.Subscribe(firstRequest, firstCursor));
Assert.Equal(
RendezvousErrorCode.CapacityExceeded,
streams.Subscribe(firstRequest, firstCursor).Error);
using SessionStreamSubscription second = AssertSuccess(
streams.Subscribe(secondRequest, secondCursor));
first.Dispose();
using SessionStreamSubscription replacement = AssertSuccess(
streams.Subscribe(firstRequest, firstCursor));
}
private static BrowseSessionsRequest Request(TenantScope scope) => new()
{
GameId = scope.GameId,
EnvironmentId = scope.EnvironmentId,
ProtocolVersion = 7,
};
private static UpdateListingCommand Update(
StoredListing listing,
string displayName,
int currentPlayers,
RegionId? regionId = null,
uint? protocolVersion = null,
ListingVisibility? visibility = null) => new(
listing.Definition.ListingId,
listing.Definition.LeaseId,
listing.Definition.LeaseFingerprint,
listing.Definition.OwnerSubject,
listing.Definition.BuildVersion,
displayName,
currentPlayers,
listing.Definition.MaximumPlayers,
listing.Definition.Metadata,
listing.Definition.DedicatedFallback,
regionId,
protocolVersion,
visibility);
private static SessionStreamEventKind SingleKind(
SessionBrowserFixture fixture,
SessionStreamSubscription subscription) =>
Assert.Single(fixture.Streams.Read(subscription).Events).Kind;
private static T AssertSuccess<T>(BrowserServiceResult<T> result)
{
Assert.True(result.Succeeded, result.Error.ToString());
return Assert.IsType<T>(result.Value);
}
}
@@ -167,6 +167,88 @@ public sealed class RendezvousClientBehaviorTests
Assert.Contains("gameId=space-game", handler.RequestUris[0].Query, StringComparison.Ordinal);
}
[Fact]
public async Task StreamRejectsMalformedAndOversizedEventEnvelopes()
{
string[] bodies =
[
"event: session_upsert\nid: valid-cursor\ndata: {}\n\n",
"data: " + new string('x', ContractLimits.SessionStreamEventMaxBytes + 1) + "\n\n",
];
foreach (string body in bodies)
{
StringContent content = new(body, Encoding.UTF8, "text/event-stream");
ScriptedHandler handler = new(Response(HttpStatusCode.OK, content));
using HttpClient httpClient = new(handler)
{
BaseAddress = new("http://rendezvous.test/"),
};
RendezvousSessionBrowserClient browser = new(httpClient);
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
.StreamAsync(new BrowseSessionsRequest
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
}, "valid-stream-cursor")
.GetAsyncEnumerator();
Assert.True(await events.MoveNextAsync());
Assert.False(events.Current.IsSuccess);
Assert.Equal(RendezvousErrorCode.InternalError, events.Current.Error);
}
}
[Fact]
public async Task StreamRequiresANonEmptySnapshotCursor()
{
using HttpClient httpClient = new(new ScriptedHandler())
{
BaseAddress = new("http://rendezvous.test/"),
};
RendezvousSessionBrowserClient browser = new(httpClient);
await Assert.ThrowsAsync<ArgumentException>(async () =>
{
await foreach (RendezvousClientResult<SessionStreamEvent> _ in browser.StreamAsync(
new BrowseSessionsRequest
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
},
string.Empty))
{
}
});
}
[Fact]
public async Task StreamOpeningIsBoundedByTheConfiguredRequestTimeout()
{
using HttpClient httpClient = new(new SilentHandler())
{
BaseAddress = new("http://rendezvous.test/"),
};
RendezvousSessionBrowserClient browser = new(
httpClient,
new RendezvousClientOptions
{
MaximumSafeRetries = 0,
RequestTimeout = TimeSpan.FromMilliseconds(20),
});
await using IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> events = browser
.StreamAsync(new BrowseSessionsRequest
{
GameId = new("space-game"),
EnvironmentId = new("production"),
ProtocolVersion = 7,
}, "valid-stream-cursor")
.GetAsyncEnumerator();
Assert.True(await events.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2)));
Assert.Equal(RendezvousErrorCode.ServiceUnavailable, events.Current.Error);
}
[Fact]
public async Task LeaseMaintainerReportsLeaseLoss()
{
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Abuse;
@@ -142,6 +143,154 @@ public sealed class RendezvousClientIntegrationTests
}
}
[Fact]
public async Task BrowserStreamResetsInvalidCursorReplaysReconnectAndReleasesConnections()
{
await using ClientTestHost host = await ClientTestHost.StartAsync();
// Budgets are sized for the release pipeline, not for a developer machine. That job
// runs this suite inside a builder container on a busy act_runner host, alongside
// parallel image builds, so the in-process Kestrel host and the HttpClient driving it
// share a thread pool that is routinely starved. A loopback round trip that costs
// microseconds locally can then cost seconds - and the first stream request in the
// process additionally pays the one-time JIT and serializer warm-up of the SSE path.
// 30s is the ceiling RendezvousClientOptions.Validate permits for RequestTimeout.
RendezvousClientOptions loadedRunner = new()
{
RequestTimeout = TimeSpan.FromSeconds(30),
};
RendezvousPublisherClient publisher = new(host.HttpClient, loadedRunner);
RendezvousSessionBrowserClient browser = new(host.HttpClient, loadedRunner);
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.FromMinutes(2));
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> invalid =
StreamWithOpenRetry(
browser,
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 =
StreamWithOpenRetry(browser, 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 =
StreamWithOpenRetry(browser, 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);
}
// Opening an event stream is a single, unretried request in the SDK, so a handshake that
// loses its wall-clock budget on a saturated runner surfaces as a typed ServiceUnavailable
// first element instead of the expected event. Reopening is semantically free: every call
// site passes a replayable snapshot cursor, so a reopened stream observes exactly the
// events the first attempt would have delivered.
//
// The retry cannot hide a product regression. StreamSessions never answers with a
// ServiceUnavailable envelope - its only rejections are InvalidRequest,
// UnsupportedContractVersion, CapacityExceeded and RateLimited - so this code path is
// reachable only from the transport catch in RendezvousHttpTransport.OpenStreamAsync,
// i.e. a timed-out or dropped handshake. Attempts are bounded, and the final failure is
// yielded verbatim, so a stream that is genuinely unopenable still fails the test with the
// original message.
private const int StreamOpenAttempts = 3;
private static async IAsyncEnumerable<RendezvousClientResult<SessionStreamEvent>> StreamWithOpenRetry(
RendezvousSessionBrowserClient browser,
BrowseSessionsRequest request,
string streamCursor,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int attempt = 1; ; attempt++)
{
bool reopen = false;
await using (IAsyncEnumerator<RendezvousClientResult<SessionStreamEvent>> source = browser
.StreamAsync(request, streamCursor, cancellationToken)
.GetAsyncEnumerator(cancellationToken))
{
bool opening = true;
while (await source.MoveNextAsync())
{
RendezvousClientResult<SessionStreamEvent> current = source.Current;
if (opening
&& !current.IsSuccess
&& current.Error == RendezvousErrorCode.ServiceUnavailable
&& attempt < StreamOpenAttempts)
{
reopen = true;
break;
}
opening = false;
// Keepalives are protocol filler with no session semantics. The server emits
// one whenever a subscription idles for its keepalive interval, which a slow
// runner reaches between the assertions below; dropping them keeps the
// reset/upsert/remove expectations exact.
if (current.IsSuccess && current.Value!.Kind == SessionStreamEventKind.Keepalive)
{
continue;
}
yield return current;
}
}
if (!reopen)
{
yield break;
}
}
}
private static string CorruptCursor(string cursor)
{
char replacement = cursor[^1] == 'a' ? 'b' : 'a';
return cursor[..^1] + replacement;
}
private static T AssertSuccess<T>(RendezvousClientResult<T> result)
{
Assert.True(result.IsSuccess, result.Message);
@@ -210,7 +359,8 @@ public sealed class RendezvousClientIntegrationTests
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
@@ -239,7 +389,10 @@ public sealed class RendezvousClientIntegrationTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
@@ -17,6 +17,7 @@ public sealed class OpenApiCompatibilityTests
"/v1/operator/principals/revoke",
"/v1/operator/status",
"/v1/sessions",
"/v1/sessions/stream",
"/v1/sessions/{listingId}",
"/v1/sessions/{listingId}/join-attempts",
"/v1/sessions/{listingId}/renew",
@@ -68,6 +69,25 @@ public sealed class OpenApiCompatibilityTests
Assert.DoesNotContain(listingProperties, static property =>
property.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Contains("playerId", StringComparison.OrdinalIgnoreCase));
JsonElement streamProperties = schemas.GetProperty("SessionStreamEvent")
.GetProperty("properties");
Assert.True(streamProperties.TryGetProperty("contractVersion", out _));
Assert.True(streamProperties.TryGetProperty("kind", out _));
Assert.True(streamProperties.TryGetProperty("cursor", out _));
Assert.True(streamProperties.TryGetProperty("session", out _));
Assert.True(streamProperties.TryGetProperty("listingId", out _));
Assert.DoesNotContain(streamProperties.EnumerateObject(), static property =>
property.Name.Contains("token", StringComparison.OrdinalIgnoreCase)
|| property.Name.Contains("capability", StringComparison.OrdinalIgnoreCase)
|| property.Name.Contains("ticket", StringComparison.OrdinalIgnoreCase)
|| property.Name.Contains("endpoint", StringComparison.OrdinalIgnoreCase));
Assert.True(root.GetProperty("paths")
.GetProperty("/v1/sessions/stream")
.GetProperty("get")
.GetProperty("responses")
.GetProperty("200")
.GetProperty("content")
.TryGetProperty("text/event-stream", out _));
JsonElement dedicatedFallback = schemas.GetProperty("SessionListing")
.GetProperty("properties")
.GetProperty("dedicatedFallback");
@@ -205,7 +225,7 @@ public sealed class OpenApiCompatibilityTests
}
}
Assert.Equal(17, overloadContracts);
Assert.Equal(18, overloadContracts);
(string Path, string Method)[] bodyOperations =
[
("/v1/sessions", "post"),
@@ -0,0 +1,205 @@
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using FinalFactory.Rendezvous.Server.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace FinalFactory.Rendezvous.Tests.Diagnostics;
public sealed class DiagnosticDashboardTests
{
[Fact]
public void ConfigurationRequiresBoundedUniqueAllowListedScopes()
{
Assert.Empty(ValidOptions().Validate());
Assert.Empty(new DiagnosticDashboardOptions().Validate());
DiagnosticDashboardOptions invalid = ValidOptions() with
{
PollIntervalSeconds = 1,
MaximumRenderedSessions = 501,
Scopes =
[
new DiagnosticDashboardScope
{
GameId = "INVALID",
EnvironmentId = "smoke",
ProtocolVersions = [0, 0],
Regions = ["INVALID", "INVALID"],
},
new DiagnosticDashboardScope
{
GameId = "INVALID",
EnvironmentId = "smoke",
ProtocolVersions = [1],
Regions = ["local"],
},
],
};
IReadOnlyList<string> errors = invalid.Validate();
Assert.Contains(errors, error => error.Contains("PollIntervalSeconds", StringComparison.Ordinal));
Assert.Contains(errors, error => error.Contains("MaximumRenderedSessions", StringComparison.Ordinal));
Assert.Contains(errors, error => error.Contains("invalid game", StringComparison.Ordinal));
Assert.Contains(errors, error => error.Contains("protocol versions", StringComparison.Ordinal));
Assert.Contains(errors, error => error.Contains("regions", StringComparison.Ordinal));
Assert.Contains(errors, error => error.Contains("duplicate", StringComparison.Ordinal));
DiagnosticDashboardOptions nullBound = ValidOptions() with
{
Scopes = null!,
};
Assert.Contains(
nullBound.Validate(),
error => error.Contains("Scopes must contain", StringComparison.Ordinal));
DiagnosticDashboardOptions nullScope = ValidOptions() with
{
Scopes = [null!],
};
Assert.Contains(
nullScope.Validate(),
error => error.Contains("null entries", StringComparison.Ordinal));
DiagnosticDashboardOptions nullCollections = ValidOptions() with
{
Scopes =
[
new DiagnosticDashboardScope
{
GameId = null!,
EnvironmentId = null!,
ProtocolVersions = null!,
Regions = null!,
},
],
};
Assert.True(nullCollections.Validate().Count >= 3);
}
[Fact]
public async Task DisabledDashboardHasNoPublicAssetOrConfigurationSurface()
{
await using DashboardHost host = await DashboardHost.StartAsync(new());
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/app.mjs")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await host.Client.GetAsync("diagnostics/config.json")).StatusCode);
}
[Fact]
public async Task EnabledDashboardServesOnlyHardenedSameOriginReadOnlyAssets()
{
await using DashboardHost host = await DashboardHost.StartAsync(ValidOptions());
using HttpRequestMessage request = new(HttpMethod.Get, "diagnostics/");
request.Headers.Add("Origin", "https://hostile.example");
using HttpResponseMessage response = await host.Client.SendAsync(request);
string html = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, html);
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
Assert.Equal("DENY", Header(response, "X-Frame-Options"));
Assert.Equal("nosniff", Header(response, "X-Content-Type-Options"));
Assert.Equal("no-referrer", Header(response, "Referrer-Policy"));
Assert.Contains("default-src 'none'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
Assert.Contains("connect-src 'self'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
Assert.Contains("frame-ancestors 'none'", Header(response, "Content-Security-Policy"), StringComparison.Ordinal);
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
Assert.Contains("<html lang=\"en\">", html, StringComparison.Ordinal);
Assert.Contains("href=\"#main-content\"", html, StringComparison.Ordinal);
Assert.Contains("aria-live=\"polite\"", html, StringComparison.Ordinal);
Assert.Contains("type=\"module\" src=\"/diagnostics/app.mjs\"", html, StringComparison.Ordinal);
Assert.DoesNotContain("<script src=\"http", html, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/v1/operator", html, StringComparison.OrdinalIgnoreCase);
using HttpResponseMessage scriptResponse = await host.Client.GetAsync("diagnostics/app.mjs");
string script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Equal("text/javascript", scriptResponse.Content.Headers.ContentType?.MediaType);
Assert.DoesNotContain("/v1/operator", script, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Authorization", script, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("innerHTML", script, StringComparison.Ordinal);
}
[Fact]
public async Task BrowserConfigurationContainsOnlyTheExplicitPublicAllowList()
{
await using DashboardHost host = await DashboardHost.StartAsync(ValidOptions());
using HttpResponseMessage response = await host.Client.GetAsync("diagnostics/config.json");
using JsonDocument document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
JsonElement root = document.RootElement;
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, root.GetProperty("contractVersion").GetInt32());
Assert.Equal(10, root.GetProperty("pollIntervalSeconds").GetInt32());
JsonElement scope = Assert.Single(root.GetProperty("scopes").EnumerateArray());
Assert.Equal("space-game", scope.GetProperty("gameId").GetString());
Assert.Equal("smoke", scope.GetProperty("environmentId").GetString());
Assert.Equal("public", scope.GetProperty("visibility").GetString());
string json = root.GetRawText();
Assert.DoesNotContain("http", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("credential", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("token", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("endpoint", json, StringComparison.OrdinalIgnoreCase);
}
private static string Header(HttpResponseMessage response, string name) =>
Assert.Single(response.Headers.GetValues(name));
private static DiagnosticDashboardOptions ValidOptions() => new()
{
Enabled = true,
PollIntervalSeconds = 10,
MaximumRenderedSessions = 100,
Scopes =
[
new DiagnosticDashboardScope
{
GameId = "space-game",
EnvironmentId = "smoke",
ProtocolVersions = [1, 2],
Regions = ["local"],
},
],
};
private sealed class DashboardHost : IAsyncDisposable
{
private readonly WebApplication _application;
private DashboardHost(WebApplication application, HttpClient client)
{
_application = application;
Client = client;
}
public HttpClient Client { get; }
public static async Task<DashboardHost> StartAsync(DiagnosticDashboardOptions options)
{
int port = ReserveTcpPort();
string address = $"http://127.0.0.1:{port}";
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls(address);
builder.Services.AddSingleton(Options.Create(options));
WebApplication application = builder.Build();
application.MapDiagnosticDashboardEndpoints();
await application.StartAsync();
return new(application, new HttpClient { BaseAddress = new Uri(address) });
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
}
private static int ReserveTcpPort()
{
using TcpListener listener = new(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
}
@@ -61,6 +61,10 @@ public sealed partial class DocumentationContractTests
Assert.Contains("metadata.st_mode & 0o077", helper, StringComparison.Ordinal);
Assert.Contains("metadata.st_nlink != 1", helper, StringComparison.Ordinal);
Assert.Contains("mint-local-publisher-credential.sh", smoke, StringComparison.Ordinal);
Assert.Contains("select(.event == \"host.ready\" and .status == \"ready\")", smoke, StringComparison.Ordinal);
Assert.Contains("hasListingId", smoke, StringComparison.Ordinal);
Assert.Contains("jq -s -e", smoke, StringComparison.Ordinal);
Assert.DoesNotContain("jq -c .", smoke, StringComparison.Ordinal);
Assert.DoesNotContain("hexkey:", smoke, StringComparison.Ordinal);
Assert.DoesNotContain("openssl dgst", smoke, StringComparison.Ordinal);
}
@@ -288,7 +288,8 @@ public sealed class JoinAttemptHttpEndpointTests
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
@@ -318,7 +319,10 @@ public sealed class JoinAttemptHttpEndpointTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
builder.Services.AddSingleton<JoinAttemptCursorCodec>();
builder.Services.AddSingleton<JoinAttemptService>();
ConnectionOutcomeMetrics outcomeMetrics = new();
@@ -0,0 +1,165 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.Observability;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.Server.State;
using FinalFactory.Rendezvous.Tests.State;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
namespace FinalFactory.Rendezvous.Tests.Observability;
[Collection(RendezvousTelemetryIsolation.Name)]
public sealed class PrometheusMetricsTests
{
private const string Token = "0123456789abcdef0123456789abcdef";
[Fact]
public void EnabledExporterRequiresAnExternalBoundedSecretReference()
{
Assert.Empty(new PrometheusMetricsOptions().Validate());
Assert.Empty(new PrometheusMetricsOptions
{
Enabled = true,
BearerTokenSecretReference = "file:/run/secrets/rendezvous-metrics-token",
}.Validate());
Assert.NotEmpty(new PrometheusMetricsOptions { Enabled = true }.Validate());
Assert.NotEmpty(new PrometheusMetricsOptions
{
Enabled = true,
BearerTokenSecretReference = "literal-secret",
}.Validate());
Assert.NotEmpty(new PrometheusMetricsOptions
{
Enabled = true,
BearerTokenSecretReference = "file:relative-token",
}.Validate());
Assert.NotEmpty(new PrometheusMetricsOptions
{
Enabled = true,
BearerTokenSecretReference = null!,
}.Validate());
}
[Fact]
public async Task ExporterIsDisabledByDefaultAndConcealsRejectedAuthentication()
{
await using MetricsHost disabled = await MetricsHost.StartAsync(
new PrometheusMetricsOptions(),
credential: null);
Assert.Equal(HttpStatusCode.NotFound, (await disabled.Client.GetAsync("metrics")).StatusCode);
PrometheusMetricsOptions options = EnabledOptions();
using MetricsAccessCredential credential = Credential(options);
await using MetricsHost enabled = await MetricsHost.StartAsync(options, credential);
Assert.Equal(HttpStatusCode.NotFound, (await enabled.Client.GetAsync("metrics")).StatusCode);
using HttpRequestMessage wrong = new(HttpMethod.Get, "metrics");
wrong.Headers.Authorization = new("Bearer", new string('x', 32));
Assert.Equal(HttpStatusCode.NotFound, (await enabled.Client.SendAsync(wrong)).StatusCode);
}
[Fact]
public async Task AuthenticatedExporterReturnsBoundedPrivacySafePrometheusFamilies()
{
PrometheusMetricsOptions options = EnabledOptions();
using MetricsAccessCredential credential = Credential(options);
await using MetricsHost host = await MetricsHost.StartAsync(options, credential);
host.Telemetry.RecordHttp("BrowseSessions", 200, 3.5);
host.Telemetry.RecordHttp("listing-id/canary", 500, 4.5);
host.Telemetry.RecordUdp("frozen", "Introduced", 1.25, 128, 2400);
host.Telemetry.RecordLimiterDrop("udp", "rate-or-concurrency");
host.Telemetry.RecordConnectionOutcome("Connected", "UnderOneSecond");
host.Telemetry.RecordPairingLatency(12.5);
using HttpRequestMessage request = new(HttpMethod.Get, "metrics");
request.Headers.Authorization = new("Bearer", Token);
using HttpResponseMessage response = await host.Client.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
Assert.Equal("no-store", response.Headers.CacheControl?.ToString());
Assert.Contains("rendezvous_http_requests_total{operation=\"BrowseSessions\",status_code=\"200\"} 1", body, StringComparison.Ordinal);
Assert.Contains("rendezvous_http_duration_milliseconds_bucket", body, StringComparison.Ordinal);
Assert.Contains("rendezvous_udp_received_bytes_total{operation=\"frozen\"} 128", body, StringComparison.Ordinal);
Assert.Contains("rendezvous_udp_response_budget_bytes_total{operation=\"frozen\"} 2400", body, StringComparison.Ordinal);
Assert.Contains("rendezvous_store_active_listings", body, StringComparison.Ordinal);
Assert.Contains("rendezvous_browser_sse_subscribers", body, StringComparison.Ordinal);
Assert.Contains("process_resident_memory_bytes", body, StringComparison.Ordinal);
Assert.Contains("dotnet_gc_heap_size_bytes", body, StringComparison.Ordinal);
Assert.Contains("operation=\"other\"", body, StringComparison.Ordinal);
Assert.DoesNotContain("listing-id", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(Token, body, StringComparison.Ordinal);
}
private static PrometheusMetricsOptions EnabledOptions() => new()
{
Enabled = true,
BearerTokenSecretReference = "file:/metrics-token",
};
private static MetricsAccessCredential Credential(PrometheusMetricsOptions options)
{
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>
{
[options.BearerTokenSecretReference] = Encoding.ASCII.GetBytes(Token + "\n"),
});
Assert.True(MetricsAccessCredential.TryCreate(options, secrets, out MetricsAccessCredential? credential));
return Assert.IsType<MetricsAccessCredential>(credential);
}
private sealed class MetricsHost : IAsyncDisposable
{
private readonly WebApplication _application;
private MetricsHost(
WebApplication application,
HttpClient client,
RendezvousTelemetry telemetry)
{
_application = application;
Client = client;
Telemetry = telemetry;
}
public HttpClient Client { get; }
public RendezvousTelemetry Telemetry { get; }
public static async Task<MetricsHost> StartAsync(
PrometheusMetricsOptions options,
MetricsAccessCredential? credential)
{
ManualRendezvousClock clock = new();
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(new(), clock, clock, changes);
RendezvousTelemetry telemetry = new(store, changes);
int port = ReserveTcpPort();
string address = $"http://127.0.0.1:{port}";
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls(address);
builder.Services.AddSingleton(telemetry);
WebApplication application = builder.Build();
application.MapPrometheusMetricsEndpoint(options, credential);
await application.StartAsync();
return new(application, new HttpClient { BaseAddress = new Uri(address) }, telemetry);
}
public async ValueTask DisposeAsync()
{
Client.Dispose();
await _application.StopAsync();
await _application.DisposeAsync();
// This test supplies an existing singleton instance, so the host does not own it.
Telemetry.Dispose();
}
private static int ReserveTcpPort()
{
using TcpListener listener = new(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}
}
@@ -86,8 +86,50 @@ public sealed class ReleaseCompatibilityTests
Assert.DoesNotContain("skip-duplicate", workflow, StringComparison.OrdinalIgnoreCase);
Assert.Contains("check-compatibility.sh", workflow, StringComparison.Ordinal);
Assert.Contains("--platform linux/amd64", workflow, StringComparison.Ordinal);
Assert.Contains("ignore-unfixed: false", workflow, StringComparison.Ordinal);
Assert.Contains("format: spdx-json", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("aquasecurity/trivy-action", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/aquasecurity/trivy/releases/download/v0.69.3/trivy_0.69.3_Linux-64bit.tar.gz",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"1816b632dfe529869c740c0913e36bd1629cb7688bd5634f4a858c1d57c88b75",
workflow,
StringComparison.Ordinal);
Assert.Contains("sha256sum --check --strict", workflow, StringComparison.Ordinal);
Assert.Contains("TRIVY_CACHE_DIR=$RENDEZVOUS_WORK_DIR/trivy-cache", workflow, StringComparison.Ordinal);
// Gitea answers the v3-era artifacts API only, so upload-artifact stays on
// v3 and never gates the release it is only meant to help debug.
Assert.DoesNotContain(
"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1",
workflow,
StringComparison.Ordinal);
Assert.Contains("continue-on-error: true", workflow, StringComparison.Ordinal);
// The signing client is fetched the way the scanner is: a pinned asset
// checked against a pinned digest, with no action resolved off github.com.
Assert.DoesNotContain("sigstore/cosign-installer", workflow, StringComparison.Ordinal);
Assert.Contains(
"https://github.com/sigstore/cosign/releases/download/v3.0.6/cosign-linux-amd64",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74",
workflow,
StringComparison.Ordinal);
Assert.Contains("echo \"$cosign_bin_dir\" >>\"$GITHUB_PATH\"", workflow, StringComparison.Ordinal);
Assert.Contains("--exit-code 1", workflow, StringComparison.Ordinal);
Assert.Contains("--severity HIGH,CRITICAL", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("--ignore-unfixed", workflow, StringComparison.Ordinal);
Assert.Contains("--format spdx-json", workflow, StringComparison.Ordinal);
Assert.Contains(
"--output \"$RENDEZVOUS_RELEASE_DIR/FinalFactory.Rendezvous.Container.$RENDEZVOUS_VERSION.spdx.json\"",
workflow,
StringComparison.Ordinal);
Assert.Contains("normalize-container-sbom", workflow, StringComparison.Ordinal);
Assert.Contains("finalize-release-candidate.sh", workflow, StringComparison.Ordinal);
Assert.Contains("verify-real-consumers.sh", workflow, StringComparison.Ordinal);
@@ -149,6 +191,68 @@ public sealed class ReleaseCompatibilityTests
Assert.Contains("<PackageReference Remove=\"FinalFactory.Rendezvous.Contracts\" />", realConsumerGate, StringComparison.Ordinal);
}
[Fact]
public void AutomationProvidesNet8RuntimeForProcessVerification()
{
string root = FindRepositoryRoot();
string qualityWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
Assert.Equal(2, qualityWorkflow.Split("8.0.128", StringSplitOptions.None).Length - 1);
string releaseWorkflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/release.yml"));
Assert.Contains("8.0.128", releaseWorkflow, StringComparison.Ordinal);
string releaseBuilder = File.ReadAllText(Path.Combine(root, "eng/release-builder.Dockerfile"));
Assert.Contains("runtime:8.0.28-noble@sha256:", releaseBuilder, StringComparison.Ordinal);
Assert.Contains("Microsoft.NETCore.App/8.0.28", releaseBuilder, StringComparison.Ordinal);
}
[Fact]
public void AutomationUsesPerRunContainerPorts()
{
string root = FindRepositoryRoot();
string compose = File.ReadAllText(Path.Combine(root, "deploy/compose/compose.yaml"));
Assert.Contains("${RENDEZVOUS_HTTP_HOST_PORT:-8080}:8080/tcp", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_UDP_HOST_PORT:-9050}:9050/udp", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_CONFIG_SOURCE:-./appsettings.Production.json}", compose, StringComparison.Ordinal);
Assert.Contains("${RENDEZVOUS_SECRET_SOURCE:-./secrets/signing-key}", compose, StringComparison.Ordinal);
foreach (string workflowName in new[] { "ci.yml", "release.yml" })
{
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows", workflowName));
Assert.Contains("GITHUB_RUN_ID", workflow, StringComparison.Ordinal);
Assert.Contains("--network \"container:${container_id}\"", workflow, StringComparison.Ordinal);
Assert.Contains("publisher_credential", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_PUBLISHER_CREDENTIAL", workflow, StringComparison.Ordinal);
Assert.Contains("for attempt in {1..180}", workflow, StringComparison.Ordinal);
Assert.Contains("sleep 1", workflow, StringComparison.Ordinal);
Assert.Contains("http://127.0.0.1:8080/", workflow, StringComparison.Ordinal);
Assert.Contains("127.0.0.1:9050", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_HTTP_HOST_PORT", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_UDP_HOST_PORT", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SMOKE_HTTP_URL", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SMOKE_UDP_ENDPOINT", workflow, StringComparison.Ordinal);
Assert.Contains("runner_workspace_source", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_CONFIG_SOURCE", workflow, StringComparison.Ordinal);
Assert.Contains("RENDEZVOUS_SECRET_SOURCE", workflow, StringComparison.Ordinal);
}
}
[Fact]
public void ContainerGateUsesValidInspectTemplates()
{
string root = FindRepositoryRoot();
string workflow = File.ReadAllText(Path.Combine(root, ".gitea/workflows/ci.yml"));
Assert.Contains(
"eq .Destination \"/app/appsettings.Production.json\"",
workflow,
StringComparison.Ordinal);
Assert.Contains(
"eq .Destination \"/run/secrets/rendezvous-signing-key\"",
workflow,
StringComparison.Ordinal);
Assert.DoesNotContain("eq .Destination \\\"", workflow, StringComparison.Ordinal);
}
[Fact]
public void ServerSourceManifestIsCompleteSortedAndDeterministic()
{
@@ -28,7 +28,8 @@ public sealed class SessionHttpEndpointTests
{
ManualRendezvousClock clock = new(ProvisioningTestData.Now);
EphemeralStoreOptions stateOptions = new();
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock);
SessionChangeJournal changes = new(new SessionChangeJournalOptions());
InMemoryEphemeralRendezvousStore store = new(stateOptions, clock, clock, changes);
EphemeralCapabilityIssuer capabilities = new();
ProvisioningRuntime provisioning = ProvisioningRuntime.Create(
ProvisioningTestData.CreateOptions(),
@@ -57,7 +58,10 @@ public sealed class SessionHttpEndpointTests
builder.Services.AddSingleton(SessionLeaseTiming.From(stateOptions));
builder.Services.AddSingleton<SessionLeaseService>();
builder.Services.AddSingleton<SessionBrowserCursorCodec>();
builder.Services.AddSingleton<SessionStreamCursorCodec>();
builder.Services.AddSingleton(changes);
builder.Services.AddSingleton<SessionBrowserService>();
builder.Services.AddSingleton<SessionStreamService>();
await using WebApplication app = builder.Build();
app.UseExceptionHandler();
app.UseMiddleware<HttpAbuseProtectionMiddleware>();
@@ -1,4 +1,5 @@
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Browser;
using FinalFactory.Rendezvous.Server.State;
namespace FinalFactory.Rendezvous.Tests.State;
@@ -24,10 +25,12 @@ internal sealed class EphemeralStateFixture
{
private int _sequence;
public EphemeralStateFixture(EphemeralStoreOptions? options = null)
public EphemeralStateFixture(
EphemeralStoreOptions? options = null,
SessionChangeJournal? changes = null)
{
Clock = new();
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock);
Store = new(options ?? new EphemeralStoreOptions(), Clock, Clock, changes);
}
public ManualRendezvousClock Clock { get; }
@@ -59,6 +59,29 @@ public sealed class TestClientCommandTests
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
}
[Fact]
public void WatchModeSupportsBoundedRuntimeAndDeliberateRecoveryExercisesOnly()
{
TestClientParseResult reset = TestClientOptionParser.Parse(
["watch", "--run-seconds", "30", "--exercise-reset", "--script", "--json"]);
Assert.True(reset.Succeeded, reset.Error);
TestClientOptions resetOptions = Assert.IsType<TestClientOptions>(reset.Options);
Assert.Equal(TestClientMode.Watch, resetOptions.Mode);
Assert.Equal(TimeSpan.FromSeconds(30), resetOptions.RunDuration);
Assert.True(resetOptions.ExerciseReset);
TestClientParseResult reconnect = TestClientOptionParser.Parse(
["watch", "--exercise-reconnect", "--script"]);
Assert.True(reconnect.Succeeded, reconnect.Error);
Assert.True(Assert.IsType<TestClientOptions>(reconnect.Options).ExerciseReconnect);
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reconnect"]).Succeeded);
Assert.False(TestClientOptionParser.Parse(["browse", "--exercise-reset"]).Succeeded);
Assert.False(TestClientOptionParser.Parse(
["watch", "--exercise-reconnect", "--exercise-reset"]).Succeeded);
Assert.False(TestClientOptionParser.Parse(["join", "--run-seconds", "30"]).Succeeded);
}
[Fact]
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
{
@@ -1 +1 @@
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002"}
{"contractVersion":1,"items":[{"contractVersion":1,"listingId":"00112233-4455-6677-8899-aabbccddeeff","gameId":"space-game","environmentId":"production","regionId":"eu-central","protocolVersion":7,"buildVersion":"1.4.2","displayName":"Europa Relay","visibility":"public","publisherTrustMode":"managedDedicated","capacity":{"currentPlayers":2,"maximumPlayers":8},"metadata":{"mode":"co-op","map":"europa"}}],"nextCursor":"cursor-002","streamCursor":"stream-cursor-002"}
@@ -40,6 +40,7 @@ TYPE FinalFactory.Rendezvous.Client.IRendezvousSessionBrowserClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.LeaseMaintenanceResult
PROP FinalFactory.Rendezvous.Contracts.RendezvousErrorCode Error {get;}
PROP FinalFactory.Rendezvous.Client.LeaseMaintenanceStopReason Reason {get;}
@@ -212,6 +213,7 @@ TYPE FinalFactory.Rendezvous.Client.RendezvousSessionBrowserClient
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<System.Collections.Generic.IReadOnlyList<FinalFactory.Rendezvous.Contracts.SessionListing>>> BrowseAllAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Int32 maximumPages, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse>> BrowseAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.Threading.CancellationToken cancellationToken)
METHOD System.Threading.Tasks.Task<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.GetSessionResponse>> GetAsync(FinalFactory.Rendezvous.Contracts.SessionListingId listingId, FinalFactory.Rendezvous.Contracts.GameId gameId, FinalFactory.Rendezvous.Contracts.EnvironmentId environmentId, System.UInt32 protocolVersion, System.Threading.CancellationToken cancellationToken)
METHOD System.Collections.Generic.IAsyncEnumerable<FinalFactory.Rendezvous.Client.RendezvousClientResult<FinalFactory.Rendezvous.Contracts.SessionStreamEvent>> StreamAsync(FinalFactory.Rendezvous.Contracts.BrowseSessionsRequest request, System.String streamCursor, System.Threading.CancellationToken cancellationToken)
TYPE FinalFactory.Rendezvous.Client.SessionLeaseMaintainer
EVENT System.EventHandler LeaseLost
METHOD System.Threading.Tasks.ValueTask DisposeAsync()
@@ -28,6 +28,7 @@ TYPE FinalFactory.Rendezvous.Contracts.BrowseSessionsResponse
PROP System.Int32 ContractVersion {get;set;}
PROP System.Collections.Generic.List<FinalFactory.Rendezvous.Contracts.SessionListing> Items {get;set;}
PROP System.String NextCursor {get;set;}
PROP System.String StreamCursor {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.ConnectionElapsedBucket
ENUM UnderOneSecond=1
ENUM OneToFiveSeconds=2
@@ -84,6 +85,7 @@ TYPE FinalFactory.Rendezvous.Contracts.ContractLimits
FIELD System.Int32 OpaqueHttpCredentialMaxCharacters=1024
FIELD System.Int32 RegionIdMaxCharacters=32
FIELD System.Int32 SessionCapacityMaxPlayers=10000
FIELD System.Int32 SessionStreamEventMaxBytes=32768
FIELD System.Int32 UdpCapabilityMaxCharacters=192
FIELD System.Int32 UdpDatagramMaxBytes=1200
TYPE FinalFactory.Rendezvous.Contracts.ContractValidation
@@ -345,6 +347,18 @@ TYPE FinalFactory.Rendezvous.Contracts.SessionListingId
METHOD System.Boolean TryParse(System.String value, FinalFactory.Rendezvous.Contracts.SessionListingId& id)
METHOD System.Boolean op_Equality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
METHOD System.Boolean op_Inequality(FinalFactory.Rendezvous.Contracts.SessionListingId left, FinalFactory.Rendezvous.Contracts.SessionListingId right)
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEvent
CTOR ()
PROP System.Int32 ContractVersion {get;set;}
PROP System.String Cursor {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionStreamEventKind Kind {get;set;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.SessionListingId> ListingId {get;set;}
PROP FinalFactory.Rendezvous.Contracts.SessionListing Session {get;set;}
TYPE FinalFactory.Rendezvous.Contracts.SessionStreamEventKind
ENUM SessionUpsert=1
ENUM SessionRemove=2
ENUM Reset=3
ENUM Keepalive=4
TYPE FinalFactory.Rendezvous.Contracts.UdpDecodeError
ENUM None=0
ENUM DatagramTooLarge=1
@@ -371,3 +385,6 @@ TYPE FinalFactory.Rendezvous.Contracts.UpdateSessionRequest
PROP System.String DisplayName {get;set;}
PROP System.String LeaseToken {get;set;}
PROP System.Collections.Generic.Dictionary<System.String,System.String> Metadata {get;set;}
PROP System.Nullable<System.UInt32> ProtocolVersion {get;set;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.RegionId> RegionId {get;set;}
PROP System.Nullable<FinalFactory.Rendezvous.Contracts.ListingVisibility> Visibility {get;set;}
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const root = new URL("../../", import.meta.url);
const dashboardPath = new URL(
"deploy/observability/grafana/dashboards/rendezvous-overview.json",
root,
);
test("dashboard is provisionable, broad, and uses privacy-safe bounded series", async () => {
const dashboard = JSON.parse(await readFile(dashboardPath, "utf8"));
assert.equal(dashboard.uid, "rendezvous-overview");
assert.equal(dashboard.editable, false);
assert.ok(dashboard.panels.length >= 20);
assert.equal(new Set(dashboard.panels.map((panel) => panel.id)).size, dashboard.panels.length);
assert.equal(new Set(dashboard.panels.map((panel) => panel.title)).size, dashboard.panels.length);
const expressions = dashboard.panels
.flatMap((panel) => panel.targets ?? [])
.map((target) => target.expr ?? "")
.join("\n");
for (const metric of [
"rendezvous_http_requests_total",
"rendezvous_http_duration_milliseconds_bucket",
"rendezvous_udp_results_total",
"rendezvous_udp_received_bytes_total",
"rendezvous_limiter_drops_total",
"rendezvous_connection_outcomes_total",
"rendezvous_browser_sse_subscribers",
"rendezvous_store_active_listings",
"rendezvous_store_active_attempts",
"rendezvous_signing_keys",
"process_resident_memory_bytes",
"process_open_file_descriptors",
"dotnet_gc_heap_size_bytes",
]) {
assert.match(expressions, new RegExp(`\\b${metric}\\b`));
}
assert.doesNotMatch(
expressions,
/listing_?id|session_?id|player|subject|token|capability|endpoint|address|metadata|credential/i,
);
for (const panel of dashboard.panels) {
assert.ok(panel.title?.trim());
assert.ok(panel.gridPos?.w > 0 && panel.gridPos?.h > 0);
for (const target of panel.targets ?? []) {
assert.equal(target.editorMode, "code");
assert.ok(target.expr?.trim());
}
}
});
test("Compose overlay pins hardened services and keeps telemetry private", async () => {
const compose = await readFile(new URL("deploy/observability/compose.yaml", root), "utf8");
assert.match(compose, /prom\/prometheus:v3\.13\.1@sha256:[a-f0-9]{64}/);
assert.match(compose, /grafana\/grafana:13\.1\.0@sha256:[a-f0-9]{64}/);
assert.match(compose, /Rendezvous__Metrics__Enabled: "true"/);
assert.match(compose, /rendezvous-metrics-token:\/run\/secrets\/rendezvous-metrics-token:ro/);
assert.match(compose, /GF_AUTH_ANONYMOUS_ENABLED: "false"/);
assert.match(compose, /GF_PLUGINS_PREINSTALL_DISABLED: "true"/);
assert.match(compose, /"127\.0\.0\.1:3000:3000\/tcp"/);
assert.doesNotMatch(compose, /9090:9090/);
assert.ok((compose.match(/read_only: true/g) ?? []).length >= 2);
assert.ok((compose.match(/no-new-privileges:true/g) ?? []).length >= 2);
});
test("Prometheus and Grafana provisioning use server-side authenticated access", async () => {
const prometheus = await readFile(
new URL("deploy/observability/prometheus/prometheus.yml", root),
"utf8",
);
const datasource = await readFile(
new URL("deploy/observability/grafana/provisioning/datasources/prometheus.yaml", root),
"utf8",
);
const provider = await readFile(
new URL("deploy/observability/grafana/provisioning/dashboards/rendezvous.yaml", root),
"utf8",
);
assert.match(prometheus, /bearer_token_file: \/run\/secrets\/rendezvous-metrics-token/);
assert.match(prometheus, /- rendezvous:8080/);
assert.match(datasource, /uid: rendezvous-prometheus/);
assert.match(datasource, /access: proxy/);
assert.match(datasource, /url: http:\/\/prometheus:9090/);
assert.match(provider, /allowUiUpdates: false/);
assert.match(provider, /path: \/var\/lib\/grafana\/dashboards/);
});