Debuggers are valuable tools when developing any kind of software. Most of them are fairly intuitive to use, which may make you wonder why I would dedicate a chapter to this subject.
Many developers, especially if they’re used to Visual Studio and the .NET Framework, don’t realize what options are available for debugging in other editors or on other operating systems. Also, command-line debuggers still have a place in the modern developer’s toolbox because they can do powerful things that GUI debuggers can’t. By the end of this chapter, you’ll be armed with the information you need to debug .NET Core applications almost anywhere.
I introduced Visual Studio Code (VS Code) in chapter 2. It’s Microsoft’s lightweight, cross-platform, extensible text editor (similar to the Atom text editor). If you installed the C# extension from Microsoft, then you’ve likely seen the debug capabilities show up on both the menu and the left-side bar. VS Code may have also nagged you to add “required assets to build and debug.” If not, try creating a new project and opening VS Code with the following commands:
mkdir Test1 cd Test1 dotnet new console code .
Open the Program.cs file and click somewhere on the text in the file. You’ll see a couple of things happen: a bin folder will be created because VS Code is building the code immediately, and a warning message will be displayed at the top asking you to add assets to Test1. Clicking Yes will create a new folder under Test1 called .vscode with two files: launch.json and tasks.json (shown in listings 8.1 and 8.2).
If you missed the prompt to add building assets, you can open the command palette and select .NET: Generate Assets for Build and Debug.
{ "version": "0.2.0", "configurations": [ { "name": ".NET Core Launch (console)", "type": "coreclr", "request": "launch", "preLaunchTask": "build", "program": 1 "${workspaceRoot}/bin/Debug/netcoreapp2.0/Test1.dll", "args": [], 2 "cwd": "${workspaceRoot}", "console": "internalConsole", 3 "stopAtEntry": false, "internalConsoleOptions": "openOnSessionStart", "justMyCode": true, 4 "requireExactSource": true, 5 "enableStepFiltering": true 6 }, { "name": ".NET Core Attach", "type": "coreclr", "request": "attach", "processId": "${command:pickProcess}" } ] }
If you’re used to Visual Studio’s external command prompt, you can change the "console" setting to "externalTerminal" to get the same behavior.
When using a library developed by another team, you can step into their code by setting "justMyCode" to false. But you may have trouble if your local copy of their source doesn’t match the version of the package you’re using. By turning off the "requireExactSource" flag, the debugger will make a best guess as to what line you’re on. This can sometimes be good enough to figure out the cause of an issue.
{ "version": "0.1.0", "command": "dotnet", 1 "isShellCommand": true, "args": [], "tasks": [ { "taskName": "build", 2 "args": [ "${workspaceRoot}/mytests.csproj" ], "isBuildCommand": true, "problemMatcher": "$msCompile" }, { "taskName": "test", 3 "args": [ "${workspaceRoot}/mytests.csproj" ], "isTestCommand": true, 4 "problemMatcher": "$msCompile" } ] }
The Tasks menu in VS Code has a Run Build Task option with a shortcut defined as Ctrl-Shift-B (the same shortcut used in Visual Studio 2017). This will execute the build task defined in tasks.json. You can also use the Run Task option item and pick the task you want to run.
If you defined test like in listing 8.2, the list of available tasks will include test.
You may find it useful to add other tasks, such as for packaging, publishing, or running tools like the Entity Framework tools.
Let’s look at an example application and see how the VS Code debugger works in action. Back in chapter 6 you created a data access library using Dapper and dependency injection (you can get the code from GitHub at http://mng.bz/F146 if you don’t have it handy). The data-access library has a method that creates an order in a database based on an Order object. If a field isn’t specified in this object, CreateOrder may fail, and you can use the debugger to determine where the failure occurs.
All the chapter 6 Dapper projects are contained in a folder called DapperDi. From a terminal or command prompt, change to the DapperDi folder and run code . to start VS Code with the current folder open. Add the build and debug resources as prompted. Then find the unit test file and modify the test as follows.
[Fact] public void Test1() { var orders = context.GetOrders(); Assert.Equal(0, orders.Count()); var supplier = context.Suppliers.First(); var part = context.Parts.First(); var order = new Order() { SupplierId = supplier.Id, Supplier = supplier, PartTypeId = part.Id, //Part = part, 1 PartCount = 10, PlacedDate = DateTime.Now }; context.CreateOrder(order); Assert.NotEqual(0, order.Id); orders = context.GetOrders(); Assert.Equal(1, orders.Count()); }
You may also want to change the config.json file to use the in-memory database, because you’ll likely run this test many times.
It’s easy to forget to set all the properties on an Order with the data-access layer you created in chapter 6. Let’s see how you could debug this issue with VS Code.
As you learned in chapter 4, VS Code will put links above the test method that allow you to run or debug an individual test. Click the Debug Test link. The debugger will stop when it gets a NullReferenceException. You should see something like figure 8.1.
The stack trace for the NullReferenceException shows the line throw; as being the line where the exception occurred. Normally, the throw; would preserve the stack trace from the original exception. But in this case you performed some work, (transaction.Rollback();), and that resulted in the stack trace being lost. You can fix this by changing the code in this catch statement, as shown in the following listing.
catch (Exception exc) { transaction.Rollback(); throw new AggregateException(exc); 1 }
The AggregateException is common to asynchronous programming because it’s possible that multiple threads can encounter exceptions and you want to capture all of them. I use an AggregateException here because it indicates to the person debugging that only the inner exceptions are important.
Now debug the test, and the exception information (shown in figure 8.2) should be slightly more helpful.
As you can see, Visual Studio Code has powerful debugging capabilities. It should feel familiar to most developers who have worked with debuggers before. Also, all of these features will work regardless of the operating system you’re using.
Visual Studio 2017 is the latest version of the flagship integrated development environment from Microsoft, and it has a rich set of debugging capabilities. To see the differences between VS 2017 and VS Code, try debugging the same unit test as before. Figure 8.3 shows what this might look like.
In VS Code, you open a folder. In Visual Studio there’s an option to open a folder, but even though it may build the projects, it doesn’t see the tests. The Test Explorer will be empty. Instead, you need to create a new solution and add the projects.
By altering the exception settings to break on the NullReferenceException, you can see the exception before it’s caught in the catch statement. In VS Code, you don’t have the same granularity, but you can break on all exceptions.
The IntelliTrace feature doesn’t come with the Community edition of Visual Studio 2017. If you happen to have an Enterprise edition, this is definitely a feature worth checking out.
IntelliTrace will capture events during the debugging session. You can then select these events from the Events window (shown in figure 8.3) and use the Historical Debugging feature to see the state of your application at that time with local variables and call stack. This comes in handy when unwinding complex problems.
Visual Studio for Mac bears a resemblance to the other products in the Visual Studio family. One slightly different feature is the Exception Catchpoint. To try this, go to the Run menu and choose New Exception Catchpoint. You’ll see the dialog box shown in figure 8.4.
The same Advanced Conditions functionality is available in other debuggers, including Visual Studio 2017 and Visual Studio Code, with the name “conditional breakpoints.” The slight differences in terminology stem from VS for Mac actually being a rebranded Xamarin Studio.
Visual Studio for Mac doesn’t look that different from Visual Studio 2017, at least when it comes to debugging. Figure 8.5 shows what VS for Mac looks like in action.
So far, we’ve only explored graphical debuggers. But some things can’t easily be expressed in a GUI. That’s why every developer should have a command-line debugger in their toolbox.
The .NET Framework comes with an extension for the Windows Debugger (WinDBG) that contains powerful commands for interpreting .NET’s managed memory, types, functions, and so on. This extension is called SOS. It works for .NET Core and on the cross-platform LLDB debugger.
SOS isn’t a distress signal—it stands for Son Of Strike. If you’re interested in trivia, you can do a search to find out the history of the name.
One of the nice things about the Visual Studio debuggers is that they hide some of the more confusing parts of the .NET SDK. When you run dotnet test, several child processes are spawned to do things like restore and build the project. Even if you skip the build and restore steps, child processes are still created. The problem isn’t insurmountable; it’s just difficult to take on if you’re beginning.
A much easier way to get started with SOS-based debugging is to create a self-contained application. You learned about these back in chapter 2. Create a new console application by running the following command from the DapperDi folder:
dotnet new console -o ScmConsole
Modify the ScmConsole.csproj file as shown in the following listing to create a self-contained application and add the necessary references.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>netcoreapp2.0</TargetFramework> <RuntimeIdentifiers>win10-x64;osx.10.12-x64 1 </RuntimeIdentifiers> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.0.0-*" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.0.0-*" /> <PackageReference Include="SQLitePCLRaw.bundle_green" Version="1.1.8" /> 2 <ProjectReference Include="../SqliteScmTest/SqliteScmTest.csproj" /> </ItemGroup> </Project>
This project takes an indirect dependency on SqliteDal. In order to get the self-contained application building, you need to change SqliteDal to .NET Standard 2.0 and change some of its references. Make the following changes.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>netstandard2.0</TargetFramework> </PropertyGroup> <ItemGroup> <PackageReference Include="Microsoft.Data.Sqlite.Core" Version="2.0.0-*" /> <PackageReference Include="Dapper" 1 Version="1.50.2" /> <PackageReference Include="System.Data.SqlClient" 2 Version="4.3.1" /> <ProjectReference Include="../ScmDataAccess/ScmDataAccess.csproj" /> </ItemGroup> </Project>
Next, add the following code to the Program.cs file of ScmConsole.
using System; using System.Linq; using Microsoft.Extensions.DependencyInjection; using ScmDataAccess; using SqliteScmTest; namespace ScmConsole { class Program { static void Main(string[] args) { SQLitePCL.Batteries.Init(); 1 var fixture = new SampleScmDataFixture(); var context = fixture.Services. GetRequiredService<IScmContext>(); var supplier = context.Suppliers.First(); var part = context.Parts.First(); var order = new Order() { SupplierId = supplier.Id, Supplier = supplier, PartTypeId = part.Id, //Part = part, PartCount = 10, PlacedDate = DateTime.Now }; context.CreateOrder(order); } } }
Now you can run dotnet restore to make sure all the packages are set up correctly. Then run the publish command to create the self-contained app:
dotnet publish -c Debug -r win10-x64 1
The executable will be published to the ScmConsoleinDebug etcoreapp2.0win10-x64publish folder. You can run ScmConsole.exe from there, and it should crash.
WinDBG and CDB are essentially the same debugger, except CDB is command-line-based, whereas WinDBG is GUI-based. For the purposes of this chapter, it doesn’t matter which one you use.
To get these tools, you’ll need to install the Debugging Tools for Windows that come with the Windows SDK (http://mng.bz/j0xk). You can choose to install only the Debugging Tools during the installation.
Once they’re installed, go back to your console and change to the folder where the self-contained ScmConsole app was published. Launch the app with the debugger attached by using the following command:
"C:Program Files (x86)Windows Kits10Debuggersx64cdb.exe" ScmConsole.exe
The debugger will pause once it’s started. That gives you a chance to set up stops and breakpoints.
In this case, you need the process to load the .NET Core CLR before you set any breakpoints. To do that, you can tell the debugger to stop when it loads a certain module or assembly. Use the following commands to tell CDB to stop when the ScmConsole assembly is loaded:
sxe ld ScmConsole g 1
The program should stop shortly. You’ll see some log messages indicating which assemblies have been loaded. Among those should be coreclr.dll from your publish folder.
Now you can load SOS and set a breakpoint for when the AggregateException is thrown:
.loadby sos coreclr !soe -create System.AggregateException 1 g
You should see two access violations before hitting the breakpoint. You’ll need to resume (g) each time an access violation is hit. The output will look something like the following.
(1ec8.4c34): Access violation - code c0000005 (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. 00007ffd`d54e22bf 3909 cmp dword ptr [rcx],ecx ds:00000000`00000000=???????? 0:000> g 1 (1ec8.4c34): Access violation - code c0000005 (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. KERNELBASE!RaiseException+0x68: 00007ffe`6f8c1f28 488b8c24c0000000 mov rcx,qword ptr [rsp+0C0h] ss:00000007`5d17daf0=0000ec5c4ecdfd0d 0:000> g 1 (1ec8.4c34): CLR exception - code e0434352 (first chance) 'System.AggregateException hit' 2 First chance exceptions are reported before any exception handling. This exception may be expected and handled. KERNELBASE!RaiseException+0x68: 00007ffe`6f8c1f28 488b8c24c0000000 mov rcx,qword ptr [rsp+0C0h] ss:00000007`5d17b900=0000ec5c4ecd9f7d
You can use the Print Exception command (!pe) to see the AggregateException. The command will output another command so you can see the NullReferenceException. The following listing shows what this might look like.
0:000> !pe Exception object: 00000151b0e54230 Exception type: System.AggregateException Message: One or more errors occurred. InnerException: System.NullReferenceException, 1 Use !PrintException 00000151b0e51a38 to see more. StackTrace (generated): <none> StackTraceString: <none> HResult: 80131500 0:000> !PrintException 00000151b0e51a38 2 Exception object: 00000151b0e51a38 Exception type: System.NullReferenceException Message: Object reference not set to an instance of an object. InnerException: <none> StackTrace (generated): 3 SP IP Function 000000075D17E070 00007FFDD54E22BF SqliteDal!SqliteDal.SqliteScmContext.CreateOrder(ScmDataAccess.Order)+0x 19f StackTraceString: <none> HResult: 80004003
Because the debugger is paused at the point when the exception was thrown, you can view the current stack. This will also show the local variables for each method.
To do this, use the command !clrstack -a. It will produce output like the following.
0:000> !clrstack -a OS Thread Id: 0x4c34 (0) Child SP IP Call Site 000000075d17db70 00007ffe34f28b28 [FaultingExceptionFrame: 000000075d17db70] 000000075d17e070 00007ffdd54e22bf SqliteDal.SqliteScmContext.CreateOrder(ScmDataAccess.Order) PARAMETERS: this (0x000000075d17e200) = 0x00000151b0e15b18 1 order (0x000000075d17e208) =0x00000151b0e35818 2 LOCALS: 0x000000075d17e1d8 = 0x00000151b0e3ac08 3 0x000000075d17e1d0 = 0x0000000000000000 0x000000075d17e1c8 = 0x0000000000000000 0x000000075d17e1c0 = 0x00000151b0e51a38 4 000000075d17e200 00007ffdd54b0cde ScmConsole.Program.Main(System.String[]) PARAMETERS: args (0x000000075d17e2c0) = 0x00000151b0df3558 LOCALS: 0x000000075d17e298 = 0x00000151b0df3570 5 0x000000075d17e290 = 0x00000151b0e15b18 6 0x000000075d17e288 = 0x00000151b0e35608 7 0x000000075d17e280 = 0x00000151b0e2cd38 8 0x000000075d17e278 = 0x00000151b0e35818 9 000000075d17e4e8 00007ffe34f923f3 [GCFrame: 000000075d17e4e8] 000000075d17e9c8 00007ffe34f923f3 [GCFrame: 000000075d17e9c8]
To view a .NET object, run the !do command with the object pointer. The following listing shows the output when viewing theOrder object.
0:000> !do 0x00000151b0e35818 1 Name: ScmDataAccess.Order 2 MethodTable: 00007ffdd5357c98 EEClass: 00007ffdd54a7ee8 Size: 72(0x48) bytes File: ... etcoreapp2.0win10-x64publishScmDataAccess.dll Fields: Type Value Name System.Int32 1 <Id>k__BackingField System.Int32 1 <SupplierId>k__BackingField ...taAccess.Supplier 00000151b0e35608 <Supplier> 3 System.Int32 0 <PartTypeId>k__BackingField ...taAccess.PartType 0000000000000000 <Part>k__BackingField System.Int32 10 <PartCount> 4 System.DateTime 00000151b0e35840 <PlacedDate>k__BackingField ...Private.CoreLib]] 00000151b0e35848 <FulfilledDate>k__BackingField Type VT Value Name System.Int32 1 1 <Id>k__BackingField System.Int32 1 1 <SupplierId>k__BackingField ...taAccess.Supplier 0 00000151b0e35608 <Supplier> 5 System.Int32 1 0 <PartTypeId>k__BackingField ...taAccess.PartType 0 0000000000000000 <Part>k__BackingField System.Int32 1 10 <PartCount> 6 System.DateTime 1 00000151b0e35840 <PlacedDate> 7 ...Private.CoreLib]] 1 00000151b0e35848 <FulfilledDate>k__BackingField
PartCount is a value type in C#, which means the value is held in memory directly. Supplier is a reference type, which is why you get a pointer value. A C# struct is also considered a value type, but in memory it’s a pointer, as you can see from the PlacedDate property. The VT column indicates 1 for a value type and 0 for a reference type. If you try !do on the PlacedDate pointer, it won’t work.
If you want to view an array, like the arguments passed to Program.Main, use the !da command instead.
All of these commands are powerful, but they’re not providing much of an advantage over what the Visual Studio debuggers do. There’s an area where SOS really shines, though, and it’s when you care about what’s in memory besides what’s on the current thread. To see what I mean, try executing !dumpheap -stat. You’ll see every .NET object in memory grouped by type and ordered by how much memory they take up. This includes objects created by the SQLite library, the dependency-injection library, and .NET Core.
To see this in action, try executing the following command.
0:000> !dumpheap -type OutOfMemory 1 Address MT Size 00000151b0dc10e0 00007ffe337a02f8 152 2 Statistics: MT Count TotalSize Class Name 3 00007ffe337a02f8 1 152 System.OutOfMemoryException Total 1 objects
If you’re looking at memory to find all the exceptions (!dumpheap -type Exception), you’ll always find OutOfMemoryException. .NET creates this exception in memory up front, because in the event that you do run out of memory, it won’t be able to allocate the memory to create the OutOfMemoryException. Instead, it will throw the one it created earlier. Creating a stack trace will also take memory, so you won’t get a stack trace for the OutOfMemoryException, but that’s typically not a problem.
If you’re interested in trying to debug the unit tests, you can use a CDB command like this:
cdb.exe -o dotnet test --no-build --no-restore
That command starts CDB and launches dotnet test without restoring or building and debugging all the child processes.
You’ll have to resume a few breaks to get to the AggregateException, so you need to pay attention to the output and stack at each break to know where you are.
After you’ve loaded SOS, you can get a full list of SOS commands by running the !help command. You can similarly get more detailed help on a command, like this: !help dumpheap.
Commands that don’t start with an exclamation mark (!) are part of CDB, and they can be a little archaic. There’s a good quick reference for managed code debugging called “WinDbg cheat sheet” at http://mng.bz/u7Ag. The reference is a bit old (it still refers to mscorwks, which is pre-.NET Framework 4.0) but still relevant.
LLDB is a debugger that’s part of the larger LLVM project, which is a collection of modular compiler and toolchain technologies. LLVM is used by Xcode, the integrated development environment for developing Mac and iOS applications.
If you’re using a Mac, the easiest way to install LLDB is to install Xcode.
Working with SOS and LLDB on a Mac is seriously complex. It requires that you build your own version of the .NET Core CLR so that you can get an SOS LLDB plugin that works with the version of LLDB you’re using. To get the .NET Core team to fix this issue, vote on GitHub on the coreclr issues page: http://mng.bz/r50R.
You can also attempt to install LLDB with Homebrew with this command:
brew install llvm --with-lldb
When attempting this command, Homebrew will first point you to a page that tells you how to install the code-signing certificate for LLDB. As mentioned in the warning, though, the next step is to build the Core CLR code, which will build the SOS plugin for LLDB. Assuming you did all these steps, you’ll have a file called libsosplugin.dylib. Use the plugin load command in LLDB, followed by the full path of the libsosplugin.dylib file to install the plugin.
Given the complexities of this method, I recommend installing Xcode instead. Xcode is free and installs LLDB without much difficulty.
On Linux, use the following command to install LLDB:
sudo apt install lldb-3.5
To test that LLDB is installed, run ./lldb from the terminal. It should give you a prompt like this: (lldb).
LLDB doesn’t have a command that breaks on module load, so you’ll need to add a Console.ReadLine to the test application. Make it the first line in the Main method in Program.cs. Then go to the publish folder for the ScmConsole application, and execute the application. It should wait for you to press Enter before continuing on with the program.
At this point you can start LLDB in another terminal and attach to it. But first, you’ll need the process ID. Use the following command to get the process ID:
ps -eclx | grep 'ScmConsole' | awk '{print $2}' | head -1
Now start LLDB and use the following command to attach to the process:
process attach -p [processid] 1
Now you’ll need to locate the libsosplugin.so file. Open a new terminal and run the following command to find it:
find /usr -name libsosplugin.so
You may see multiple versions of this file. Choose the one that matches the .NET SDK version you’re using, which is usually the latest one. Back in LLDB, enter the command plugin load followed by the full path of libsosplugin.so.
Now you can try the following sequence of commands, just like you did in section 8.4.2 on WinDBG:
!soe -create System.AggregateException process continue !pe !dumpheap -type OutOfMemory
SOS is addictive. It’s especially useful when you’re trying to diagnose an issue on a production server. You won’t want to set breakpoints, but you can get a memory dump of the process and copy it to your workstation for analysis. Having access to all of the .NET objects in memory gives you all kinds of power. If you’re interested in learning more about debugging .NET, check out some of these resources:
In this chapter you learned about the various tools for debugging .NET Core applications. These are some key concepts from this chapter:
You also used a few techniques to debug your application:
If you’re a .NET Framework developer, you’re probably used to Visual Studio and its powerful debugging capabilities. In this chapter, you learned that those same capabilities are available in .NET Core. We also explored some other options when developing on Mac and Linux. Command-line debuggers give you the power to work with a memory dump or via terminal or SSH, which comes in handy when a bug only happens in production.
In the next chapter, we’ll explore how to test and analyze the performance of your .NET applications.
3.14.253.152