Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
667698f
feat: add support for conditional breakpoints and hit counts
Jan 15, 2026
7998f13
tests: add tests covering new conditional breakpoint functionality
Jan 15, 2026
88fbfe1
Merge branch 'main' into feat/debugging-condition-support
MattParkerDev Feb 10, 2026
04d0e99
Update DebugAdapter.cs
MattParkerDev Feb 10, 2026
6c187f4
remove unused method
MattParkerDev Feb 10, 2026
2cc28c7
remove null check
MattParkerDev Feb 10, 2026
85716fa
remove comments
MattParkerDev Feb 10, 2026
8970ece
refactor to partial
MattParkerDev Feb 10, 2026
eed8c3f
Update ManagedDebugger_ConditionalBreakpoints.cs
MattParkerDev Feb 10, 2026
123353f
simplify null check
MattParkerDev Feb 10, 2026
285f31c
Update ManagedDebugger_EventHandlers.cs
MattParkerDev Feb 10, 2026
942ec11
refactor: improve new conditional breakpoint test structure
Feb 12, 2026
de1b47b
remove unused method
MattParkerDev Mar 22, 2026
8ec6e9b
Merge branch 'main' into pr/5
MattParkerDev Mar 22, 2026
c2cf4dc
Update BreakpointManager.cs
MattParkerDev Mar 22, 2026
00396ef
Update ManagedDebugger_RequestHandlers.cs
MattParkerDev Mar 22, 2026
30af604
rename record
MattParkerDev Mar 22, 2026
7392672
fix test
MattParkerDev Mar 22, 2026
9e0fb76
refactor
MattParkerDev Mar 22, 2026
07f6884
refactor
MattParkerDev Mar 22, 2026
073ca1a
remove test method
MattParkerDev Mar 22, 2026
287500d
refactor
MattParkerDev Mar 22, 2026
6d59690
Update ManagedDebugger_ConditionalBreakpoints.cs
MattParkerDev Mar 22, 2026
1cfdfb0
avoid allocations
MattParkerDev Mar 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/SharpDbg.Application/DebugAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ protected override InitializeResponse HandleInitializeRequest(InitializeArgument
{
SupportsConfigurationDoneRequest = true,
SupportsFunctionBreakpoints = true,
SupportsConditionalBreakpoints = false,
SupportsConditionalBreakpoints = true,
SupportsHitConditionalBreakpoints = true,
SupportsEvaluateForHovers = true,
SupportsStepBack = false,
SupportsSetVariable = false,
Expand Down Expand Up @@ -261,8 +262,14 @@ protected override SetBreakpointsResponse HandleSetBreakpointsRequest(SetBreakpo
throw new ProtocolException("Missing source path");
}

var lines = arguments.Breakpoints?.Select(bp => ConvertClientLineToDebugger(bp.Line)).ToArray() ?? Array.Empty<int>();
var breakpoints = _debugger.SetBreakpoints(arguments.Source.Path, lines);
var breakpointRequests = arguments.Breakpoints?
.Select(bp => new SharpDbgBreakpointRequest(
ConvertClientLineToDebugger(bp.Line),
bp.Condition,
bp.HitCondition))
.ToArray() ?? [];

var breakpoints = _debugger.SetBreakpoints(arguments.Source.Path, breakpointRequests);

var responseBreakpoints = breakpoints.Select(bp => new MSBreakpoint
{
Expand Down
18 changes: 16 additions & 2 deletions src/SharpDbg.Infrastructure/Debugger/BreakpointManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,36 @@ public class BreakpointInfo
public int? MethodToken { get; set; }
public int? ILOffset { get; set; }
public CORDB_ADDRESS? ModuleBaseAddress { get; set; }

/// <summary>Conditional expression to evaluate when breakpoint is hit</summary>
public string? Condition { get; set; }

/// <summary>Hit count condition (e.g., ">=10", "==5", "%3")</summary>
public string? HitCondition { get; set; }

/// <summary>Current hit count for this breakpoint</summary>
public int HitCount { get; set; }
}

/// <summary>
/// Create a new breakpoint
/// </summary>
public BreakpointInfo CreateBreakpoint(string filePath, int line)
public BreakpointInfo CreateBreakpoint(string filePath, int line, string? condition = null, string? hitCondition = null)
{
lock (_lock)
{
var id = _nextBreakpointId++;
if (string.IsNullOrWhiteSpace(condition)) condition = null;
if (string.IsNullOrWhiteSpace(hitCondition)) hitCondition = null;
var bp = new BreakpointInfo
{
Id = id,
FilePath = filePath,
Line = line,
Verified = false
Verified = false,
Condition = condition,
HitCondition = hitCondition,
HitCount = 0
};

_breakpoints[id] = bp;
Expand Down
2 changes: 1 addition & 1 deletion src/SharpDbg.Infrastructure/Debugger/ManagedDebugger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public partial class ManagedDebugger : IDisposable
private int? _pendingAttachProcessId;
private bool _justMyCode;
private AsyncStepper? _asyncStepper;
private CompiledExpressionInterpreter? _expressionInterpreter;
private CompiledExpressionInterpreter _expressionInterpreter = null!;

public event Action<int, string>? OnStopped;
// ThreadId, FilePath, Line, Reason
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Runtime.InteropServices;
using ClrDebug;
using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator;
using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator.Compiler;

namespace SharpDbg.Infrastructure.Debugger;

public partial class ManagedDebugger
{
private async Task<bool> EvaluateBreakpointCondition(CorDebugThread corThread, string condition)
{
try
{
var threadId = new ThreadId(corThread.Id);
var frameStackDepth = new FrameStackDepth(0); // Top frame

var compiledExpression = ExpressionCompiler.Compile(condition, false);
var evalContext = new CompiledExpressionEvaluationContext(corThread, threadId, frameStackDepth);
var result = await _expressionInterpreter.Interpret(compiledExpression, evalContext);

if (result.Error is not null)
{
_logger?.Invoke($"Condition evaluation error for '{condition}': {result.Error}");
return false; // Don't stop on error - condition couldn't be evaluated, so skip the breakpoint
}

return IsTruthyValue(result.Value);
}
catch (Exception ex)
{
_logger?.Invoke($"Exception evaluating condition '{condition}': {ex.Message}");
return false; // Don't stop on exception - condition couldn't be evaluated, so skip the breakpoint
}
}

private static bool EvaluateHitCondition(int hitCount, string hitCondition)
{
// Support common hit count formats:
// "10" or "==10" - break when hit count equals 10
// ">=10" - break when hit count is >= 10
// ">10" - break when hit count is > 10
// "%10" - break every 10th hit (modulo)

hitCondition = hitCondition.Trim();
var hitConditionSpan = hitCondition.AsSpan();
return hitConditionSpan switch
{
['>', '=', ..] => int.TryParse(hitConditionSpan[2..], out var threshold) && hitCount >= threshold,
['>', ..] => int.TryParse(hitConditionSpan[1..], out var threshold) && hitCount > threshold,
['<', '=', ..] => int.TryParse(hitConditionSpan[2..], out var threshold) && hitCount <= threshold,
['<', ..] => int.TryParse(hitConditionSpan[1..], out var threshold) && hitCount < threshold,
['%', ..] => int.TryParse(hitConditionSpan[1..], out var modulo) && modulo > 0 && hitCount % modulo == 0,
['=', '=', ..] => int.TryParse(hitConditionSpan[2..], out var target) && hitCount == target,
_ => int.TryParse(hitConditionSpan, out var target) && hitCount == target // Plain number means break when hit count = number
};
}

/// <summary>
/// Check if a debug value is truthy (true, non-zero, non-null)
/// </summary>
private static bool IsTruthyValue(CorDebugValue? value)
{
if (value is null) return false;

var unwrapped = value.UnwrapDebugValue();

if (unwrapped is CorDebugGenericValue genericValue)
{
var buffer = Marshal.AllocHGlobal(genericValue.Size);
try
{
genericValue.GetValue(buffer);
return genericValue.Type switch
{
CorElementType.Boolean => Marshal.ReadByte(buffer) != 0,
CorElementType.I1 or CorElementType.U1 => Marshal.ReadByte(buffer) != 0,
CorElementType.I2 or CorElementType.U2 => Marshal.ReadInt16(buffer) != 0,
CorElementType.I4 or CorElementType.U4 => Marshal.ReadInt32(buffer) != 0,
CorElementType.I8 or CorElementType.U8 => Marshal.ReadInt64(buffer) != 0,
CorElementType.R4 => BitConverter.ToSingle(BitConverter.GetBytes(Marshal.ReadInt32(buffer)), 0) != 0,
CorElementType.R8 => BitConverter.ToDouble(BitConverter.GetBytes(Marshal.ReadInt64(buffer)), 0) != 0,
_ => true // Unknown types - default to true
};
}
catch
{
return false;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}

if (unwrapped is CorDebugReferenceValue refValue)
{
return !refValue.IsNull;
}

return true;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using ClrDebug;
using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator;
using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator.Interpreter;
Expand Down Expand Up @@ -148,6 +148,23 @@ private async void HandleBreakpoint(object? sender, BreakpointCorDebugManagedCal

var managedBreakpoint = _breakpointManager.FindByCorBreakpoint(functionBreakpoint.Raw);
ArgumentNullException.ThrowIfNull(managedBreakpoint);

managedBreakpoint.HitCount++;

if (managedBreakpoint.HitCondition is not null && EvaluateHitCondition(managedBreakpoint.HitCount, managedBreakpoint.HitCondition) is false)
{
_logger?.Invoke($"Hit count condition not met: count={managedBreakpoint.HitCount}, condition={managedBreakpoint.HitCondition}");
Continue();
return;
}

if (managedBreakpoint.Condition is not null && await EvaluateBreakpointCondition(corThread, managedBreakpoint.Condition) is false)
{
_logger?.Invoke($"Conditional breakpoint condition not met: {managedBreakpoint.Condition}");
Continue();
return;
}

IsRunning = false;
OnStopped2?.Invoke(corThread.Id, managedBreakpoint.FilePath, managedBreakpoint.Line, "breakpoint", null);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

namespace SharpDbg.Infrastructure.Debugger;

public record SharpDbgBreakpointRequest(int Line, string? Condition = null, string? HitCondition = null);

public partial class ManagedDebugger
{
// Store launch info for deferred attach in ConfigurationDone
Expand Down Expand Up @@ -315,12 +317,12 @@ public async void StepOut(int threadId)
}

/// <summary>
/// Set breakpoints for a source file
/// Set breakpoints for a source file with optional conditions
/// </summary>
public List<BreakpointManager.BreakpointInfo> SetBreakpoints(string filePath, int[] lines)
public List<BreakpointManager.BreakpointInfo> SetBreakpoints(string filePath, SharpDbgBreakpointRequest[] breakpoints)
{
//System.Diagnostics.Debugger.Launch();
_logger?.Invoke($"SetBreakpoints: {filePath}, lines: {string.Join(",", lines)}");
_logger?.Invoke($"SetBreakpoints: {filePath}, breakpoints: {string.Join(",", breakpoints.Select(b => $"L{b.Line}" + (b.Condition != null ? $"[{b.Condition}]" : "")))}");

// Deactivate and clear existing breakpoints for this file
var existingBreakpoints = _breakpointManager.GetBreakpointsForFile(filePath);
Expand All @@ -342,9 +344,9 @@ public async void StepOut(int threadId)

// Create new breakpoints
var result = new List<BreakpointManager.BreakpointInfo>();
foreach (var line in lines)
foreach (var request in breakpoints)
{
var bp = _breakpointManager.CreateBreakpoint(filePath, line);
var bp = _breakpointManager.CreateBreakpoint(filePath, request.Line, request.Condition, request.HitCondition);

// Try to bind the breakpoint if we have a process
if (_process != null)
Expand Down
12 changes: 12 additions & 0 deletions tests/DebuggableConsoleApp/HitConditionClass.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace DebuggableConsoleApp;

public class HitConditionClass
{
private static int _count = 0;

public void Test()
{
_count++;
; // breakpoint here
}
}
2 changes: 2 additions & 0 deletions tests/DebuggableConsoleApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ public static void Main(string[] args)
var myClass = new MyClass();
var myAsyncClass = new MyAsyncClass();
var myClassNoMembers = new MyClassNoMembers();
var hitConditionClass = new HitConditionClass();
while (true)
{
// Keep the application running to allow debugging
myLambdaClass.Test();
myClass.MyMethod(13, 6);
myClassNoMembers.MyMethod(42);
hitConditionClass.Test();
var asyncResult = myAsyncClass.MyMethodAsync(4).GetAwaiter().GetResult();
Thread.Sleep(100);
//await Task.Delay(500);
Expand Down
Loading