What am I trying to solve
To improve my build pipeline, I'd like to add an end-to-end test step. I'm planning to achieve it by means of a CLI tool (.NET "Console App"). The tool will spin up and orchestrate a few npm
/node
commands (processes).
More specifically, there will be:
- a back-end process;
- a front-end process;
- and a test process.
When a test process (3
) completes, the CLI tool should terminate back-end (1
) and front-end (2
) processes gracefully, plus return 0
exit code if every orchestrated process has successfully terminated.
Trouble
In my Minimal, Complete, and Verifiable example below I'm trying to spin up a process serviceAlikeProcess
and a failing process (brokenWithErrorProcess
). When the latter one fails, I'm trying to forcibly terminate the former one via Kill(process)
method.
!!! As it is suggested here, the node
/npm
processes are being launched via cmd
process. I.e. I'm first spinning up a cmd
process, and then write node test.js
to its stdin
stream. The node
process gets launched just fine but when the cmd
process is terminated later, the node
process keeps running and producing the output.
I suppose this happens due to the fact cmd
and node
processes are not getting linked in a parent-child relationship (because if I manually terminate the cmd
process from a Task Manager, I observe same exact behavior).
Question
How do I reliably kill both processes?
Idea: I was thinking about capturing the node
process' pid
and then terminate both cmd
and node
processes myself, but I haven't found a way to capture that pid
...
Code
Program.cs
using System;
using System.Diagnostics;
using System.IO;
namespace RunE2E
{
public class Program
{
static string currentDirectory = Directory.GetCurrentDirectory();
public static int Main(string[] args)
{
var serviceAlikeProcess = StartProcessViaCmd("node", "test.js", "");
var brokenWithErrorProcess = StartProcessViaCmd("npm", "THIS IS NOT A REAL COMMAND, THEREFORE EXPECTED TO FAIL", "");
brokenWithErrorProcess.Exited += (_, __) => KillProcess(serviceAlikeProcess);
serviceAlikeProcess.WaitForExit();
return serviceAlikeProcess.ExitCode;
}
private static Process StartProcessViaCmd(string command, string arguments, string workingDirectory)
{
workingDirectory = NormalizeWorkingDirectory(workingDirectory);
var process = new Process
{
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo
{
FileName = "cmd",
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
}
};
process.ErrorDataReceived += (_, e) => handle(command, arguments, workingDirectory, "ERROR", e.Data);
process.OutputDataReceived += (_, e) => handle(command, arguments, workingDirectory, "OUTPUT", e.Data);
try
{
Console.WriteLine($"[{workingDirectory}] {command} {arguments}");
var _ = process.Start();
process.BeginOutputReadLine();
process.StandardInput.WriteLine($"{command} {arguments} & exit");
}
catch (Exception exc)
{
Console.WriteLine($"[{workingDirectory}] {command} {arguments} : {exc}");
throw;
}
return process;
}
static string NormalizeWorkingDirectory(string workingDirectory)
{
if (string.IsNullOrWhiteSpace(workingDirectory))
return currentDirectory;
else if (Path.IsPathRooted(workingDirectory))
return workingDirectory;
else
return Path.GetFullPath(Path.Combine(currentDirectory, workingDirectory));
}
static Action<string, string, string, string, string> handle =
(string command, string arguments, string workingDirectory, string level, string message) =>
Console.WriteLine($"[{workingDirectory}] {command} {arguments} {level}: {message}");
static void KillProcess(Process process)
{
if (process != null && !process.HasExited)
process.Kill();
}
}
}
test.js
setInterval(() => {
console.info(new Date());
}, 1000);
Screenshots
Before launching any processes programmatically
![](https://www.manongdao.com/static/images/pcload.jpg)
After the `cmd` process is killed
![](https://www.manongdao.com/static/images/pcload.jpg)
Console output
![](https://www.manongdao.com/static/images/pcload.jpg)
I really really don't like ending up answering my own question(s), especially when the answer is based on a hack-ish way of achieving the results.
However, I understand that this may save somebody else their time. So, here's my solution:
Code
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
namespace RunE2E
{
public class Program
{
static string currentDirectory = Directory.GetCurrentDirectory();
public static int Main(string[] args)
{
var serviceAlikeProcessResult = StartProcessViaCmd("node", "test.js", "");
var serviceAlikeProcess = serviceAlikeProcessResult.MainProcess;
var brokenWithErrorResult = StartProcessViaCmd("npm", "THIS IS NOT A REAL COMMAND, THEREFORE EXPECTED TO FAIL", "");
var brokenWithErrorProcess = brokenWithErrorResult.MainProcess;
brokenWithErrorProcess.Exited += (_, __) =>
{
KillProcesses("Front-End", serviceAlikeProcessResult.MainProcess, serviceAlikeProcessResult.CreatedProcesses);
KillProcesses("E2E-Test", brokenWithErrorResult.MainProcess, brokenWithErrorResult.CreatedProcesses);
};
serviceAlikeProcess.WaitForExit();
return serviceAlikeProcess.ExitCode;
}
private static CommandStartResult StartProcessViaCmd(string command, string arguments, string workingDirectory)
{
workingDirectory = NormalizeWorkingDirectory(workingDirectory);
var process = new Process
{
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo
{
FileName = "cmd",
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
},
};
var createdProcesses = new List<Process>();
process.ErrorDataReceived += (_, e) => handle(command, arguments, workingDirectory, "ERROR", e.Data);
process.OutputDataReceived += (_, e) => handle(command, arguments, workingDirectory, "", e.Data);
var commandId = $"[{workingDirectory}] {command} {arguments}";
try
{
WriteLine(commandId);
createdProcesses = StartProcessAndCapture(commandId, process);
process.BeginOutputReadLine();
process.StandardInput.WriteLine($"{command} {arguments} & exit");
}
catch (Exception exc)
{
WriteLine($"{commandId}: {exc}");
throw;
}
return new CommandStartResult
{
MainProcess = process,
CreatedProcesses = createdProcesses,
};
}
static List<Process> StartProcessAndCapture(string commandId, Process processToStart)
{
var before = Process.GetProcesses().ToList();
var beforePidSet = new HashSet<int>(before.Select(process => process.Id));
var _ = processToStart.Start();
Thread.Sleep(3000);
var after = Process.GetProcesses().ToList();
var newlyCreatedProcessIdList = new HashSet<int>(after.Select(process => process.Id));
newlyCreatedProcessIdList.ExceptWith(beforePidSet);
var createdProcesses = after.Where(process => newlyCreatedProcessIdList.Contains(process.Id)).ToList();
foreach (var process in createdProcesses)
WriteLine($"{commandId} ||| [{process.Id}] {process.ProcessName}", ConsoleColor.Blue);
return createdProcesses;
}
static string NormalizeWorkingDirectory(string workingDirectory)
{
if (string.IsNullOrWhiteSpace(workingDirectory))
return currentDirectory;
else if (Path.IsPathRooted(workingDirectory))
return workingDirectory;
else
return Path.GetFullPath(Path.Combine(currentDirectory, workingDirectory));
}
static Action<string, string, string, string, string> handle =
(string command, string arguments, string workingDirectory, string level, string message) =>
{
var defaultColor = Console.ForegroundColor;
Write($"[{workingDirectory}] ");
Write($"{command} ", ConsoleColor.DarkGreen);
Write($"{arguments}", ConsoleColor.Green);
Write($"{level} ", level == "" ? defaultColor : ConsoleColor.Red);
WriteLine($": {message}");
};
static void KillProcesses(string prefix, Process baseProcess, List<Process> processList)
{
processList = baseProcess == null ?
processList :
processList.Where(process => process.Id != baseProcess.Id).Append(baseProcess).ToList();
foreach (var process in processList)
KillProcess(prefix, process);
}
static void KillProcess(string prefix, Process process)
{
if (process != null && !process.HasExited)
try
{
WriteLine(prefix + " | Kill (" + process.ProcessName + ") [" + process.Id + "]");
process.Kill();
}
catch (Win32Exception win32exc)
{
WriteLine(prefix + " | Kill (" + process.ProcessName + ") [" + process.Id + "]: " + win32exc.Message);
}
}
static void WaitForExit(Process process)
{
while (process.HasExited == false) { }
}
static object console = new object();
static void Write(string text, ConsoleColor? color = null)
{
lock (console)
{
var original = Console.ForegroundColor;
Console.ForegroundColor = color.HasValue ? color.Value : original;
Console.Write(text);
Console.ForegroundColor = original;
}
}
static void WriteLine(string text = null, ConsoleColor? color = null)
{
lock (console)
{
var original = Console.ForegroundColor;
Console.ForegroundColor = color.HasValue ? color.Value : original;
Console.WriteLine(text);
Console.ForegroundColor = original;
}
}
}
class CommandStartResult
{
public Process MainProcess { get; set; }
public List<Process> CreatedProcesses { get; set; }
}
}
Additionally, one may want to use the following methods when dealing with .NET Core processes.
private static CommandStartResult StartDotnetProcess(string arguments, string workingDirectory)
{
var command = "dotnet";
workingDirectory = NormalizeWorkingDirectory(workingDirectory);
var process = PrepareProcess(command, arguments, workingDirectory);
var createdProcesses = new List<Process>();
var commandId = $"[{workingDirectory}] {command} {arguments}";
try
{
WriteLine(commandId);
createdProcesses = StartProcessAndCapture(commandId, process);
process.BeginOutputReadLine();
}
catch (Exception exc)
{
WriteLine($"{commandId} : {exc}");
throw;
}
return new CommandStartResult
{
MainProcess = process,
CreatedProcesses = createdProcesses,
};
}
private static Process PrepareProcess(
string command,
string arguments,
string workingDirectory
)
{
var process = new Process
{
EnableRaisingEvents = true,
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
},
};
process.ErrorDataReceived += (_, e) => handle(command, arguments, workingDirectory, "ERROR", e.Data);
process.OutputDataReceived += (_, e) => handle(command, arguments, workingDirectory, "", e.Data);
process.StartInfo.Environment.Add("ASPNETCORE_ENVIRONMENT", "Development");
return process;
}