Frustrations with command-line add-ins for Visual Studio

Here is another frustration when working with the automation model of Visual Studio: suppose that you want to make an add-in that is loaded before a command-line build (for example using devenv.exe mysolution.sln /build Debug) to perform some checkings that, if failed, should abort the build. Quite reasonable, isn’t it?. So, how do you approach this? Any programmer familiar with the automation model knows that the add-in wizard offers an option to create command-line add-ins and that the OnConnection method has a connectMode = ext_ConnectMode.ext_cm_CommandLine value, so that seems the way to go. But it happens that:

  • VS.NET 2002 does never receive that value, even when you launch a build from the command line. I reported this bug long time ago: BUG: ext_ConnectMode.ext_cm_CommandLine flag not passed to Visual Studio .NET 2002 add-ins.
  • VS.NET 2003 fixed that bug, at least apparently. Further experimentation has shown that if the solution to build is very small (for example, the default ClassLibrary project created by VS) then the build is performed without the add-in loaded !!! If the solution is bigger so that the compilation is not so immediate, then the add-in is loaded (you can test all this if you create a simple add-in with a messagebox in the OnConnection and OnDisconnection methods). Apparently it seems that add-ins are not loaded before the build begins but some time later, maybe in some parallel fashion. This, of course, ruins this approach.
  • VS 2005 has a somewhat different bug and the add-in is never loaded, no matter the size of the solution. I reported this here yesterday:

https://connect.microsoft.com/VisualStudio/feedback/ViewFeedback.aspx?FeedbackID=303803

(UPDATE August 17, 2008: Microsoft released a hotfix for this problem after VS 2005 SP1: FIX: An command-line add-in does not load when you start the Visual Studio 2005 IDE)

  • VS 2008 (Beta 2) seems to fix all the problems of all predecessors and it loads the add-in even for builds of small solutions (I have not tested it very much, so I could be wrong)
  • Bets are open about the behavior in the future VS 2010 or whatever new version… 😉

Well, let’s assume that the behavior is correct and the add-in gets loaded. How do you write some results to the log file or the console output that Visual Studio uses (with the /out switch)? While I was able to write to the Output window when using the full IDE, I was unable to write to the log file. After some searching it seems that Console.Write should do the trick:

http://www.dotnet247.com/247reference/msgs/30/151588.aspx

But I was unable to succeed. Maybe someone from Microsoft can verify if this indeed works…

OK, the add-in can write to a different log file, no big deal. Now, how can the add-in cancel the build if it needs to? It happens that, in an apparent terrible oversight, the Build events lack a cancel parameter:

HOWTO: Canceling a build from a Visual Studio .NET add-in.
http://www.mztools.com/articles/2005/MZ2005010.aspx

No big deal because you can use DTE.ExecuteCommand(“Build.Cancel”)…. Alas, when the IDE is run for a command-line build, commands are not created.

Then you can try some ugly TerminateProcess API call, but it seems that it won’t work properly…

So, why on earth does the automation model provide a command-line connectMode that, when you are lucky enough that your VS version honors it, is basically useless? It would be nice if the Microsoft people could address all this in the automation model (I’m sure that an SDK package can handle all this OK)…

Well, after some relaxing time, I got a new idea. Forget the command-line add-in. Let’s try another approach. I don’t really care about the build. I just want some command-line utility to automate Visual Studio to perform a review before another command-line utility (devenv.exe) perform the build. If the first utility reports some error, my script does not execute the build. Sometime ago I wrote an article about how to automate Visual Studio from the outside:

HOWTO: Automating Visual Studio .NET from outside the IDE
http://www.mztools.com/articles/2005/MZ2005005.aspx

