feat(tooling): add standalone rendezvous test client (#25)
quality-gate / quality (push) Failing after 1m3s
quality-gate / quality (push) Failing after 1m3s
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user