Visual SOS – Visual Studio extension to debug managed applications through SOS

Visual SOS is both an executable and extension for Visual Studio that leverages the debugger engine available in Windows in conjunction with SOS debugging extension. It’s similar to having Windbg embedded into Visual Studio but in a much simpler way, because it exposes some of the most commonly used commands just a click away. Visual Studio debugger is a great tool but it lacks some options that are available in SOS only.

image image

Any .NET developer should be proficient at using SOS and Windbg but for some this is considered a daunting and somewhat frustrating task, because in order to use SOS it’s required to have loaded the CLR prior loading SOS, then being able to know (and master) the cryptic commands that are required to perform debugging tasks, these two points were my main drivers and motivation to build Visual SOS.

The Visual SOS solution comprises the following components:

image

  • VisualSOS.Abstractions: These are the interfaces used throughout the solution.
  • VisualSOS.Common:  Support code in the solution.
  • VisualSOS.Core:  Core functionality in the solution (IoC setup, repositories, models and SosManager –> Proxy to SosWrapper).
  • VisualSOS.Extension: Visual Studio extension code that’s compiled into a VSIX and interoperability code to interact with WPF application.
  • VisualSOS.UIWPF application that implements MVVM and it’s the UI to interact with SosWrapper.
  • SosWrapper:  Native library that implements required debugger engine interfaces (COM) and interacts with SOS and debugger extensions from managed code.

Visual SOS supports managed code debugging only and that’s its purpose. Said that, instead of writing a query on  Process.GetProcesses()  which it’s slow and most times throws exceptions when trying to inspect some processes (such as access denied, for instance), there is a better, faster way to determine what managed applications are running. This is done via tasklist command by specifying the module that contains the CLR (mscorlib).

image

The following code-snippets show how to call tasklist and also parse the information returned by it.

/// <summary>

/// Runs the task list.

/// </summary>

/// <returns></returns>

private ExecutionResult RunTaskList() {

    var retval = ExecutionResult.Empty;

    try {

        retval.Tag = new StringBuilder();

        using (var newProc = new Process() {

            StartInfo = new ProcessStartInfo("tasklist.exe") {

                UseShellExecute = false,

                RedirectStandardOutput = true,

                Arguments = $"/m mscorlib*",

                CreateNoWindow = true,

                WindowStyle = ProcessWindowStyle.Hidden


            }, EnableRaisingEvents = true

        }) {

            retval.IsSuccess = true;

            newProc.OutputDataReceived += (s, e) => ((StringBuilder)retval.Tag).AppendLine(e.Data);

            newProc.Start();

            newProc.BeginOutputReadLine();

            newProc.WaitForExit();


        }

    } catch (Exception e) {

        retval.Tag = null;

        retval.IsSuccess = false;

        retval.LastExceptionIfAny = e;

    }

    return retval;

}


/// <summary>

/// Gets the managed apps.

/// </summary>

/// <returns></returns>

public async Task<ExecutionResult> GetManagedApps() {

    return await Task.Run(() => {

        Match m;

        var retval = RunTaskList();

        var regex = new Regex(@"\b(\d+)\b");

        var managedApps = new List<ManagedApp>();


        if (retval.IsSuccess) {

            var processes = ((StringBuilder)retval.Tag).ToString().Split("\r\n".ToCharArray()).Where(x => !string.IsNullOrEmpty(x)).ToArray();


            if (processes.Length > 1) {

                // Let's ignore header returned by TaskList

                for (var n = 2; n < processes.Length; n++) {

                    if ((m = regex.Match(processes[n])).Success) {

                        var p = Process.GetProcessById(int.Parse(m.Value));


                        if (!p.ProcessName.ToLower().Contains("visualsos.ui")) // We'll ignore "VisualSOS"

                            managedApps.Add(new ManagedApp { Pid = p.Id, ImageName = p.ProcessName, ImagePath = p.MainModule.FileName });

                    }

                }

                retval.Tag = managedApps;

            }

        }

        return retval;

    });

}