So, the idea is:

  • Create an add-in that offers a public method Sub Review(ByVal logFileName As String) in the Connect class.
  • Create a console application that receives a solution file name, examines its format (“7.00”, “8.00”, “9.00”, “10.00”) to guess the Visual Studio ProgID to use (VisualStudio.DTE.7, VisualStudio.DTE.7.1, VisualStudio.DTE.8.0, VisualStudio.DTE.9.0) (don’t ask me why the solution format numbers and the ProgID numbers don’t match), creates the DTE object as the article above explains, iterates its DTE.AddIns collection and locates the add-in by the AddIn.ProgId property, call its Connect = True property to load it, calls DTE.Solution.Open(solutionFileName) to load the solution, gets the AddIn.Object property to get the instance of the class that represents the add-in and then calls its Review(logFileName) method. Sounds feasible, right?

The first problem is that AddIn.Object returns a System.__ComObject that can not be converted to the .NET type of the Connect class of the add-in, even if your console application has a reference to the add-in dll. I am not a COM expert, but since this works with in-process DLLs, it can only be that such casts are not allowed from one process to another. Fortunately, you can call the Review method using late binding (Option Strict Off) in VB.NET, or using InvokeMethod of reflection using C# or VB.NET.

So, basically, I got this working…most of the time :-(. I noticed that from time to time, I get COMExceptions of the kind ‘Call was Rejected By Callee’ (that is, RPC_E_CALL_REJECTED). Ummmhh, more frustation. After some searching, it happens that that’s normal automating COM servers and fortunately the MSDN docs of Visual Studio report this problem and solution:

Fixing ‘Application is Busy’ and ‘Call was Rejected By Callee’ Errors
http://msdn2.microsoft.com/en-us/library/ms228772(VS.80).aspx

Kudos to the Microsoft guy that documented that. I didn’t know about that esoteric IMessageFilter::RetryRejectedCall stuff but it seems to work.

I am posting the code so other add-in developers facing the same problem can report problems or improvements:

The add-in (named CommandLineAddIn) VB.NET code is:

Imports System
Imports Microsoft.VisualStudio.CommandBars
Imports Extensibility
Imports EnvDTE
Imports EnvDTE80

Public Class Connect
   Implements IDTExtensibility2

   Private m_DTE As DTE

   Private Sub OnConnection(ByVal application As Object, ByVal connectMode As ext_ConnectMode, ByVal addInInst As Object, ByRef custom As Array) Implements IDTExtensibility2.OnConnection

      ' MessageBox.Show(connectMode.ToString)

      m_DTE = DirectCast(application, DTE)

   End Sub

   Private Sub OnDisconnection(ByVal disconnectMode As ext_DisconnectMode, ByRef custom As Array) Implements IDTExtensibility2.OnDisconnection

      ' MessageBox.Show(disconnectMode.ToString)

   End Sub

   Public Sub ExecuteReview(ByVal logFullFileName As String)

      Dim logStreamWriter As System.IO.StreamWriter = Nothing

      Try

         logStreamWriter = New System.IO.StreamWriter(logFullFileName, False, System.Text.Encoding.Default)

         If m_DTE.Solution.IsOpen = False Then
            logStreamWriter.WriteLine("No solution loaded!")
         Else
            logStreamWriter.WriteLine("Solution has " & m_DTE.Solution.Projects.Count.ToString & " projects.")
         End If

      Catch ex As Exception

         If Not (logStreamWriter Is Nothing) Then
            logStreamWriter.WriteLine(ex.ToString)
         End If

      Finally

         If Not (logStreamWriter Is Nothing) Then
            logStreamWriter.Close()
            logStreamWriter.Dispose()
         End If

      End Try

   End Sub

   Private Sub OnAddInsUpdate(ByRef custom As Array) Implements IDTExtensibility2.OnAddInsUpdate
   End Sub

   Private Sub OnStartupComplete(ByRef custom As Array) Implements IDTExtensibility2.OnStartupComplete
   End Sub

   Private Sub OnBeginShutdown(ByRef custom As Array) Implements IDTExtensibility2.OnBeginShutdown
   End Sub

End Class

And the command-line utility is:

'---------------------------------------------------------------------------------------------
' Module VSAutomator.vb
'---------------------------------------------------------------------------------------------

Module VSAutomator

   Private Enum VisualStudioVersion
      Unknown = 0
      VSNET2002 = 1
      VSNET2003 = 2
      VS2005 = 3
      VS2008 = 4
   End Enum

   <STAThread()> _
   Sub Main(ByVal args() As String)

      Const ADDIN_PROGID As String = "CommandLineAddIn.Connect"
      Const ADDIN_METHOD As String = "ExecuteReview"

      Dim dte As EnvDTE.DTE = Nothing
      Dim dteType As Type
      Dim commandLineAddIn As CommandLineAddIn.Connect = Nothing
      Dim solutionFullFileName As String
      Dim solutionFolder As String
      Dim solutionName As String
      Dim logFullFileName As String
      Dim connectObject As Object = Nothing
      Dim connectObjectType As Type
      Dim version As VisualStudioVersion
      Dim progID As String
      Dim executableName As String
      Dim addIn As EnvDTE.AddIn
      Dim msgFilter As MessageFilter = Nothing

      Try

         msgFilter = New MessageFilter

         If args.Length = 0 Then
            executableName = IO.Path.GetFileName(System.Reflection.Assembly.GetExecutingAssembly.Location)
            ReportError("Usage: " & executableName & " solution_file_name.sln")
         Else
            solutionFullFileName = args(0)

            If Not IO.File.Exists(solutionFullFileName) Then
               ReportError("Solution file '" & solutionFullFileName & "' does not exist.")
            Else

               solutionFolder = IO.Path.GetDirectoryName(solutionFullFileName)
               solutionName = IO.Path.GetFileNameWithoutExtension(solutionFullFileName)
               logFullFileName = IO.Path.Combine(solutionFolder, solutionName & ".log")

               If IO.File.Exists(logFullFileName) Then
                  IO.File.Delete(logFullFileName)
               End If

               version = GetSolutionVersion(solutionFullFileName)

               If version = VisualStudioVersion.Unknown Then
                  ReportError("The format version of the solution file is not supported.")
               Else

                  progID = GetVisualStudioProgID(version)

                  dteType = System.Type.GetTypeFromProgID(progID)

                  If dteType Is Nothing Then
                     ReportError("Could not find the ActiveX Server for ProgID '" & progID & "'. Likely the proper version of Visual Studio is not installed.")
                  Else

                     dte = DirectCast(System.Activator.CreateInstance(dteType), EnvDTE.DTE)
                     dte.SuppressUI = True
                     dte.UserControl = False

                     addIn = GetAddInByProgID(dte, ADDIN_PROGID)

                     If addIn Is Nothing Then
                        ReportError("The Add-in " & ADDIN_PROGID & " was not found in Visual Studio.")
                     Else

                        addIn.Connected = True

                        connectObject = addIn.Object

                        dte.Solution.Open(solutionFullFileName)

                        connectObjectType = connectObject.GetType

                        connectObjectType.InvokeMember(ADDIN_METHOD, Reflection.BindingFlags.InvokeMethod Or Reflection.BindingFlags.Instance Or Reflection.BindingFlags.Public, Nothing, connectObject, New String() {logFullFileName})

                     End If

                  End If

               End If

            End If

         End If

      Catch ex As Exception
         ReportError(ex.ToString)
      Finally

         If Not (dte Is Nothing) Then
            Try
               dte.Quit()
            Catch ex As Exception
            End Try
         End If

         If Not (msgFilter Is Nothing) Then
            msgFilter.Dispose()
         End If

      End Try

   End Sub

   Private Sub ReportError(ByVal msg As String)

#If DEBUG Then
      MsgBox(msg)
#End If

      Console.WriteLine(msg)

   End Sub

   Private Function GetAddInByProgID(ByVal dte As EnvDTE.DTE, ByVal addinProgID As String) As EnvDTE.AddIn

      Dim addinResult As EnvDTE.AddIn = Nothing
      Dim addin As EnvDTE.AddIn

      For Each addin In dte.AddIns

         If addin.ProgID = addinProgID Then

            addinResult = addin
            Exit For

         End If

      Next

      Return addinResult

   End Function

   Private Function GetSolutionVersion(ByVal solutionFullFileName As String) As VisualStudioVersion

      Dim version As VisualStudioVersion = VisualStudioVersion.Unknown
      Dim solutionStreamReader As IO.StreamReader = Nothing
      Dim firstLine As String
      Dim format As String

      Try

         solutionStreamReader = New IO.StreamReader(solutionFullFileName)
         firstLine = solutionStreamReader.ReadLine()

         format = firstLine.Substring(firstLine.LastIndexOf(" ")).Trim

         Select Case format
            Case "7.00"
               version = VisualStudioVersion.VSNET2002
            Case "8.00"
               version = VisualStudioVersion.VSNET2003
            Case "9.00"
               version = VisualStudioVersion.VS2005
            Case "10.00"
               version = VisualStudioVersion.VS2008
         End Select

      Finally

         If Not (solutionStreamReader Is Nothing) Then
            solutionStreamReader.Close()
         End If

      End Try

      Return version

   End Function

   Private Function GetVisualStudioProgID(ByVal version As VisualStudioVersion) As String

      Dim progID As String = ""

      Select Case version
         Case VisualStudioVersion.VSNET2002
            progID = "VisualStudio.DTE.7"
         Case VisualStudioVersion.VSNET2003
            progID = "VisualStudio.DTE.7.1"
         Case VisualStudioVersion.VS2005
            progID = "VisualStudio.DTE.8.0"
         Case VisualStudioVersion.VS2008
            progID = "VisualStudio.DTE.9.0"
      End Select

      Return progID

   End Function

End Module

And the message filter is:

'---------------------------------------------------------------------------------------------
' Module MessageFilter.vb
'---------------------------------------------------------------------------------------------

Imports System.Runtime.InteropServices

' See: Fixing 'Application is Busy' and 'Call was Rejected By Callee' Errors
' http://msdn2.microsoft.com/en-us/library/ms228772(VS.80).aspx

<ComImport(), Guid("00000016-0000-0000-C000-000000000046"), InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)> _
Interface IOleMessageFilter
   <PreserveSig()> _
   Function HandleInComingCall(ByVal dwCallType As Integer, ByVal hTaskCaller As IntPtr, ByVal dwTickCount As Integer, ByVal lpInterfaceInfo As IntPtr) As Integer

   <PreserveSig()> _
   Function RetryRejectedCall(ByVal hTaskCallee As IntPtr, ByVal dwTickCount As Integer, ByVal dwRejectType As Integer) As Integer

   <PreserveSig()> _
   Function MessagePending(ByVal hTaskCallee As IntPtr, ByVal dwTickCount As Integer, ByVal dwPendingType As Integer) As Integer
