using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; namespace zero.Backoffice.DevServer; public class ProcessProxy { string workingDirectory = null; string script = null; Action onProcessConfigure = null; Action captureLog = null; bool isWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); Dictionary envVars = new(); HashSet arguments = new(); bool forwardLog = false; Process process = null; EventedStreamReader stdOut = null; EventedStreamReader stdErr = null; public ProcessProxy(string workingDirectory, string script, bool forwardLog = false) { this.workingDirectory = workingDirectory; this.script = script; this.forwardLog = forwardLog; } /// /// Adds an environment variable /// public ProcessProxy EnvVar(string key, string value) { envVars.Add(key, value); return this; } /// /// Adds an argument /// public ProcessProxy Argument(string argument) { arguments.Add(argument); return this; } /// /// Configures the process start info /// public ProcessProxy Configure(Action onProcessConfigure) { this.onProcessConfigure = onProcessConfigure; return this; } /// /// Capture the log instead of outputting it to the console /// public ProcessProxy Capture(Action action) { this.captureLog = action; return this; } /// /// Run the script and wait for completion. /// This is only recommended for scripts which finish automatically and have no user interaction. /// public async Task ExecuteAsync(TimeSpan timeout = default) { StartProcess(); using var stdErrReader = new EventedStreamStringReader(stdErr); try { // TODO implement timeout // https://stackoverflow.com/questions/18760252/timeout-an-async-method-implemented-with-taskcompletionsource await stdOut.WaitForFinish(); } catch (EndOfStreamException ex) { throw new InvalidOperationException($"The script '{script}' exited without indicating that the server was listening for requests.\nThe error output was: " + $"{stdErrReader.ReadAsString()}", ex); } } /// /// Run the script and return as soon as a condition is met. /// The script will not cancel and will continue to run as a sub-process as long as it does not auto-close. /// public async Task RunAsync(string startupCondition, TimeSpan startupTimeout = default) { StartProcess(); using var stdErrReader = new EventedStreamStringReader(stdErr); try { await stdOut.WaitForMatch(new Regex(startupCondition, RegexOptions.IgnoreCase, startupTimeout == default ? TimeSpan.FromMinutes(5) : startupTimeout)); } catch (EndOfStreamException ex) { throw new InvalidOperationException($"The script '{script}' exited without indicating that the server was listening for requests.\nThe error output was: " + $"{stdErrReader.ReadAsString()}", ex); } } public void Exit() { try { process?.Kill(); } catch { } try { process?.WaitForExit(); } catch { } AppDomain.CurrentDomain.DomainUnload -= UnloadHandler; AppDomain.CurrentDomain.ProcessExit -= UnloadHandler; AppDomain.CurrentDomain.UnhandledException -= UnloadHandler; } /// /// Run /// Process StartProcess() { string executable = script; StringBuilder command = new(); command.Append(script); command.Append(' '); foreach (string arg in arguments) { command.Append(arg); } string argumentList = command.ToString(); if (isWindows) { argumentList = $"/c {argumentList}"; executable = "cmd"; } ProcessStartInfo startInfo = new(executable) { Arguments = argumentList, UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = workingDirectory }; foreach (var envVar in envVars) { startInfo.Environment[envVar.Key] = envVar.Value; } onProcessConfigure?.Invoke(startInfo); try { process = Process.Start(startInfo); process.EnableRaisingEvents = true; } catch (Exception ex) { var message = $"Failed to start '{startInfo.FileName}'. To resolve this:.\n\n" + $"[1] Ensure that '{startInfo.FileName}' is installed and can be found in one of the PATH directories.\n" + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + "[2] See the InnerException for further details of the cause."; throw new InvalidOperationException(message, ex); } AttachLogger(); AppDomain.CurrentDomain.DomainUnload += UnloadHandler; AppDomain.CurrentDomain.ProcessExit += UnloadHandler; AppDomain.CurrentDomain.UnhandledException += UnloadHandler; return process; } void UnloadHandler(object sender, EventArgs e) { Exit(); } void AttachLogger(bool isStream = false) { stdOut = new EventedStreamReader(process.StandardOutput); stdErr = new EventedStreamReader(process.StandardError); stdOut.OnReceivedLine += line => WriteToLog(line); stdOut.OnReceivedChunk += chunk => WriteToLog(chunk); stdErr.OnReceivedLine += line => WriteToLog(line, true); stdErr.OnReceivedChunk += chunk => WriteToLog(chunk, true); } void WriteToLog(string line, bool isError = false) { if (String.IsNullOrWhiteSpace(line)) { return; } line = line.StartsWith("") ? line.Substring(3) : line; if (captureLog != null) { captureLog(line, isError); } if (forwardLog) { (isError ? Console.Error : Console.Out).WriteLine(line); } } void WriteToLog(ArraySegment chunk, bool isError = false) { bool containsNewline = Array.IndexOf(chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; if (containsNewline) { return; } if (captureLog != null) { captureLog(new String(chunk.Array, chunk.Offset, chunk.Count), isError); } if (forwardLog) { (isError ? Console.Error : Console.Out).Write(chunk.Array, chunk.Offset, chunk.Count); } } }