I have a method for executing batch scripts in memory by passing along a list of commands and executing them in a new Process
. I use this method for running things like psql
and gpg
commands and it works perfectly for my use-case so that I don't have to keep random .BAT
files lying around the network with user credentials and such.
The only "problem" is that I'm currently having to maintain several copies of the method for some of the relatively minor variations I require in the output or error handlers (.OutputDataReceived
and .ErrorDataReceived
). What I'd like to do is basically create a "BatchFile
" class that accepts a custom DataReceivedEventHandler
for these events via the constructor.
Here's the original code I'm currently copy/pasting every time I need to run a batch file:
Private Sub ExecuteBatchInMemory(ByVal Commands As List(Of String), ByVal CurrentUser As NetworkCredential)
Dim BatchStartInfo As New ProcessStartInfo
Dim BatchError As String = String.Empty
With BatchStartInfo
.FileName = "cmd.exe"
.WorkingDirectory = Environment.SystemDirectory
.Domain = CurrentUser.Domain
.UserName = CurrentUser.UserName
.Password = CurrentUser.SecurePassword
.UseShellExecute = False
.ErrorDialog = False
.WindowStyle = ProcessWindowStyle.Normal
.CreateNoWindow = False
.RedirectStandardOutput = True
.RedirectStandardError = True
.RedirectStandardInput = True
End With
Using BatchProcess As New Process
Dim BATExitCode As Integer = 0
Dim CommandIndex As Integer = 0
Dim ProcOutput As New Text.StringBuilder
Dim ProcError As New Text.StringBuilder
With BatchProcess
.StartInfo = BatchStartInfo
Using OutputWaitHandle As New Threading.AutoResetEvent(False)
Using ErrorWaitHandle As New Threading.AutoResetEvent(False)
Dim ProcOutputHandler = Sub(sender As Object, e As DataReceivedEventArgs)
If e.Data Is Nothing Then
OutputWaitHandle.Set()
Else
ProcOutput.AppendLine(e.Data)
End If
End Sub
'>> This is effectively the DataReceivedEventHandler for
' most of the "batch files" that execute psql.exe
Dim ProcErrorHandler = Sub(sender As Object, e As DataReceivedEventArgs)
If e.Data Is Nothing Then
ErrorWaitHandle.Set()
ElseIf e.Data.ToUpper.Contains("FAILED: ") Then
ProcError.AppendLine(e.Data)
End If
End Sub
AddHandler .OutputDataReceived, ProcOutputHandler
AddHandler .ErrorDataReceived, ProcErrorHandler
.Start()
.BeginOutputReadLine()
.BeginErrorReadLine()
While Not .HasExited
If .Threads.Count >= 1 AndAlso CommandIndex < Commands.Count Then
.StandardInput.WriteLine(Commands(Math.Min(System.Threading.Interlocked.Increment(CommandIndex), CommandIndex - 1)))
End If
End While
BATExitCode = .ExitCode
BatchError = ProcError.ToString.Trim
.WaitForExit()
RemoveHandler .OutputDataReceived, ProcOutputHandler
RemoveHandler .ErrorDataReceived, ProcErrorHandler
End Using
End Using
End With
If BATExitCode <> 0 OrElse (BatchError IsNot Nothing AndAlso Not String.IsNullOrEmpty(BatchError.Trim)) Then
Throw New BatchFileException(BATExitCode, $"An error occurred: {BatchError}")
End If
End Using
End Sub
Depending on what I'm trying to capture from the command-line for the specific batch file, I will modify either the ProcErrorHandler
or ProcOutputHandler
to look for specific values in e.Data
. In this particular example I'm looking for errors from GnuPG (gpg.exe
) that indicate a failure in either encryption or decryption of a file. For a psql
version, I might change the ProcErrorHandler
to look for FATAL
or something.
So, instead of defining the ProcOutputHandler
and ProcErrorHandler
in-line with the rest of the code, I've started on the BatchFile
class and it currently looks like this:
Imports System.Net
Public Class BatchFile
Implements IDisposable
Private STDOUTWaitHandle As Threading.AutoResetEvent
Private STDERRWaitHandle As Threading.AutoResetEvent
Private Disposed As Boolean
Private STDOUTHandler As DataReceivedEventHandler
Private STDERRHandler As DataReceivedEventHandler
Public Sub New()
Initialize()
End Sub
Public Sub New(ByVal OutputHandler As DataReceivedEventHandler, ByVal ErrorHandler As DataReceivedEventHandler)
Initialize()
STDOUTHandler = OutputHandler
STDERRHandler = ErrorHandler
End Sub
Public Sub Execute(ByVal Commands As List(Of String), Optional ByVal User As NetworkCredential = Nothing)
Dim BatchStartInfo As New ProcessStartInfo
Dim BatchError As String = String.Empty
Dim CurrentUser As NetworkCredential = User
If User Is Nothing Then
CurrentUser = CredentialCache.DefaultNetworkCredentials
End If
With BatchStartInfo
.FileName = "cmd.exe"
.WorkingDirectory = Environment.SystemDirectory
.Domain = CurrentUser.Domain
.UserName = CurrentUser.UserName
.Password = CurrentUser.SecurePassword
.UseShellExecute = False
.ErrorDialog = False
.WindowStyle = ProcessWindowStyle.Normal
.CreateNoWindow = False
.RedirectStandardOutput = True
.RedirectStandardError = True
.RedirectStandardInput = True
End With
Using BatchProcess As New Process
Dim BATExitCode As Integer = 0
Dim CommandIndex As Integer = 0
Dim ProcOutput As New Text.StringBuilder
Dim ProcError As New Text.StringBuilder
With BatchProcess
.StartInfo = BatchStartInfo
.EnableRaisingEvents = True
AddHandler .OutputDataReceived, STDOUTHandler
AddHandler .ErrorDataReceived, STDERRHandler
End With
End Using
End Sub
Private Sub Initialize()
STDOUTWaitHandle = New Threading.AutoResetEvent(False)
STDERRWaitHandle = New Threading.AutoResetEvent(False)
End Sub
Protected Overridable Sub Dispose(Disposing As Boolean)
If Not Disposed Then
If Disposing Then
If STDOUTWaitHandle IsNot Nothing Then
STDOUTWaitHandle.Dispose()
End If
If STDERRWaitHandle IsNot Nothing Then
STDERRWaitHandle.Dispose()
End If
End If
Disposed = True
End If
End Sub
Public Sub Dispose() Implements IDisposable.Dispose
Dispose(True)
GC.SuppressFinalize(Me)
End Sub
End Class
Where I'm running into an issue is trying to actually create the event handler methods to pass in to the constructor for assigning to the STDOUTHandler
and STDERRHANDLER
. I've looked at several different examples, including:
I'm probably just being dense, but I can't seem to figure out how to actually build and pass the handler method from outside of the BatchFile
class into the constructor since I don't have values to assign to the sender
and DataReceivedEventArgs
parameters of the handler.
I built a simple method:
Friend Sub TestHandler(ByVal sender as Object, ByVal e As DataReceivedEventArgs)
Console.WriteLine(e.Data)
End Sub
But, when I try to declare a new BatchFile
:
Dim testbatch As New BatchFile(TestHandler, TestHandler)
The compiler obviously throws an error indicating that the parameter arguments aren't specified. I also tried it with:
Dim testbatch As New BatchFile(DataReceivedEventHandler(AddressOf TestHandler), DataReceivedEventHandler(AddressOf TestHandler))
But that doesn't work because DataReceivedEventHandler
is a type and can't be used in an expression. Other variations I've tried meet with similar results, so I'm not sure what to do at this point. Any help or directions would be greatly appreciated.
Of course, there's still one "problem" with this that's outside of the scope of this question, and that is including the OutputWaitHandle
and ErrorWaitHandle
objects in the handler definitions from outside of the class, but I believe I can figure that one out once I get the handler method(s) to properly pass into my constructor.
Well, it seems I was just being dense, and I believe I just figured it out. Keeping the TestHandler
method from above, it appears that I was either trying to make it too simple or too complicated. After reading Microsoft's How to: Pass Procedures to Another Procedure in Visual Basic, I realized I didn't need to specify the DataReceivedEventHandler
as a part of the parameter definition in the constructor call, but I did need to use the AddressOf
syntax to correctly assign the method definition. The BatchFile
declaration that looks like it's going to work is as follows:
Dim testbatch As New BatchFile(AddressOf TestHandler, AddressOf TestHandler)
I've run a quick test using some simple commands for a GnuPG decryption, and everything appears to have worked exactly as intended. I got the results of STDOUT printed to the console and, when I intentionally introduced a logic error, it printed the contents of the STDERR to the console.
I realize this was a "simple fix", but because I had gone through as many iterations as I had, I was flailing a bit. In an effort to prevent someone else from going through the same frustrations, I'm going to leave this question/answer here with the full working code:
Module BatchCommandTest
Sub Main()
Dim testbatch As New BatchFile(AddressOf TestHandler, AddressOf TestHandler)
testbatch.Execute(New List(Of String) From {"CLS", "C:\GnuPG\gpg.exe --batch --verbose --passphrase <SECRET PASSWORD> --output ""C:\Temp\mytest.pdf"" --decrypt ""C:\Temp\test.pgp""", "EXIT"}, CredentialCache.DefaultNetworkCredentials)
End Sub
Friend Sub TestHandler(ByVal sender As Object, ByVal e As DataReceivedEventArgs)
Console.WriteLine(e.Data)
End Sub
End Module
BATCHFILE
CLASSImports System.Net
Public Class BatchFile
Implements IDisposable
Private STDOUTWaitHandle As Threading.AutoResetEvent
Private STDERRWaitHandle As Threading.AutoResetEvent
Private Disposed As Boolean
Private STDOUTHandler As DataReceivedEventHandler
Private STDERRHandler As DataReceivedEventHandler
Public Sub New()
Initialize()
End Sub
Public Sub New(ByVal OutputHandler As Action(Of Object, DataReceivedEventArgs), ByVal ErrorHandler As Action(Of Object, DataReceivedEventArgs))
Initialize()
STDOUTHandler = TryCast(Cast(OutputHandler, GetType(DataReceivedEventHandler)), DataReceivedEventHandler)
STDERRHandler = TryCast(Cast(ErrorHandler, GetType(DataReceivedEventHandler)), DataReceivedEventHandler)
End Sub
Public Sub Execute(ByVal Commands As List(Of String), Optional ByVal User As NetworkCredential = Nothing)
Dim BatchStartInfo As New ProcessStartInfo
Dim BatchError As String = String.Empty
Dim CurrentUser As NetworkCredential = User
If User Is Nothing Then
CurrentUser = CredentialCache.DefaultNetworkCredentials
End If
With BatchStartInfo
.FileName = "cmd.exe"
.WorkingDirectory = Environment.SystemDirectory
.Domain = CurrentUser.Domain
.UserName = CurrentUser.UserName
.Password = CurrentUser.SecurePassword
.UseShellExecute = False
.ErrorDialog = False
.WindowStyle = ProcessWindowStyle.Normal
.CreateNoWindow = False
.RedirectStandardOutput = True
.RedirectStandardError = True
.RedirectStandardInput = True
End With
Using BatchProcess As New Process
Dim BATExitCode As Integer = 0
Dim CommandIndex As Integer = 0
Dim ProcOutput As New Text.StringBuilder
Dim ProcError As New Text.StringBuilder
With BatchProcess
.StartInfo = BatchStartInfo
AddHandler .OutputDataReceived, STDOUTHandler
AddHandler .ErrorDataReceived, STDERRHandler
.Start()
.BeginOutputReadLine()
.BeginErrorReadLine()
While Not .HasExited
If .Threads.Count >= 1 AndAlso CommandIndex < Commands.Count Then
.StandardInput.WriteLine(Commands(Math.Min(System.Threading.Interlocked.Increment(CommandIndex), CommandIndex - 1)))
End If
End While
.WaitForExit()
BATExitCode = .ExitCode
BatchError = ProcError.ToString.Trim
RemoveHandler .OutputDataReceived, STDOUTHandler
RemoveHandler .ErrorDataReceived, STDERRHandler
If BATExitCode <> 0 OrElse Not (BatchError Is Nothing OrElse String.IsNullOrEmpty(BatchError.Trim)) Then
Throw New BatchFileException(BATExitCode, $"An error occurred executing the in-memory batch script: {BatchError}")
End If
End With
End Using
End Sub
Private Sub Initialize()
STDOUTWaitHandle = New Threading.AutoResetEvent(False)
STDERRWaitHandle = New Threading.AutoResetEvent(False)
End Sub
Protected Overridable Sub Dispose(Disposing As Boolean)
If Not Disposed Then
If Disposing Then
If STDOUTWaitHandle IsNot Nothing Then
STDOUTWaitHandle.Dispose()
End If
If STDERRWaitHandle IsNot Nothing Then
STDERRWaitHandle.Dispose()
End If
End If
Disposed = True
End If
End Sub
Public Sub Dispose() Implements IDisposable.Dispose
Dispose(True)
GC.SuppressFinalize(Me)
End Sub
' Cast() method from Faithlife Code Blog (https://faithlife.codes/blog/2008/07/casting_delegates/)
Function Cast(ByVal source As [Delegate], ByVal type As Type) As [Delegate]
If source Is Nothing Then
Return Nothing
End If
Dim delegates As [Delegate]() = source.GetInvocationList()
If delegates.Length = 1 Then
Return [Delegate].CreateDelegate(type, delegates(0).Target, delegates(0).Method)
End If
Dim delegatesDest As [Delegate]() = New [Delegate](delegates.Length - 1) {}
For nDelegate As Integer = 0 To delegates.Length - 1
delegatesDest(nDelegate) = [Delegate].CreateDelegate(type, delegates(nDelegate).Target, delegates(nDelegate).Method)
Next
Return [Delegate].Combine(delegatesDest)
End Function
End Class
You'll note a couple of important differences in this "final" iteration from what I originally posted:
Execute()
method of the BatchFile
class is a bit more "complete" to match the functionality from the original ExecuteBatchInMemory()
methodAction(Of Object, DataReceivedEventArgs)
instead of the DataReceivedEventHandler
type for the parameters. I guess I made that change at some point but forgot to note it anywhere else, so I wanted to point it out here.Cast()
method from the Casting delegates post on Faithlife Code Blog to get the Action(Of Object, DataReceivedEventArgs)
parameter cast to the specific, correct DataReceivedEventHandler
type so that the method can subscribe/unsubscribe to it as required.