diff --git a/tests/NATS.E2E.Cluster.Tests/Infrastructure/NatsServerProcess.cs b/tests/NATS.E2E.Cluster.Tests/Infrastructure/NatsServerProcess.cs new file mode 100644 index 0000000..a271798 --- /dev/null +++ b/tests/NATS.E2E.Cluster.Tests/Infrastructure/NatsServerProcess.cs @@ -0,0 +1,248 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace NATS.E2E.Cluster.Tests.Infrastructure; + +/// +/// Manages a NATS.Server.Host child process for E2E testing. +/// Launches the server on an ephemeral port and polls TCP readiness. +/// +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(); + } + + /// + /// Constructor with pre-assigned ports for kill/restart scenarios. + /// + 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); + } + + /// + /// Convenience factory for creating a server with a config file. + /// + 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; + } +}