diff --git a/src/ElectronNET.API/Common/ProcessRunner.cs b/src/ElectronNET.API/Common/ProcessRunner.cs new file mode 100644 index 0000000..f3c3529 --- /dev/null +++ b/src/ElectronNET.API/Common/ProcessRunner.cs @@ -0,0 +1,595 @@ +namespace ElectronNET.Common +{ + using System; + using System.Diagnostics; + using System.Diagnostics.CodeAnalysis; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Class encapsulating out-of-process execution of console applications. + /// + /// + /// Why this class? + /// Probably everybody who has tried to use System.Diagnotics.Process cross-platform and with reading + /// stderr and stdout will know that it is a pretty quirky API. + /// The code below may look weird, even non-sensical, but it works 100% reliable with all .net frameworks + /// and .net versions and on every platform where .net runs. This is just the innermost core, that's why + /// there are many dead ends, but it has all the crucial parts. + /// + /// + [SuppressMessage("ReSharper", "SuspiciousLockOverSynchronizationPrimitive")] + public class ProcessRunner : IDisposable + { + private volatile Process process; + private readonly StringBuilder stdOut = new StringBuilder(4 * 1024); + private readonly StringBuilder stdErr = new StringBuilder(4 * 1024); + + private volatile ManualResetEvent stdOutEvent; + private volatile ManualResetEvent stdErrEvent; + private volatile Stopwatch stopwatch; + + /// Initializes a new instance of the class. + /// A name identifying the process to execute. + public ProcessRunner(string name) + { + this.Name = name; + } + + public event EventHandler ProcessExited; + + public bool IsDisposed { get; private set; } + + private Process Process + { + get + { + return this.process; + } + } + + public bool IsRunning + { + get + { + var proc = this.process; + if (proc != null) + { + try + { + return !proc.HasExited; + } + catch + { + return false; + } + } + + return false; + } + } + + /// Gets the name identifying the process. + /// The name. + public string Name { get; } + + public string CommandLine { get; private set; } + + public string ExecutableFileName { get; private set; } + + public string WorkingFolder { get; private set; } + + public bool RecordStandardOutput { get; set; } + + public bool RecordStandardError { get; set; } + + public string StandardOutput + { + get + { + lock (this.stdOut) + { + return this.stdOut.ToString(); + } + } + } + + public string StandardError + { + get + { + lock (this.stdErr) + { + return this.stdErr.ToString(); + } + } + } + + public int? LastExitCode { get; private set; } + + public bool Run(string exeFileName, string commandLineArgs, string workingDirectory) + { + this.CommandLine = commandLineArgs; + this.WorkingFolder = workingDirectory; + this.ExecutableFileName = exeFileName; + + var startInfo = new RunnerParams(exeFileName) + { + Arguments = commandLineArgs, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + ErrorDialog = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + + return this.Run(startInfo); + } + + protected bool Run(RunnerParams runnerParams) + { + if (this.IsDisposed) + { + throw new ObjectDisposedException(this.GetType().ToString()); + } + + this.Close(); + + this.LastExitCode = null; + + lock (this.stdOut) + { + this.stdOut.Clear(); + } + + lock (this.stdErr) + { + this.stdErr.Clear(); + } + + this.stdOutEvent = new ManualResetEvent(false); + this.stdErrEvent = new ManualResetEvent(false); + + if (!this.OnBeforeStartProcessCore(runnerParams)) + { + return false; + } + + var startInfo = new ProcessStartInfo(runnerParams.FileName) + { + Arguments = runnerParams.Arguments, + UseShellExecute = runnerParams.UseShellExecute, + RedirectStandardOutput = runnerParams.RedirectStandardOutput, + RedirectStandardError = runnerParams.RedirectStandardError, + RedirectStandardInput = runnerParams.RedirectStandardInput, + ErrorDialog = runnerParams.ErrorDialog, + CreateNoWindow = runnerParams.CreateNoWindow, + WorkingDirectory = runnerParams.WorkingDirectory + }; + + foreach (var variableSetting in runnerParams.EnvironmentVariables) + { + startInfo.EnvironmentVariables[variableSetting.Key] = variableSetting.Value; + } + + var proc = new Process { StartInfo = startInfo }; + + proc.EnableRaisingEvents = true; + + this.RegisterProcessEvents(proc); + + this.process = proc; + + try + { + this.process.Start(); + this.stopwatch = Stopwatch.StartNew(); + this.process.BeginOutputReadLine(); + this.process.BeginErrorReadLine(); + this.process.Refresh(); + this.OnProcessStartedCore(); + } + catch (Exception ex) + { + this.OnProcessErrorCore(ex); + this.Close(); + throw; + } + + return true; + } + + public async Task WriteAsync(string data) + { + var proc = this.Process; + if (proc != null && !proc.HasExited) + { + try + { + await proc.StandardInput.WriteAsync(data).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + Console.WriteLine("{0}.{1}: {2}", ex, nameof(ProcessRunner), nameof(this.WriteAsync)); + } + } + + return false; + } + + public bool WaitForExit() + { + var proc = this.process; + + if (proc == null) + { + return true; + } + + try + { + // Wait for process and all I/O to finish. + proc.WaitForExit(); + return true; + } + catch (Exception ex) + { + this.OnProcessErrorCore(ex); + return false; + } + } + + /// Sychronously waits for the specified amount and ends the process afterwards. + /// The timeout ms. + /// This method allows for a clean exit, since it also waits until the StandardOutput and + /// StandardError pipes are processed to the end. + /// true, if the process has exited gracefully; false otherwise. + public bool WaitAndKill(int timeoutMs) + { + var proc = this.process; + + if (proc == null) + { + return true; + } + + try + { + if (timeoutMs <= 0) + { + throw new ArgumentException("Argument must be greater then 0", nameof(timeoutMs)); + } + + // Timed waiting. We need to wait for I/O ourselves. + if (!proc.WaitForExit(timeoutMs)) + { + this.Cancel(); + } + + // Wait for the I/O to finish. + var waitMs = (int)(timeoutMs - this.stopwatch.ElapsedMilliseconds); + waitMs = Math.Max(waitMs, 10); + this.stdOutEvent?.WaitOne(waitMs); + + waitMs = (int)(timeoutMs - this.stopwatch.ElapsedMilliseconds); + waitMs = Math.Max(waitMs, 10); + return this.stdErrEvent?.WaitOne(waitMs) ?? false; + } + finally + { + // Cleanup. + this.Cancel(); + } + } + + /// Asynchronously waits for the specified amount and ends the process afterwards. + /// The timeout ms. + /// Tjhis method performs the wait operation on a threadpool thread. + /// Only recommended for short timeouts and situations where a synchronous call is undesired. + /// true, if the process has exited gracefully; false otherwise. + public Task WaitAndKillAsync(int timeoutMs) + { + var task = Task.Run(() => this.WaitAndKill(timeoutMs)); + return task; + } + + /// Waits asynchronously for the process to exit. + /// The timeout ms. + /// The cancellation token. + /// true, if the process has exited, false if the process is still running. + /// + /// This methods waits until the process has existed or the + /// has elapsed. + /// This method does not end the process itself. + /// + public Task WaitForExitAsync(int timeoutMs, CancellationToken cancellationToken = default) + { + timeoutMs = Math.Max(0, timeoutMs); + + var timeoutSource = new CancellationTokenSource(timeoutMs); + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(timeoutSource.Token, cancellationToken); + + return this.WaitForExitAsync(linkedSource.Token); + } + + /// Waits asynchronously for the process to exit. + /// The cancellation token. + /// This methods waits until the process has existed or the + /// has been triggered. + /// This method does not end the process itself. + /// true, if the process has exited, false if the process is still running. + public async Task WaitForExitAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + var proc = this.process; + + if (proc == null) + { + return true; + } + + var tcs = new TaskCompletionSource(); + + // Use local function instead of a lambda to allow proper deregistration of the event + void ProcessExited(object sender, EventArgs e) + { + tcs.TrySetResult(true); + } + + try + { + proc.EnableRaisingEvents = true; + proc.Exited += ProcessExited; + + if (proc.HasExited) + { + return true; + } + + using (cancellationToken.Register(() => tcs.TrySetResult(false))) + { + return await tcs.Task.ConfigureAwait(false); + } + } + finally + { + proc.Exited -= ProcessExited; + } + } + + public void Cancel() + { + var proc = this.process; + + if (proc != null) + { + try + { + // Invalidate cached data to requery. + proc.Refresh(); + + // We need to do this in case of a non-UI proc + // or one to be forced to cancel. + if (!proc.HasExited) + { + // Cancel all pending IO ops. + proc.CancelErrorRead(); + proc.CancelOutputRead(); + } + + if (!proc.HasExited) + { + proc.Kill(); + } + } + catch + { + // Kill will throw when/if the process has already exited. + } + } + + var outEvent = this.stdOutEvent; + this.stdOutEvent = null; + if (outEvent != null) + { + lock (outEvent) + { + outEvent.Close(); + outEvent.Dispose(); + } + } + + var errEvent = this.stdErrEvent; + this.stdErrEvent = null; + if (errEvent != null) + { + lock (errEvent) + { + errEvent.Close(); + errEvent.Dispose(); + } + } + } + + private void Close() + { + this.Cancel(); + + var proc = this.process; + this.process = null; + if (proc != null) + { + try + { + this.UnRegisterProcessEvents(proc); + + // Dispose in all cases. + proc.Close(); + proc.Dispose(); + } + catch (Exception ex) + { + this.OnProcessErrorCore(ex); + } + } + } + + protected virtual void OnDispose() + { + } + + void IDisposable.Dispose() + { + this.IsDisposed = true; + this.Close(); + this.OnDispose(); + } + + public override string ToString() + { + return string.Format("{0}: {1} {2}", this.GetType().Name, this.Name, this.process); + } + + protected virtual bool OnBeforeStartProcessCore(RunnerParams processRunnerInfo) + { + return true; + } + + protected virtual void OnProcessStartedCore() + { + } + + protected virtual void OnProcessErrorCore(Exception processException) + { + } + + protected virtual void OnProcessExitCore(int exitCode) + { + } + + private void RegisterProcessEvents(Process proc) + { + proc.ErrorDataReceived += this.Process_ErrorDataReceived; + proc.OutputDataReceived += this.Process_OutputDataReceived; + proc.Exited += this.Process_Exited; + } + + private void UnRegisterProcessEvents(Process proc) + { + proc.ErrorDataReceived -= this.Process_ErrorDataReceived; + proc.OutputDataReceived -= this.Process_OutputDataReceived; + proc.Exited -= this.Process_Exited; + } + + private void Process_Exited(object sender, EventArgs e) + { + this.WaitForExitAfterExited(); + this.SetExitCode(); + this.OnProcessExitCore(this.LastExitCode ?? -9998); + this.ProcessExited?.Invoke(this, new EventArgs()); + } + + private void WaitForExitAfterExited() + { + try + { + // This shouldn't throw here, but the mono process implementation doesn't always behave as it should. + this.process.WaitForExit(); + } + catch (Exception ex) + { + Console.WriteLine("Error when calling WaitForExit after exited event has fired: {0}.{1}: {2}", ex, nameof(ProcessRunner), nameof(this.WaitForExitAfterExited)); + } + } + + private void SetExitCode() + { + int exitCode = -9999; + + try + { + if (this.Process != null) + { + exitCode = this.Process.ExitCode; + } + } + catch + { + // Ignore error. + } + + this.LastExitCode = exitCode; + } + + private void Process_ErrorDataReceived(object sender, DataReceivedEventArgs e) + { + if (this.RecordStandardError) + { + lock (this.stdErr) + { + this.stdErr.AppendLine(e.Data); + } + } + + if (e.Data != null) + { + Console.WriteLine("|| " + e.Data); + } + else + { + var evt = this.stdErrEvent; + if (evt != null) + { + lock (evt) + { + try + { + evt.Set(); + } + catch + { + // Ignore error. + } + } + } + } + } + + private void Process_OutputDataReceived(object sender, DataReceivedEventArgs e) + { + if (this.RecordStandardOutput) + { + lock (this.stdOut) + { + this.stdOut.AppendLine(e.Data); + } + } + + if (e.Data != null) + { + Console.WriteLine("|| " + e.Data); + } + else + { + var evt = this.stdOutEvent; + if (evt != null) + { + lock (evt) + { + try + { + evt.Set(); + } + catch + { + // Ignore error. + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/ElectronNET.API/Common/RunnerParams.cs b/src/ElectronNET.API/Common/RunnerParams.cs new file mode 100644 index 0000000..001b881 --- /dev/null +++ b/src/ElectronNET.API/Common/RunnerParams.cs @@ -0,0 +1,163 @@ +namespace ElectronNET.Common +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Diagnostics; + using System.Text; + + public sealed class RunnerParams + { + private string fileName; + private string arguments; + private string directory; + private string userName; + private string verb; + private ProcessWindowStyle windowStyle; + + /// + /// Default constructor. At least the + /// property must be set before starting the process. + /// + public RunnerParams() + { + } + + /// + /// Specifies the name of the application or document that is to be started. + /// + public RunnerParams(string fileName) + { + this.fileName = fileName; + } + + /// + /// Specifies the name of the application that is to be started, as well as a set + /// of command line arguments to pass to the application. + /// + public RunnerParams(string fileName, string arguments) + { + this.fileName = fileName; + this.arguments = arguments; + } + + /// + /// Specifies the set of command line arguments to use when starting the application. + /// + public string Arguments + { + get + { + return this.arguments ?? string.Empty; + } + + set + { + this.arguments = value; + } + } + + public bool CreateNoWindow { get; set; } + + public Dictionary EnvironmentVariables { get; set; } = new Dictionary(); + + public bool RedirectStandardInput { get; set; } + + public bool RedirectStandardOutput { get; set; } + + public bool RedirectStandardError { get; set; } + + public Encoding StandardInputEncoding { get; set; } + + public Encoding StandardErrorEncoding { get; set; } + + public Encoding StandardOutputEncoding { get; set; } + + /// + /// + /// Returns or sets the application, document, or URL that is to be launched. + /// + /// + public string FileName + { + get + { + return this.fileName ?? string.Empty; + } + + set + { + this.fileName = value; + } + } + + /// + /// Returns or sets the initial directory for the process that is started. + /// Specify "" to if the default is desired. + /// + public string WorkingDirectory + { + get + { + return this.directory ?? string.Empty; + } + + set + { + this.directory = value; + } + } + + public bool ErrorDialog { get; set; } + + public IntPtr ErrorDialogParentHandle { get; set; } + + public string UserName + { + get + { + return this.userName ?? string.Empty; + } + + set + { + this.userName = value; + } + } + + [DefaultValue("")] + public string Verb + { + get + { + return this.verb ?? string.Empty; + } + + set + { + this.verb = value; + } + } + + [DefaultValue(ProcessWindowStyle.Normal)] + public ProcessWindowStyle WindowStyle + { + get + { + return this.windowStyle; + } + + set + { + if (!Enum.IsDefined(typeof(ProcessWindowStyle), value)) + { + throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(ProcessWindowStyle)); + } + + this.windowStyle = value; + } + } + + public bool UseShellExecute { get; set; } + } +} \ No newline at end of file