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 Overflow and Discourse. Disclaimer: I have no idea what I'm talking about. Find me here: https://infosec.exchange/@codinghorror