using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Text; namespace NATS.Server.Benchmark.Tests.Infrastructure; /// /// Manages a Go nats-server child process for benchmark testing. /// Builds the Go binary if not present and skips gracefully if Go is not installed. /// public sealed class GoServerProcess : IAsyncDisposable { private Process? _process; private readonly StringBuilder _output = new(); private readonly object _outputLock = new(); private readonly string? _configContent; private string? _configFilePath; public int Port { get; } public string Output { get { lock (_outputLock) return _output.ToString(); } } public GoServerProcess(string? configContent = null) { Port = PortAllocator.AllocateFreePort(); _configContent = configContent; } /// /// Returns true if Go is installed and the Go NATS server source exists. /// public static bool IsAvailable() { try { var goVersion = Process.Start(new ProcessStartInfo { FileName = "go", Arguments = "version", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }); goVersion!.WaitForExit(); if (goVersion.ExitCode != 0) return false; var solutionRoot = FindSolutionRoot(); return solutionRoot is not null && Directory.Exists(Path.Combine(solutionRoot, "golang", "nats-server")); } catch { return false; } } public async Task StartAsync() { var binary = ResolveGoBinary(); if (_configContent is not null) { _configFilePath = Path.Combine(Path.GetTempPath(), $"nats-bench-go-{Guid.NewGuid():N}.conf"); await File.WriteAllTextAsync(_configFilePath, _configContent); } var args = new StringBuilder($"-p {Port}"); if (_configFilePath is not null) args.Append($" -c \"{_configFilePath}\""); var psi = new ProcessStartInfo { FileName = binary, 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(); } 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) { throw new InvalidOperationException( "Go nats-server process did not exit within 5s after kill.", ex); } } _process.Dispose(); _process = null; } if (_configFilePath is not null && File.Exists(_configFilePath)) { File.Delete(_configFilePath); _configFilePath = null; } } private async Task WaitForTcpReadyAsync() { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); 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; } catch (SocketException ex) { lastError = ex; } } throw new TimeoutException( $"Go nats-server did not become ready on port {Port} within 15s. Last error: {lastError?.Message}\n\nServer output:\n{Output}"); } private static string ResolveGoBinary() { var solutionRoot = FindSolutionRoot() ?? throw new FileNotFoundException( "Could not find solution root (NatsDotNet.slnx) walking up from " + AppContext.BaseDirectory); var goServerDir = Path.Combine(solutionRoot, "golang", "nats-server"); if (!Directory.Exists(goServerDir)) throw new DirectoryNotFoundException($"Go nats-server source not found at: {goServerDir}"); var binaryName = OperatingSystem.IsWindows() ? "nats-server.exe" : "nats-server"; var binaryPath = Path.Combine(goServerDir, binaryName); if (File.Exists(binaryPath)) return binaryPath; // Build the Go binary var build = Process.Start(new ProcessStartInfo { FileName = "go", Arguments = "build -o " + binaryName, WorkingDirectory = goServerDir, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }); build!.WaitForExit(); if (build.ExitCode != 0) throw new InvalidOperationException( $"Failed to build Go nats-server:\n{build.StandardError.ReadToEnd()}"); if (File.Exists(binaryPath)) return binaryPath; throw new FileNotFoundException($"Built Go nats-server but binary not found at: {binaryPath}"); } internal static string? FindSolutionRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { if (File.Exists(Path.Combine(dir.FullName, "NatsDotNet.slnx"))) return dir.FullName; dir = dir.Parent; } return null; } }