# Start-to-finish TestClient guide Tracking: #20, #25 `FinalFactory.Rendezvous.TestClient` is the supported executable proof that a consumer can publish, browse, authorize, punch, connect, exchange direct traffic, and diagnose a failure using only the public Client and Contracts packages. It is intentionally thin: a polished server browser and player-facing connection UI belong in each game repository. > **Traversal boundary:** Rendezvous v1 is not a relay and cannot guarantee a > connection through symmetric NAT, carrier-grade NAT, restrictive firewalls, > VPNs, or platform policy. It provides no accounts, social system, skill-based > matchmaking, gameplay server, gameplay authority, or gameplay transport. The automated scenario matrix, privileged Linux namespace run, and simulation limits are in the [deterministic topology harness](topology-harness.md). The [SDK seam guide](sdk-seams.md) covers the few integration details that this executable cannot show. ## First local connection from a clean checkout Prerequisites are the pinned .NET SDK, Docker with Compose, OpenSSL, Python 3, `curl`, and `jq`. Run these commands from the repository root. The generated key and credential are disposable local fixtures, not production provisioning. ```bash install -d -m 0700 deploy/compose/secrets umask 077 openssl rand -out deploy/compose/secrets/signing-key 32 export RENDEZVOUS_UID="$(id -u)" export RENDEZVOUS_GID="$(id -g)" test "$RENDEZVOUS_UID" -ne 0 docker compose -f deploy/compose/compose.yaml up --build --detach ready=false for attempt in {1..45}; do if curl --fail --silent http://127.0.0.1:8080/health/ready >/dev/null; then ready=true break fi sleep 1 done test "$ready" = true curl --fail http://127.0.0.1:8080/health/live curl --fail http://127.0.0.1:8080/health/ready dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release ``` Only the host terminal needs a publisher credential. Disable shell tracing before capturing it; the helper prints the credential on stdout so command substitution can place it directly in the environment without writing it to disk. ```bash set +x export RENDEZVOUS_PUBLISHER_CREDENTIAL="$(./scripts/mint-local-publisher-credential.sh)" ``` The helper accepts no arguments, reads the ignored `0600` local Compose key, and mints only `space-game` / `smoke` / `local` / protocol `1` for ten minutes. It is not a reusable issuer or an example for production. Never put the result in a command argument, URL, shell history, log, screenshot, support ticket, captured fixture, or source file. In terminal 1, publish a host. It stays alive for at most 60 seconds and exits after a joining peer completes the authenticated echo exchange: ```bash dotnet run --project src/FinalFactory.Rendezvous.TestClient \ --configuration Release --no-build -- \ host --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \ --game space-game --environment smoke --region local --protocol 1 \ --display-name "Local diagnostic" --timeout-seconds 60 --run-seconds 60 \ --exit-after-echo ``` Copy the public listing ID printed by the host, or discover it from terminal 2: ```bash dotnet run --project src/FinalFactory.Rendezvous.TestClient \ --configuration Release --no-build -- \ browse --service http://127.0.0.1:8080/ \ --game space-game --environment smoke --region local --protocol 1 ``` In terminal 3, either omit `--listing` and select interactively, or provide the copied ID for deterministic selection: ```bash dotnet run --project src/FinalFactory.Rendezvous.TestClient \ --configuration Release --no-build -- \ join --service http://127.0.0.1:8080/ --mediator 127.0.0.1:9050 \ --game space-game --environment smoke --region local --protocol 1 \ --listing REPLACE_WITH_LISTING_UUID --timeout-seconds 30 ``` Success means the joiner prints `join.connected` and verified direct traffic, and the host prints verified direct traffic before deregistering. The host and joiner each create one caller-owned LiteNetLib manager. The same UDP socket sends presence and punch traffic, accepts the authenticated peer, and carries the ping/echo/ack/completion payload; direct traffic does not pass through the HTTP service or mediator. Clean up secrets and the disposable service when finished: ```bash unset RENDEZVOUS_PUBLISHER_CREDENTIAL docker compose -f deploy/compose/compose.yaml down rm deploy/compose/secrets/signing-key ``` ## Script and JSON automation `--script` forbids prompts and selects the first compatible listing unless `--listing UUID` fixes the choice. `--json` emits one JSON object per line with `version: 1`. New optional properties may be added, but event names and exit codes are stable automation contracts. Informational events use stdout and failures use stderr. Successful direct-connection and direct-traffic events include the coarse `addressFamily` value `ipv4` or `ipv6`. They never include the peer address. The deployment smoke performs the full health, publish, join, mediation, direct traffic, outcome-report, and cleanup flow using bounded waits: ```bash dotnet build src/FinalFactory.Rendezvous.TestClient --configuration Release ./scripts/smoke-deployment.sh ``` For custom automation, capture JSON and preserve the process status separately: ```bash set +e dotnet run --project src/FinalFactory.Rendezvous.TestClient \ --configuration Release --no-build -- \ browse --service http://127.0.0.1:8080/ \ --game space-game --environment smoke --region local --protocol 1 \ --script --json >browse.jsonl status=$? set -e jq -e 'select(.version == 1 and .event == "browse.completed")' browse.jsonl test "$status" -eq 0 ``` 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`, `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 | | ---: | --- | | `0` | Requested diagnostic flow completed successfully | | `2` | Invalid command or options | | `3` | Missing or invalid local configuration | | `10` | HTTP, registration, browser, lease, or socket failure | | `11` | No compatible session was available or selected | | `12` | Authorization or traversal reached a typed terminal failure | | `13` | Direct connection succeeded but the direct traffic proof failed | | `130` | Caller cancellation or Ctrl+C | ## Observe a safe failure Run this after the protocol-1 browse in terminal 2 and before the terminal-3 join (or restart terminal 1 first). The preceding browse proves that one protocol-1 host is present. Now browse for deliberately incompatible protocol `999`. The command emits a successful directory response with `browse.completed`, `count: 0`, then exits `11` to distinguish compatibility from a service outage: ```bash set +e dotnet run --project src/FinalFactory.Rendezvous.TestClient \ --configuration Release --no-build -- \ browse --service http://127.0.0.1:8080/ \ --game space-game --environment smoke --region local --protocol 999 \ --script --json >incompatible.jsonl status=$? set -e jq -e 'select(.event == "browse.completed" and .phase == "directory" and .count == 0)' \ incompatible.jsonl test "$status" -eq 11 ``` This is a diagnostic failure drill, not a bypass: unknown tenant scope and protocols still fail closed, and the local helper cannot mint a credential for them. ## Diagnose by phase, not by guesswork Start with the exit code, then the last versioned event and its `phase`, `status`, and typed `outcome`. Endpoint categories may be reported as `loopback`, `private`, or `public`; raw endpoints, credentials, capabilities, metadata, and player identities are never emitted. | Symptom or last event | Distinction | Check next | | --- | --- | --- | | `host.configuration`, exit `3` | Local credential variable is missing or malformed before any request | Confirm the named environment variable exists, tracing is off, and the credential has not expired | | `host.registration`, exit `10` | Publisher authentication, tenant policy, metadata, quota, or HTTP failure | Use the typed status; compare credential scope with game/environment/region and the provisioned policy, then correlate protected server telemetry by operation and time | | `browse.sessions`, exit `10` | Directory request failed | Check HTTP reachability, `/health/ready`, rate limiting, and contract compatibility | | `browse.completed` count `0`, or `join.selection` empty, exit `11` | Healthy directory but no compatible visible listing | Match game, environment, region, and exact gameplay protocol; then confirm a host lease is still active | | Exact `join.selection` failure, exit `10` | Listing disappeared, is hidden, or scope no longer matches | Browse again; do not retry an old listing ID forever | | `join.authorization`, exit `12` | Service rejected the attempt before NAT traversal | Inspect typed category/outcome for policy, capacity, stale host, or active-attempt limits | | `join.punch` / `join.traversal`, exit `12` | Mediation or NAT traversal did not establish a peer | Confirm UDP endpoint/reply path, host presence, clocks, firewall/NAT behavior, and topology; use a game-owned fallback if policy supplies one | | `join.direct-connect`, exit `12` | Introduction occurred but authenticated direct admission failed | Confirm host is polling the same socket, the one-time ticket is current, and game admission did not reject capacity, identity, or bans | | `join.connected` followed by exit `13` | Peer connected but the direct gameplay-like echo did not finish | Inspect the peer lifecycle and caller polling; this is not an HTTP/directory failure | Stopping a host without deregistration may leave its listing visible only until the bounded lease expires. During that window, a join can produce a typed stale host or traversal outcome; it must not be interpreted as a healthy host. Restarting the single-active service intentionally loses all ephemeral listings and attempts, so hosts re-register and clients browse again. If a terminal outcome reports an authoritative dedicated fallback, `join.fallback` exposes only availability and endpoint type. TestClient never connects to it automatically. The game owns the decision, authentication, and connection policy. If no fallback is present, Rendezvous v1 offers no relay. ## Production use Do not copy a production signing key to a diagnostic host. Supply a short-lived, least-scope publisher credential from the deployment secret boundary and set the external service, mediator, and matching scope variables described in the [secure Linux deployment smoke](../deployment/linux.md#http-and-udp-smoke). Run representative external-network tests; loopback success is not NAT coverage. Use the redacting, bounded [real-network canary procedure](../operations/production-readiness.md) for formal production evidence rather than committing raw TestClient JSON.