While we’re all benefiting from the push towards “One .NET”, the reality is that there will always be differences in operating systems. Windows, macOS, and Linux are, in fact, different operating systems and have their advantages and disadvantages. The difference is a truism easily overlooked when writing C# code and compiling our apps to an intermediate language. In general, everything works, but sometimes we get a rude reminder. One of those reminders is the Debugger
class. This post will show you an admittedly janky way of achieving the same result as Debugger.Launch
across operating systems.
What Is Debugger.Launch?
.NET started life as a Windows-only platform. Therefore, the .NET team could make assumptions about the environment and add functionality specific to the Windows operating system. For example, when calling Debugger.Launch
from .NET code within a Windows environment, the .NET framework will make a system-level call to pause the current process and then launch and attach the debugger. In most cases, that debugger is typically Visual Studio or JetBrains Rider. Developers usually use this method to isolate an issue that is hard to reproduce, but they have a general idea of when or where it might appear.
Sounds helpful, right? Well, only for Windows users. Let’s take a look at the Launch
method.
// Launch launches & attaches a debugger to the process. If a debugger is already attached,
// nothing happens.
//
public static bool Launch() => IsAttached ? true : LaunchInternal();
[DllImport(RuntimeHelpers.QCall, CharSet = CharSet.Unicode)]
private static extern bool LaunchInternal();
For those unfamiliar with native APIs, the attribute of DllImport
calls down to native code compiled to the current operating system. On Windows, the operating system implements these APIs as you would expect. However, on macOS and Linux, the Windows native method is replaced by a no-op call, which, as you may have guessed, does absolutely nothing.
Well, what can folks on macOS and Linux do? I have a solution for you, but you may not like it.
Janky Debugger.Launch for macOS and Linux
While Debugger.Launch
doesn’t work, the Debugger.IsAttached
property does. When you attach the debugger, the property value will change. We can write a method that waits for the debugger to connect to the currently paused process.
using System.Diagnostics;
public static class Janky
{
public static async Task<bool> WaitForDebugger(TimeSpan? limit = null)
{
limit ??= TimeSpan.FromSeconds(30);
var source = new CancellationTokenSource(limit.Value);
Console.WriteLine($"◉ Waiting {limit.Value.TotalSeconds} secs for debugger (PID: {Environment.ProcessId})...");
try
{
await Task.Run(async () => {
while (!Debugger.IsAttached) {
await Task.Delay(TimeSpan.FromMilliseconds(100), source.Token);
}
}, source.Token);
}
catch (OperationCanceledException)
{
// it's ok
}
Console.WriteLine(Debugger.IsAttached
? "✔ Debugger attached"
: "✕ Continuing without debugger");
return Debugger.IsAttached;
}
}
Now, you can call the WaitForDebugger
method in place of Debugger.Launch
.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => Debugger.IsAttached);
await Janky.WaitForDebugger();
Debugger.Launch();
app.Run();
To avoid an infinite loop, I decided to add a configurable timeout period through the method’s arguments. We can see the debugger waiting in the console output when running our app. The technique also outputs the process id for user-friendliness. Let’s see an unsuccessful attempt first.
◉ Waiting 30 secs for debugger (PID: 14302)...
✕ Continuing without debugger
Now let’s see a successful attempt to attach the debugger.
◉ Waiting 30 secs for debugger (PID: 14644)...
✔ Debugger attached
Note, we stop waiting as soon as the debugger is attached, so there is no need to worry about wasting time waiting for the total time limit to elapse.
I hope you found this post helpful, and as always, thanks for reading and sharing. If you have a different approach, please let me know on Twitter @buhakmeh.