The responsibility to interact with debugger engine and SOS lies with SOSWrapper, effectively.  This is a a native library (DLL) that implements code that allows controlling the debugging session, load of the CLR (if not loaded) and debugging extensions. In this particular case the CLR is loaded by the WPF application.

/// <summary>

/// Loads the sos.

/// </summary>

/// <returns></returns>

HRESULT SosWrapper::LoadSos() {

    HANDLE hProcess;

    auto isLoaded = FALSE;

    auto retval = S_FALSE;

    CComPtr<ICLRMetaHost> pMetaHost;

    CComPtr<IEnumUnknown> pEnumerator;

    CComPtr<ICLRRuntimeInfo> pRuntimeInfo;


    if ((hProcess = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, GetCurrentProcessId())) != nullptr) {

        if (SUCCEEDED(CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&pMetaHost))) {

            isLoaded = SUCCEEDED(pMetaHost->EnumerateLoadedRuntimes(hProcess, &pEnumerator)) && CheckIfClrIsLoaded(pEnumerator);

            // If CLR is not loaded we'll proceed to load and start it

            if (!isLoaded &&  SUCCEEDED(pMetaHost->GetRuntime(TargetFrameworkVersion, IID_ICLRRuntimeInfo, (LPVOID*)&pRuntimeInfo))) {

                if (SUCCEEDED(pRuntimeInfo->GetInterface(CLSID_CLRRuntimeHost, IID_ICLRRuntimeHost, (LPVOID*)&m_pRuntimeHost))) {

                    retval = RunCommand(ExtPath, TRUE);

                    retval = SUCCEEDED(m_pRuntimeHost->Start()) && SUCCEEDED(RunCommand(LoadSosCommand, TRUE)) ? TRUE : FALSE;

                }

            }

            else {

                retval = SUCCEEDED(RunCommand(ExtPath, TRUE)) && SUCCEEDED(RunCommand(LoadSosCommand, TRUE)) ? TRUE : FALSE;

            }

        }

    }


    CloseHandle(hProcess);


    return retval;

}


/// <summary>

/// Checks if color is loaded.

/// </summary>

/// <param name="pEnumerator">The p enumerator.</param>

/// <returns></returns>

BOOL SosWrapper::CheckIfClrIsLoaded(const CComPtr<IEnumUnknown>& pEnumerator) {

    ULONG fetched = 0;

    DWORD bufferSize;

    auto retval = FALSE;

    wchar_t szBuffer[MAX_PATH];

    CComPtr<ICLRRuntimeInfo> pRuntimeInfo;


    while (SUCCEEDED(pEnumerator->Next(1, (IUnknown **)&pRuntimeInfo, &fetched)) && fetched > 0) {

        if ((SUCCEEDED(pRuntimeInfo->GetVersionString(szBuffer, &bufferSize))))

            if (wcscmp(szBuffer, TargetFrameworkVersion) == 0) {

                retval = TRUE;

                break;

            }

    }


    return retval;

}

SOSWrapper heavily relies on an XML file (Visual SOS.Xml) that acts as a .NET config file.

<?xml version="1.0" encoding="utf-8"?>

<config>

    <sendOutputToVSWindow enabled="true" />

    <extensions>

        <extension name="ext" path="C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\winext\ext.dll" />

        <extension name="wow64exts" path="C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\WINXP\wow64exts.dll" />

        <extension name="exts" path="C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\WINXP\exts.dll" />

        <extension name="uext" path="C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\winext\uext.dll" />

        <extension name="ntsdexts" path="C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\WINXP\ntsdexts.dll" />

    </extensions>

</config>

The code-snippets below show how configuration settings are retrieved from XML file by ConfigReader class in SOSWrapper.

/// <summary>

/// Parses the configuration file.

/// </summary>

/// <param name="configFile">The configuration file.</param>

/// <returns></returns>

