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; } }