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 class 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 = Assembly.GetEntryAssembly().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 associated 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(Assembly.GetEntryAssembly().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); } }