使用 Process RedirectStandardError 和 RedirectStandardOutput 时的竞争条件,
Race conditition when using Process RedirectStandardError and RedirectStandardOutput,
我发现自己在订阅 System.Diagnostics.Process
的输出和错误流时处于竞争状态。
这是我所做的一个最小示例:
private string execute(string command, string arguments, int mstimeout)
{
string report = string.Empty;
StringBuilder output = new StringBuilder();
StringBuilder error = new StringBuilder();
Process p = new Process();
DataReceivedEventHandler ErrorDataReceived = (o, e) => { error.Append(e.Data); };
DataReceivedEventHandler OutputDataReceived = (o, e) => { output.Append(e.Data); };
try
{
p.StartInfo.FileName = command;
p.StartInfo.Arguments = arguments;
p.EnableRaisingEvents = true;
p.StartInfo.CreateNoWindow = true;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.RedirectStandardOutput = true;
p.OutputDataReceived += OutputDataReceived;
p.ErrorDataReceived += ErrorDataReceived;
p.Start();
p.BeginErrorReadLine();
p.BeginOutputReadLine();
p.WaitForExit(mstimeout);
report = output.ToString() + "\n" + error.ToString();
}
finally
{
p.OutputDataReceived -= OutputDataReceived;
p.ErrorDataReceived -= ErrorDataReceived;
}
return report;
}
当调试缓慢时,行为是我希望的。当 运行 没有停止时,报告最终为空。
我假设存在一种竞争条件,即在处理所有输出之前处理底层流对象。
我可以做些什么来等待所有输出被处理吗?
问题是在某些情况下超时。我需要 Kill
Process
以避免后续问题。
if(!p.WaitForExit(mstimeout))
{
p.Kill();
}
我在可能不需要的 finally
部分进行了清理。
finally
{
p.OutputDataReceived -= OutputDataReceived;
p.ErrorDataReceived -= ErrorDataReceived;
p.Dispose();
p = null;
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true);
}
编辑:我删除了 finally 部分,因为下面的评论似乎是正确的。
编辑:存在更深层次的问题,即由于需要输入而超时。我最终调用了另一个无声的命令。
供将来参考 - 我现在决定这样做:
private string execute(string command, string arguments, int mstimeout)
{
bool timeout = false;
string report = string.Empty;
StringBuilder output = new StringBuilder();
StringBuilder error = new StringBuilder();
Process p = new Process();
DataReceivedEventHandler StoreError = (o, e) => { error.Append(e.Data); };
DataReceivedEventHandler StoreOutput = (o, e) => { output.Append(e.Data); };
try
{
Debug.WriteLine(command);
Debug.WriteLine(arguments);
p.StartInfo.FileName = command;
p.StartInfo.Arguments = arguments;
p.EnableRaisingEvents = true;
p.StartInfo.CreateNoWindow = true;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.RedirectStandardOutput = true;
p.OutputDataReceived += StoreOutput;
p.ErrorDataReceived += StoreError;
p.Start();
p.BeginErrorReadLine();
p.BeginOutputReadLine();
if (!p.WaitForExit(mstimeout))
{
p.Kill();
timeout = true;
Debug.WriteLine("Process killed");
}
else
{
p.WaitForExit();
}
}
finally
{
report = output.ToString() + "\n" + error.ToString();
Debug.WriteLine(report);
p.Dispose();
}
if (timeout)
{
throw new TimeoutException("Timeout during call: " + command + " " + arguments);
}
return report;
}
我不认为你无能为力。我认为 Microsoft 完全错过了您想要获得其输出(输出和错误)的凝视过程。总会有问题的。至少,这是您所拥有的竞争条件。
我在 Microsoft 报告了一个错误:https://connect.microsoft.com/VisualStudio/feedback/details/3119134/race-condition-in-process-asynchronous-output-stream-read
作为参考,这是我现在使用的代码(它包含相同的竞争条件问题,异步模式下的任何实现 运行 都会有)。
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
namespace HQ.Util.General
{
public class ProcessExecutionWithOutputCapture
{
// ************************************************************************
public class ProcessWithOutputCaptureResult
{
public string Error { get; internal set; }
public string Output { get; internal set; }
public string ExecutionError
{
get
{
if (String.IsNullOrEmpty(Error))
{
return Error;
}
return Exception?.ToString();
}
}
public bool HasTimeout { get; internal set; }
/// <summary>
/// Can be cancel through the eventCancel which will cancel the wait (and if set, will kill the process)
/// </summary>
public bool HasBeenCanceled { get; internal set; }
public int ExitCode { get; internal set; }
public Exception Exception { get; internal set; }
public bool HasSucceded => !HasTimeout && Exception == null;
}
// ************************************************************************
private StringBuilder _sbOutput = new StringBuilder();
private StringBuilder _sbError = new StringBuilder();
private AutoResetEvent _outputWaitHandle = null;
private AutoResetEvent _errorWaitHandle = null;
// Could be usefull when user want to exit to not wait for process to end and kill it (if wanted)
public EventWaitHandle AdditionalConditionToStopWaitingProcess { get; set; }
public bool IsAdditionalConditionToStopWaitingProcessShouldAlsoKill { get; set; }
public ProcessWindowStyle ProcessWindowStyle { get; set; } = ProcessWindowStyle.Hidden;
public bool CreateWindow { get; set; } = false;
public static ProcessWithOutputCaptureResult ExecuteWith(string executablePath, string arguments, int timeout = Timeout.Infinite, ProcessWindowStyle processWindowStyle = ProcessWindowStyle.Hidden, bool createWindow = false)
{
var p = new ProcessExecutionWithOutputCapture();
return p.Execute(executablePath, arguments, timeout);
}
// ************************************************************************
/// <summary>
/// Only support existing exectuable (no association or dos command which have no executable like 'dir').
/// But accept full path, partial path or no path where it will use regular system/user path.
/// </summary>
/// <param name="executablePath"></param>
/// <param name="arguments"></param>
/// <param name="timeout"></param>
/// <returns></returns>
private ProcessWithOutputCaptureResult Execute(string executablePath, string arguments = null, int timeout = Timeout.Infinite)
{
ProcessWithOutputCaptureResult processWithOutputCaptureResult = null;
using (Process process = new Process())
{
process.StartInfo.FileName = executablePath;
process.StartInfo.Arguments = arguments;
process.StartInfo.UseShellExecute = false; // required to redirect output to appropriate (output or error) process stream
process.StartInfo.WindowStyle = ProcessWindowStyle;
process.StartInfo.CreateNoWindow = CreateWindow;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
_outputWaitHandle = new AutoResetEvent(false);
_errorWaitHandle = new AutoResetEvent(false);
bool asyncReadStarted = false;
try
{
process.OutputDataReceived += ProcessOnOutputDataReceived;
process.ErrorDataReceived += ProcessOnErrorDataReceived;
process.Start();
// Here there is a race condition. See: https://connect.microsoft.com/VisualStudio/feedback/details/3119134/race-condition-in-process-asynchronous-output-stream-read
process.BeginOutputReadLine();
process.BeginErrorReadLine();
asyncReadStarted = true;
// See: ProcessStartInfo.RedirectStandardOutput Property:
// https://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k(System.Diagnostics.ProcessStartInfo.RedirectStandardOutput);k(TargetFrameworkMoniker-.NETFramework,Version%3Dv4.5.2);k(DevLang-csharp)&rd=true
// All 4 next lines should only be called when not using asynchronous read (process.BeginOutputReadLine() and process.BeginErrorReadLine())
//_sbOutput.AppendLine(process.StandardOutput.ReadToEnd());
//_sbError.AppendLine(process.StandardError.ReadToEnd());
//_sbOutput.AppendLine(process.StandardOutput.ReadToEnd());
//_sbError.AppendLine(process.StandardError.ReadToEnd());
var waitHandles = new WaitHandle[1 + (AdditionalConditionToStopWaitingProcess == null ? 0 : 1)];
waitHandles[0] = new ProcessWaitHandle(process);
if (AdditionalConditionToStopWaitingProcess != null)
{
waitHandles[1] = AdditionalConditionToStopWaitingProcess;
}
bool hasSucceded = false;
int waitResult = WaitHandle.WaitAny(waitHandles, timeout);
if (waitResult == 1) // The wait has been interrrupted by an external event
{
if (IsAdditionalConditionToStopWaitingProcessShouldAlsoKill)
{
process.Kill();
}
}
else if (waitResult == 0) // Process has completed normally, no timeout or external event
{
// Ensure internal process code has completed like ensure to wait until stdout et stderr had been fully completed
hasSucceded = process.WaitForExit(timeout);
if (_outputWaitHandle.WaitOne(timeout) && _errorWaitHandle.WaitOne(timeout))
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.ExitCode = process.ExitCode;
processWithOutputCaptureResult.Output = _sbOutput.ToString();
processWithOutputCaptureResult.Error = _sbError.ToString();
}
}
else // Process timeout
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.HasTimeout = true;
}
}
catch (Exception ex)
{
if (ex.HResult == -2147467259)
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.Exception = new FileNotFoundException("File not found: " + executablePath, ex);
}
else
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.Exception = ex;
}
}
finally
{
if (asyncReadStarted)
{
process.CancelOutputRead();
process.CancelErrorRead();
}
process.OutputDataReceived -= ProcessOnOutputDataReceived;
process.ErrorDataReceived -= ProcessOnOutputDataReceived;
_outputWaitHandle.Close();
_outputWaitHandle.Dispose();
_errorWaitHandle.Close();
_errorWaitHandle.Dispose();
}
}
return processWithOutputCaptureResult;
}
// ************************************************************************
private void ProcessOnOutputDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
_outputWaitHandle.Set();
}
else
{
_sbOutput.AppendLine(e.Data);
}
}
// ************************************************************************
private void ProcessOnErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
_errorWaitHandle.Set();
}
else
{
_sbError.AppendLine(e.Data);
}
}
// ************************************************************************
}
}
用法(作为转发执行的应用程序):
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using HQ.Util.General;
using System.Reflection;
namespace ExecutionForwarder
{
class Program
{
static void Main(string[] args)
{
Stopwatch stopwatch = Stopwatch.StartNew();
Console.WriteLine($"App: {Assembly.GetEntryAssembly().FullName}");
Console.WriteLine($"Executing from folder: {Environment.CurrentDirectory}");
Console.WriteLine($"at: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}");
Console.WriteLine($"With args: [{string.Join(" ", args.Skip(1))}]");
if (args.Length == 1 && args[0].ToLower().StartsWith("-delay:"))
{
int millisec;
if (Int32.TryParse(args[0].Substring(args[0].IndexOf(":") + 1), out millisec))
{
Console.WriteLine($"Sleeping for {millisec} milliseconds and will exit.");
Thread.Sleep(millisec);
}
else
{
Console.Error.WriteLine("Error while trying to read the delay.");
Environment.ExitCode = -99;
}
}
else
{
if (args.Length == 0)
{
Console.Error.WriteLine($"Can't forward execution. There is no argument (executable) provided.");
Environment.ExitCode = -99;
}
else
{
var result = ProcessExecutionWithOutputCapture.ExecuteWith(args[0], string.Join(" ", args.Skip(1)));
Console.Write(result.Output);
Console.Error.Write(result.Error);
Environment.ExitCode = result.ExitCode;
}
}
Console.WriteLine($"Done in {stopwatch.ElapsedMilliseconds} millisecs");
}
}
}
我发现自己在订阅 System.Diagnostics.Process
的输出和错误流时处于竞争状态。
这是我所做的一个最小示例:
private string execute(string command, string arguments, int mstimeout)
{
string report = string.Empty;
StringBuilder output = new StringBuilder();
StringBuilder error = new StringBuilder();
Process p = new Process();
DataReceivedEventHandler ErrorDataReceived = (o, e) => { error.Append(e.Data); };
DataReceivedEventHandler OutputDataReceived = (o, e) => { output.Append(e.Data); };
try
{
p.StartInfo.FileName = command;
p.StartInfo.Arguments = arguments;
p.EnableRaisingEvents = true;
p.StartInfo.CreateNoWindow = true;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.RedirectStandardOutput = true;
p.OutputDataReceived += OutputDataReceived;
p.ErrorDataReceived += ErrorDataReceived;
p.Start();
p.BeginErrorReadLine();
p.BeginOutputReadLine();
p.WaitForExit(mstimeout);
report = output.ToString() + "\n" + error.ToString();
}
finally
{
p.OutputDataReceived -= OutputDataReceived;
p.ErrorDataReceived -= ErrorDataReceived;
}
return report;
}
当调试缓慢时,行为是我希望的。当 运行 没有停止时,报告最终为空。
我假设存在一种竞争条件,即在处理所有输出之前处理底层流对象。
我可以做些什么来等待所有输出被处理吗?
问题是在某些情况下超时。我需要 Kill
Process
以避免后续问题。
if(!p.WaitForExit(mstimeout))
{
p.Kill();
}
我在可能不需要的 finally
部分进行了清理。
finally
{
p.OutputDataReceived -= OutputDataReceived;
p.ErrorDataReceived -= ErrorDataReceived;
p.Dispose();
p = null;
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true);
}
编辑:我删除了 finally 部分,因为下面的评论似乎是正确的。
编辑:存在更深层次的问题,即由于需要输入而超时。我最终调用了另一个无声的命令。
供将来参考 - 我现在决定这样做:
private string execute(string command, string arguments, int mstimeout)
{
bool timeout = false;
string report = string.Empty;
StringBuilder output = new StringBuilder();
StringBuilder error = new StringBuilder();
Process p = new Process();
DataReceivedEventHandler StoreError = (o, e) => { error.Append(e.Data); };
DataReceivedEventHandler StoreOutput = (o, e) => { output.Append(e.Data); };
try
{
Debug.WriteLine(command);
Debug.WriteLine(arguments);
p.StartInfo.FileName = command;
p.StartInfo.Arguments = arguments;
p.EnableRaisingEvents = true;
p.StartInfo.CreateNoWindow = true;
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardError = true;
p.StartInfo.RedirectStandardOutput = true;
p.OutputDataReceived += StoreOutput;
p.ErrorDataReceived += StoreError;
p.Start();
p.BeginErrorReadLine();
p.BeginOutputReadLine();
if (!p.WaitForExit(mstimeout))
{
p.Kill();
timeout = true;
Debug.WriteLine("Process killed");
}
else
{
p.WaitForExit();
}
}
finally
{
report = output.ToString() + "\n" + error.ToString();
Debug.WriteLine(report);
p.Dispose();
}
if (timeout)
{
throw new TimeoutException("Timeout during call: " + command + " " + arguments);
}
return report;
}
我不认为你无能为力。我认为 Microsoft 完全错过了您想要获得其输出(输出和错误)的凝视过程。总会有问题的。至少,这是您所拥有的竞争条件。 我在 Microsoft 报告了一个错误:https://connect.microsoft.com/VisualStudio/feedback/details/3119134/race-condition-in-process-asynchronous-output-stream-read
作为参考,这是我现在使用的代码(它包含相同的竞争条件问题,异步模式下的任何实现 运行 都会有)。
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
namespace HQ.Util.General
{
public class ProcessExecutionWithOutputCapture
{
// ************************************************************************
public class ProcessWithOutputCaptureResult
{
public string Error { get; internal set; }
public string Output { get; internal set; }
public string ExecutionError
{
get
{
if (String.IsNullOrEmpty(Error))
{
return Error;
}
return Exception?.ToString();
}
}
public bool HasTimeout { get; internal set; }
/// <summary>
/// Can be cancel through the eventCancel which will cancel the wait (and if set, will kill the process)
/// </summary>
public bool HasBeenCanceled { get; internal set; }
public int ExitCode { get; internal set; }
public Exception Exception { get; internal set; }
public bool HasSucceded => !HasTimeout && Exception == null;
}
// ************************************************************************
private StringBuilder _sbOutput = new StringBuilder();
private StringBuilder _sbError = new StringBuilder();
private AutoResetEvent _outputWaitHandle = null;
private AutoResetEvent _errorWaitHandle = null;
// Could be usefull when user want to exit to not wait for process to end and kill it (if wanted)
public EventWaitHandle AdditionalConditionToStopWaitingProcess { get; set; }
public bool IsAdditionalConditionToStopWaitingProcessShouldAlsoKill { get; set; }
public ProcessWindowStyle ProcessWindowStyle { get; set; } = ProcessWindowStyle.Hidden;
public bool CreateWindow { get; set; } = false;
public static ProcessWithOutputCaptureResult ExecuteWith(string executablePath, string arguments, int timeout = Timeout.Infinite, ProcessWindowStyle processWindowStyle = ProcessWindowStyle.Hidden, bool createWindow = false)
{
var p = new ProcessExecutionWithOutputCapture();
return p.Execute(executablePath, arguments, timeout);
}
// ************************************************************************
/// <summary>
/// Only support existing exectuable (no association or dos command which have no executable like 'dir').
/// But accept full path, partial path or no path where it will use regular system/user path.
/// </summary>
/// <param name="executablePath"></param>
/// <param name="arguments"></param>
/// <param name="timeout"></param>
/// <returns></returns>
private ProcessWithOutputCaptureResult Execute(string executablePath, string arguments = null, int timeout = Timeout.Infinite)
{
ProcessWithOutputCaptureResult processWithOutputCaptureResult = null;
using (Process process = new Process())
{
process.StartInfo.FileName = executablePath;
process.StartInfo.Arguments = arguments;
process.StartInfo.UseShellExecute = false; // required to redirect output to appropriate (output or error) process stream
process.StartInfo.WindowStyle = ProcessWindowStyle;
process.StartInfo.CreateNoWindow = CreateWindow;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
_outputWaitHandle = new AutoResetEvent(false);
_errorWaitHandle = new AutoResetEvent(false);
bool asyncReadStarted = false;
try
{
process.OutputDataReceived += ProcessOnOutputDataReceived;
process.ErrorDataReceived += ProcessOnErrorDataReceived;
process.Start();
// Here there is a race condition. See: https://connect.microsoft.com/VisualStudio/feedback/details/3119134/race-condition-in-process-asynchronous-output-stream-read
process.BeginOutputReadLine();
process.BeginErrorReadLine();
asyncReadStarted = true;
// See: ProcessStartInfo.RedirectStandardOutput Property:
// https://msdn.microsoft.com/query/dev14.query?appId=Dev14IDEF1&l=EN-US&k=k(System.Diagnostics.ProcessStartInfo.RedirectStandardOutput);k(TargetFrameworkMoniker-.NETFramework,Version%3Dv4.5.2);k(DevLang-csharp)&rd=true
// All 4 next lines should only be called when not using asynchronous read (process.BeginOutputReadLine() and process.BeginErrorReadLine())
//_sbOutput.AppendLine(process.StandardOutput.ReadToEnd());
//_sbError.AppendLine(process.StandardError.ReadToEnd());
//_sbOutput.AppendLine(process.StandardOutput.ReadToEnd());
//_sbError.AppendLine(process.StandardError.ReadToEnd());
var waitHandles = new WaitHandle[1 + (AdditionalConditionToStopWaitingProcess == null ? 0 : 1)];
waitHandles[0] = new ProcessWaitHandle(process);
if (AdditionalConditionToStopWaitingProcess != null)
{
waitHandles[1] = AdditionalConditionToStopWaitingProcess;
}
bool hasSucceded = false;
int waitResult = WaitHandle.WaitAny(waitHandles, timeout);
if (waitResult == 1) // The wait has been interrrupted by an external event
{
if (IsAdditionalConditionToStopWaitingProcessShouldAlsoKill)
{
process.Kill();
}
}
else if (waitResult == 0) // Process has completed normally, no timeout or external event
{
// Ensure internal process code has completed like ensure to wait until stdout et stderr had been fully completed
hasSucceded = process.WaitForExit(timeout);
if (_outputWaitHandle.WaitOne(timeout) && _errorWaitHandle.WaitOne(timeout))
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.ExitCode = process.ExitCode;
processWithOutputCaptureResult.Output = _sbOutput.ToString();
processWithOutputCaptureResult.Error = _sbError.ToString();
}
}
else // Process timeout
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.HasTimeout = true;
}
}
catch (Exception ex)
{
if (ex.HResult == -2147467259)
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.Exception = new FileNotFoundException("File not found: " + executablePath, ex);
}
else
{
processWithOutputCaptureResult = new ProcessWithOutputCaptureResult();
processWithOutputCaptureResult.Exception = ex;
}
}
finally
{
if (asyncReadStarted)
{
process.CancelOutputRead();
process.CancelErrorRead();
}
process.OutputDataReceived -= ProcessOnOutputDataReceived;
process.ErrorDataReceived -= ProcessOnOutputDataReceived;
_outputWaitHandle.Close();
_outputWaitHandle.Dispose();
_errorWaitHandle.Close();
_errorWaitHandle.Dispose();
}
}
return processWithOutputCaptureResult;
}
// ************************************************************************
private void ProcessOnOutputDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
_outputWaitHandle.Set();
}
else
{
_sbOutput.AppendLine(e.Data);
}
}
// ************************************************************************
private void ProcessOnErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data == null)
{
_errorWaitHandle.Set();
}
else
{
_sbError.AppendLine(e.Data);
}
}
// ************************************************************************
}
}
用法(作为转发执行的应用程序):
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using HQ.Util.General;
using System.Reflection;
namespace ExecutionForwarder
{
class Program
{
static void Main(string[] args)
{
Stopwatch stopwatch = Stopwatch.StartNew();
Console.WriteLine($"App: {Assembly.GetEntryAssembly().FullName}");
Console.WriteLine($"Executing from folder: {Environment.CurrentDirectory}");
Console.WriteLine($"at: {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}");
Console.WriteLine($"With args: [{string.Join(" ", args.Skip(1))}]");
if (args.Length == 1 && args[0].ToLower().StartsWith("-delay:"))
{
int millisec;
if (Int32.TryParse(args[0].Substring(args[0].IndexOf(":") + 1), out millisec))
{
Console.WriteLine($"Sleeping for {millisec} milliseconds and will exit.");
Thread.Sleep(millisec);
}
else
{
Console.Error.WriteLine("Error while trying to read the delay.");
Environment.ExitCode = -99;
}
}
else
{
if (args.Length == 0)
{
Console.Error.WriteLine($"Can't forward execution. There is no argument (executable) provided.");
Environment.ExitCode = -99;
}
else
{
var result = ProcessExecutionWithOutputCapture.ExecuteWith(args[0], string.Join(" ", args.Skip(1)));
Console.Write(result.Output);
Console.Error.Write(result.Error);
Environment.ExitCode = result.ExitCode;
}
}
Console.WriteLine($"Done in {stopwatch.ElapsedMilliseconds} millisecs");
}
}
}