End Interface

Public Class MessageFilter
   Implements IOleMessageFilter, IDisposable

   <DllImport("Ole32.dll")> _
   Private Shared Function CoRegisterMessageFilter(ByVal newFilter As IOleMessageFilter, ByRef oldFilter As IOleMessageFilter) As Integer
   End Function

   ' Class containing the IOleMessageFilter thread error-handling functions.

   Private Enum SERVERCALL
      SERVERCALL_ISHANDLED = 0
      SERVERCALL_REJECTED = 1
      SERVERCALL_RETRYLATER = 2
   End Enum

   Private Enum PENDINGMSG
      PENDINGMSG_CANCELCALL = 0
      PENDINGMSG_WAITNOPROCESS = 1
      PENDINGMSG_WAITDEFPROCESS = 2
   End Enum

   Private m_oldFilter As IOleMessageFilter
   Private m_disposedValue As Boolean = False

   Public Sub New()

      Dim hr As Integer

      m_oldFilter = Nothing

      hr = CoRegisterMessageFilter(Me, m_oldFilter)
      System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(hr)

   End Sub

   Private Function HandleInComingCall(ByVal dwCallType As Integer, ByVal hTaskCaller As System.IntPtr, ByVal dwTickCount As Integer, ByVal lpInterfaceInfo As System.IntPtr) As Integer Implements IOleMessageFilter.HandleInComingCall

      ' Return the ole default (don't let the call through)
      Return SERVERCALL.SERVERCALL_ISHANDLED

   End Function

   Private Function MessagePending(ByVal hTaskCallee As System.IntPtr, ByVal dwTickCount As Integer, ByVal dwPendingType As Integer) As Integer Implements IOleMessageFilter.MessagePending

      Return PENDINGMSG.PENDINGMSG_WAITDEFPROCESS

   End Function

   Private Function RetryRejectedCall(ByVal hTaskCallee As System.IntPtr, ByVal dwTickCount As Integer, ByVal dwRejectType As Integer) As Integer Implements IOleMessageFilter.RetryRejectedCall

      Dim iResult As Integer

      ' See: IMessageFilter::RetryRejectedCall
      ' http://msdn2.microsoft.com/en-us/library/ms680739.aspx

      ' Return values:
      ' -1: The call should be canceled. COM then returns RPC_E_CALL_REJECTED from the original method call.
      ' Value >= 0 and <100: The call is to be retried immediately.
      ' Value >= 100: COM will wait for this many milliseconds and then retry the call.

      If dwRejectType = SERVERCALL.SERVERCALL_RETRYLATER Then ' Thread call was rejected, so try again.
         iResult = 99 ' Retry immediately
      Else
         ' Too busy; cancel call.
         iResult = -1
      End If

      Return iResult

   End Function

   Protected Overridable Sub Dispose(ByVal disposing As Boolean)

      Dim dummyFilter As IOleMessageFilter
      Dim hr As Integer

      dummyFilter = Nothing

      If Not m_disposedValue Then

         hr = CoRegisterMessageFilter(m_oldFilter, dummyFilter)
         System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(hr)

         m_disposedValue = True

      End If

   End Sub

   Public Sub Dispose() Implements IDisposable.Dispose

      ' Do not change this code.  Put cleanup code in Dispose(ByVal disposing As Boolean) above.
      Dispose(True)
      GC.SuppressFinalize(Me)

   End Sub

