using Microsoft.Extensions.Options; using MxGateway.Server.Configuration; using MxGateway.Server.Metrics; using MxGateway.Server.Workers; namespace MxGateway.Tests.Gateway.Workers; /// /// Server-002 regression: per gateway.md the gateway must terminate /// orphaned worker processes on startup. These tests pin that the terminator /// kills leftover workers (matched by executable path, or by image name when /// the path is unreadable) without touching unrelated processes or itself. /// public sealed class OrphanWorkerTerminatorTests { private const string WorkerExecutablePath = @"C:\app\src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe"; [Fact] public void TerminateOrphans_KillsWorkerProcessesMatchingConfiguredExecutablePath() { FakeProcessInspector inspector = new( [ new RunningProcessInfo(101, WorkerExecutablePath), new RunningProcessInfo(102, WorkerExecutablePath), ]); OrphanWorkerTerminator terminator = CreateTerminator(inspector); int killed = terminator.TerminateOrphans(); Assert.Equal(2, killed); Assert.Equal([101, 102], inspector.KilledProcessIds.Order()); } [Fact] public void TerminateOrphans_KillsImageNameMatchWhenExecutablePathUnreadable() { // The x64 gateway cannot introspect the x86 worker's main module, so the // path comes back null. Image-name match is the only signal — and it is // exactly the orphan worker case, so the process must still be killed. FakeProcessInspector inspector = new( [ new RunningProcessInfo(201, ExecutablePath: null), ]); OrphanWorkerTerminator terminator = CreateTerminator(inspector); int killed = terminator.TerminateOrphans(); Assert.Equal(1, killed); Assert.Equal([201], inspector.KilledProcessIds); } [Fact] public void TerminateOrphans_DoesNotKillUnrelatedProcessSharingImageName() { // A process with the same image name but a different executable path is // not our worker and must be left alone. FakeProcessInspector inspector = new( [ new RunningProcessInfo(301, @"C:\other\place\MxGateway.Worker.exe"), ]); OrphanWorkerTerminator terminator = CreateTerminator(inspector); int killed = terminator.TerminateOrphans(); Assert.Equal(0, killed); Assert.Empty(inspector.KilledProcessIds); } [Fact] public void TerminateOrphans_DoesNotKillCurrentProcess() { FakeProcessInspector inspector = new( [ new RunningProcessInfo(Environment.ProcessId, WorkerExecutablePath), ]); OrphanWorkerTerminator terminator = CreateTerminator(inspector); int killed = terminator.TerminateOrphans(); Assert.Equal(0, killed); Assert.Empty(inspector.KilledProcessIds); } [Fact] public void TerminateOrphans_ContinuesWhenOneKillThrows() { FakeProcessInspector inspector = new( [ new RunningProcessInfo(401, WorkerExecutablePath), new RunningProcessInfo(402, WorkerExecutablePath), ]) { ThrowOnKillProcessId = 401, }; OrphanWorkerTerminator terminator = CreateTerminator(inspector); int killed = terminator.TerminateOrphans(); Assert.Equal(1, killed); Assert.Contains(402, inspector.KilledProcessIds); } private static OrphanWorkerTerminator CreateTerminator(IRunningProcessInspector inspector) { GatewayOptions options = new() { Worker = new WorkerOptions { ExecutablePath = WorkerExecutablePath, }, }; return new OrphanWorkerTerminator( Options.Create(options), inspector, new GatewayMetrics()); } private sealed class FakeProcessInspector(IReadOnlyList processes) : IRunningProcessInspector { public List KilledProcessIds { get; } = []; public int? ThrowOnKillProcessId { get; init; } public IReadOnlyList GetProcessesByName(string processName) => processes; public void Kill(int processId) { if (ThrowOnKillProcessId == processId) { throw new InvalidOperationException("Process has already exited."); } KilledProcessIds.Add(processId); } } }