using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Reflection; using System.Text; using Xunit; namespace Mbproxy.Tests.Sim; /// /// xUnit v3 async fixture that manages the lifecycle of a pymodbus DL205 simulator /// process for end-to-end tests. /// /// /// Usage: declare [Collection(nameof(DL205SimulatorCollection))] on any test /// class that needs a live simulator. The fixture is shared across all tests in the /// collection (one process per test run). /// /// Skip policy: if Python or pymodbus is unavailable, /// is populated and tests should call /// Assert.Skip(fixture.SkipReason) rather than failing. /// public sealed class DL205SimulatorFixture : IAsyncLifetime { // ── Public surface ──────────────────────────────────────────────────────── /// Always "127.0.0.1". public string Host { get; } = "127.0.0.1"; /// The free port picked for this fixture instance. public int Port { get; private set; } /// /// Non-null when the simulator could not start (Python missing, venv provisioning /// failed, etc.). Tests should call Assert.Skip(fixture.SkipReason). /// public string? SkipReason { get; private set; } /// Last ~50 lines of the simulator's stderr, for diagnosis. public string LogTail => BuildLogTail(); // ── Private state ───────────────────────────────────────────────────────── private Process? _process; /// Ring buffer of captured stderr lines (capacity = 50). private readonly ConcurrentQueue _stderrLines = new(); private const int LogTailLines = 50; // ── IAsyncLifetime ──────────────────────────────────────────────────────── // Total time to wait for the simulator to accept a TCP connection. // On a warm run (venv exists) this is typically < 2 s. // On a cold run (first-ever provisioning) pip-installing pymodbus can take 30-90 s // depending on network speed, so we allow 120 s to cover both paths. // The spec's "up to 10 s" refers to warm-run server startup; cold-run provisioning // is additive and cannot be separated without a separate pre-provision step. private static readonly TimeSpan ReadinessTimeout = TimeSpan.FromSeconds(120); /// /// Picks a free port, spawns pwsh run-dl205-sim.ps1, and polls for TCP /// readiness for up to . /// public async ValueTask InitializeAsync() { // ── 1. Pick a free local port ───────────────────────────────────────── // TOCTOU note: we bind on :0, capture the OS-assigned port, then release // the listener. Between the release and pymodbus binding there is a window // where another process could grab the port. This race is rare in practice // and is an acceptable trade-off for the simplicity of a plain TcpListener // approach. A retry loop in step 3 provides resilience if the port is stolen. Port = PickFreePort(); // ── 2. Locate the launcher script ───────────────────────────────────── var scriptPath = ResolveScriptPath(); if (scriptPath is null) { SkipReason = "Could not locate tests/sim/run-dl205-sim.ps1 next to the test assembly."; return; } // ── 3. Verify pwsh (PowerShell 7+) is on PATH ───────────────────────── if (!PwshIsAvailable()) { SkipReason = "pwsh (PowerShell 7+) is not available on PATH; cannot launch the simulator."; return; } // ── 4. Spawn the simulator ──────────────────────────────────────────── var psi = new ProcessStartInfo { FileName = "pwsh", Arguments = $"-NoProfile -File \"{scriptPath}\" -Port {Port}", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; try { _process = Process.Start(psi) ?? throw new InvalidOperationException("Process.Start returned null."); } catch (Exception ex) { SkipReason = $"Failed to spawn pwsh: {ex.Message}"; return; } // Drain stdout and stderr asynchronously into the ring buffer so the // child process is never blocked on a full pipe buffer. _process.OutputDataReceived += (_, e) => AppendLine(e.Data); _process.ErrorDataReceived += (_, e) => AppendLine(e.Data); _process.BeginOutputReadLine(); _process.BeginErrorReadLine(); // ── 5. Poll for TCP readiness (up to ReadinessTimeout) ─────────────── // Link the readiness deadline against the test-runner's cancellation token so a // CI job timeout / keyboard interrupt aborts the poll promptly instead of running // the full 120 s and leaving the spawned Python process orphaned (review M3). using var deadline = new CancellationTokenSource(ReadinessTimeout); using var linked = CancellationTokenSource.CreateLinkedTokenSource( deadline.Token, TestContext.Current.CancellationToken); bool ready = false; while (!linked.Token.IsCancellationRequested) { // If the process exited early, no point waiting further. if (_process.HasExited) break; try { using var probe = new TcpClient(); await probe.ConnectAsync(Host, Port, linked.Token).ConfigureAwait(false); ready = true; break; } catch (OperationCanceledException) { break; } catch { // Not ready yet — wait 100 ms and retry. try { await Task.Delay(100, linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } if (!ready) { // Capture why before we kill the process. string tail = BuildLogTail(); await DisposeProcessAsync().ConfigureAwait(false); SkipReason = _process?.HasExited == true ? $"Simulator process exited prematurely (exit code {_process.ExitCode}). " + $"Likely cause: Python not found or pymodbus not installed. Log tail:\n{tail}" : $"Simulator did not accept a TCP connection on port {Port} within {ReadinessTimeout.TotalSeconds} s. " + $"Log tail:\n{tail}"; } } /// /// Kills the simulator process tree and waits up to 5 s for it to exit. /// public async ValueTask DisposeAsync() { await DisposeProcessAsync().ConfigureAwait(false); } // ── Private helpers ─────────────────────────────────────────────────────── private static int PickFreePort() { // Bind on loopback:0 so the OS picks a free port, read it, then stop. // See TOCTOU note in InitializeAsync. var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); int port = ((IPEndPoint)listener.LocalEndpoint).Port; listener.Stop(); return port; } private static string? ResolveScriptPath() { // Walk upward from the assembly directory looking for tests/sim/run-dl205-sim.ps1. // The assembly is typically at tests/Mbproxy.Tests/bin//net10.0/ var assemblyDir = Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location) ?? string.Empty; var dir = new DirectoryInfo(assemblyDir); while (dir is not null) { var candidate = Path.Combine(dir.FullName, "tests", "sim", "run-dl205-sim.ps1"); if (File.Exists(candidate)) return candidate; // Also check if we're already inside a tests/sim sibling. var direct = Path.Combine(dir.FullName, "run-dl205-sim.ps1"); if (File.Exists(direct)) return direct; dir = dir.Parent; } return null; } private static bool PwshIsAvailable() { try { using var p = Process.Start(new ProcessStartInfo { FileName = "pwsh", Arguments = "-NoProfile -Command exit 0", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }); p?.WaitForExit(3000); return p?.ExitCode == 0; } catch { return false; } } private void AppendLine(string? line) { if (line is null) return; _stderrLines.Enqueue(line); // Trim to the last LogTailLines entries. while (_stderrLines.Count > LogTailLines) _stderrLines.TryDequeue(out _); } private string BuildLogTail() { var sb = new StringBuilder(); foreach (var line in _stderrLines) sb.AppendLine(line); return sb.ToString(); } private async Task DisposeProcessAsync() { if (_process is null || _process.HasExited) return; try { // Windows lacks a portable "send SIGTERM" from .NET without P/Invoke. // Pymodbus handles graceful shutdown via Ctrl-C (SIGINT), but raising // Ctrl-C to a child process on Windows requires attaching to its console // group, which is fragile. Process.Kill(entireProcessTree: true) is the // pragmatic choice: it terminates pymodbus and any child processes it may // have spawned (e.g. the pwsh → python chain). // // Trade-off: pymodbus does not get to flush its log or call atexit // handlers, so the last few log lines may be missing. This is acceptable // for test cleanup. _process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { // Process already exited between the HasExited check and Kill(). } // Wait up to 5 s for the process to actually exit. using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await _process.WaitForExitAsync(cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) { // 5 s elapsed — give up; the OS will clean up the orphaned process. } } }