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