Shellicious

November 19, 2004

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

Posted by Jeff Atwood
9 Comments

Hi,

I am in process of creating a script in vb.net that will backup our sharepoint sites/databases etc... I found your code on the internet and it seems to be very helpful...however i try to dim s as New Shell and Visual Studio underlines the object Shell as if the namespace is not imported or something. Do you think that is the problem? If so, what is the namespaces that i have to import? There are two commands that i will be running in the dos command console and i want to capture the output of the commands to write into a log file. So, it seems like some of the code that you have here would be quite useful and helpful for me to do this. Any help would be greatly appreciated.

Thanks,

dave

Dave on April 4, 2005 2:44 AM

Hello-- you need to add the second file (starting with "Public Class Shell") as a class file to your solution.

Jeff Atwood on April 4, 2005 12:02 PM

Brilliant. Just what I was looking for.

Ed Manet on March 29, 2006 10:15 AM

Yeah, what Ed said. Exactly what I needed. You rock yet again Mr Atwood.

You appear above the fold when googling for "vb.net capture shell output" (sans quotes) too, nifty :)

Dan F on April 12, 2006 4:30 AM

Very nice work. I needed a quick way to execute shell commands and capture the output, and this fitted the bill nicely.

Thanks!

James Shields on May 23, 2006 4:16 AM

You are the man. Exactly what I was looking for.

Mike Adkins on May 24, 2006 10:00 AM

That is some high-speed stuff right there. Thank you Sir.

PirateCodeMonkey on January 13, 2008 9:21 AM

can u tell me how to open a network file using impersonation

raj on August 28, 2008 5:04 AM

Can u tell me how to Open the Network files using Imeprsonation in vb.net

raj on August 28, 2008 5:05 AM

The comments to this entry are closed.