Coding Horror

programming and human factors

Shellicious

I mentioned in a previous post that I was launching command line utilities from an ASP.NET web app and capturing the output. I wrote a little multithreaded .Process wrapper class to encapsulate this behavior. It's nothing magical, but it is handy for these scenarios:

  
Dim cmd As String  
cmd = "whoami.exe"  
Dim s As New Shell  
Console.WriteLine("executing " & cmd)  
s.Execute(cmd)  
Console.WriteLine("output:")  
Console.Write(s.Output)  
Console.WriteLine("error:")  
Console.Write(s.Error)  
Console.WriteLine("execution took " & _  
s.ExecutionTime.ToString & " milliseconds")  
Console.WriteLine("exit code was " & _  
s.ExitCode.ToString)  
Console.ReadLine()  

Don't forget to set the .WorkingDirectory if your executables aren't in the default path.

The Shell class is currently synchronous-- code execution halts until the command returns or times out. If you have long running console processes, you might want to make this class asynchronous (eg, non-blocking) and raise events for things like console lines being written, command terminating, etcetera. This was added.

Code follows...

  
Imports System.Text  
Imports System.IO  
Imports System.Diagnostics  
Imports System.Threading  
''' <summary>
''' Execute a command line string and return the output and/or error.
''' </summary>
Public Class Shell  
Implements IDisposable  
Private _p As Process  
Private _intMaxWaitMs As Integer = 120000  
Private _blnDisposed As Boolean = False  
Private _OutputBuilder As StringBuilder  
Private _ErrorBuilder As StringBuilder  
Private _blnGetOutput As Boolean = True  
Private _blnGetError As Boolean = True  
Private _blnLaunchInThread As Boolean = False  
Private _strWorkingDirectory As String  
Private _StartTime As DateTime  
Private _blnCancelRequested As Boolean = False  
Private Const _intSleepMs As Integer = 200  
Private _OutputThread As Thread  
Private _ErrorThread As Thread  
Private _blnProcessLaunched As Boolean = False  
Public Event OutputLine(ByVal LineText As String)  
Public Event ExecutionComplete(ByVal TimedOut As Boolean)  
''' <summary>
''' The working directory to be used by the process that is launched.
''' If left blank, will default to the whatever the current path is.
''' </summary>
Public Property WorkingDirectory() As String  
Get  
Return _strWorkingDirectory  
End Get  
Set(ByVal Value As String)  
_strWorkingDirectory = Value  
End Set  
End Property  
''' <summary>
''' capture any returned output from the command into the .Output string
''' </summary>
Public Property CaptureOutput() As Boolean  
Get  
Return _blnGetOutput  
End Get  
Set(ByVal Value As Boolean)  
_blnGetOutput = Value  
End Set  
End Property  
''' <summary>
''' capture any returned errors from the command into the .Error string
''' </summary>
Public Property CaptureError() As Boolean  
Get  
Return _blnGetError  
End Get  
Set(ByVal Value As Boolean)  
_blnGetError = Value  
End Set  
End Property  
''' <summary>
''' Maximum number of seconds to wait for the process to finish running.
''' Use Integer.MaxValue to specify infinite wait.
''' If the process is not finished in this time, it will be automatically killed.
''' </summary>
Public Property MaximumWaitSeconds() As Integer  
Get  
Return Convert.ToInt32(_intMaxWaitMs / 1000)  
End Get  
Set(ByVal Value As Integer)  
_intMaxWaitMs = Value * 1000  
End Set  
End Property  
''' <summary>
''' execute the command in a seperate thread, synchronously; if not set, execution is asynchronous (blocking)
''' </summary>
Public Property UseNewThread() As Boolean  
Get  
Return _blnLaunchInThread  
End Get  
Set(ByVal Value As Boolean)  
_blnLaunchInThread = Value  
End Set  
End Property  
''' <summary>
''' any returned output from the command. Only provided if .CaptureOutput is True.
''' </summary>
Public ReadOnly Property Output() As String  
Get  
If _OutputBuilder Is Nothing Then  
Return ""  
Else  
Return _OutputBuilder.ToString  
End If  
End Get  
End Property  
''' <summary>
''' any returned errors from the command. Only provided if .CaptureError is True.
''' </summary>
Public ReadOnly Property [Error]() As String  
Get  
If _ErrorBuilder Is Nothing Then  
Return ""  
Else  
Return _ErrorBuilder.ToString  
End If  
End Get  
End Property  
''' <summary>
''' command execution time in milliseconds. Returns zero until execution is complete.
''' </summary>
Public ReadOnly Property ExecutionTime() As Integer  
Get  
If _p Is Nothing Then Return 0  
If Not ProcessHasExited() Then Return 0  
Return Convert.ToInt32(New TimeSpan(_p.ExitTime.Ticks - _StartTime.Ticks).TotalMilliseconds)  
End Get  
End Property  
''' <summary>
''' exit code for the command. Returns -1 until execution is complete.
''' </summary>
''' <remarks>
''' Developers usually indicate a successful exit by an ExitCode value of zero, and designate errors by nonzero
''' values that the calling method can use to identify the cause of an abnormal process termination.
''' It is not necessary to follow these guidelines, but they are the convention.
''' </remarks>
Public ReadOnly Property ExitCode() As Integer  
Get  
If _p Is Nothing Then Return -1  
If Not ProcessHasExited() Then Return -1  
Return _p.ExitCode  
End Get  
End Property  
''' <summary>
''' Executes a command line and waits for it to finish. Check .Error and .Output for results.
''' Set .WorkingDirectory if your command is not fully pathed, or not in the path on this machine.
''' </summary>
''' <param name="Command">valid command line string to execute</param>
Public Sub Execute(ByVal Command As String)  
StartProcess("cmd.exe", "/c """ & Command & """")  
End Sub  
''' <summary>
''' Cancels execution of the command if it is still running
''' </summary>
Public Sub CancelExecution()  
_blnCancelRequested = True  
End Sub  
Private Function ProcessHasExited() As Boolean  
If _p Is Nothing Then  
Return True  
End If  
Return _p.HasExited  
End Function  
Private Sub LaunchThreadHandler()  
'-- launch process
_p.Start()  
_blnProcessLaunched = True  
WaitForExit()  
End Sub  
Private Sub OutputThreadHandler()  
Dim strLine As String  
'-- this will run forever until the thread is aborted or suspended; this is by design
Do While True  
If _blnProcessLaunched Then  
If _p Is Nothing Then Exit Do  
If _blnCancelRequested Then Exit Do  
strLine = _p.StandardOutput.ReadLine  
If Not strLine Is Nothing Then  
_OutputBuilder.Append(strLine)  
_OutputBuilder.Append(Environment.NewLine)  
RaiseEvent OutputLine(strLine)  
Else  
'-- suspend
Thread.Sleep(0)  
End If  
Else  
Thread.Sleep(20)  
End If  
Loop  
End Sub  
Private Sub ErrorThreadHandler()  
Dim strLine As String  
'-- this will run forever until the thread is aborted or suspended; this is by design
Do While True  
If _blnProcessLaunched Then  
If _p Is Nothing Then Exit Do  
If _blnCancelRequested Then Exit Do  
strLine = _p.StandardError.ReadLine  
If Not strLine Is Nothing Then  
_ErrorBuilder.Append(strLine)  
_ErrorBuilder.Append(Environment.NewLine)  
Else  
'-- suspend
Thread.Sleep(0)  
End If  
Else  
Thread.Sleep(20)  
End If  
Loop  
End Sub  
Private Sub StartProcess(ByVal strFileName As String, Optional ByVal strArguments As String = "")  
Dim LaunchThread As Thread  
_p = New Process  
With _p.StartInfo  
If Not _strWorkingDirectory Is Nothing Then  
.WorkingDirectory = _strWorkingDirectory
End If  
.FileName = strFileName
.Arguments = strArguments
.UseShellExecute = False
.CreateNoWindow = True
.RedirectStandardOutput = _blnGetOutput
.RedirectStandardError = _blnGetError
End With  
_StartTime = DateTime.Now  
If _blnLaunchInThread Then  
LaunchThread = New Thread(New ThreadStart(AddressOf LaunchThreadHandler))  
LaunchThread.Name = "ShellLaunchThread"  
LaunchThread.Start()  
Else  
_p.Start()  
_blnProcessLaunched = True  
End If  
'-- spawn threads to read in output and error as they are created
If _blnGetOutput Then  
_OutputBuilder = New StringBuilder  
_OutputThread = New Thread(New ThreadStart(AddressOf OutputThreadHandler))  
_OutputThread.Name = "ShellOutputThread"  
_OutputThread.Start()  
End If  
If _blnGetError Then  
_ErrorBuilder = New StringBuilder  
_ErrorThread = New Thread(New ThreadStart(AddressOf ErrorThreadHandler))  
_ErrorThread.Name = "ShellErrorThread"  
_ErrorThread.Start()  
End If  
If LaunchThread Is Nothing Then  
WaitForExit()  
End If  
End Sub  
Private Sub WaitForExit()  
'-- wait for process to exit, or else we time out
_blnCancelRequested = False  
Dim intWaitedMs As Integer = 0  
Do While (Not ProcessHasExited()) And (intWaitedMs < _intMaxWaitMs) And (Not _blnCancelRequested)  
Thread.Sleep(_intSleepMs)  
intWaitedMs += _intSleepMs  
Loop  
CloseThreads()  
'-- if we timed out, kill the process
If (intWaitedMs >= _intMaxWaitMs) Or _blnCancelRequested Then  
_p.Kill()  
RaiseEvent ExecutionComplete(True)  
Else  
RaiseEvent ExecutionComplete(False)  
End If  
End Sub  
Private Sub CloseThreads()  
If Not _OutputThread Is Nothing Then  
If _OutputThread.IsAlive() Then  
_OutputThread.Abort()  
End If  
_OutputThread = Nothing  
End If  
If Not _ErrorThread Is Nothing Then  
If _ErrorThread.IsAlive() Then  
_ErrorThread.Abort()  
End If  
_ErrorThread = Nothing  
End If  
End Sub  
#Region "  Destructor"
Public Overloads Sub Dispose() Implements System.IDisposable.Dispose  
Dispose(False)  
GC.SuppressFinalize(Me)  
End Sub  
Protected Overridable Overloads Sub Dispose(ByVal IsFinalizer As Boolean)  
If Not _blnDisposed Then  
If IsFinalizer Then  
End If  
If Not _p Is Nothing Then  
_p.Close()  
_p = Nothing  
End If  
CloseThreads()  
End If  
_blnDisposed = True  
End Sub  
Protected Overrides Sub Finalize()  
Dispose(True)  
End Sub  
#End Region
End Class  

Written by Jeff Atwood

Indoor enthusiast. Co-founder of Stack Exchange and Discourse. Disclaimer: I have no idea what I'm talking about. Find me here: http://twitter.com/codinghorror