Wednesday 27 November 2019

C++, VBA, WinApi - Signal another process with WinApi Synchronization Event

In this post I give code where a C++ program signals to a VBA program running in a separate process. This demonstrates inter-process communication (IPC) using the native Windows API (and not related to COM).

I do like playing with the Windows API and in this instance I am playing with a Windows API Event which is used for synchronization; it is not to be confused with COM events. A good definition of an Event is given by this doc page ...

Applications can use event objects in a number of situations to notify a waiting thread of the occurrence of an event. For example, overlapped I/O operations on files, named pipes, and communications devices use an event object to signal their completion.

I needed this for a use case where I wanted to signal to a child process to terminate. So instead of using brute force to terminate the child process, I signal an event which is periodically inspected and if signalled the child process will terminate cleanly and voluntarily.

Before we get to that use case (which I intend to post shortly) I wanted to post the details of ...

In the first (C++) process that hosts the event...
  • Creating an event, including creating a security descriptor.
  • Setting the event
  • Resetting the event
In the second (VBA) process...
  • Opening the event
  • Waiting for event

Code for the C++ process that hosts the event

I have moved some code the error reporting code to Appendix A, the remainder of code is relevant to the teaching point of this post.

The first thing required is to create a security descriptor object (of type SECURITY_DESCRIPTOR). The security descriptor is created on the heap using LocalAlloc and so has to be freed before the program exits; then it has to be initialized with a call to InitializeSecurityDescriptor(). Next an access control list (ACL) has to be set for the security descriptor or not! I have saved plenty of code by opting not to use an access control list (ACL). I have seen plenty of voluminous code which does program an ACL but I did not want to distract from the main teaching point. In production you'd likely want to use an ACL to secure the Event object. I'm skipping the ACL and so I pass NULL into 3rd argument of SetSecurityDescriptorDacl() and set the 2nd argument to FALSE to show I intended not to pass an ACL.

Then the security descriptor is added to a parent structure called security attributes (of type SECURITY_ATTRIBUTES).

It is worth stressing that in the post the bulk of the headaches in getting this to work was with the security. Once the security attributes structure is created (with its nested security descriptor) then we are good to start calling CreatEvent() which in comparison is straightforward. The call to CreatEvent() specifies that the Event object is to be signalled manually and that its initial state is False, i.e. not signalled/not set. The final argument to CreatEvent() is the name of the object.

After creating the event there is a line of code to SetEvent() which sets or signals the event. Then, the Event object needs to be reset and so the call to ResetEvent. If you are playing along then you will want to place a breakpoint on the SetEvent() line of code.

So the following code can be pasted into a C++ console app.

#include "pch.h"
#include <iostream>
#include <Windows.h>
#include <strsafe.h>

void ErrorExit(LPCTSTR lpszFunction)
{
 // Omitted , see Appendix A
}

//https://docs.microsoft.com/en-us/windows/win32/secauthz/creating-a-security-descriptor-for-a-new-object-in-c--
//https://stackoverflow.com/questions/49253537/the-createfilemapping-is-failed-with-security-attributes-the-revision-level-is

int main()
{
 PSECURITY_DESCRIPTOR pSD = NULL;
 // Initialize a security descriptor.  
 pSD = (PSECURITY_DESCRIPTOR)LocalAlloc(LPTR,
  SECURITY_DESCRIPTOR_MIN_LENGTH);
 if (NULL == pSD)
 {
  ErrorExit(TEXT("LocalAlloc"));
 }

 if (NULL == InitializeSecurityDescriptor(pSD, SECURITY_DESCRIPTOR_REVISION))
 {
  ErrorExit(TEXT("InitializeSecurityDescriptor"));
 }

 // Add the ACL to the security descriptor. 
 if (!SetSecurityDescriptorDacl(pSD,
  FALSE,     // bDaclPresent flag   
  NULL,      // We're passing NULL to say there is no Access Control List for this object
  FALSE))   // not a default DACL 
 {
  ErrorExit(TEXT("SetSecurityDescriptorDacl"));
 }

 SECURITY_ATTRIBUTES sa;
 // Initialize a security attributes structure.
 sa.nLength = sizeof(SECURITY_ATTRIBUTES);
 sa.lpSecurityDescriptor = pSD;
 sa.bInheritHandle = FALSE;

 /*
  * Use this following line to abolish any mention of security attributes/descriptors
  * HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, TEXT("C++ExcelVBAIPC"));
  */
 HANDLE hEvent = CreateEvent(&sa, TRUE, FALSE, TEXT("C++ExcelVBAIPC"));
 if (0 == hEvent)
 {
  ErrorExit(TEXT("CreateEvent"));
 }
 else
 {
  // PLACE BREAKPOINT HERE TO EXPERIMENT WITH A CLIENT CALLING WaitForSingleObject
  SetEvent(hEvent);
 }
 ResetEvent(hEvent);
 CloseHandle(hEvent);
        std::cout << "Finished!\n"; 

 // free any allocated heap memory
 if (pSD)
  LocalFree(pSD);

}

Code for the VBA ++ process that waits on the event

The following VBA code can sit in Excel or Word. It requires the GetSystemErrorMessageText module from Chip Pearson to give nice error messages for any errors encountered using the WinAPI, I recommend!

