diff --git a/src/MultiSeat.Tests/Streaming/ApolloStrayKillTests.cs b/src/MultiSeat.Tests/Streaming/ApolloStrayKillTests.cs new file mode 100644 index 0000000..1d5ef38 --- /dev/null +++ b/src/MultiSeat.Tests/Streaming/ApolloStrayKillTests.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MultiSeat.Service.Configuration; +using MultiSeat.Service.Streaming; +using Xunit; + +namespace MultiSeat.Tests.Streaming; + +/// +/// The stray-Apollo kill against real processes and real WMI (issue #124). The unit tests in +/// cover the matching rule; this proves the whole path +/// finds a live process by its command line, kills it, and leaves a same-named process on another +/// seat's config alone. +/// +public class ApolloStrayKillTests +{ + private static readonly BindingFlags NonPublic = BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + + // A copy of cmd.exe named sunshine.exe. "cmd /k rem " stays alive while its redirected + // stdin stays open, and the config path is a real argument on the command line. + private static Process StartFakeApollo(string exe, string configPath) + { + var start = new ProcessStartInfo(exe) + { + RedirectStandardInput = true, RedirectStandardOutput = true, + UseShellExecute = false, CreateNoWindow = true + }; + start.ArgumentList.Add("/k"); + start.ArgumentList.Add("rem"); + start.ArgumentList.Add(configPath); + return Process.Start(start)!; + } + + // WMI can lag a moment behind a process that has just started. + private static List WaitForWmi(MethodInfo query, string configPath, int expectedPid) + { + var seen = new List(); + for (var i = 0; i < 50; i++) + { + seen = (List)query.Invoke(null, ["sunshine", configPath, null])!; + if (seen.Contains(expectedPid)) break; + Thread.Sleep(100); + } + return seen; + } + + [Fact] + public void KillStray_KillsOnlyTheApolloOnThatSeatsConfig() + { + var directory = Path.Combine(Path.GetTempPath(), "ms-straykill-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var exe = Path.Combine(directory, "sunshine.exe"); + File.Copy(Path.Combine(Environment.SystemDirectory, "cmd.exe"), exe); + var targetConfig = Path.Combine(directory, "seatA", "sunshine.conf"); + var otherConfig = Path.Combine(directory, "seatB", "sunshine.conf"); + + // Any real Apollo on this machine must come through untouched. + var realBefore = Process.GetProcessesByName("sunshine").Select(p => p.Id).ToHashSet(); + using var target = StartFakeApollo(exe, targetConfig); + using var other = StartFakeApollo(exe, otherConfig); + try + { + var manager = new ApolloManager(NullLogger.Instance, + Options.Create(new MultiSeatOptions { ApolloExePath = exe }), null!, null!); + var query = typeof(ApolloManager).GetMethod("QueryApolloPidsForConfig", NonPublic); + var kill = typeof(ApolloManager).GetMethod("KillStrayApollosForConfig", NonPublic); + Assert.NotNull(query); // a renamed method must fail loudly, not pass vacuously + Assert.NotNull(kill); + + // Positive control: WMI sees each fake under its own config only. + Assert.Equal([target.Id], WaitForWmi(query, targetConfig, target.Id)); + Assert.Equal([other.Id], WaitForWmi(query, otherConfig, other.Id)); + + // Negative control: a config no process runs kills nothing. + Assert.Equal(0, (int)kill.Invoke(manager, [Path.Combine(directory, "nobody", "sunshine.conf"), "control"])!); + Assert.False(target.HasExited); + Assert.False(other.HasExited); + + Assert.Equal(1, (int)kill.Invoke(manager, [targetConfig, "test"])!); + Assert.True(target.WaitForExit(5000)); + Assert.False(other.HasExited); + + var realAfter = Process.GetProcessesByName("sunshine").Select(p => p.Id).ToHashSet(); + Assert.True(realBefore.IsSubsetOf(realAfter), "a sunshine.exe that was not ours was killed"); + + // Nothing is left on that config. + Assert.Equal(0, (int)kill.Invoke(manager, [targetConfig, "again"])!); + } + finally + { + try { if (!target.HasExited) target.Kill(true); } catch { } + try { if (!other.HasExited) other.Kill(true); } catch { } + try { Directory.Delete(directory, true); } catch { } + } + } +}