Search code examples
vb.netconstructordelegatesparameter-passingeventhandler

Pass event handler to object constructor as an argument


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.


Solution

  • 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:


    MAIN CONSOLE APPLICATION MODULE

    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 CLASS

    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 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:

    1. The Execute() method of the BatchFile class is a bit more "complete" to match the functionality from the original ExecuteBatchInMemory() method
    2. The constructor now uses the type Action(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.
    3. I'm using the 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.