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:
- How can I pass EventHandler as a method parameter on StackOverflow
- How to pass a delegate as an argument to subscribe as an event handler? on StackOverflow
- VB.NET Add Any EventHandler Type with Delegate on StackOverflow
- Pass action delegate as parameter in C# on StackOverflow
- Casting delegates on Faithlife Code Blog
- Passing a Function as a Parameter Using C# on Linux Hint
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
TestHandlermethod 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 theDataReceivedEventHandleras a part of the parameter definition in the constructor call, but I did need to use theAddressOfsyntax to correctly assign the method definition. TheBatchFiledeclaration that looks like it's going to work is as follows: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:
MAIN CONSOLE APPLICATION MODULE
BATCHFILECLASSYou'll note a couple of important differences in this "final" iteration from what I originally posted:
Execute()method of theBatchFileclass is a bit more "complete" to match the functionality from the originalExecuteBatchInMemory()methodAction(Of Object, DataReceivedEventArgs)instead of theDataReceivedEventHandlertype 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 theAction(Of Object, DataReceivedEventArgs)parameter cast to the specific, correctDataReceivedEventHandlertype so that the method can subscribe/unsubscribe to it as required.