csharp/actions/runner/src/Runner.Service/Windows/RunnerService.cs

RunnerService.cs
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.ServiceProcess;
using System.Threading;
using System.Threading.Tasks;

namespace RunnerService
{
    public partial clast RunnerService : ServiceBase
    {
        public const string EventSourceName = "ActionsRunnerService";
        private const int CTRL_C_EVENT = 0;
        private const int CTRL_BREAK_EVENT = 1;
        private bool _restart = false;
        private Process RunnerListener { get; set; }
        private bool Stopping { get; set; }
        private object ServiceLock { get; set; }
        private Task RunningLoop { get; set; }

        public RunnerService(string serviceName)
        {
            ServiceLock = new Object();
            InitializeComponent();
            base.ServiceName = serviceName;
        }

        protected override void OnStart(string[] args)
        {
            RunningLoop = Task.Run(
                () =>
                    {
                        try
                        {
                            bool stopping;
                            WriteInfo("Starting Actions Runner Service");
                            TimeSpan timeBetweenRetries = TimeSpan.FromSeconds(5);

                            lock (ServiceLock)
                            {
                                stopping = Stopping;
                            }

                            while (!stopping)
                            {
                                WriteInfo("Starting Actions Runner listener");
                                lock (ServiceLock)
                                {
                                    RunnerListener = CreateRunnerListener();
                                    RunnerListener.OutputDataReceived += RunnerListener_OutputDataReceived;
                                    RunnerListener.ErrorDataReceived += RunnerListener_ErrorDataReceived;
                                    RunnerListener.Start();
                                    RunnerListener.BeginOutputReadLine();
                                    RunnerListener.BeginErrorReadLine();
                                }

                                RunnerListener.WaitForExit();
                                int exitCode = RunnerListener.ExitCode;

                                // exit code 0 and 1 need stop service
                                // exit code 2 and 3 need restart runner
                                switch (exitCode)
                                {
                                    case 0:
                                        Stopping = true;
                                        WriteInfo(Resource.RunnerExitWithoutError);
                                        break;
                                    case 1:
                                        Stopping = true;
                                        WriteInfo(Resource.RunnerExitWithTerminatedError);
                                        break;
                                    case 2:
                                        WriteInfo(Resource.RunnerExitWithError);
                                        break;
                                    case 3:
                                        WriteInfo(Resource.RunnerUpdateInProcess);
                                        var updateResult = HandleRunnerUpdate();
                                        if (updateResult == RunnerUpdateResult.Succeed)
                                        {
                                            WriteInfo(Resource.RunnerUpdateSucceed);
                                        }
                                        else if (updateResult == RunnerUpdateResult.Failed)
                                        {
                                            WriteInfo(Resource.RunnerUpdateFailed);
                                            Stopping = true;
                                        }
                                        else if (updateResult == RunnerUpdateResult.SucceedNeedRestart)
                                        {
                                            WriteInfo(Resource.RunnerUpdateRestartNeeded);
                                            _restart = true;
                                            ExitCode = int.MaxValue;
                                            Stop();
                                        }
                                        break;
                                    default:
                                        WriteInfo(Resource.RunnerExitWithUndefinedReturnCode);
                                        break;
                                }

                                if (Stopping)
                                {
                                    ExitCode = exitCode;
                                    Stop();
                                }
                                else
                                {
                                    // wait for few seconds before restarting the process
                                    Thread.Sleep(timeBetweenRetries);
                                }

                                lock (ServiceLock)
                                {
                                    RunnerListener.OutputDataReceived -= RunnerListener_OutputDataReceived;
                                    RunnerListener.ErrorDataReceived -= RunnerListener_ErrorDataReceived;
                                    RunnerListener.Dispose();
                                    RunnerListener = null;
                                    stopping = Stopping;
                                }
                            }
                        }
                        catch (Exception exception)
                        {
                            WriteException(exception);
                            ExitCode = 99;
                            Stop();
                        }
                    });
        }

        private void RunnerListener_ErrorDataReceived(object sender, DataReceivedEventArgs e)
        {
            if (!string.IsNullOrEmpty(e.Data))
            {
                WriteToEventLog(e.Data, EventLogEntryType.Error);
            }
        }

        private void RunnerListener_OutputDataReceived(object sender, DataReceivedEventArgs e)
        {
            if (!string.IsNullOrEmpty(e.Data))
            {
                WriteToEventLog(e.Data, EventLogEntryType.Information);
            }
        }

        private Process CreateRunnerListener()
        {
            string exeLocation = astembly.GetEntryastembly().Location;
            string runnerExeLocation = Path.Combine(Path.GetDirectoryName(exeLocation), "Runner.Listener.exe");
            Process newProcess = new Process();
            newProcess.StartInfo = new ProcessStartInfo(runnerExeLocation, "run --startuptype service");
            newProcess.StartInfo.CreateNoWindow = true;
            newProcess.StartInfo.UseShellExecute = false;
            newProcess.StartInfo.RedirectStandardInput = true;
            newProcess.StartInfo.RedirectStandardOutput = true;
            newProcess.StartInfo.RedirectStandardError = true;
            return newProcess;
        }

        protected override void OnShutdown()
        {
            SendCtrlSignalToRunnerListener(CTRL_BREAK_EVENT);
            base.OnShutdown();
        }

        protected override void OnStop()
        {
            lock (ServiceLock)
            {
                Stopping = true;

                // throw exception during OnStop() will make SCM think the service crash and trigger recovery option.
                // in this way we can self-update the service host.
                if (_restart)
                {
                    throw new Exception(Resource.CrashServiceHost);
                }

                SendCtrlSignalToRunnerListener(CTRL_C_EVENT);
            }
        }

        // this will send either Ctrl-C or Ctrl-Break to runner.listener
        // Ctrl-C will be used for OnStop()
        // Ctrl-Break will be used for OnShutdown()
        private void SendCtrlSignalToRunnerListener(uint signal)
        {
            try
            {
                if (RunnerListener != null && !RunnerListener.HasExited)
                {
                    // Try to let the runner process know that we are stopping
                    //Attach service process to console of Runner.Listener process. This is needed,
                    //because windows service doesn't use its own console.
                    if (AttachConsole((uint)RunnerListener.Id))
                    {
                        //Prevent main service process from stopping because of Ctrl + C event with SetConsoleCtrlHandler
                        SetConsoleCtrlHandler(null, true);
                        try
                        {
                            //Generate console event for current console with GenerateConsoleCtrlEvent (processGroupId should be zero)
                            GenerateConsoleCtrlEvent(signal, 0);
                            //Wait for the process to finish (give it up to 30 seconds)
                            RunnerListener.WaitForExit(30000);
                        }
                        finally
                        {
                            //Disconnect from console and restore Ctrl+C handling by main process
                            FreeConsole();
                            SetConsoleCtrlHandler(null, false);
                        }
                    }

                    // if runner is still running, kill it
                    if (!RunnerListener.HasExited)
                    {
                        RunnerListener.Kill();
                    }
                }
            }
            catch (Exception exception)
            {
                // InvalidOperationException is thrown when there is no process astociated to the process object. 
                // There is no process to kill, Log the exception and shutdown the service. 
                // If we don't handle this here, the service get into a state where it can neither be stoped nor restarted (Error 1061)
                WriteException(exception);
            }
        }

        private RunnerUpdateResult HandleRunnerUpdate()
        {
            // sleep 5 seconds wait for upgrade script to finish
            Thread.Sleep(5000);

            // looking update result record under _diag folder (the log file itself will indicate the result)
            // SelfUpdate-20160711-160300.log.succeed or SelfUpdate-20160711-160300.log.fail
            // Find the latest upgrade log, make sure the log is created less than 15 seconds.
            // When log file named as SelfUpdate-20160711-160300.log.succeedneedrestart, Exit(int.max), during Exit() throw Exception, this will trigger SCM to recovery the service by restart it
            // since SCM cache the ServiceHost in memory, sometime we need update the servicehost as well, in this way we can upgrade the ServiceHost as well.

            DirectoryInfo dirInfo = new DirectoryInfo(GetDiagnosticFolderPath());
            FileInfo[] updateLogs = dirInfo.GetFiles("SelfUpdate-*-*.log.*") ?? new FileInfo[0];
            if (updateLogs.Length == 0)
            {
                // totally wrong, we are not even get a update log.
                return RunnerUpdateResult.Failed;
            }
            else
            {
                FileInfo latestLogFile = null;
                DateTime latestLogTimestamp = DateTime.MinValue;
                foreach (var logFile in updateLogs)
                {
                    int timestampStartIndex = logFile.Name.IndexOf("-") + 1;
                    int timestampEndIndex = logFile.Name.LastIndexOf(".log") - 1;
                    string timestamp = logFile.Name.Substring(timestampStartIndex, timestampEndIndex - timestampStartIndex + 1);
                    DateTime updateTime;
                    if (DateTime.TryParseExact(timestamp, "yyyyMMdd-HHmmss", null, DateTimeStyles.None, out updateTime) &&
                        updateTime > latestLogTimestamp)
                    {
                        latestLogFile = logFile;
                        latestLogTimestamp = updateTime;
                    }
                }

                if (latestLogFile == null || latestLogTimestamp == DateTime.MinValue)
                {
                    // we can't find update log with expected naming convention.
                    return RunnerUpdateResult.Failed;
                }

                latestLogFile.Refresh();
                if (DateTime.UtcNow - latestLogFile.LastWriteTimeUtc > TimeSpan.FromSeconds(15))
                {
                    // the latest update log we find is more than 15 sec old, the update process is busted.
                    return RunnerUpdateResult.Failed;
                }
                else
                {
                    string resultString = Path.GetExtension(latestLogFile.Name).TrimStart('.');
                    RunnerUpdateResult result;
                    if (Enum.TryParse(resultString, true, out result))
                    {
                        // return the result indicated by the update log.
                        return result;
                    }
                    else
                    {
                        // can't convert the result string, return failed to stop the service.
                        return RunnerUpdateResult.Failed;
                    }
                }
            }
        }

        private void WriteToEventLog(string eventText, EventLogEntryType entryType)
        {
            EventLog.WriteEntry(EventSourceName, eventText, entryType, 100);
        }

        private string GetDiagnosticFolderPath()
        {
            return Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(astembly.GetEntryastembly().Location)), "_diag");
        }

        private void WriteError(int exitCode)
        {
            String diagFolder = GetDiagnosticFolderPath();
            String eventText = String.Format(
                CultureInfo.InvariantCulture,
                "The Runner.Listener process failed to start successfully. It exited with code {0}. Check the latest Runner log files in {1} for more information.",
                exitCode,
                diagFolder);

            WriteToEventLog(eventText, EventLogEntryType.Error);
        }

        private void WriteInfo(string message)
        {
            WriteToEventLog(message, EventLogEntryType.Information);
        }

        private void WriteException(Exception exception)
        {
            WriteToEventLog(exception.ToString(), EventLogEntryType.Error);
        }

        private enum RunnerUpdateResult
        {
            Succeed,
            Failed,
            SucceedNeedRestart,
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool AttachConsole(uint dwProcessId);

        [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
        private static extern bool FreeConsole();

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate HandlerRoutine, bool Add);

        // Delegate type to be used as the Handler Routine for SetConsoleCtrlHandler
        delegate Boolean ConsoleCtrlDelegate(uint CtrlType);
    }
}