diff --git a/src/DiffEngine.Tests/PsAbsentTests.cs b/src/DiffEngine.Tests/PsAbsentTests.cs new file mode 100644 index 00000000..c9af77bf --- /dev/null +++ b/src/DiffEngine.Tests/PsAbsentTests.cs @@ -0,0 +1,25 @@ +/// +/// A machine with no ps. +/// +/// process.Start was unguarded and a non-zero exit threw, and both propagate out of +/// ProcessCleanup's static constructor - so a minimal container without procps, which is also one +/// that does not set DOTNET_RUNNING_IN_CONTAINER, got a permanent TypeInitializationException on +/// every launch and kill rather than "no running processes". The timeout path already degraded. +/// +/// +/// Windows is the machine with no ps, which is what makes this testable at all: the code is only +/// used on Linux and macOS, but nothing about it refuses to run here, and here the executable is +/// genuinely missing. +/// +/// +[RunOn(TUnit.Core.Enums.OS.Windows)] +public class PsAbsentTests +{ + [Test] + public async Task NoPsMeansNoProcessesRatherThanAThrow() + { + var commands = LinuxOsxProcess.FindAll(); + + await Assert.That(commands).IsEmpty(); + } +} diff --git a/src/DiffEngine/Process/LinuxOsxProcess.cs b/src/DiffEngine/Process/LinuxOsxProcess.cs index ba0a3c4f..ad48aeb7 100644 --- a/src/DiffEngine/Process/LinuxOsxProcess.cs +++ b/src/DiffEngine/Process/LinuxOsxProcess.cs @@ -107,7 +107,24 @@ static bool TryRunPs([NotNullWhen(true)] out string? result) CreateNoWindow = false } }; - process.Start(); + try + { + process.Start(); + } + catch (Exception exception) + when (exception is System.ComponentModel.Win32Exception or InvalidOperationException) + { + // No ps on this machine. A minimal container without procps is the ordinary case, and + // one that does not set DOTNET_RUNNING_IN_CONTAINER gets this far. Degrading to "no + // running processes" is what the timeout below already does, and the alternative is + // far worse than a wrong answer: this runs from ProcessCleanup's static constructor, + // so it becomes a TypeInitializationException on every launch and kill for the life + // of the process + Trace.WriteLine($"DiffEngine: Could not start ps. Treating as no running processes. {exception.Message}"); + result = null; + return false; + } + process.OutputDataReceived += (_, args) => { outputBuilder.AppendLine(args.Data); @@ -127,12 +144,16 @@ static bool TryRunPs([NotNullWhen(true)] out string? result) if (process.ExitCode != 0) { - var error = $""" - Could not execute process. Command line: ps {arguments}. - Output: {outputBuilder} - Error: {errorBuilder} - """; - throw new(error); + // Reported rather than thrown, for the same reason a failure to start is: the caller + // is a static constructor, and a throw there is permanent for the process + Trace.WriteLine( + $""" + DiffEngine: ps exited with {process.ExitCode}. Treating as no running processes. Command line: ps {arguments}. + Output: {outputBuilder} + Error: {errorBuilder} + """); + result = null; + return false; } result = outputBuilder.ToString();