feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s

This commit is contained in:
KyuubiYoru
2026-07-16 11:05:56 +02:00
parent 94aba8a3bb
commit 7e3be2cad1
16 changed files with 2721 additions and 12 deletions
+5 -2
View File
@@ -77,8 +77,9 @@ The initial service does not provide:
Rendezvous is under active roadmap development. The versioned contracts, Rendezvous is under active roadmap development. The versioned contracts,
directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned directory leases, authenticated join attempts, LiteNetLib mediator, caller-owned
SDK coordination, and typed connection outcomes are implemented. The thin test SDK coordination, typed connection outcomes, and thin public-SDK diagnostic client
client, deployment hardening, and production-readiness roadmap remain in progress; are implemented. Deployment hardening, the broader NAT-topology harness, and
the production-readiness roadmap remain in progress;
participating games must not treat the current repository as a finished production participating games must not treat the current repository as a finished production
service until those gates land. service until those gates land.
@@ -88,6 +89,8 @@ The frozen v1 wire surface is documented in the
[HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md). [HTTP, UDP, and generated OpenAPI contracts](docs/contracts/README.md).
Tenant policy, publisher/operator principals, and production key custody are Tenant policy, publisher/operator principals, and production key custody are
defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md). defined in [game provisioning and signing-key lifecycle](docs/security/provisioning.md).
The scriptable host/browser/join diagnostic and its stable automation contract are
documented in the [TestClient integration guide](docs/integration/test-client.md).
## Development ## Development
+94
View File
@@ -0,0 +1,94 @@
# Diagnostic TestClient integration guide
Tracking: #25
`FinalFactory.Rendezvous.TestClient` is the smallest supported public-SDK consumer.
It exists for integration development, CI smoke checks, deployment verification,
and operator diagnosis. It is intentionally not a production game client, game
server, matchmaking UI, or relay.
## Prerequisites
Start a configured Rendezvous service and note both its HTTP base URL and UDP
mediator endpoint. The host needs a tenant-scoped publisher credential from the
deployment secret boundary. Put it in an environment variable and pass only that
variable's name when the default is unsuitable:
```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<deployment-supplied value>'
```
Never put the credential in a command argument, URL, checked-in configuration,
shell trace, or captured test fixture. The development server's signing material
is process-ephemeral; credentials from a prior development process are invalid.
## Manual three-terminal flow
Start the host:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1
```
Browse from another terminal:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
browse --service http://127.0.0.1:5000/ \
--game space-game --environment development --region local --protocol 1
```
Join from a third terminal. Omit `--listing` for an interactive choice:
```bash
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
join --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1 \
--listing 00000000-0000-0000-0000-000000000000
```
Replace the sample UUID with the public listing ID printed by host or browse.
Host and join each create one caller-owned LiteNetLib manager. That same socket
sends presence/punch traffic, establishes the authenticated direct connection,
and carries the ping/echo/ack/completion payload. The final completion confirms
that the host received the reliable acknowledgement; none of this traffic passes through the HTTP
service or UDP mediator.
## CI and deployment smoke flow
Use `--script --json`, set `--listing` when deterministic selection matters, and
check the documented process exit code. `--timeout-seconds` bounds each startup,
traversal, or direct-traffic stage; a script host also uses it as its total runtime
unless `--run-seconds` is explicit. A host can add `--exit-after-echo` so it
terminates after the joining peer acknowledges direct traffic and receives the
host's completion confirmation. Every wait is
bounded by coordinator state and `--timeout-seconds`; no orchestration should use
an unbounded sleep.
The normal test suite contains a real process gate that starts the built Server,
host TestClient, and join TestClient, waits for readiness and versioned events,
and verifies direct traffic, cleanup, JSON shape, and secret canaries. Process
trees are force-terminated in the test cleanup path if normal shutdown fails.
Useful success events are:
- `host.registered`, `host.ready`, `host.direct-traffic`, and `host.deregistered`;
- `browse.completed` and `browse.session`; and
- `join.connected`, `join.direct-traffic`, and `join.outcome-report`.
Failure events preserve stable typed phases and outcomes. When a terminal outcome
contains a configured dedicated endpoint, `join.fallback` reports `available`
with endpoint type `dedicated`; no raw address is printed and no fallback is
started implicitly.
## What the proof does and does not establish
The deterministic loopback test proves the complete service/host/client protocol,
ticket admission, and peer-to-peer payload path. Loopback is not evidence that all
consumer routers, carrier-grade NATs, symmetric NATs, firewalls, VPNs, IPv6 paths,
or platform policies permit hole punching. Same-LAN, separated observed endpoints,
network namespaces/containers, mediator restart, and adverse topology coverage
belong to the topology harness tracked by #14. Production rollout still requires
tests from representative networks and a game-owned fallback policy.
@@ -75,7 +75,8 @@ public sealed class RendezvousJoinClient : IRendezvousJoinClient
RendezvousConnectionOutcomeSource.Caller, RendezvousConnectionOutcomeSource.Caller,
RendezvousConnectionFailureCategory.Lifecycle, RendezvousConnectionFailureCategory.Lifecycle,
RendezvousConnectionPhase.Authorization, RendezvousConnectionPhase.Authorization,
elapsed.Elapsed)); elapsed.Elapsed,
dedicatedFallback));
} }
} }
@@ -0,0 +1,130 @@
using System.Security.Cryptography;
using System.Text;
using LiteNetLib;
using LiteNetLib.Utils;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class DirectEchoProtocol : IDisposable
{
private const string PingPrefix = "rv1-ping:";
private const string EchoPrefix = "rv1-echo:";
private const string AckPrefix = "rv1-ack:";
private const string DonePrefix = "rv1-done:";
private readonly EventBasedNetListener _events;
private readonly bool _host;
private readonly Dictionary<NetPeer, string> _hostNonces = [];
private readonly TaskCompletionSource<bool> _completed = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private string? _nonce;
private bool _disposed;
internal DirectEchoProtocol(EventBasedNetListener events, bool host)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_host = host;
_events.NetworkReceiveEvent += OnReceive;
_events.PeerDisconnectedEvent += OnPeerDisconnected;
}
internal Task Completion => _completed.Task;
internal int PendingHostExchangeCount => _hostNonces.Count;
internal event Action<NetPeer>? ExchangeCompleted;
internal void BeginJoin(NetPeer peer)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_host || _nonce is not null)
{
throw new InvalidOperationException("The direct echo exchange is already active.");
}
_nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
Send(peer, PingPrefix + _nonce);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_events.NetworkReceiveEvent -= OnReceive;
_events.PeerDisconnectedEvent -= OnPeerDisconnected;
_hostNonces.Clear();
_disposed = true;
}
private void OnReceive(
NetPeer peer,
NetPacketReader reader,
byte channel,
DeliveryMethod deliveryMethod)
{
try
{
ReadOnlySpan<byte> payload = reader.GetRemainingBytesSpan();
if (payload.Length is < 9 or > 64)
{
return;
}
string message = Encoding.ASCII.GetString(payload);
if (_host && TryNonce(message, PingPrefix, out string? pingNonce))
{
_hostNonces[peer] = pingNonce!;
Send(peer, EchoPrefix + pingNonce);
}
else if (_host
&& _hostNonces.TryGetValue(peer, out string? hostNonce)
&& string.Equals(message, AckPrefix + hostNonce, StringComparison.Ordinal))
{
_hostNonces.Remove(peer);
Send(peer, DonePrefix + hostNonce);
ExchangeCompleted?.Invoke(peer);
_completed.TrySetResult(true);
}
else if (!_host
&& _nonce is not null
&& string.Equals(message, EchoPrefix + _nonce, StringComparison.Ordinal))
{
Send(peer, AckPrefix + _nonce);
}
else if (!_host
&& _nonce is not null
&& string.Equals(message, DonePrefix + _nonce, StringComparison.Ordinal))
{
ExchangeCompleted?.Invoke(peer);
_completed.TrySetResult(true);
}
}
finally
{
reader.Recycle();
}
}
private static bool TryNonce(string message, string prefix, out string? nonce)
{
nonce = null;
if (!message.StartsWith(prefix, StringComparison.Ordinal)
|| message.Length != prefix.Length + 32)
{
return false;
}
string candidate = message[prefix.Length..];
if (!candidate.All(static character => character is >= '0' and <= '9'
or >= 'a' and <= 'f'))
{
return false;
}
nonce = candidate;
return true;
}
private static void Send(NetPeer peer, string message) => peer.Send(
Encoding.ASCII.GetBytes(message),
DeliveryMethod.ReliableOrdered);
private void OnPeerDisconnected(NetPeer peer, DisconnectInfo disconnectInfo) =>
_hostNonces.Remove(peer);
}
@@ -0,0 +1,36 @@
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class HostServiceFailureBudget
{
private const int MaximumConsecutiveTransientFailures = 3;
private int _consecutiveTransientFailures;
internal bool ShouldStop(
RendezvousErrorCode error,
DateTimeOffset leaseExpiresAt,
DateTimeOffset now)
{
if (error == RendezvousErrorCode.None)
{
Reset();
return false;
}
if (!IsTransient(error))
{
return true;
}
_consecutiveTransientFailures++;
return _consecutiveTransientFailures >= MaximumConsecutiveTransientFailures
|| now >= leaseExpiresAt;
}
internal void Reset() => _consecutiveTransientFailures = 0;
private static bool IsTransient(RendezvousErrorCode error) => error is
RendezvousErrorCode.RateLimited
or RendezvousErrorCode.ServiceUnavailable
or RendezvousErrorCode.InternalError;
}
@@ -1,16 +1,29 @@
namespace FinalFactory.Rendezvous.TestClient; namespace FinalFactory.Rendezvous.TestClient;
/// <summary>
/// Bootstrap entry point for the public-SDK-only diagnostic client.
/// </summary>
public static class Program public static class Program
{ {
/// <summary> public static async Task<int> Main(string[] args)
/// Runs the bootstrap diagnostic.
/// </summary>
public static int Main()
{ {
Console.WriteLine("Rendezvous TestClient bootstrap is ready."); using CancellationTokenSource shutdown = new();
return 0; ConsoleCancelEventHandler cancelHandler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
shutdown.Cancel();
};
Console.CancelKeyPress += cancelHandler;
try
{
TestClientApplication application = new(new RendezvousCommandRunner());
return await application.RunAsync(
args,
Console.In,
Console.Out,
Console.Error,
shutdown.Token).ConfigureAwait(false);
}
finally
{
Console.CancelKeyPress -= cancelHandler;
}
} }
} }
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("FinalFactory.Rendezvous.Tests")]
@@ -0,0 +1,56 @@
# FinalFactory.Rendezvous.TestClient
This is a diagnostic executable for exercising Rendezvous through the same public
Client and Contracts API available to a game. It is not a production game client,
server browser, dedicated server, relay, account system, or gameplay host.
The executable has three explicit modes:
- `host` publishes a session, maintains presence and its lease, accepts an
authenticated direct peer, and answers a bounded ping/echo/ack/completion exchange;
- `browse` prints compatible public listings; and
- `join` selects or accepts a listing, drives traversal on its caller-owned
LiteNetLib socket, proves direct traffic, reports the typed outcome, and exits.
Run `dotnet run --project src/FinalFactory.Rendezvous.TestClient -- --help` for
the complete option reference. A typical script-mode invocation is:
```bash
export RENDEZVOUS_PUBLISHER_CREDENTIAL='<credential from the deployment boundary>'
dotnet run --project src/FinalFactory.Rendezvous.TestClient -- \
host --service http://127.0.0.1:5000/ --mediator 127.0.0.1:9050 \
--game space-game --environment development --region local --protocol 1 \
--script --json --exit-after-echo
```
Publisher credentials are accepted only through a named environment variable.
There is deliberately no command-line credential option because process command
lines are routinely exposed to other local tools and diagnostics. Output uses an
allowlisted event model and never includes lease tokens, punch capabilities,
connection tickets, raw metadata, signing material, or reusable credentials.
Script mode never prompts. Join mode selects the first compatible listing unless
`--listing UUID` fixes the choice. `--json` emits one JSON object per line with
`version: 1`; event names and the process exit codes below are stable automation
contracts. A script-mode host without `--run-seconds` uses `--timeout-seconds` as
its total runtime bound. New optional event properties may be added without changing
the version. JSON help and usage failures are versioned events as well; informational
events use stdout and failures use stderr.
| 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` | A requested direct ping/echo proof did not complete |
| `130` | Caller cancellation or Ctrl+C |
The client prints the selected direct endpoint category (`loopback`, `private`, or
`public`) but never the raw endpoint. A traversal failure reports whether an
authoritative dedicated fallback is available; the diagnostic does not connect to
that fallback automatically. A host may publish a policy-authorized endpoint with
`--fallback IP:PORT`. See the repository integration guide for process
orchestration and topology limitations.
@@ -0,0 +1,774 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Client;
using FinalFactory.Rendezvous.Contracts;
using LiteNetLib;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class RendezvousCommandRunner : ITestClientCommandRunner
{
private static readonly TimeSpan PollDelay = TimeSpan.FromMilliseconds(5);
private static readonly TimeSpan HostRefreshInterval = TimeSpan.FromMilliseconds(250);
private static readonly TimeSpan DirectTrafficFlushGrace = TimeSpan.FromMilliseconds(500);
public Task<TestClientExitCode> RunAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken) => options.Mode switch
{
TestClientMode.Host => RunHostAsync(options, output, cancellationToken),
TestClientMode.Browse => RunBrowseAsync(options, output, cancellationToken),
TestClientMode.Join => RunJoinAsync(options, output, input, cancellationToken),
_ => Task.FromResult(TestClientExitCode.Usage),
};
private static async Task<TestClientExitCode> RunHostAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
string? publisherCredential = Environment.GetEnvironmentVariable(
options.PublisherCredentialEnvironmentVariable);
if (!ContractValidation.IsOpaqueHttpCredentialValid(publisherCredential))
{
output.WriteError(
"host.configuration",
"failed",
"The publisher credential environment variable is missing or invalid.",
phase: "configuration");
return TestClientExitCode.Configuration;
}
string credential = publisherCredential!;
using HttpClient http = CreateHttpClient(options);
RendezvousPublisherClient publisher = new(http, ClientOptions(options));
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
RendezvousJoinClient joins = new(http, ClientOptions(options));
RendezvousNetListener events = new();
NetManager manager = events.CreateManager();
if (!manager.Start(options.LocalPort))
{
output.WriteError("host.socket", "failed", "The gameplay UDP socket could not start.", phase: "presence");
return TestClientExitCode.ServiceFailure;
}
PublishedSession? session = null;
using CancellationTokenSource hostOperations = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task<RendezvousClientResult<int>>? refresh = null;
Task<RendezvousClientResult<RenewLeaseResponse>>? renewal = null;
Task<RendezvousClientResult<GetSessionResponse>>? readiness = null;
DirectEchoProtocol? echo = null;
RendezvousHostCoordinator? coordinator = null;
TestClientExitCode hostResult = TestClientExitCode.ServiceFailure;
bool cleanupFailed = false;
try
{
output.Write("host.registration", "started", phase: "registration");
RendezvousClientResult<PublishedSession> registration;
using (CancellationTokenSource registrationTimeout = CreateOperationTimeout(options, cancellationToken))
{
try
{
registration = await publisher.RegisterAsync(
new RegisterSessionRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
RegionId = options.RegionId,
ProtocolVersion = options.ProtocolVersion,
BuildVersion = options.BuildVersion,
DisplayName = options.DisplayName,
Visibility = ListingVisibility.Public,
Capacity = new SessionCapacity { CurrentPlayers = 1, MaximumPlayers = 8 },
Metadata = new Dictionary<string, string>(options.Metadata, StringComparer.Ordinal),
DedicatedFallback = options.DedicatedFallback,
},
credential,
registrationTimeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.WriteError(
"host.registration",
"timed-out",
"Host registration exceeded the bounded startup stage.",
phase: "registration");
return TestClientExitCode.ServiceFailure;
}
}
if (!registration.IsSuccess || registration.Value is null)
{
WriteServiceFailure(output, "host.registration", "registration", registration);
return TestClientExitCode.ServiceFailure;
}
session = registration.Value;
output.Write(
"host.registered",
"registered",
phase: "registration",
listingId: session.ListingId.ToString(),
displayName: options.DisplayName);
echo = new DirectEchoProtocol(events.GameplayEvents, host: true);
echo.ExchangeCompleted += _ => output.Write(
"host.direct-traffic",
"verified",
phase: "direct-traffic",
endpointType: "peer-to-peer");
coordinator = new RendezvousHostCoordinator(
manager,
events,
options.Mediator,
session,
joins,
CoordinatorOptions(options));
coordinator.AttemptCompleted += (_, completion) =>
{
output.Write(
"host.attempt.completed",
completion.Outcome.IsSuccess ? "connected" : "failed",
phase: completion.Outcome.Phase.ToString(),
outcome: completion.Outcome.Kind.ToString(),
elapsedMilliseconds: ToMilliseconds(completion.Outcome.Elapsed));
if (completion.Outcome.IsSuccess)
{
output.Write(
"host.direct-connect",
"connected",
phase: "direct-connection",
endpointType: "peer-to-peer");
}
};
Stopwatch running = Stopwatch.StartNew();
TimeSpan nextRefresh = TimeSpan.Zero;
TimeSpan nextRenewal = TimeSpan.FromSeconds(session.LeaseRenewAfterSeconds);
TimeSpan nextReadinessProbe = TimeSpan.Zero;
bool directTrafficReported = false;
TimeSpan? directTrafficCompletedAt = null;
bool ready = false;
bool terminalFailure = false;
int previousPendingAttempts = 0;
HostServiceFailureBudget refreshFailures = new();
HostServiceFailureBudget renewalFailures = new();
using PeriodicTimer pollTimer = new(PollDelay);
while (!cancellationToken.IsCancellationRequested)
{
coordinator.Poll();
if (coordinator.State != RendezvousHostState.Active)
{
output.WriteError(
"host.lifecycle",
"failed",
"The host coordinator stopped before shutdown was requested.",
phase: "lifecycle",
outcome: coordinator.State.ToString());
terminalFailure = true;
break;
}
if (coordinator.PendingAttemptCount > previousPendingAttempts)
{
output.Write(
"host.punch",
"started",
phase: "nat-traversal",
count: coordinator.PendingAttemptCount);
}
previousPendingAttempts = coordinator.PendingAttemptCount;
if (readiness is { IsCompleted: true })
{
RendezvousClientResult<GetSessionResponse> result = await readiness.ConfigureAwait(false);
readiness = null;
if (result.IsSuccess)
{
ready = true;
output.Write(
"host.ready",
"ready",
phase: "presence",
listingId: session.ListingId.ToString());
}
else
{
nextReadinessProbe = running.Elapsed + TimeSpan.FromMilliseconds(50);
}
}
if (!ready && readiness is null && running.Elapsed >= nextReadinessProbe)
{
readiness = browser.GetAsync(
session.ListingId,
options.GameId,
options.EnvironmentId,
options.ProtocolVersion,
hostOperations.Token);
}
if (refresh is { IsCompleted: true })
{
RendezvousClientResult<int> result = await refresh.ConfigureAwait(false);
refresh = null;
nextRefresh = running.Elapsed + (result.IsSuccess
? HostRefreshInterval
: TimeSpan.FromSeconds(1));
if (!result.IsSuccess)
{
WriteServiceFailure(output, "host.authorization", "authorization", result);
if (refreshFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
{
terminalFailure = true;
break;
}
}
else
{
refreshFailures.Reset();
}
}
if (refresh is null && running.Elapsed >= nextRefresh)
{
refresh = coordinator.RefreshJoinAttemptsAsync(hostOperations.Token);
}
if (renewal is { IsCompleted: true })
{
RendezvousClientResult<RenewLeaseResponse> result = await renewal.ConfigureAwait(false);
renewal = null;
nextRenewal = running.Elapsed + (result.IsSuccess && result.Value is not null
? TimeSpan.FromSeconds(result.Value.RenewAfterSeconds)
: TimeSpan.FromSeconds(1));
output.Write(
"host.lease",
result.IsSuccess ? "renewed" : "failed",
phase: "lease",
message: result.IsSuccess ? null : SafeServiceMessage(result));
if (!result.IsSuccess
&& renewalFailures.ShouldStop(result.Error, session.ExpiresAt, DateTimeOffset.UtcNow))
{
terminalFailure = true;
break;
}
if (result.IsSuccess)
{
renewalFailures.Reset();
}
}
if (renewal is null && running.Elapsed >= nextRenewal)
{
renewal = publisher.RenewAsync(session, credential, hostOperations.Token);
}
if (echo.Completion.IsCompleted && !directTrafficReported)
{
directTrafficReported = true;
directTrafficCompletedAt = running.Elapsed;
}
if (options.ExitAfterEcho
&& directTrafficCompletedAt.HasValue
&& running.Elapsed - directTrafficCompletedAt.Value >= DirectTrafficFlushGrace)
{
break;
}
if (options.RunDuration.HasValue && running.Elapsed >= options.RunDuration.Value)
{
break;
}
if (!await pollTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
break;
}
}
if (cancellationToken.IsCancellationRequested)
{
hostResult = TestClientExitCode.Cancelled;
}
else if (terminalFailure)
{
hostResult = TestClientExitCode.ServiceFailure;
}
else if (options.ExitAfterEcho && !directTrafficReported)
{
output.WriteError(
"host.direct-traffic",
"timed-out",
"No authenticated ping/echo/ack exchange completed within the host runtime.",
phase: "direct-traffic");
hostResult = TestClientExitCode.DirectTrafficFailed;
}
else
{
hostResult = TestClientExitCode.Success;
}
}
finally
{
hostOperations.Cancel();
await ObserveCancellationAsync(refresh).ConfigureAwait(false);
await ObserveCancellationAsync(renewal).ConfigureAwait(false);
await ObserveCancellationAsync(readiness).ConfigureAwait(false);
coordinator?.Dispose();
echo?.Dispose();
if (session is not null)
{
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
try
{
RendezvousClientResult<bool> deregistered = await publisher.DeregisterAsync(
session,
credential,
cleanup.Token).ConfigureAwait(false);
output.Write(
"host.deregistered",
deregistered.IsSuccess ? "complete" : "failed",
phase: "lifecycle",
listingId: session.ListingId.ToString());
if (!deregistered.IsSuccess)
{
cleanupFailed = true;
}
}
catch (OperationCanceledException)
{
output.WriteError(
"host.deregistered",
"timed-out",
"Deregistration did not complete within the cleanup budget.",
phase: "lifecycle");
cleanupFailed = true;
}
}
manager.Stop();
}
return cleanupFailed && !cancellationToken.IsCancellationRequested
? TestClientExitCode.ServiceFailure
: hostResult;
}
private static async Task<TestClientExitCode> RunBrowseAsync(
TestClientOptions options,
TestClientOutput output,
CancellationToken cancellationToken)
{
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
output.Write("browse.sessions", "started", phase: "directory");
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
BrowseRequest(options),
maximumPages: 10,
cancellationToken: operation.Token).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "browse.sessions", "directory", result);
return TestClientExitCode.ServiceFailure;
}
WriteListings(output, result.Value);
return result.Value.Count == 0
? TestClientExitCode.NoCompatibleSession
: TestClientExitCode.Success;
}
private static async Task<TestClientExitCode> RunJoinAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken)
{
using CancellationTokenSource operation = CreateOperationTimeout(options, cancellationToken);
using HttpClient http = CreateHttpClient(options);
RendezvousSessionBrowserClient browser = new(http, ClientOptions(options));
RendezvousJoinClient joins = new(http, ClientOptions(options));
SessionSelection selection = await SelectListingAsync(
options,
output,
input,
browser,
operation.Token).ConfigureAwait(false);
if (selection.Listing is null)
{
return selection.ExitCode;
}
SessionListing listing = selection.Listing;
RendezvousNetListener events = new();
NetManager manager = events.CreateManager();
if (!manager.Start(options.LocalPort))
{
output.WriteError("join.socket", "failed", "The gameplay UDP socket could not start.", phase: "mediation");
return TestClientExitCode.ServiceFailure;
}
RendezvousClientCoordinator? coordinator = null;
bool directConnected = false;
try
{
output.Write(
"join.authorization",
"started",
phase: "authorization",
listingId: listing.ListingId.ToString());
RendezvousConnectionStartResult start = await joins.CreateConnectionAttemptAsync(
new CreateJoinAttemptRequest
{
IdempotencyKey = Guid.NewGuid().ToString("N"),
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
ListingId = listing.ListingId,
ProtocolVersion = options.ProtocolVersion,
},
listing.DedicatedFallback,
operation.Token).ConfigureAwait(false);
if (start.Outcome is { } serviceOutcome)
{
cancellationToken.ThrowIfCancellationRequested();
WriteOutcome(output, "join.authorization", serviceOutcome);
WriteFallback(output, serviceOutcome);
return TestClientExitCode.TraversalFailed;
}
CreateJoinAttemptResponse attempt = start.Attempt
?? throw new InvalidOperationException("The typed start result had no attempt or outcome.");
using DirectEchoProtocol echo = new(events.GameplayEvents, host: false);
coordinator = new RendezvousClientCoordinator(
manager,
events,
options.Mediator,
attempt,
CoordinatorOptions(options));
output.Write("join.punch", "started", phase: "nat-traversal");
using (CancellationTokenSource traversal = CreateOperationTimeout(options, cancellationToken))
using (PeriodicTimer traversalPoll = new(PollDelay))
{
RendezvousConnectionState previousState = coordinator.State;
while (!coordinator.IsCompleted)
{
traversal.Token.ThrowIfCancellationRequested();
coordinator.Poll();
if (coordinator.State != previousState)
{
previousState = coordinator.State;
if (previousState == RendezvousConnectionState.Connecting)
{
output.Write(
"join.direct-connect",
"started",
phase: "direct-connection");
}
}
if (!coordinator.IsCompleted
&& !await traversalPoll.WaitForNextTickAsync(traversal.Token).ConfigureAwait(false))
{
break;
}
}
}
RendezvousConnectionOutcome outcome = coordinator.Outcome
?? throw new InvalidOperationException("The completed coordinator had no typed outcome.");
WriteOutcome(output, "join.traversal", outcome);
if (!outcome.IsSuccess || coordinator.ConnectedPeer is null)
{
WriteFallback(output, outcome);
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
return TestClientExitCode.TraversalFailed;
}
NetPeer peer = coordinator.ConnectedPeer;
directConnected = true;
string endpointType = EndpointType(peer.Address);
output.Write(
"join.connected",
"connected",
phase: "direct-connection",
endpointType: endpointType,
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
await ReportOutcomeAsync(coordinator, joins, output, cancellationToken).ConfigureAwait(false);
echo.BeginJoin(peer);
using (CancellationTokenSource traffic = CreateOperationTimeout(options, cancellationToken))
using (PeriodicTimer trafficPoll = new(PollDelay))
{
while (!echo.Completion.IsCompleted)
{
traffic.Token.ThrowIfCancellationRequested();
manager.PollEvents();
if (!echo.Completion.IsCompleted
&& !await trafficPoll.WaitForNextTickAsync(traffic.Token).ConfigureAwait(false))
{
break;
}
}
}
await echo.Completion.ConfigureAwait(false);
output.Write(
"join.direct-traffic",
"verified",
phase: "direct-traffic",
endpointType: endpointType);
peer.Disconnect();
manager.PollEvents();
return TestClientExitCode.Success;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
output.WriteError(
"join.timeout",
"timed-out",
"The bounded join operation timed out.",
phase: "lifecycle",
outcome: ConnectionOutcomeKind.TimedOut.ToString());
if (coordinator is not null && !coordinator.IsCompleted)
{
coordinator.Poll();
}
if (coordinator is not null && !coordinator.IsCompleted)
{
coordinator.Cancel();
coordinator.Poll();
if (coordinator.Outcome is { } timeoutOutcome)
{
WriteOutcome(output, "join.traversal", timeoutOutcome);
WriteFallback(output, timeoutOutcome, listing.DedicatedFallback);
await ReportOutcomeAsync(
coordinator,
joins,
output,
cancellationToken).ConfigureAwait(false);
}
}
return directConnected
? TestClientExitCode.DirectTrafficFailed
: TestClientExitCode.TraversalFailed;
}
finally
{
coordinator?.Dispose();
manager.Stop();
}
}
private static async Task<SessionSelection> SelectListingAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
RendezvousSessionBrowserClient browser,
CancellationToken cancellationToken)
{
if (options.ListingId.HasValue)
{
RendezvousClientResult<GetSessionResponse> exact = await browser.GetAsync(
options.ListingId.Value,
options.GameId,
options.EnvironmentId,
options.ProtocolVersion,
cancellationToken).ConfigureAwait(false);
if (!exact.IsSuccess || exact.Value is null)
{
WriteServiceFailure(output, "join.selection", "directory", exact);
return new(null, TestClientExitCode.ServiceFailure);
}
return new(exact.Value.Session, TestClientExitCode.Success);
}
RendezvousClientResult<IReadOnlyList<SessionListing>> result = await browser.BrowseAllAsync(
BrowseRequest(options),
maximumPages: 10,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess || result.Value is null)
{
WriteServiceFailure(output, "join.selection", "directory", result);
return new(null, TestClientExitCode.ServiceFailure);
}
if (result.Value.Count == 0)
{
output.Write("join.selection", "empty", phase: "directory", count: 0);
return new(null, TestClientExitCode.NoCompatibleSession);
}
WriteListings(output, result.Value);
if (options.Script)
{
return new(result.Value[0], TestClientExitCode.Success);
}
output.WritePrompt($"Select session [1-{result.Value.Count}]: ");
string? selection = await input.ReadLineAsync(cancellationToken).ConfigureAwait(false);
SessionListing? selected = int.TryParse(selection, out int index)
&& index >= 1
&& index <= result.Value.Count
? result.Value[index - 1]
: null;
return selected is null
? new(null, TestClientExitCode.NoCompatibleSession)
: new(selected, TestClientExitCode.Success);
}
private static void WriteListings(TestClientOutput output, IReadOnlyList<SessionListing> listings)
{
output.Write("browse.completed", "complete", phase: "directory", count: listings.Count);
foreach (SessionListing listing in listings)
{
output.Write(
"browse.session",
"available",
phase: "directory",
listingId: listing.ListingId.ToString(),
displayName: listing.DisplayName);
}
}
private static BrowseSessionsRequest BrowseRequest(TestClientOptions options) => new()
{
GameId = options.GameId,
EnvironmentId = options.EnvironmentId,
ProtocolVersion = options.ProtocolVersion,
RegionId = options.RegionId,
PageSize = options.PageSize,
ExcludeFull = true,
};
private static HttpClient CreateHttpClient(TestClientOptions options) => new()
{
BaseAddress = options.ServiceUri,
Timeout = Timeout.InfiniteTimeSpan,
};
private static RendezvousClientOptions ClientOptions(TestClientOptions options) => new()
{
RequestTimeout = TimeSpan.FromSeconds(Math.Min(30, options.OperationTimeout.TotalSeconds)),
};
private static RendezvousCoordinatorOptions CoordinatorOptions(TestClientOptions options)
{
TimeSpan phaseTimeout = TimeSpan.FromSeconds(
Math.Min(30, options.OperationTimeout.TotalSeconds * 0.45));
return new RendezvousCoordinatorOptions
{
PunchTimeout = phaseTimeout,
DirectConnectTimeout = phaseTimeout,
};
}
private static CancellationTokenSource CreateOperationTimeout(
TestClientOptions options,
CancellationToken cancellationToken)
{
CancellationTokenSource source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
source.CancelAfter(options.OperationTimeout);
return source;
}
private static async Task ReportOutcomeAsync(
RendezvousClientCoordinator coordinator,
RendezvousJoinClient joins,
TestClientOutput output,
CancellationToken callerCancellationToken)
{
using CancellationTokenSource telemetry = CancellationTokenSource.CreateLinkedTokenSource(
callerCancellationToken);
telemetry.CancelAfter(TimeSpan.FromSeconds(5));
try
{
RendezvousClientResult<ReportConnectionOutcomeResponse> report =
await coordinator.ReportOutcomeAsync(joins, telemetry.Token).ConfigureAwait(false);
output.Write(
"join.outcome-report",
report.IsSuccess ? "accepted" : "failed",
phase: "telemetry",
message: report.IsSuccess ? null : SafeServiceMessage(report));
}
catch (OperationCanceledException) when (!callerCancellationToken.IsCancellationRequested)
{
output.WriteError(
"join.outcome-report",
"cancelled",
"Outcome reporting was cancelled within the operation budget.",
phase: "telemetry");
}
}
private static void WriteOutcome(
TestClientOutput output,
string eventName,
RendezvousConnectionOutcome outcome) => output.Write(
eventName,
outcome.IsSuccess ? "connected" : "failed",
phase: outcome.Phase.ToString(),
outcome: outcome.Kind.ToString(),
elapsedMilliseconds: ToMilliseconds(outcome.Elapsed));
private static void WriteFallback(
TestClientOutput output,
RendezvousConnectionOutcome outcome,
NetworkEndpoint? authoritativeFallback = null)
{
bool hasFallback = outcome.HasDedicatedFallback || authoritativeFallback is not null;
output.Write(
"join.fallback",
hasFallback ? "available" : "unavailable",
phase: "fallback",
outcome: outcome.Kind.ToString(),
endpointType: hasFallback ? "dedicated" : "none");
}
private static void WriteServiceFailure<T>(
TestClientOutput output,
string eventName,
string phase,
RendezvousClientResult<T> result) => output.WriteError(
eventName,
"failed",
SafeServiceMessage(result),
phase,
result.Error.ToString());
private static string SafeServiceMessage<T>(RendezvousClientResult<T> result) =>
$"Rendezvous returned {result.Error}.";
private static async Task ObserveCancellationAsync<T>(Task<T>? task)
{
if (task is null)
{
return;
}
try
{
await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (ObjectDisposedException)
{
}
}
private static string EndpointType(IPAddress address)
{
if (IPAddress.IsLoopback(address))
{
return "loopback";
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
byte[] ipv6 = address.GetAddressBytes();
return address.IsIPv6LinkLocal || (ipv6[0] & 0xfe) == 0xfc
? "private"
: "public";
}
byte[] bytes = address.GetAddressBytes();
bool privateAddress = bytes[0] == 10
|| bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|| bytes[0] == 192 && bytes[1] == 168;
return privateAddress ? "private" : "public";
}
private static long ToMilliseconds(TimeSpan elapsed) =>
(long)Math.Min(long.MaxValue, Math.Max(0, elapsed.TotalMilliseconds));
private sealed record SessionSelection(
SessionListing? Listing,
TestClientExitCode ExitCode);
}
@@ -0,0 +1,95 @@
namespace FinalFactory.Rendezvous.TestClient;
internal enum TestClientExitCode
{
Success = 0,
Usage = 2,
Configuration = 3,
ServiceFailure = 10,
NoCompatibleSession = 11,
TraversalFailed = 12,
DirectTrafficFailed = 13,
Cancelled = 130,
}
internal interface ITestClientCommandRunner
{
Task<TestClientExitCode> RunAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken);
}
internal sealed class TestClientApplication(ITestClientCommandRunner runner)
{
private readonly ITestClientCommandRunner _runner = runner ?? throw new ArgumentNullException(nameof(runner));
internal async Task<int> RunAsync(
string[] args,
TextReader input,
TextWriter standardOutput,
TextWriter standardError,
CancellationToken cancellationToken)
{
bool jsonRequested = args.Contains("--json", StringComparer.Ordinal);
TestClientParseResult parsed = TestClientOptionParser.Parse(args);
TestClientOutput output = new(standardOutput, standardError, jsonRequested);
if (parsed.ShowHelp)
{
if (jsonRequested)
{
output.Write(
"cli.help",
"complete",
phase: "configuration",
message: "Run without --json to read the full command reference.");
}
else
{
await standardOutput.WriteLineAsync(TestClientOptionParser.Usage).ConfigureAwait(false);
}
return (int)TestClientExitCode.Success;
}
if (!parsed.Succeeded || parsed.Options is null)
{
if (jsonRequested)
{
output.WriteError(
"cli.usage",
"failed",
parsed.Error ?? "Invalid command line.",
phase: "configuration");
}
else
{
await standardError.WriteLineAsync(parsed.Error ?? "Invalid command line.").ConfigureAwait(false);
await standardError.WriteLineAsync("Use --help for documented options.").ConfigureAwait(false);
}
return (int)TestClientExitCode.Usage;
}
output = new TestClientOutput(standardOutput, standardError, parsed.Options.Json);
try
{
return (int)await _runner.RunAsync(
parsed.Options,
output,
input,
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
output.Write("lifecycle.cancelled", "cancelled", phase: "lifecycle");
return (int)TestClientExitCode.Cancelled;
}
catch (Exception exception)
{
output.WriteError(
"lifecycle.failed",
"failed",
$"Unexpected {exception.GetType().Name}; credentials remain redacted.");
return (int)TestClientExitCode.ServiceFailure;
}
}
}
@@ -0,0 +1,377 @@
using System.Net;
using System.Net.Sockets;
using FinalFactory.Rendezvous.Contracts;
namespace FinalFactory.Rendezvous.TestClient;
internal enum TestClientMode
{
Host,
Browse,
Join,
}
internal sealed class TestClientOptions
{
internal TestClientMode Mode { get; init; }
internal Uri ServiceUri { get; init; } = new("http://127.0.0.1:5000/");
internal IPEndPoint Mediator { get; init; } = new(IPAddress.Loopback, 9050);
internal GameId GameId { get; init; } = new("space-game");
internal EnvironmentId EnvironmentId { get; init; } = new("development");
internal RegionId RegionId { get; init; } = new("local");
internal uint ProtocolVersion { get; init; } = 1;
internal string BuildVersion { get; init; } = "test-client";
internal string DisplayName { get; init; } = "Rendezvous diagnostic host";
internal string PublisherCredentialEnvironmentVariable { get; init; } =
"RENDEZVOUS_PUBLISHER_CREDENTIAL";
internal Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
internal NetworkEndpoint? DedicatedFallback { get; init; }
internal SessionListingId? ListingId { get; init; }
internal int LocalPort { get; init; }
internal int PageSize { get; init; } = 20;
internal TimeSpan OperationTimeout { get; init; } = TimeSpan.FromSeconds(20);
internal TimeSpan? RunDuration { get; init; }
internal bool Script { get; init; }
internal bool Json { get; init; }
internal bool ExitAfterEcho { get; init; }
}
internal sealed class TestClientParseResult
{
private TestClientParseResult(TestClientOptions? options, string? error, bool showHelp)
{
Options = options;
Error = error;
ShowHelp = showHelp;
}
internal TestClientOptions? Options { get; }
internal string? Error { get; }
internal bool ShowHelp { get; }
internal bool Succeeded => Options is not null;
internal static TestClientParseResult Success(TestClientOptions options) => new(options, null, false);
internal static TestClientParseResult Failure(string error) => new(null, error, false);
internal static TestClientParseResult Help() => new(null, null, true);
}
internal static class TestClientOptionParser
{
internal const string Usage = """
Rendezvous diagnostic client
Usage:
rendezvous-test-client host [options]
rendezvous-test-client browse [options]
rendezvous-test-client join [options]
Common options:
--service URL HTTP(S) Rendezvous base URL
--mediator IP:PORT UDP mediator endpoint
--game ID Game scope (default: space-game)
--environment ID Environment scope (default: development)
--protocol NUMBER Exact gameplay protocol (default: 1)
--region ID Region filter/publication (default: local)
--timeout-seconds NUMBER Bounded startup/traversal stage, 1-300 (default: 20)
--port NUMBER Caller-owned gameplay UDP port; 0 chooses one
--page-size NUMBER Bounded browser page size, 1-100 (default: 20)
--script Never prompt; select the first compatible listing
--json Emit one versioned JSON event per line
--help Show this help
Host options:
--publisher-credential-env NAME Environment variable containing the credential
--display-name TEXT Public listing name
--build-version TEXT Public build version
--metadata KEY=VALUE Bounded public metadata; may be repeated
--fallback IP:PORT Optional policy-authorized dedicated fallback
--run-seconds NUMBER Stop after 1-86400 seconds
--exit-after-echo Stop after an authenticated ping/echo/ack exchange
Join options:
--listing UUID Join an exact listing; otherwise browse/select
Credentials are accepted only through the named environment variable. They are never
accepted on the command line and are never written to human or JSON output.
""";
internal static TestClientParseResult Parse(string[] args)
{
if (args.Length == 0 || args.Length == 1 && IsHelp(args[0]))
{
return TestClientParseResult.Help();
}
if (args.Length > 64)
{
return TestClientParseResult.Failure("Too many command-line arguments.");
}
if (!TryMode(args[0], out TestClientMode mode))
{
return TestClientParseResult.Failure("The first argument must be host, browse, or join.");
}
Uri serviceUri = new("http://127.0.0.1:5000/");
IPEndPoint mediator = new(IPAddress.Loopback, 9050);
string game = "space-game";
string environment = "development";
string region = "local";
uint protocol = 1;
string buildVersion = "test-client";
string displayName = "Rendezvous diagnostic host";
string credentialEnvironmentVariable = "RENDEZVOUS_PUBLISHER_CREDENTIAL";
Dictionary<string, string> metadata = new(StringComparer.Ordinal);
NetworkEndpoint? dedicatedFallback = null;
SessionListingId? listingId = null;
int localPort = 0;
int pageSize = 20;
int timeoutSeconds = 20;
int? runSeconds = null;
bool script = false;
bool json = false;
bool exitAfterEcho = false;
HashSet<string> seen = new(StringComparer.Ordinal);
for (int index = 1; index < args.Length; index++)
{
string option = args[index];
if (IsHelp(option))
{
return TestClientParseResult.Help();
}
if (option is "--script" or "--json" or "--exit-after-echo")
{
if (!seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
script |= option == "--script";
json |= option == "--json";
exitAfterEcho |= option == "--exit-after-echo";
continue;
}
if (!option.StartsWith("--", StringComparison.Ordinal)
|| index + 1 >= args.Length)
{
return TestClientParseResult.Failure("Every option must use the form --name value.");
}
string value = args[++index];
if (value.Length is 0 or > 512)
{
return TestClientParseResult.Failure($"Option {option} has an invalid value length.");
}
if (option != "--metadata" && !seen.Add(option))
{
return TestClientParseResult.Failure($"Option {option} was specified more than once.");
}
switch (option)
{
case "--service":
if (!TryServiceUri(value, out serviceUri))
{
return TestClientParseResult.Failure("The service URL must be absolute HTTP(S), credential-free, and query-free.");
}
break;
case "--mediator":
if (!IPEndPoint.TryParse(value, out IPEndPoint? parsedMediator)
|| parsedMediator.Port == 0)
{
return TestClientParseResult.Failure("The mediator must be an IP endpoint with a non-zero port.");
}
mediator = parsedMediator;
break;
case "--game":
game = value;
break;
case "--environment":
environment = value;
break;
case "--region":
region = value;
break;
case "--protocol":
if (!uint.TryParse(value, out protocol) || protocol == 0)
{
return TestClientParseResult.Failure("The protocol must be a positive integer.");
}
break;
case "--build-version":
buildVersion = value;
break;
case "--display-name":
displayName = value;
break;
case "--publisher-credential-env":
if (!IsEnvironmentVariableName(value))
{
return TestClientParseResult.Failure("The credential environment-variable name is invalid.");
}
credentialEnvironmentVariable = value;
break;
case "--metadata":
if (!TryMetadata(value, metadata))
{
return TestClientParseResult.Failure("Metadata must be a unique KEY=VALUE pair with a non-empty key.");
}
break;
case "--fallback":
if (!IPEndPoint.TryParse(value, out IPEndPoint? fallbackEndpoint)
|| fallbackEndpoint.Port == 0)
{
return TestClientParseResult.Failure("The fallback must be an IP endpoint with a non-zero port.");
}
dedicatedFallback = new NetworkEndpoint
{
AddressFamily = fallbackEndpoint.AddressFamily == AddressFamily.InterNetwork
? AddressFamilyKind.Ipv4
: AddressFamilyKind.Ipv6,
Address = fallbackEndpoint.Address.ToString(),
Port = fallbackEndpoint.Port,
};
break;
case "--listing":
if (!Guid.TryParse(value, out Guid parsedListing) || parsedListing == Guid.Empty)
{
return TestClientParseResult.Failure("The listing must be a non-empty UUID.");
}
listingId = new SessionListingId(parsedListing);
break;
case "--port":
if (!int.TryParse(value, out localPort) || localPort is < 0 or > 65_535)
{
return TestClientParseResult.Failure("The local UDP port must be between 0 and 65535.");
}
break;
case "--page-size":
if (!int.TryParse(value, out pageSize)
|| pageSize is < 1 or > ContractLimits.BrowserPageMaxItems)
{
return TestClientParseResult.Failure("The page size is outside the contract limit.");
}
break;
case "--timeout-seconds":
if (!int.TryParse(value, out timeoutSeconds) || timeoutSeconds is < 1 or > 300)
{
return TestClientParseResult.Failure("The timeout must be between 1 and 300 seconds.");
}
break;
case "--run-seconds":
if (!int.TryParse(value, out int parsedRunSeconds)
|| parsedRunSeconds is < 1 or > 86_400)
{
return TestClientParseResult.Failure("The host run duration must be between 1 and 86400 seconds.");
}
runSeconds = parsedRunSeconds;
break;
default:
return TestClientParseResult.Failure($"Unknown option {option}.");
}
}
if (!IsSlug(game, ContractLimits.GameIdMaxCharacters)
|| !IsSlug(environment, ContractLimits.EnvironmentIdMaxCharacters)
|| !IsSlug(region, ContractLimits.RegionIdMaxCharacters)
|| !ContractValidation.IsBuildVersionValid(buildVersion)
|| !ContractValidation.IsDisplayNameValid(displayName)
|| !ContractValidation.IsMetadataValid(metadata))
{
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
|| exitAfterEcho && mode != TestClientMode.Host
|| metadata.Count > 0 && mode != TestClientMode.Host
|| dedicatedFallback is not null && mode != TestClientMode.Host
|| seen.Contains("--publisher-credential-env") && mode != TestClientMode.Host
|| seen.Contains("--display-name") && mode != TestClientMode.Host
|| seen.Contains("--build-version") && mode != TestClientMode.Host)
{
return TestClientParseResult.Failure("One or more options do not apply to the selected mode.");
}
return TestClientParseResult.Success(new TestClientOptions
{
Mode = mode,
ServiceUri = serviceUri,
Mediator = mediator,
GameId = new(game),
EnvironmentId = new(environment),
RegionId = new(region),
ProtocolVersion = protocol,
BuildVersion = buildVersion,
DisplayName = displayName,
PublisherCredentialEnvironmentVariable = credentialEnvironmentVariable,
Metadata = metadata,
DedicatedFallback = dedicatedFallback,
ListingId = listingId,
LocalPort = localPort,
PageSize = pageSize,
OperationTimeout = TimeSpan.FromSeconds(timeoutSeconds),
RunDuration = runSeconds.HasValue
? TimeSpan.FromSeconds(runSeconds.Value)
: mode == TestClientMode.Host && script
? TimeSpan.FromSeconds(timeoutSeconds)
: null,
Script = script,
Json = json,
ExitAfterEcho = exitAfterEcho,
});
}
private static bool TryMode(string value, out TestClientMode mode) =>
Enum.TryParse(value, true, out mode) && Enum.IsDefined(mode);
private static bool IsHelp(string value) => value is "--help" or "-h" or "help";
private static bool TryServiceUri(string value, out Uri uri)
{
uri = null!;
if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)
|| parsed.Scheme is not ("http" or "https")
|| !string.IsNullOrEmpty(parsed.UserInfo)
|| !string.IsNullOrEmpty(parsed.Query)
|| !string.IsNullOrEmpty(parsed.Fragment))
{
return false;
}
UriBuilder builder = new(parsed) { Path = parsed.AbsolutePath.TrimEnd('/') + "/" };
uri = builder.Uri;
return true;
}
private static bool IsEnvironmentVariableName(string value)
{
if (value.Length is 0 or > 64 || !(char.IsLetter(value[0]) || value[0] == '_'))
{
return false;
}
return value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character == '_');
}
private static bool TryMetadata(string value, Dictionary<string, string> metadata)
{
int separator = value.IndexOf('=');
if (separator is < 1 or > ContractLimits.MetadataKeyMaxBytes
|| metadata.Count >= ContractLimits.MetadataMaxKeys)
{
return false;
}
string key = value[..separator];
string metadataValue = value[(separator + 1)..];
return !string.IsNullOrWhiteSpace(key)
&& ContractValidation.IsUtf8LengthWithin(key, ContractLimits.MetadataKeyMaxBytes)
&& ContractValidation.IsUtf8LengthWithin(metadataValue, ContractLimits.MetadataValueMaxBytes)
&& metadata.TryAdd(key, metadataValue);
}
private static bool IsSlug(string value, int maximumCharacters) =>
value.Length is > 0
&& value.Length <= maximumCharacters
&& value[0] is >= 'a' and <= 'z'
&& value.All(static character => character is >= 'a' and <= 'z'
or >= '0' and <= '9'
or '-');
}
@@ -0,0 +1,181 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace FinalFactory.Rendezvous.TestClient;
internal sealed class TestClientOutput(TextWriter standardOutput, TextWriter standardError, bool json)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
};
private readonly object _gate = new();
private readonly TextWriter _standardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput));
private readonly TextWriter _standardError = standardError ?? throw new ArgumentNullException(nameof(standardError));
private readonly bool _json = json;
internal void Write(
string eventName,
string status,
string? phase = null,
string? listingId = null,
string? displayName = null,
string? outcome = null,
string? endpointType = null,
int? count = null,
long? elapsedMilliseconds = null,
string? message = null) => WriteCore(
_standardOutput,
new TestClientEvent
{
Event = SafeToken(eventName) ?? string.Empty,
Status = SafeToken(status) ?? string.Empty,
Phase = SafeToken(phase),
ListingId = SafeToken(listingId),
DisplayName = SafeText(displayName),
Outcome = SafeToken(outcome),
EndpointType = SafeToken(endpointType),
Count = count,
ElapsedMilliseconds = elapsedMilliseconds,
Message = SafeText(message),
});
internal void WriteError(
string eventName,
string status,
string message,
string? phase = null,
string? outcome = null) => WriteCore(
_standardError,
new TestClientEvent
{
Event = SafeToken(eventName) ?? string.Empty,
Status = SafeToken(status) ?? string.Empty,
Phase = SafeToken(phase),
Outcome = SafeToken(outcome),
Message = SafeText(message),
});
internal void WritePrompt(string prompt)
{
if (_json)
{
return;
}
lock (_gate)
{
_standardOutput.Write(SafeText(prompt));
_standardOutput.Flush();
}
}
private void WriteCore(TextWriter writer, TestClientEvent item)
{
string line = _json
? JsonSerializer.Serialize(item, JsonOptions)
: HumanLine(item);
lock (_gate)
{
writer.WriteLine(line);
writer.Flush();
}
}
private static string HumanLine(TestClientEvent item)
{
StringBuilder line = new();
line.Append('[').Append(item.Status).Append("] ").Append(item.Event);
Append(line, "phase", item.Phase);
Append(line, "listing", item.ListingId);
Append(line, "name", item.DisplayName, quote: true);
Append(line, "outcome", item.Outcome);
Append(line, "endpoint", item.EndpointType);
if (item.Count.HasValue)
{
Append(line, "count", item.Count.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
if (item.ElapsedMilliseconds.HasValue)
{
Append(
line,
"elapsedMs",
item.ElapsedMilliseconds.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
Append(line, "message", item.Message, quote: true);
return line.ToString();
}
private static void Append(
StringBuilder builder,
string name,
string? value,
bool quote = false)
{
if (!string.IsNullOrEmpty(value))
{
builder.Append(' ').Append(name).Append('=');
if (quote)
{
builder.Append('"').Append(value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
}
else
{
builder.Append(value);
}
}
}
private static string? SafeToken(string? value)
{
if (value is null)
{
return null;
}
return new string(value
.Take(96)
.Select(static character => char.IsAsciiLetterOrDigit(character)
|| character is '.' or '-' or '_' or ':'
? char.ToLowerInvariant(character)
: '_')
.ToArray());
}
private static string? SafeText(string? value)
{
if (value is null)
{
return null;
}
return new string(value
.Take(160)
.Select(static character => IsUnsafeHumanCharacter(character) ? '?' : character)
.ToArray());
}
private static bool IsUnsafeHumanCharacter(char character) =>
char.GetUnicodeCategory(character) is
UnicodeCategory.Control
or UnicodeCategory.Format
or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator
or UnicodeCategory.Surrogate
or UnicodeCategory.PrivateUse;
private sealed class TestClientEvent
{
public int Version { get; init; } = 1;
public string Event { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? Phase { get; init; }
public string? ListingId { get; init; }
public string? DisplayName { get; init; }
public string? Outcome { get; init; }
public string? EndpointType { get; init; }
public int? Count { get; init; }
public long? ElapsedMilliseconds { get; init; }
public string? Message { get; init; }
}
}
@@ -161,10 +161,17 @@ public sealed class RendezvousJoinClientTests
RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync( RendezvousConnectionStartResult result = await client.CreateConnectionAttemptAsync(
CreateRequest("cancelled-before-send"), CreateRequest("cancelled-before-send"),
new NetworkEndpoint
{
AddressFamily = AddressFamilyKind.Ipv4,
Address = "203.0.113.90",
Port = 7777,
},
cancellationToken: cancellation.Token); cancellationToken: cancellation.Token);
Assert.Empty(handler.Requests); Assert.Empty(handler.Requests);
Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind); Assert.Equal(ConnectionOutcomeKind.Cancelled, result.Outcome!.Kind);
Assert.True(result.Outcome.HasDedicatedFallback);
Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source); Assert.Equal(RendezvousConnectionOutcomeSource.Caller, result.Outcome.Source);
} }
@@ -0,0 +1,135 @@
using System.Net;
using System.Text;
using FinalFactory.Rendezvous.TestClient;
using LiteNetLib;
namespace FinalFactory.Rendezvous.Tests.TestClient;
public sealed class DirectEchoProtocolTests
{
[Fact]
public async Task HostReleasesPendingNonceWhenPeerDisconnectsBeforeAcknowledgement()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
EventBasedNetListener hostEvents = new();
EventBasedNetListener clientEvents = new();
hostEvents.ConnectionRequestEvent += request => request.Accept();
NetPeer? clientPeer = null;
clientEvents.PeerConnectedEvent += peer => clientPeer = peer;
NetManager hostManager = new(hostEvents);
NetManager clientManager = new(clientEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(clientManager.Start(0));
clientManager.Connect(
new IPEndPoint(IPAddress.Loopback, hostManager.LocalPort),
"echo-test");
await PumpUntilAsync(
() => clientPeer is not null,
hostManager,
clientManager,
clientManager,
timeout.Token);
using DirectEchoProtocol host = new(hostEvents, host: true);
clientPeer!.Send(
Encoding.ASCII.GetBytes("rv1-ping:00112233445566778899aabbccddeeff"),
DeliveryMethod.ReliableOrdered);
await PumpUntilAsync(
() => host.PendingHostExchangeCount == 1,
hostManager,
clientManager,
clientManager,
timeout.Token);
clientPeer.Disconnect();
await PumpUntilAsync(
() => host.PendingHostExchangeCount == 0,
hostManager,
clientManager,
clientManager,
timeout.Token);
Assert.Equal(0, host.PendingHostExchangeCount);
}
finally
{
clientManager.Stop();
hostManager.Stop();
}
}
[Fact]
public async Task HostVerifiesOverlappingPeersAgainstTheirOwnNonces()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(3));
EventBasedNetListener hostEvents = new();
EventBasedNetListener firstEvents = new();
EventBasedNetListener secondEvents = new();
hostEvents.ConnectionRequestEvent += request => request.Accept();
NetPeer? firstPeer = null;
NetPeer? secondPeer = null;
firstEvents.PeerConnectedEvent += peer => firstPeer = peer;
secondEvents.PeerConnectedEvent += peer => secondPeer = peer;
NetManager hostManager = new(hostEvents);
NetManager firstManager = new(firstEvents);
NetManager secondManager = new(secondEvents);
try
{
Assert.True(hostManager.Start(0));
Assert.True(firstManager.Start(0));
Assert.True(secondManager.Start(0));
IPEndPoint hostEndpoint = new(IPAddress.Loopback, hostManager.LocalPort);
firstManager.Connect(hostEndpoint, "echo-test");
secondManager.Connect(hostEndpoint, "echo-test");
await PumpUntilAsync(
() => firstPeer is not null && secondPeer is not null,
hostManager,
firstManager,
secondManager,
timeout.Token);
using DirectEchoProtocol host = new(hostEvents, host: true);
using DirectEchoProtocol first = new(firstEvents, host: false);
using DirectEchoProtocol second = new(secondEvents, host: false);
int hostCompletions = 0;
host.ExchangeCompleted += _ => hostCompletions++;
first.BeginJoin(firstPeer!);
second.BeginJoin(secondPeer!);
await PumpUntilAsync(
() => first.Completion.IsCompleted
&& second.Completion.IsCompleted
&& hostCompletions == 2,
hostManager,
firstManager,
secondManager,
timeout.Token);
Assert.Equal(2, hostCompletions);
}
finally
{
firstManager.Stop();
secondManager.Stop();
hostManager.Stop();
}
}
private static async Task PumpUntilAsync(
Func<bool> predicate,
NetManager host,
NetManager first,
NetManager second,
CancellationToken cancellationToken)
{
while (!predicate())
{
cancellationToken.ThrowIfCancellationRequested();
host.PollEvents();
first.PollEvents();
second.PollEvents();
await Task.Delay(2, cancellationToken);
}
}
}
@@ -0,0 +1,217 @@
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.TestClient;
namespace FinalFactory.Rendezvous.Tests.TestClient;
public sealed class TestClientCommandTests
{
[Fact]
public void HostOptionsParseBoundedPublicConfigurationWithoutAcceptingASecretArgument()
{
TestClientParseResult parsed = TestClientOptionParser.Parse(
[
"host",
"--service", "https://rendezvous.example/base",
"--mediator", "127.0.0.1:9050",
"--game", "space-game",
"--environment", "production",
"--region", "eu-central",
"--protocol", "7",
"--metadata", "mode=online-coop",
"--fallback", "203.0.113.50:7777",
"--publisher-credential-env", "TEST_PUBLISHER_CREDENTIAL",
"--script",
"--json",
"--exit-after-echo",
]);
Assert.True(parsed.Succeeded, parsed.Error);
TestClientOptions options = Assert.IsType<TestClientOptions>(parsed.Options);
Assert.Equal(TestClientMode.Host, options.Mode);
Assert.Equal(new Uri("https://rendezvous.example/base/"), options.ServiceUri);
Assert.Equal(7u, options.ProtocolVersion);
Assert.Equal("online-coop", options.Metadata["mode"]);
Assert.Equal("203.0.113.50", options.DedicatedFallback?.Address);
Assert.Equal(7777, options.DedicatedFallback?.Port);
Assert.Equal("TEST_PUBLISHER_CREDENTIAL", options.PublisherCredentialEnvironmentVariable);
Assert.True(options.Script);
Assert.True(options.Json);
Assert.True(options.ExitAfterEcho);
Assert.Equal(TimeSpan.FromSeconds(20), options.RunDuration);
TestClientParseResult secret = TestClientOptionParser.Parse(
["host", "--publisher-credential", "secret-canary"]);
Assert.False(secret.Succeeded);
Assert.Contains("Unknown option", secret.Error, StringComparison.Ordinal);
}
[Fact]
public void ScriptExitCodesRemainStable()
{
Assert.Equal(0, (int)TestClientExitCode.Success);
Assert.Equal(2, (int)TestClientExitCode.Usage);
Assert.Equal(3, (int)TestClientExitCode.Configuration);
Assert.Equal(10, (int)TestClientExitCode.ServiceFailure);
Assert.Equal(11, (int)TestClientExitCode.NoCompatibleSession);
Assert.Equal(12, (int)TestClientExitCode.TraversalFailed);
Assert.Equal(13, (int)TestClientExitCode.DirectTrafficFailed);
Assert.Equal(130, (int)TestClientExitCode.Cancelled);
}
[Fact]
public void HostFailureBudgetStopsAuthorityLossAndBoundsTransientRetries()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
HostServiceFailureBudget authority = new();
Assert.True(authority.ShouldStop(
RendezvousErrorCode.NotFound,
now.AddMinutes(1),
now));
HostServiceFailureBudget transient = new();
Assert.False(transient.ShouldStop(
RendezvousErrorCode.ServiceUnavailable,
now.AddMinutes(1),
now));
Assert.False(transient.ShouldStop(
RendezvousErrorCode.RateLimited,
now.AddMinutes(1),
now));
Assert.True(transient.ShouldStop(
RendezvousErrorCode.InternalError,
now.AddMinutes(1),
now));
transient.Reset();
Assert.True(transient.ShouldStop(
RendezvousErrorCode.ServiceUnavailable,
now,
now));
}
[Theory]
[InlineData("https://user:password@rendezvous.example/")]
[InlineData("file:///tmp/rendezvous")]
[InlineData("https://rendezvous.example/?token=secret")]
public void ServiceUrlRejectsCredentialAndNonHttpShapes(string url)
{
TestClientParseResult parsed = TestClientOptionParser.Parse(["browse", "--service", url]);
Assert.False(parsed.Succeeded);
Assert.Contains("service URL", parsed.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ApplicationRoutesParsedOptionsThroughTheInjectableUiFlow()
{
FakeCommandRunner runner = new(TestClientExitCode.NoCompatibleSession);
TestClientApplication application = new(runner);
StringWriter output = new();
StringWriter error = new();
int exitCode = await application.RunAsync(
["browse", "--script", "--json"],
new StringReader(string.Empty),
output,
error,
CancellationToken.None);
Assert.Equal((int)TestClientExitCode.NoCompatibleSession, exitCode);
Assert.NotNull(runner.Options);
Assert.Equal(TestClientMode.Browse, runner.Options.Mode);
Assert.True(runner.Options.Script);
using JsonDocument item = JsonDocument.Parse(output.ToString());
Assert.Equal(1, item.RootElement.GetProperty("version").GetInt32());
Assert.Equal("fake.completed", item.RootElement.GetProperty("event").GetString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task InvalidArgumentsFailBeforeTheRunnerAndDoNotEchoTheValue()
{
FakeCommandRunner runner = new(TestClientExitCode.Success);
TestClientApplication application = new(runner);
StringWriter output = new();
StringWriter error = new();
int exitCode = await application.RunAsync(
["host", "--publisher-credential", "secret-canary"],
new StringReader(string.Empty),
output,
error,
CancellationToken.None);
Assert.Equal((int)TestClientExitCode.Usage, exitCode);
Assert.Null(runner.Options);
Assert.DoesNotContain("secret-canary", error.ToString(), StringComparison.Ordinal);
}
[Theory]
[InlineData("host", "--json", "--unknown", "value", "cli.usage", 2)]
[InlineData("host", "--json", "--help", "", "cli.help", 0)]
public async Task JsonModeKeepsHelpAndUsageFailuresMachineReadable(
string mode,
string json,
string option,
string value,
string expectedEvent,
int expectedExit)
{
FakeCommandRunner runner = new(TestClientExitCode.Success);
TestClientApplication application = new(runner);
StringWriter output = new();
StringWriter error = new();
string[] args = string.IsNullOrEmpty(value)
? [mode, json, option]
: [mode, json, option, value];
int exitCode = await application.RunAsync(
args,
new StringReader(string.Empty),
output,
error,
CancellationToken.None);
Assert.Equal(expectedExit, exitCode);
string jsonLine = expectedExit == 0 ? output.ToString() : error.ToString();
using JsonDocument item = JsonDocument.Parse(jsonLine);
Assert.Equal(expectedEvent, item.RootElement.GetProperty("event").GetString());
}
[Fact]
public void HumanOutputNeutralizesControlCharactersFromPublicListingText()
{
StringWriter output = new();
TestClientOutput sink = new(output, new StringWriter(), json: false);
sink.Write(
"browse.session",
"available",
displayName: "host\nforged-line\u001b[31m outcome=connected\u2028next\u2029line\u202eright");
string line = output.ToString();
Assert.Equal(1, line.Count(static character => character == '\n'));
Assert.DoesNotContain('\u001b', line);
Assert.DoesNotContain('\u2028', line);
Assert.DoesNotContain('\u2029', line);
Assert.DoesNotContain('\u202e', line);
Assert.Contains("name=\"host?forged-line?[31m outcome=connected?next?line?right\"", line, StringComparison.Ordinal);
}
private sealed class FakeCommandRunner(TestClientExitCode exitCode) : ITestClientCommandRunner
{
internal TestClientOptions? Options { get; private set; }
public Task<TestClientExitCode> RunAsync(
TestClientOptions options,
TestClientOutput output,
TextReader input,
CancellationToken cancellationToken)
{
Options = options;
output.Write("fake.completed", "complete", phase: "test");
return Task.FromResult(exitCode);
}
}
}
@@ -0,0 +1,587 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using FinalFactory.Rendezvous.Contracts;
using FinalFactory.Rendezvous.Server.Provisioning;
using FinalFactory.Rendezvous.TestClient;
namespace FinalFactory.Rendezvous.Tests.TestClient;
public sealed class TestClientProcessIntegrationTests
{
#if DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
[Fact]
public async Task ServerHostAndJoinProcessesExchangeAuthenticatedDirectTrafficWithoutLeakingSecrets()
{
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(30));
string root = RepositoryRoot();
string serverAssembly = Path.Combine(
root,
$"src/FinalFactory.Rendezvous.Server/bin/{BuildConfiguration}/net10.0/FinalFactory.Rendezvous.Server.dll");
string clientAssembly = Path.Combine(
root,
$"src/FinalFactory.Rendezvous.TestClient/bin/{BuildConfiguration}/net8.0/FinalFactory.Rendezvous.TestClient.dll");
Assert.True(File.Exists(serverAssembly), $"Missing server build output: {serverAssembly}");
Assert.True(File.Exists(clientAssembly), $"Missing TestClient build output: {clientAssembly}");
DateTimeOffset now = DateTimeOffset.UtcNow;
byte[] signingKey = RandomNumberGenerator.GetBytes(32);
string signingKeyText = Convert.ToBase64String(signingKey);
string publisherCredential = IssuePublisherCredential(signingKey, now);
CryptographicOperations.ZeroMemory(signingKey);
Dictionary<string, string> serverEnvironment = ServerEnvironment(
signingKeyText,
now);
await using ProcessCapture server = Start(
serverAssembly,
[],
serverEnvironment);
string httpLine = await server.WaitForLineAsync("Now listening on: http://127.0.0.1:", timeout.Token);
string serviceUrl = ParseServiceUrl(httpLine);
string udpLine = await server.WaitForLineAsync("UDP mediator listening on 127.0.0.1:", timeout.Token);
int udpPort = ParseTrailingPort(udpLine);
await WaitForReadyAsync(serviceUrl, server, timeout.Token);
string[] emptyBrowseArguments =
[
"browse",
"--service", serviceUrl,
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--script",
"--json",
"--timeout-seconds", "15",
];
await using ProcessCapture emptyBrowse = Start(
clientAssembly,
emptyBrowseArguments,
new Dictionary<string, string>(StringComparer.Ordinal));
Assert.Equal(
(int)TestClientExitCode.NoCompatibleSession,
await emptyBrowse.WaitForExitAsync(timeout.Token));
Assert.True(emptyBrowse.HasEvent("browse.completed", "complete"), emptyBrowse.DiagnosticText());
Assert.Contains(
emptyBrowse.JsonEvents(),
item => item.GetProperty("event").GetString() == "browse.completed"
&& item.GetProperty("count").GetInt32() == 0);
string[] missingJoinArguments =
[
"join",
"--service", serviceUrl,
"--mediator", $"127.0.0.1:{udpPort}",
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--listing", Guid.NewGuid().ToString("D"),
"--script",
"--json",
"--timeout-seconds", "15",
];
await using ProcessCapture missingJoin = Start(
clientAssembly,
missingJoinArguments,
new Dictionary<string, string>(StringComparer.Ordinal));
Assert.Equal((int)TestClientExitCode.ServiceFailure, await missingJoin.WaitForExitAsync(timeout.Token));
Assert.True(missingJoin.HasEvent("join.selection", "failed"), missingJoin.DiagnosticText());
string[] boundedHostArguments =
[
"host",
"--service", serviceUrl,
"--mediator", $"127.0.0.1:{udpPort}",
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--script",
"--json",
"--exit-after-echo",
"--run-seconds", "1",
"--timeout-seconds", "15",
];
await using ProcessCapture boundedHost = Start(
clientAssembly,
boundedHostArguments,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
});
Assert.Equal((int)TestClientExitCode.DirectTrafficFailed, await boundedHost.WaitForExitAsync(timeout.Token));
Assert.True(boundedHost.HasEvent("host.direct-traffic", "timed-out"), boundedHost.DiagnosticText());
Assert.True(boundedHost.HasEvent("host.deregistered", "complete"), boundedHost.DiagnosticText());
string[] hostArguments =
[
"host",
"--service", serviceUrl,
"--mediator", $"127.0.0.1:{udpPort}",
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--script",
"--json",
"--exit-after-echo",
"--timeout-seconds", "15",
];
await using ProcessCapture host = Start(
clientAssembly,
hostArguments,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["RENDEZVOUS_PUBLISHER_CREDENTIAL"] = publisherCredential,
});
JsonElement hostRegistered = await host.WaitForEventAsync(
"host.ready",
timeout.Token);
string listingId = Assert.IsType<string>(hostRegistered.GetProperty("listingId").GetString());
string[] browseArguments =
[
"browse",
"--service", serviceUrl,
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--script",
"--json",
"--timeout-seconds", "15",
];
await using ProcessCapture browse = Start(
clientAssembly,
browseArguments,
new Dictionary<string, string>(StringComparer.Ordinal));
Assert.Equal(0, await browse.WaitForExitAsync(timeout.Token));
Assert.True(browse.HasEvent("browse.completed", "complete"), browse.DiagnosticText());
Assert.True(
browse.JsonEvents().Any(item =>
item.GetProperty("event").GetString() == "browse.session"
&& item.GetProperty("status").GetString() == "available"
&& item.GetProperty("listingId").GetString() == listingId),
browse.DiagnosticText());
string[] joinArguments =
[
"join",
"--service", serviceUrl,
"--mediator", $"127.0.0.1:{udpPort}",
"--game", "space-game",
"--environment", "integration",
"--region", "local",
"--protocol", "1",
"--listing", listingId,
"--script",
"--json",
"--timeout-seconds", "15",
];
await using ProcessCapture join = Start(
clientAssembly,
joinArguments,
new Dictionary<string, string>(StringComparer.Ordinal));
Assert.Equal(0, await join.WaitForExitAsync(timeout.Token));
Assert.Equal(0, await host.WaitForExitAsync(timeout.Token));
Assert.True(join.HasEvent("join.connected", "connected"), join.DiagnosticText());
Assert.True(join.HasEvent("join.punch", "started"), join.DiagnosticText());
Assert.True(join.HasEvent("join.direct-connect", "started"), join.DiagnosticText());
Assert.True(join.HasEvent("join.direct-traffic", "verified"), join.DiagnosticText());
Assert.True(join.HasEvent("join.outcome-report", "accepted"), join.DiagnosticText());
Assert.True(host.HasEvent("host.direct-traffic", "verified"), host.DiagnosticText());
Assert.True(host.HasEvent("host.punch", "started"), host.DiagnosticText());
Assert.True(host.HasEvent("host.direct-connect", "connected"), host.DiagnosticText());
Assert.True(host.HasEvent("host.deregistered", "complete"), host.DiagnosticText());
Assert.Equal(host.AllLines().Count(), host.JsonEvents().Count);
Assert.Equal(emptyBrowse.AllLines().Count(), emptyBrowse.JsonEvents().Count);
Assert.Equal(missingJoin.AllLines().Count(), missingJoin.JsonEvents().Count);
Assert.Equal(browse.AllLines().Count(), browse.JsonEvents().Count);
Assert.Equal(join.AllLines().Count(), join.JsonEvents().Count);
Assert.All(
host.JsonEvents()
.Concat(emptyBrowse.JsonEvents())
.Concat(missingJoin.JsonEvents())
.Concat(browse.JsonEvents())
.Concat(join.JsonEvents()),
AssertAllowlistedEventShape);
using HttpClient service = new() { BaseAddress = new Uri(serviceUrl) };
using HttpResponseMessage removed = await service.GetAsync(
$"v1/sessions/{listingId}?contractVersion=1&gameId=space-game&environmentId=integration&protocolVersion=1",
timeout.Token);
Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode);
string captured = string.Join(
'\n',
server.AllLines()
.Concat(boundedHost.AllLines())
.Concat(emptyBrowse.AllLines())
.Concat(missingJoin.AllLines())
.Concat(host.AllLines())
.Concat(browse.AllLines())
.Concat(join.AllLines()));
Assert.False(
captured.Contains(publisherCredential, StringComparison.Ordinal),
"Captured process output contained the publisher credential.");
Assert.False(
captured.Contains(signingKeyText, StringComparison.Ordinal),
"Captured process output contained signing-key material.");
}
private static string IssuePublisherCredential(byte[] signingKey, DateTimeOffset now)
{
const string secretReference = "env:RENDEZVOUS_INTEGRATION_SIGNING_KEY";
ProvisioningOptions options = ProvisioningOptions(now, secretReference);
using DictionarySecretProvider secrets = new(new Dictionary<string, byte[]>(StringComparer.Ordinal)
{
[secretReference] = signingKey,
});
using ProvisioningRuntime provisioning = ProvisioningRuntime.Create(options, secrets, now);
return provisioning.Credentials.Issue(
new DedicatedPublisherPrincipal(
"test-client-process-host",
now.AddMinutes(5),
new GameId("space-game"),
new EnvironmentId("integration"),
[new RegionId("local")]),
now);
}
private static ProvisioningOptions ProvisioningOptions(
DateTimeOffset now,
string secretReference) => new()
{
Issuer = "rendezvous-process-test",
Audience = "rendezvous-process-test-client",
ClockSkewSeconds = 5,
SigningKeys =
[
new SigningKeyOptions
{
KeyId = "process-test-key",
SecretReference = secretReference,
CredentialKinds = [PrincipalCredentialKind.DedicatedPublisher],
GameId = "space-game",
EnvironmentId = "integration",
NotBefore = now.AddMinutes(-1),
SignUntil = now.AddMinutes(10),
VerifyUntil = now.AddMinutes(20),
},
],
Games =
[
new GamePolicyOptions
{
GameId = "space-game",
EnvironmentId = "integration",
Enabled = true,
ProtocolVersions = [1],
Regions = ["local"],
VisibilityModes = [ListingVisibility.Public],
PublisherTrustModes = [PublisherTrustMode.ManagedDedicated],
MetadataMaxBytes = 256,
MetadataMaxKeys = 2,
MaxListingsPerPrincipal = 4,
MaxAnonymousListingsPerAddress = 1,
MaxActiveJoinAttempts = 16,
},
],
};
private static Dictionary<string, string> ServerEnvironment(
string signingKey,
DateTimeOffset now)
{
Dictionary<string, string> values = new(StringComparer.Ordinal)
{
["ASPNETCORE_ENVIRONMENT"] = "Production",
["ASPNETCORE_URLS"] = "http://127.0.0.1:0",
["Rendezvous__Udp__ListenAddress"] = "127.0.0.1",
["Rendezvous__Udp__Port"] = "0",
["Rendezvous__Udp__PollIntervalMilliseconds"] = "1",
["Rendezvous__Provisioning__Issuer"] = "rendezvous-process-test",
["Rendezvous__Provisioning__Audience"] = "rendezvous-process-test-client",
["Rendezvous__Provisioning__ClockSkewSeconds"] = "5",
["Rendezvous__Provisioning__SigningKeys__0__KeyId"] = "process-test-key",
["Rendezvous__Provisioning__SigningKeys__0__SecretReference"] =
"env:RENDEZVOUS_INTEGRATION_SIGNING_KEY",
["Rendezvous__Provisioning__SigningKeys__0__CredentialKinds__0"] = "DedicatedPublisher",
["Rendezvous__Provisioning__SigningKeys__0__GameId"] = "space-game",
["Rendezvous__Provisioning__SigningKeys__0__EnvironmentId"] = "integration",
["Rendezvous__Provisioning__SigningKeys__0__NotBefore"] = now.AddMinutes(-1).ToString("O"),
["Rendezvous__Provisioning__SigningKeys__0__SignUntil"] = now.AddMinutes(10).ToString("O"),
["Rendezvous__Provisioning__SigningKeys__0__VerifyUntil"] = now.AddMinutes(20).ToString("O"),
["Rendezvous__Provisioning__Games__0__GameId"] = "space-game",
["Rendezvous__Provisioning__Games__0__EnvironmentId"] = "integration",
["Rendezvous__Provisioning__Games__0__Enabled"] = "true",
["Rendezvous__Provisioning__Games__0__ProtocolVersions__0"] = "1",
["Rendezvous__Provisioning__Games__0__Regions__0"] = "local",
["Rendezvous__Provisioning__Games__0__VisibilityModes__0"] = "Public",
["Rendezvous__Provisioning__Games__0__PublisherTrustModes__0"] = "ManagedDedicated",
["Rendezvous__Provisioning__Games__0__MetadataMaxBytes"] = "256",
["Rendezvous__Provisioning__Games__0__MetadataMaxKeys"] = "2",
["Rendezvous__Provisioning__Games__0__MaxListingsPerPrincipal"] = "4",
["Rendezvous__Provisioning__Games__0__MaxAnonymousListingsPerAddress"] = "1",
["Rendezvous__Provisioning__Games__0__MaxActiveJoinAttempts"] = "16",
["RENDEZVOUS_INTEGRATION_SIGNING_KEY"] = signingKey,
};
return values;
}
private static ProcessCapture Start(
string assembly,
IReadOnlyList<string> arguments,
IReadOnlyDictionary<string, string> environment)
{
ProcessStartInfo start = new()
{
FileName = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
start.ArgumentList.Add(assembly);
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
foreach (KeyValuePair<string, string> item in environment)
{
start.Environment[item.Key] = item.Value;
}
return new ProcessCapture(start);
}
private static async Task WaitForReadyAsync(
string serviceUrl,
ProcessCapture server,
CancellationToken cancellationToken)
{
using HttpClient client = new() { BaseAddress = new Uri(serviceUrl) };
while (!cancellationToken.IsCancellationRequested)
{
if (server.HasExited)
{
throw new Xunit.Sdk.XunitException($"Server exited before readiness. {server.DiagnosticText()}");
}
try
{
using HttpResponseMessage response = await client.GetAsync(
"health/ready",
cancellationToken);
if (response.StatusCode == HttpStatusCode.OK)
{
return;
}
}
catch (HttpRequestException)
{
}
await Task.Delay(25, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
}
private static string ParseServiceUrl(string line)
{
const string marker = "Now listening on: ";
string value = line[(line.IndexOf(marker, StringComparison.Ordinal) + marker.Length)..].Trim();
return new Uri(value).AbsoluteUri;
}
private static int ParseTrailingPort(string line)
{
string value = line[(line.LastIndexOf(':') + 1)..].Trim();
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
}
private static string RepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "Rendezvous.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate the Rendezvous repository root.");
}
private static void AssertAllowlistedEventShape(JsonElement item)
{
string[] forbidden = ["credential", "capability", "ticket", "token", "secret", "metadata"];
foreach (JsonProperty property in item.EnumerateObject())
{
Assert.DoesNotContain(forbidden, forbiddenName =>
property.Name.Contains(forbiddenName, StringComparison.OrdinalIgnoreCase));
}
}
private sealed class ProcessCapture : IAsyncDisposable
{
private readonly Process _process;
private readonly ConcurrentQueue<string> _standardOutput = new();
private readonly ConcurrentQueue<string> _standardError = new();
internal ProcessCapture(ProcessStartInfo start)
{
_process = new Process { StartInfo = start };
_process.OutputDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardOutput);
_process.ErrorDataReceived += (_, eventArgs) => Add(eventArgs.Data, _standardError);
Assert.True(_process.Start(), $"Failed to start {start.FileName}.");
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
}
internal bool HasExited => _process.HasExited;
internal async Task<JsonElement> WaitForEventAsync(
string eventName,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
foreach (JsonElement item in JsonEvents())
{
if (item.TryGetProperty("event", out JsonElement eventProperty)
&& eventProperty.GetString() == eventName)
{
return item;
}
}
if (_process.HasExited)
{
throw new Xunit.Sdk.XunitException(
$"Process exited before event {eventName}. {DiagnosticText()}");
}
await Task.Delay(10, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
throw new UnreachableException();
}
internal async Task<string> WaitForLineAsync(
string marker,
CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
string? line = AllLines().FirstOrDefault(item =>
item.Contains(marker, StringComparison.Ordinal));
if (line is not null)
{
return line;
}
if (_process.HasExited)
{
throw new Xunit.Sdk.XunitException(
$"Process exited before output marker {marker}. {DiagnosticText()}");
}
await Task.Delay(10, cancellationToken);
}
cancellationToken.ThrowIfCancellationRequested();
throw new UnreachableException();
}
internal bool HasEvent(string eventName, string status) => JsonEvents().Any(item =>
item.TryGetProperty("event", out JsonElement eventProperty)
&& eventProperty.GetString() == eventName
&& item.TryGetProperty("status", out JsonElement statusProperty)
&& statusProperty.GetString() == status);
internal List<JsonElement> JsonEvents()
{
List<JsonElement> items = [];
foreach (string line in _standardOutput.Concat(_standardError))
{
try
{
using JsonDocument document = JsonDocument.Parse(line);
items.Add(document.RootElement.Clone());
}
catch (JsonException)
{
}
}
return items;
}
internal IEnumerable<string> AllLines() => _standardOutput.Concat(_standardError);
internal async Task<int> WaitForExitAsync(CancellationToken cancellationToken)
{
await _process.WaitForExitAsync(cancellationToken);
return _process.ExitCode;
}
internal string DiagnosticText()
{
string events = string.Join(
',',
JsonEvents().Select(static item =>
$"{item.GetProperty("event").GetString()}:{item.GetProperty("status").GetString()}"));
return $"stdoutLines={_standardOutput.Count}; stderrLines={_standardError.Count}; events=[{events}]";
}
public async ValueTask DisposeAsync()
{
bool cleanupTimedOut = false;
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
using CancellationTokenSource cleanup = new(TimeSpan.FromSeconds(5));
await _process.WaitForExitAsync(cleanup.Token);
}
catch (InvalidOperationException)
{
}
catch (OperationCanceledException)
{
cleanupTimedOut = !_process.HasExited;
if (cleanupTimedOut)
{
try
{
_process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
}
}
}
finally
{
_process.Dispose();
}
if (cleanupTimedOut)
{
throw new Xunit.Sdk.XunitException("Child process did not exit within the cleanup deadline.");
}
}
private static void Add(string? line, ConcurrentQueue<string> destination)
{
if (!string.IsNullOrEmpty(line))
{
destination.Enqueue(line);
}
}
}
}