End Class

Automation model documentation for Visual Studio 2008 available

Just in case you haven’t notice it yet, there is documentation available about the new automation assemblies specific to Visual Studio 2008, such as EnvDTE90 or VSLangProj90:

http://msdn2.microsoft.com/en-us/library/1xt0ezx9(VS.90).aspx

No much new automation for add-ins or macros in Visual Studio 2008, I think. It seems that the Visual Studio team is much more focused on the Visual Studio SDK and the automation model is quite forgotten which it is a pity, because it is by far the easiest and cleanest way of extending Visual Studio, and for most people approaching the Visual Studio extensibility is the way to go. Packages are much more complex and overkill for most purposes and their history is not the best: unmanaged COM-based interfaces, Interop assemblies, Managed Package Framework, etc. which seem patch after patch…

There is still a lot of room for improvement in the automation model such as:

  • Providing mandatory automation model support in all Visual Studio projects, such as setup projects and others. New VSTS database projects support automation, but that was not the case in VS.NET 2002/2003 database projects.
  • Providing better source code control support.
  • Providing better commandbar support, for example to create dropdown menus with image, such as the Add New Item button on the Standard toolbar.
  • Providing better text editing capabilities, such as markers, colorizers, etc.

In general, for each interface of the SDK there should be an automation model where applicable. I know that you can call the interfaces of the SDK from an add-in, and I have written several articles about that in the past, but it is quite painful to read the SDK docs to figure out how things work or how to call them. For the most part, Reflector for .NET is the best tool to understand things if you are lucky enough to be calling a managed Visual Studio package…