BOOL ConfigReader::ParseConfigFile(const std::wstring& configFile) {

    auto retval = FALSE;

    VARIANT_BOOL success;

    IXMLDOMDocumentPtr pDocPtr;

    IXMLDOMNodePtr selectedNode;


    CoInitialize(nullptr);


    pDocPtr.CreateInstance("Msxml2.DOMDocument.6.0");


    if (SUCCEEDED(pDocPtr->load(_variant_t(configFile.c_str()), &success))) {

        if (SUCCEEDED(pDocPtr->selectSingleNode(_bstr_t(XmlRootNode), &selectedNode))) {

            ProcessElementRecursively(selectedNode);

            retval = TRUE;

        }

    }


    CoUninitialize();


    return retval;

}


/// <summary>

/// Processes the element recursively.

/// </summary>

/// <param name="node">The node.</param>

void ConfigReader::ProcessElementRecursively(IXMLDOMNodePtr& node) {

    long childrenCount = 0;

    IXMLDOMNodePtr childNode;

    IXMLDOMNodeListPtr children;


    CoInitialize(nullptr);


    if (SUCCEEDED(node->get_childNodes(&children)) && SUCCEEDED(children->get_length(&childrenCount)) && childrenCount > 0) {

        for (auto nCount = 0; nCount < childrenCount; nCount++) {

            if (SUCCEEDED(children->get_item(nCount, &childNode))) {

                ExtractInformationFromElement(childNode);

                ProcessElementRecursively(childNode);

            }

        }

    }


    CoUninitialize();

}





/// <summary>

/// Extracts the information from element.

/// </summary>

/// <param name="node">The node.</param>

void ConfigReader::ExtractInformationFromElement(IXMLDOMNodePtr& node) {

    size_t nSize;

    VARIANT value;

    std::wstring key;

    BSTR nodeContent;

    DOMNodeType nodeType;

    WCHAR szNodeText[512] = {0};

    char szBuffer[MAX_PATH] = {0};


    CoInitialize(nullptr);


    if (SUCCEEDED(node->get_nodeType(&nodeType)) && nodeType == DOMNodeType::NODE_ELEMENT) {

        nodeContent = SysAllocString(szNodeText);

        auto pElement = (IXMLDOMElementPtr)node;

        pElement->get_tagName(&nodeContent);


        if (wcscmp(nodeContent, L"sendOutputToVSWindow") == 0) {

            pElement->getAttribute(_bstr_t(L"enabled"), &value);


            if (value.vt != VT_NULL)

                Properties.insert(std::make_pair(nodeContent, value.bstrVal));


        } else if (wcscmp(nodeContent, L"extension") == 0) {

            pElement->getAttribute(_bstr_t(L"name"), &value);


            if (value.vt != VT_NULL)

                key.assign(value.bstrVal);


            pElement->getAttribute(_bstr_t(L"path"), &value);


            if (value.vt != VT_NULL && !key.empty()) {

                Properties.insert(std::make_pair(key.c_str(), value.bstrVal));

                wcstombs_s(&nSize, szBuffer, key.c_str(), key.size());

                std::string name(szBuffer);

                wcstombs_s(&nSize, szBuffer, value.bstrVal, wcslen(value.bstrVal));

                std::string path(szBuffer);

                m_extensions.push_back(ExtInformation(name, path));

            }


        }


        SysFreeString(nodeContent);

    }


    CoUninitialize();

}



/// <summary>

/// Gets the setting.

/// </summary>

/// <param name="key">The key.</param>

/// <returns></returns>

const std::wstring ConfigReader::GetSetting(const wchar_t* key) {

    std::wstring retval;


    if (!Properties.empty() && key != nullptr && wcslen(key) > 0) {

        typedef std::pair<const std::wstring, const std::wstring> item;


        std::find_if(Properties.begin(), Properties.end(), [&](item i) {

            auto ret = FALSE;


            if (retval.empty()) {

                if (wcscmp(i.first.data(), key) == 0) {

                    retval.assign(i.second);

                    ret = TRUE;

                }

            }

            return ret;

        });

    }



    return retval;

}


The SOSWrapper initialization, finalization and RunCommand code are shown below