The code is relatively simple. It opens an event with OpenEvent() specifying SYNCHRONIZE permissions only. Then the code calls WaitForSingleObject(). In this code we pass -1 which means wait indefinitely but in a more realistic scenario one would pass a positive integer of milliseconds for how long to wait.

If you are playing along with the breakpoint in the C++ code then the VBA code will wait forever (hang) so do please save your work! The VBA code will progress once the event object is signalled on the C++ side, so switch back to the C++ code and make it continue then the VBA code will continue. If running the experiment multiple times don't forget to let the C++ side call ResetEvent (or pass the C++ CloseHandle() statement) otherwise the Event object will remain signalled.

On detroying Event objects a good quote here from this page ...

Use the CloseHandle function to close the handle. The system closes the handle automatically when the process terminates. The event object is destroyed when its last handle has been closed.
Option Explicit

Private Const SYNCHRONIZE As Long = &H100000

Private Declare Function CloseHandle Lib "kernel32.dll" (ByVal hObject As Long) As Long

Private Declare Function OpenEvent Lib "kernel32.dll" Alias "OpenEventA" (ByVal dwDesiredAccess As Long, _
        ByVal bInheritHandle As Long, ByVal lpName As String) As Long

Private Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long

Private Enum eWaitResult
    WAIT_ABANDONED = &H80       'The specified object is a mutex object that was not released by the thread that owned the mutex
    WAIT_OBJECT_0 = &H0         'The state of the specified object is signaled.
    WAIT_TIMEOUT = &H102        'The time-out interval elapsed, and the object's state is nonsignaled.
    WAIT_FAILED = &HFFFFFFFF    'The function has failed. To get extended error information, call GetLastError.
End Enum

Private hEvent As Long

Sub OpenEventAndWait()

    hEvent = OpenEvent(SYNCHRONIZE, 0, "C++ExcelVBAIPC")
    If hEvent = 0 Then
        Debug.Print "Failed in call to OpenEvent."

        // requires http://www.cpearson.com/Excel/FormatMessage.aspx
        Debug.Print GetSystemErrorMessageText(Err.LastDllError) 
    Else
        Stop
        Dim dwWaitResult As eWaitResult
        dwWaitResult = WaitForSingleObject(hEvent, -1)
        
        Stop
        Select Case dwWaitResult
        
        Case eWaitResult.WAIT_OBJECT_0
            Debug.Print "The state of the specified object is signaled."
        Case eWaitResult.WAIT_TIMEOUT
            Debug.Print "wait timeout"
            
        Case eWaitResult.WAIT_FAILED
            Debug.Print "wait failed"
            Debug.Print GetSystemErrorMessageText(Err.LastDllError)
        Case eWaitResult.WAIT_ABANDONED
            Debug.Print "wait abandoned"
        End Select
        'Stop
        Call CloseHandle(hEvent)
    End If
    
    'Stop
End Sub

Conclusions and other thoughts

So even this post with its stripped down permissioning requires a lot of explanation. If you feel cheated in that I have skipped code regarding security and access control lists then I recommend this link which has some relevant code.

Now that I have given explanation for this code I can now proceed with the post where I spawn a child process and when I need to terminate it I signal using an Event object. That code will be VBA creating the event whilst a Python script polls the event.

UPDATE: it looks like one can pass NULL instead of a security attributes object into CreateEvent and this also has no permissions. Oh well, I'm not going to strip out the security attributes code because I hope to upgrade it with some ACL code one day. But it does mean the VBA equivalent should be much easier to write and I will slip in another post to show this.

Appendix A - Extra C++ code which calls GetLastError, formats the error message and throws an message box

So this code was taken out of the full C++ listing given above in order not to distract from the main teaching point. Nevertheless it is useful.

//With thanks to https://docs.microsoft.com/en-us/windows/win32/debug/retrieving-the-last-error-code

#include <strsafe.h>

void ErrorExit(LPCTSTR lpszFunction)
{
 // Retrieve the system error message for the last-error code

 LPVOID lpMsgBuf;
 LPVOID lpDisplayBuf;
 DWORD dw = GetLastError();

 FormatMessage(
  FORMAT_MESSAGE_ALLOCATE_BUFFER |
  FORMAT_MESSAGE_FROM_SYSTEM |
  FORMAT_MESSAGE_IGNORE_INSERTS,
  NULL,
  dw,
  MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
  (LPTSTR)&lpMsgBuf,
  0, NULL);

 // Display the error message and exit the process

 lpDisplayBuf = (LPVOID)LocalAlloc(LMEM_ZEROINIT,
  (lstrlen((LPCTSTR)lpMsgBuf) + lstrlen((LPCTSTR)lpszFunction) + 40) * sizeof(TCHAR));
 StringCchPrintf((LPTSTR)lpDisplayBuf,
  LocalSize(lpDisplayBuf) / sizeof(TCHAR),
  TEXT("%s failed with error %d: %s"),
  lpszFunction, dw, lpMsgBuf);
 MessageBox(NULL, (LPCTSTR)lpDisplayBuf, TEXT("Error"), MB_OK);

 LocalFree(lpMsgBuf);
 LocalFree(lpDisplayBuf);
 ExitProcess(dw);
}

No comments:

Post a Comment