feat: add NatsServerProcess to cluster E2E infrastructure
Includes pre-assigned port constructor for kill/restart scenarios.
This commit is contained in:
248
tests/NATS.E2E.Cluster.Tests/Infrastructure/NatsServerProcess.cs
Normal file
248
tests/NATS.E2E.Cluster.Tests/Infrastructure/NatsServerProcess.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.E2E.Cluster.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a NATS.Server.Host child process for E2E testing.
|
||||
/// Launches the server on an ephemeral port and polls TCP readiness.
|
||||
/// </summary>
|
||||
public sealed class NatsServerProcess : IAsyncDisposable
|
||||
{
|
||||
private Process? _process;
|
||||
private readonly StringBuilder _output = new();
|
||||
private readonly object _outputLock = new();
|
||||
private readonly string[]? _extraArgs;
|
||||
private readonly string? _configContent;
|
||||
private readonly bool _enableMonitoring;
|
||||
private string? _configFilePath;
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public int? MonitorPort { get; }
|
||||
|
||||
public string Output
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_outputLock)
|
||||
return _output.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public NatsServerProcess(string[]? extraArgs = null, string? configContent = null, bool enableMonitoring = false)
|
||||
{
|
||||
Port = AllocateFreePort();
|
||||
_extraArgs = extraArgs;
|
||||
_configContent = configContent;
|
||||
_enableMonitoring = enableMonitoring;
|
||||
|
||||
if (_enableMonitoring)
|
||||
MonitorPort = AllocateFreePort();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructor with pre-assigned ports for kill/restart scenarios.
|
||||
/// </summary>
|
||||
public NatsServerProcess(int port, string[]? extraArgs = null, string? configContent = null, bool enableMonitoring = false, int? monitorPort = null)
|
||||
{
|
||||
Port = port;
|
||||
_extraArgs = extraArgs;
|
||||
_configContent = configContent;
|
||||
_enableMonitoring = enableMonitoring;
|
||||
MonitorPort = monitorPort ?? (enableMonitoring ? AllocateFreePort() : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience factory for creating a server with a config file.
|
||||
/// </summary>
|
||||
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false, string[]? extraArgs = null)
|
||||
=> new(extraArgs: extraArgs, configContent: configContent, enableMonitoring: enableMonitoring);
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
var hostDll = ResolveHostDll();
|
||||
|
||||
// Write config file if provided
|
||||
if (_configContent is not null)
|
||||
{
|
||||
_configFilePath = Path.Combine(Path.GetTempPath(), $"nats-e2e-{Guid.NewGuid():N}.conf");
|
||||
await File.WriteAllTextAsync(_configFilePath, _configContent);
|
||||
}
|
||||
|
||||
// Build argument string
|
||||
var args = new StringBuilder($"exec \"{hostDll}\" -p {Port}");
|
||||
if (_configFilePath is not null)
|
||||
args.Append($" -c \"{_configFilePath}\"");
|
||||
if (_enableMonitoring && MonitorPort.HasValue)
|
||||
args.Append($" -m {MonitorPort.Value}");
|
||||
if (_extraArgs is not null)
|
||||
{
|
||||
foreach (var arg in _extraArgs)
|
||||
args.Append($" {arg}");
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = args.ToString(),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
|
||||
_process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
lock (_outputLock) _output.AppendLine(e.Data);
|
||||
};
|
||||
_process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
lock (_outputLock) _output.AppendLine(e.Data);
|
||||
};
|
||||
|
||||
_process.Start();
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
|
||||
await WaitForTcpReadyAsync();
|
||||
|
||||
if (_enableMonitoring && MonitorPort.HasValue)
|
||||
await WaitForMonitorPortReadyAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_process is not null)
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
await _process.WaitForExitAsync(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!_process.HasExited)
|
||||
{
|
||||
// Kill timed out and process is still running — force-terminate and surface the error
|
||||
throw new InvalidOperationException(
|
||||
$"NATS server process did not exit within 5s after kill.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
|
||||
// Clean up temp config file
|
||||
if (_configFilePath is not null && File.Exists(_configFilePath))
|
||||
{
|
||||
File.Delete(_configFilePath);
|
||||
_configFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForTcpReadyAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
|
||||
SocketException? lastError = null;
|
||||
|
||||
while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, Port), timeout.Token);
|
||||
return; // Connected — server is ready
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
lastError = ex; // Server not yet accepting connections — retry on next tick
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"NATS server did not become ready on port {Port} within 10s. Last error: {lastError?.Message}\n\nServer output:\n{Output}");
|
||||
}
|
||||
|
||||
private async Task WaitForMonitorPortReadyAsync()
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
|
||||
SocketException? lastError = null;
|
||||
|
||||
while (await timer.WaitForNextTickAsync(timeout.Token).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(new IPEndPoint(IPAddress.Loopback, MonitorPort!.Value), timeout.Token);
|
||||
return; // Monitor HTTP port is accepting connections
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
lastError = ex; // Monitor not yet accepting connections — retry on next tick
|
||||
}
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"NATS monitor port {MonitorPort} did not become ready within 10s. Last error: {lastError?.Message}\n\nServer output:\n{Output}");
|
||||
}
|
||||
|
||||
private static string ResolveHostDll()
|
||||
{
|
||||
// Walk up from test output directory to find solution root (contains NatsDotNet.slnx)
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "NatsDotNet.slnx")))
|
||||
{
|
||||
var dll = Path.Combine(dir.FullName, "src", "NATS.Server.Host", "bin", "Debug", "net10.0", "NATS.Server.Host.dll");
|
||||
if (File.Exists(dll))
|
||||
return dll;
|
||||
|
||||
// DLL not found — build it
|
||||
var build = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = "build src/NATS.Server.Host/NATS.Server.Host.csproj -c Debug",
|
||||
WorkingDirectory = dir.FullName,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
});
|
||||
build!.WaitForExit();
|
||||
|
||||
if (build.ExitCode != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to build NATS.Server.Host:\n{build.StandardError.ReadToEnd()}");
|
||||
|
||||
if (File.Exists(dll))
|
||||
return dll;
|
||||
|
||||
throw new FileNotFoundException($"Built NATS.Server.Host but DLL not found at: {dll}");
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not find solution root (NatsDotNet.slnx) walking up from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
internal static int AllocateFreePort()
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)socket.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user