/// <summary>

/// Initializes this instance.

/// </summary>

void SosWrapper::Initialize() {

    if (!m_bIsInitialized) {

        CoInitialize(nullptr);


        if (SUCCEEDED(DebugCreate(__uuidof(IDebugClient), (void**)&m_pDbgClient))) {

            if (SUCCEEDED(m_pDbgClient->QueryInterface(__uuidof(IDebugControl), (void**)&m_pDbgControl))) {

                // Load extensions

                std::for_each(m_configReader.Extensions_get().begin(), m_configReader.Extensions_get().end(), [&, this](ExtInformation& item) {

                    m_pDbgControl->AddExtension(item.Path.data(), NULL, &item.pHandle);

                });


                m_pUnk.CoCreateInstance(L"VisualSOS.Core.Infrastructure.OutputMarshalling", nullptr);

                m_pDbgClient->SetOutputCallbacks(&m_pOutputCallback);

                m_pDbgClient->SetEventCallbacks(&m_pEventCallback);

                m_bIsInitialized = TRUE;

                OutputCallbacks::m_pUnk = m_pUnk;

            }

        }

        CoUninitialize();

    }


}



/// <summary>

/// Finalizes an instance of the <see cref="SosWrapper"/> class.

/// </summary>

SosWrapper::~SosWrapper() {

    // Unload extensions

    if (!m_configReader.Extensions_get().empty())

        std::for_each(m_configReader.Extensions_get().begin(), m_configReader.Extensions_get().end(), [&, this](const ExtInformation& item) {

        m_pDbgControl->RemoveExtension(item.pHandle);

    });


    m_pDbgClient->EndSession(DEBUG_END_ACTIVE_DETACH);

}



/// <summary>

/// Runs the command.

/// </summary>

/// <param name="szCommand">The sz command.</param>

/// <param name="bPrivate">The b private.</param>

/// <returns></returns>

HRESULT SosWrapper::RunCommand(const char* szCommand, BOOL bPrivate) {

    auto retval = S_FALSE;


    if (szCommand != nullptr && strlen(szCommand) > 0) {

        try {

            if (bPrivate)

                retval = m_pDbgControl->Execute(DEBUG_OUTCTL_THIS_CLIENT | DEBUG_OUTCTL_NOT_LOGGED,

                    szCommand, DEBUG_EXECUTE_NOT_LOGGED | DEBUG_EXECUTE_NO_REPEAT);

            else retval = m_pDbgControl->Execute(DEBUG_OUTCTL_ALL_CLIENTS, szCommand, DEBUG_EXECUTE_NO_REPEAT);

        }

        catch (std::exception &ex) {

            printException(ex);

        }

    }


    return retval;

}

The debugger engine’s output is intercepted and sent to an assembly in Visual SOS that has been exposed as COM that acts as IPC and marshalling  mechanism to pass information from native code into managed.  Therefore, this information or message comes across to .NET thus effectively updating the model bound to the output textbox in the WPF application.

/// <summary>

/// Queries the interface.

/// </summary>

/// <param name="InterfaceId">The interface identifier.</param>

/// <param name="Interface">The interface.</param>

/// <returns></returns>

STDMETHODIMP OutputCallbacks::QueryInterface(__in REFIID InterfaceId, __out PVOID* Interface) {

    *Interface = nullptr;

    if (IsEqualIID(InterfaceId, __uuidof(IUnknown)) || IsEqualIID(InterfaceId, __uuidof(IDebugOutputCallbacks))) {

        *Interface = (IDebugOutputCallbacks *)this;

        InterlockedIncrement(&m_ref);

        return S_OK;

    }

    else {

        return E_NOINTERFACE;

    }

}


/// <summary>

/// Adds the reference.

/// </summary>

/// <returns></returns>

STDMETHODIMP_(ULONG) OutputCallbacks::AddRef() {

    return InterlockedIncrement(&m_ref);

}


/// <summary>

/// Releases this instance.

/// </summary>

/// <returns></returns>

