Reliably kill node.js process launched via cmd pro

2019-08-23 18:15发布

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:

  1. a back-end process;
  2. a front-end process;
  3. 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

enter image description here

After the `cmd` process is killed

enter image description here

Console output

enter image description here

1条回答
成全新的幸福
2楼-- · 2019-08-23 18:59

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;
        }
查看更多
登录 后发表回答