using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace NATS.E2E.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();
}
///
/// Convenience factory for creating a server with a config file.
///
public static NatsServerProcess WithConfig(string configContent, bool enableMonitoring = false)
=> new(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();
}
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)
{
// Already killed the tree above; nothing more to do
}
}
_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));
while (!timeout.Token.IsCancellationRequested)
{
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)
{
await Task.Delay(100, timeout.Token);
}
}
throw new TimeoutException(
$"NATS server did not become ready on port {Port} within 10s.\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;
}
}