STDMETHODIMP_(ULONG) OutputCallbacks::Release() {

    if (InterlockedDecrement(&m_ref) == 0) {

        delete this;

        return 0;

    }

    return m_ref;

}


/// <summary>

/// Outputs the specified mask.

/// </summary>

/// <param name="Mask">The mask.</param>

/// <param name="Text">The text.</param>

/// <returns></returns>

STDMETHODIMP OutputCallbacks::Output(__in ULONG Mask, __in PCSTR Text) {

    // We intercept messages from dbgengine and pass it across to Visual.SOS (.NET application) via COM

    // To reduce impact to UI, we do this from a newly created thread


    unsigned int id;


    CloseHandle(reinterpret_cast<HANDLE>(_beginthreadex(nullptr, NULL, [](void* pData) -> unsigned int {

        auto text = reinterpret_cast<std::string*>(pData);

        m_pUnk.Invoke1(_bstr_t("RedirectOutput"), &_variant_t(text->c_str()));

        delete pData;

        return 0;

    }, new std::string(Text), NULL, &id)));



   // More code goes here...



   return S_OK;

}

The SosManager (in VisualSOS.Core) is responsible for the interoperability between our managed solution (WPF application and VS extension).

/// <summary>

  /// Attaches the or detach.

  /// </summary>

  /// <param name="behavior">The behavior.</param>

  /// <param name="pid">The pid.</param>

  /// <returns></returns>

  /// <exception cref="NotImplementedException"></exception>

  public ExecutionResult AttachOrDetach(DebuggerBehavior behavior, int pid) {

      var hProcAddress = IntPtr.Zero;

      var retval = ExecutionResult.Empty;


      if (SosWrapperHandle != IntPtr.Zero) {

          if ((hProcAddress = GetProcAddress(SosWrapperHandle, "AttachOrDetach")) != IntPtr.Zero) {

              var functor = Marshal.GetDelegateForFunctionPointer(hProcAddress, typeof(AttachOrDetachDelegate));

              retval.IsSuccess = (int)functor.DynamicInvoke((int)behavior, pid) == 0;

              var debugeePath = Path.GetDirectoryName(Process.GetProcessById(pid)?.MainModule?.FileName);

              LoadSymbols(debugeePath);

          }

      }

      return retval;

  }


  /// <summary>

  /// Loads the symbols.

  /// </summary>

  /// <param name="debugeePath">The debugee path.</param>

  /// <returns></returns>

  public ExecutionResult LoadSymbols(string debugeePath) {

      var retval = ExecutionResult.Empty;

      var command = $".sympath srv*https://msdl.microsoft.com/download/symbols;{debugeePath}";

      retval.IsSuccess = RunCommand(command, true).IsSuccess && RunCommand(".reload", true).IsSuccess;


      return retval;

  }


  /// <summary>

  /// Runs the command.

  /// </summary>

  /// <param name="command">The command.</param>

  /// <param name="privateExecution">if set to <c>true</c> [private execution].</param>

  /// <returns></returns>

  public ExecutionResult RunCommand(string command, bool privateExecution) {

      var hProcAddress = IntPtr.Zero;

      var retval = ExecutionResult.Empty;


      if (SosWrapperHandle != IntPtr.Zero) {

          if ((hProcAddress = GetProcAddress(SosWrapperHandle, "RunCommand")) != IntPtr.Zero) {

              var functor = Marshal.GetDelegateForFunctionPointer(hProcAddress, typeof(ExecuteCommandDelegate));

              retval.IsSuccess = (int)functor.DynamicInvoke(command, privateExecution ? 1 : 0) == 0;

          }

      }

      return retval;


  }

As mentioned above, one of the assemblies in Visual SOS has been made visible to COM so messages coming from debugger engine are sent directly to  RedirectOutput method which in turn updates the model in the ViewModel.

///////////////////////////

// OutputMarshalling class

/////////////////////////// 


/// <summary>

/// Redirects the output.

/// </summary>

/// <param name="message">The message.</param>

/// <exception cref="NotImplementedException"></exception>

public void RedirectOutput(string message) {

    if (!string.IsNullOrEmpty(message))

        (new Thread(() => m_outputMessageFunctor?.Invoke(message))).Start();

}


/// <summary>

/// Pins the functor.

/// </summary>

/// <param name="action">The action.</param>

public void PinFunctor(Action<string> action) {

    if (action != null) {

        m_outputMessageFunctor = action;

    } else

        throw new NullReferenceException(nameof(action));

}


///////////////////////////

// OutputMarshalling class

/////////////////////////// 


/// <summary>

/// Gets or sets the debuggee.

/// </summary>

/// <value>

/// The debuggee.

/// </value>

private ManagedApp Debuggee {

    get; set;

}


/// <summary>

/// Gets a value indicating whether this instance can attach.

/// </summary>

/// <value>

///   <c>true</c> if this instance can attach; otherwise, <c>false</c>.

/// </value>

public bool CanAttach => IsInitialized && !ManagedAppsVm.Repository.SosManager.IsCurrentlyAttached;


/// <summary>

/// Gets a value indicating whether this instance is initialized.

/// </summary>

/// <value>

///   <c>true</c> if this instance is initialized; otherwise, <c>false</c>.

/// </value>

public bool IsInitialized => ManagedAppsVm.Repository.SosManager.Initialized;


/// <summary>

/// Gets a value indicating whether this instance is debugging.

/// </summary>

/// <value>

///   <c>true</c> if this instance is debugging; otherwise, <c>false</c>.

/// </value>

public bool IsDebugging => IsInitialized && Debuggee != null && ManagedAppsVm.Repository.SosManager.IsCurrentlyAttached;


/// <summary>

/// Initializes a new instance of the <see cref="CombinedViewModel"/> class.

/// </summary>

public CombinedViewModel() {

    ManagedAppsVm.Container = this;

    RunSosCommand = new CommandBase(RunSosCommand_Handler);


    // Pass VM method as function pointer into OutputMarshalling class

    OutputMarshalling.Current.PinFunctor(UpdateOutputWindow);


    AttachOrDetachCommand = new CommandBase(AttachOrDetachCommand_Handler);

    DoubleClickGridCommand = new CommandBase(DoubleClickGridCommand_Handler);

    AutoScrollOutputCommand = new CommandBase(AutoScrollOutputCommand_Handler);

    RefreshManagedAppsCommand = new CommandBase(RefreshManagedAppCommand_Handler);

}



/// <summary>

/// Updates the output window.

/// </summary>

/// <param name="message">The message.</param>

private void UpdateOutputWindow(string message) {

    Dispatcher.CurrentDispatcher.Invoke(DispatcherPriority.Normal, new Action(() => ManagedAppsVm.OutPut += message));

}

Our resulting VSIX package contains a combination of .NET assemblies, executable (Visual SOS) and native library. Hence, Visual Studio extensions are not limited to managed assemblies but they can have anything in them actually. The images depicted below Visual SOS extension during and after installation.

 

Pre-Requisites

  • Debugging tools for Windows
  • Ensure debuggers’ extensions are stored in valid locations as per VisualSOS.xml values.  The file can be found here in a path similar to  C:\Users\{YourUserName}\AppData\Local\Microsoft\VisualStudio\{VisualStudioInstance}\Extensions\Angel Hernandez\VisualSOS.Extension\1.0

Known issues

  • AlwaysFloat (ToolWindowPane’s style) Visual Studio SDK doesn’t work because Window can be docked regardless of this value. This was reported to Microsoft as a bug in the SDK.
  • Docking the VisualSOS extension Window will cause the child window (WPF application) to exit. I don’t have much details on this, but it’s very likely that once the ToolWindowPane is docked it’s not a valid Window anymore, thus killing the child window. This is similar to closing the extension window by clicking on the close button.

Source code – https://github.com/angelhernandezm/visualsos 

 

Leave a Reply

Your email address will not be published. Required fields are marked *