From 667698fb135b8db720eca3c222cd63c2760b67be Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Thu, 15 Jan 2026 16:15:38 -0700 Subject: [PATCH 1/2] feat: add support for conditional breakpoints and hit counts Adds support for conditional breakpoints and hit count evaluation with common expressions Adds event and request handlers for exposing new functionality --- src/SharpDbg.Application/DebugAdapter.cs | 13 +- .../Debugger/BreakpointManager.cs | 32 +++- .../Debugger/ManagedDebugger_EventHandlers.cs | 161 +++++++++++++++++- .../ManagedDebugger_RequestHandlers.cs | 22 ++- 4 files changed, 218 insertions(+), 10 deletions(-) diff --git a/src/SharpDbg.Application/DebugAdapter.cs b/src/SharpDbg.Application/DebugAdapter.cs index d18ccdb..d3df1e1 100644 --- a/src/SharpDbg.Application/DebugAdapter.cs +++ b/src/SharpDbg.Application/DebugAdapter.cs @@ -182,7 +182,8 @@ protected override InitializeResponse HandleInitializeRequest(InitializeArgument { SupportsConfigurationDoneRequest = true, SupportsFunctionBreakpoints = true, - SupportsConditionalBreakpoints = false, + SupportsConditionalBreakpoints = true, + SupportsHitConditionalBreakpoints = true, SupportsEvaluateForHovers = true, SupportsStepBack = false, SupportsSetVariable = false, @@ -260,8 +261,14 @@ protected override SetBreakpointsResponse HandleSetBreakpointsRequest(SetBreakpo throw new ProtocolException("Missing source path"); } - var lines = arguments.Breakpoints?.Select(bp => ConvertClientLineToDebugger(bp.Line)).ToArray() ?? Array.Empty(); - var breakpoints = _debugger.SetBreakpoints(arguments.Source.Path, lines); + var breakpointRequests = arguments.Breakpoints? + .Select(bp => new BreakpointRequest( + ConvertClientLineToDebugger(bp.Line), + bp.Condition, + bp.HitCondition)) + .ToArray() ?? Array.Empty(); + + var breakpoints = _debugger.SetBreakpoints(arguments.Source.Path, breakpointRequests); var responseBreakpoints = breakpoints.Select(bp => new MSBreakpoint { diff --git a/src/SharpDbg.Infrastructure/Debugger/BreakpointManager.cs b/src/SharpDbg.Infrastructure/Debugger/BreakpointManager.cs index 1f81f38..439c356 100644 --- a/src/SharpDbg.Infrastructure/Debugger/BreakpointManager.cs +++ b/src/SharpDbg.Infrastructure/Debugger/BreakpointManager.cs @@ -48,12 +48,23 @@ public class BreakpointInfo /// Module base address where breakpoint is bound public long? ModuleBaseAddress { get; set; } + + // Conditional breakpoint support + + /// Conditional expression to evaluate when breakpoint is hit + public string? Condition { get; set; } + + /// Hit count condition (e.g., ">=10", "==5", "%3") + public string? HitCondition { get; set; } + + /// Current hit count for this breakpoint + public int HitCount { get; set; } } /// /// Create a new breakpoint /// - public BreakpointInfo CreateBreakpoint(string filePath, int line) + public BreakpointInfo CreateBreakpoint(string filePath, int line, string? condition = null, string? hitCondition = null) { lock (_lock) { @@ -63,7 +74,10 @@ public BreakpointInfo CreateBreakpoint(string filePath, int line) Id = id, FilePath = filePath, Line = line, - Verified = false + Verified = false, + Condition = condition, + HitCondition = hitCondition, + HitCount = 0 }; _breakpoints[id] = bp; @@ -78,6 +92,20 @@ public BreakpointInfo CreateBreakpoint(string filePath, int line) } } + /// + /// Reset hit counts for all breakpoints (e.g., when restarting debugging) + /// + public void ResetHitCounts() + { + lock (_lock) + { + foreach (var bp in _breakpoints.Values) + { + bp.HitCount = 0; + } + } + } + /// /// Update breakpoint with ClrDebug breakpoint /// diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs index 0d1766f..41c9805 100644 --- a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs @@ -1,5 +1,7 @@ -using ClrDebug; +using System.Runtime.InteropServices; +using ClrDebug; using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator; +using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator.Compiler; using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator.Interpreter; namespace SharpDbg.Infrastructure.Debugger; @@ -145,6 +147,30 @@ private async void HandleBreakpoint(object? sender, BreakpointCorDebugManagedCal var managedBreakpoint = _breakpointManager.FindByCorBreakpoint(functionBreakpoint.Raw); ArgumentNullException.ThrowIfNull(managedBreakpoint); + + managedBreakpoint.HitCount++; + + if (!string.IsNullOrEmpty(managedBreakpoint.HitCondition)) + { + if (!EvaluateHitCondition(managedBreakpoint.HitCount, managedBreakpoint.HitCondition)) + { + _logger?.Invoke($"Hit count condition not met: count={managedBreakpoint.HitCount}, condition={managedBreakpoint.HitCondition}"); + Continue(); + return; + } + } + + if (!string.IsNullOrEmpty(managedBreakpoint.Condition)) + { + var conditionResult = await EvaluateBreakpointCondition(corThread, managedBreakpoint.Condition); + if (!conditionResult) + { + _logger?.Invoke($"Conditional breakpoint condition not met: {managedBreakpoint.Condition}"); + Continue(); + return; + } + } + IsRunning = false; OnStopped2?.Invoke(corThread.Id, managedBreakpoint.FilePath, managedBreakpoint.Line, "breakpoint"); } @@ -154,6 +180,139 @@ private async void HandleBreakpoint(object? sender, BreakpointCorDebugManagedCal } } + /// + /// Evaluate a breakpoint condition expression + /// + private async Task EvaluateBreakpointCondition(CorDebugThread corThread, string condition) + { + try + { + if (_expressionInterpreter is null) + { + _logger?.Invoke("Expression interpreter not initialized, condition evaluation skipped"); + return true; // Stop anyway if we can't evaluate + } + + 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 + } + } + + /// + /// Evaluate a hit count condition + /// + 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(); + + if (hitCondition.StartsWith(">=")) + { + if (int.TryParse(hitCondition[2..], out var threshold)) + return hitCount >= threshold; + } + else if (hitCondition.StartsWith(">")) + { + if (int.TryParse(hitCondition[1..], out var threshold)) + return hitCount > threshold; + } + else if (hitCondition.StartsWith("<=")) + { + if (int.TryParse(hitCondition[2..], out var threshold)) + return hitCount <= threshold; + } + else if (hitCondition.StartsWith("<")) + { + if (int.TryParse(hitCondition[1..], out var threshold)) + return hitCount < threshold; + } + else if (hitCondition.StartsWith("%")) + { + if (int.TryParse(hitCondition[1..], out var modulo) && modulo > 0) + return hitCount % modulo == 0; + } + else if (hitCondition.StartsWith("==")) + { + if (int.TryParse(hitCondition[2..], out var target)) + return hitCount == target; + } + else + { + // Plain number means "break when hit count equals this" + if (int.TryParse(hitCondition, out var target)) + return hitCount == target; + } + + return false; + } + + /// + /// Check if a debug value is truthy (true, non-zero, non-null) + /// + private bool IsTruthyValue(CorDebugValue? value) + { + if (value is null) return false; + + var unwrapped = value.UnwrapDebugValue(); + + if (unwrapped is CorDebugGenericValue genericValue) + { + IntPtr 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; + } + private void HandleStepComplete(object? sender, StepCompleteCorDebugManagedCallbackEventArgs stepCompleteEventArgs) { var corThread = stepCompleteEventArgs.Thread; diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs index 54176ed..03aac72 100644 --- a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs @@ -7,6 +7,11 @@ namespace SharpDbg.Infrastructure.Debugger; +/// +/// Request to set a breakpoint with optional condition and hit condition +/// +public record BreakpointRequest(int Line, string? Condition = null, string? HitCondition = null); + public partial class ManagedDebugger { /// @@ -172,12 +177,21 @@ public async void StepOut(int threadId) } /// - /// Set breakpoints for a source file + /// Set breakpoints for a source file without conditions /// public List SetBreakpoints(string filePath, int[] lines) + { + var requests = lines.Select(line => new BreakpointRequest(line)).ToArray(); + return SetBreakpoints(filePath, requests); + } + + /// + /// Set breakpoints for a source file with optional conditions + /// + public List SetBreakpoints(string filePath, BreakpointRequest[] 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); @@ -199,9 +213,9 @@ public async void StepOut(int threadId) // Create new breakpoints var result = new List(); - 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) From 7998f132514bb463161a670366a859db36a8dbfc Mon Sep 17 00:00:00 2001 From: Tom Brewer Date: Thu, 15 Jan 2026 16:24:36 -0700 Subject: [PATCH 2/2] tests: add tests covering new conditional breakpoint functionality --- .../ConditionalBreakpointTests.cs | 149 ++++++++++++++++++ .../Helpers/DebugAdapterProcessHelper.cs | 4 +- tests/SharpDbg.Cli.Tests/TestHelper.cs | 8 + 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 tests/SharpDbg.Cli.Tests/ConditionalBreakpointTests.cs diff --git a/tests/SharpDbg.Cli.Tests/ConditionalBreakpointTests.cs b/tests/SharpDbg.Cli.Tests/ConditionalBreakpointTests.cs new file mode 100644 index 0000000..a3d5967 --- /dev/null +++ b/tests/SharpDbg.Cli.Tests/ConditionalBreakpointTests.cs @@ -0,0 +1,149 @@ +using AwesomeAssertions; +using SharpDbg.Cli.Tests.Helpers; + +namespace SharpDbg.Cli.Tests; + +public class ConditionalBreakpointTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public async Task ConditionalBreakpoint_WithTrueCondition_Stops() + { + var startSuspended = true; + + var (debugProtocolHost, initializedEventTcs, stoppedEventTcs, adapter, p2) = TestHelper.GetRunningDebugProtocolHostInProc(testOutputHelper, startSuspended); + using var _ = adapter; + using var __ = new ProcessKiller(p2); + + await debugProtocolHost + .WithInitializeRequest() + .WithAttachRequest(p2.Id) + .WaitForInitializedEvent(initializedEventTcs); + + debugProtocolHost + .WithConditionalBreakpointsRequest(22, condition: "myInt == 4") + .WithConfigurationDoneRequest() + .WithOptionalResumeRuntime(p2.Id, startSuspended); + + var stoppedEvent = await debugProtocolHost.WaitForStoppedEvent(stoppedEventTcs); + var stopInfo = stoppedEvent.ReadStopInfo(); + stopInfo.filePath.Should().EndWith("MyClass.cs"); + stopInfo.line.Should().Be(22); + } + + [Fact] + public async Task ConditionalBreakpoint_WithFalseCondition_DoesNotStop() + { + var startSuspended = true; + + var (debugProtocolHost, initializedEventTcs, stoppedEventTcs, adapter, p2) = TestHelper.GetRunningDebugProtocolHostInProc(testOutputHelper, startSuspended); + using var _ = adapter; + using var __ = new ProcessKiller(p2); + + await debugProtocolHost + .WithInitializeRequest() + .WithAttachRequest(p2.Id) + .WaitForInitializedEvent(initializedEventTcs); + + debugProtocolHost + .WithConditionalBreakpointsRequest(22, condition: "myInt == 999") + .WithBreakpointsRequest(20, Path.JoinFromGitRoot("tests", "DebuggableConsoleApp", "MyClass.cs")) + .WithConfigurationDoneRequest() + .WithOptionalResumeRuntime(p2.Id, startSuspended); + + // Should hit the unconditional breakpoint on line 20, not the conditional one on line 22 + var stoppedEvent = await debugProtocolHost.WaitForStoppedEvent(stoppedEventTcs); + var stopInfo = stoppedEvent.ReadStopInfo(); + stopInfo.filePath.Should().EndWith("MyClass.cs"); + stopInfo.line.Should().Be(20); + } + + [Fact] + public async Task HitCondition_EqualsN_StopsOnNthHit() + { + var startSuspended = true; + + var (debugProtocolHost, initializedEventTcs, stoppedEventTcs, adapter, p2) = TestHelper.GetRunningDebugProtocolHostInProc(testOutputHelper, startSuspended); + using var _ = adapter; + using var __ = new ProcessKiller(p2); + + await debugProtocolHost + .WithInitializeRequest() + .WithAttachRequest(p2.Id) + .WaitForInitializedEvent(initializedEventTcs); + + debugProtocolHost + .WithConditionalBreakpointsRequest(22, hitCondition: "==2") + .WithConfigurationDoneRequest() + .WithOptionalResumeRuntime(p2.Id, startSuspended); + + // Should stop on 2nd hit, not 1st + var stoppedEvent = await debugProtocolHost.WaitForStoppedEvent(stoppedEventTcs); + var stopInfo = stoppedEvent.ReadStopInfo(); + stopInfo.filePath.Should().EndWith("MyClass.cs"); + stopInfo.line.Should().Be(22); + } + + [Fact] + public async Task HitCondition_GreaterThanOrEqual_StopsAfterThreshold() + { + var startSuspended = true; + + var (debugProtocolHost, initializedEventTcs, stoppedEventTcs, adapter, p2) = TestHelper.GetRunningDebugProtocolHostInProc(testOutputHelper, startSuspended); + using var _ = adapter; + using var __ = new ProcessKiller(p2); + + await debugProtocolHost + .WithInitializeRequest() + .WithAttachRequest(p2.Id) + .WaitForInitializedEvent(initializedEventTcs); + + debugProtocolHost + .WithConditionalBreakpointsRequest(22, hitCondition: ">=2") + .WithConfigurationDoneRequest() + .WithOptionalResumeRuntime(p2.Id, startSuspended); + + // First stop should be on 2nd iteration (hit count >= 2) + var stoppedEvent = await debugProtocolHost.WaitForStoppedEvent(stoppedEventTcs); + var stopInfo = stoppedEvent.ReadStopInfo(); + stopInfo.filePath.Should().EndWith("MyClass.cs"); + stopInfo.line.Should().Be(22); + + // Continue - should stop again on 3rd iteration + var stoppedEvent2 = await debugProtocolHost.WithContinueRequest().WaitForStoppedEvent(stoppedEventTcs); + var stopInfo2 = stoppedEvent2.ReadStopInfo(); + stopInfo2.filePath.Should().EndWith("MyClass.cs"); + stopInfo2.line.Should().Be(22); + } + + [Fact] + public async Task HitCondition_Modulo_StopsEveryNthHit() + { + var startSuspended = true; + + var (debugProtocolHost, initializedEventTcs, stoppedEventTcs, adapter, p2) = TestHelper.GetRunningDebugProtocolHostInProc(testOutputHelper, startSuspended); + using var _ = adapter; + using var __ = new ProcessKiller(p2); + + await debugProtocolHost + .WithInitializeRequest() + .WithAttachRequest(p2.Id) + .WaitForInitializedEvent(initializedEventTcs); + + debugProtocolHost + .WithConditionalBreakpointsRequest(22, hitCondition: "%2") + .WithConfigurationDoneRequest() + .WithOptionalResumeRuntime(p2.Id, startSuspended); + + // First stop should be on 2nd iteration (2 % 2 == 0) + var stoppedEvent = await debugProtocolHost.WaitForStoppedEvent(stoppedEventTcs); + var stopInfo = stoppedEvent.ReadStopInfo(); + stopInfo.filePath.Should().EndWith("MyClass.cs"); + stopInfo.line.Should().Be(22); + + // Continue - should skip 3rd, stop on 4th (4 % 2 == 0) + var stoppedEvent2 = await debugProtocolHost.WithContinueRequest().WaitForStoppedEvent(stoppedEventTcs); + var stopInfo2 = stoppedEvent2.ReadStopInfo(); + stopInfo2.filePath.Should().EndWith("MyClass.cs"); + stopInfo2.line.Should().Be(22); + } +} diff --git a/tests/SharpDbg.Cli.Tests/Helpers/DebugAdapterProcessHelper.cs b/tests/SharpDbg.Cli.Tests/Helpers/DebugAdapterProcessHelper.cs index 9900c35..d95c305 100644 --- a/tests/SharpDbg.Cli.Tests/Helpers/DebugAdapterProcessHelper.cs +++ b/tests/SharpDbg.Cli.Tests/Helpers/DebugAdapterProcessHelper.cs @@ -86,7 +86,7 @@ public static AttachRequest GetAttachRequest(int processId) }; } - public static SetBreakpointsRequest GetSetBreakpointsRequest(int? line = null, string? filePath = null) + public static SetBreakpointsRequest GetSetBreakpointsRequest(int? line = null, string? filePath = null, string? condition = null, string? hitCondition = null) { line ??= 22; filePath ??= Path.JoinFromGitRoot("tests", "DebuggableConsoleApp", "MyClass.cs"); @@ -96,7 +96,7 @@ public static SetBreakpointsRequest GetSetBreakpointsRequest(int? line = null, s var setBreakpointsRequest = new SetBreakpointsRequest { Source = new Source { Path = debugFilePath }, - Breakpoints = [new SourceBreakpoint { Line = debugFileBreakpointLine }] + Breakpoints = [new SourceBreakpoint { Line = debugFileBreakpointLine, Condition = condition, HitCondition = hitCondition }] }; return setBreakpointsRequest; } diff --git a/tests/SharpDbg.Cli.Tests/TestHelper.cs b/tests/SharpDbg.Cli.Tests/TestHelper.cs index 0bbca6f..3cee74f 100644 --- a/tests/SharpDbg.Cli.Tests/TestHelper.cs +++ b/tests/SharpDbg.Cli.Tests/TestHelper.cs @@ -70,6 +70,14 @@ public static DebugProtocolHost WithBreakpointsRequest(this DebugProtocolHost de return debugProtocolHost; } + public static DebugProtocolHost WithConditionalBreakpointsRequest(this DebugProtocolHost debugProtocolHost, int line, string? condition = null, string? hitCondition = null, string? filePath = null) + { + var setBreakpointsRequest = DebugAdapterProcessHelper.GetSetBreakpointsRequest(line, filePath, condition, hitCondition); + if (File.Exists(setBreakpointsRequest.Source.Path) is false) throw new FileNotFoundException("Source file for breakpoint not found", setBreakpointsRequest.Source.Path); + debugProtocolHost.SendRequestSync(setBreakpointsRequest); + return debugProtocolHost; + } + public static DebugProtocolHost WithClearBreakpointsRequest(this DebugProtocolHost debugProtocolHost, string filePath) { var setBreakpointsRequest = new SetBreakpointsRequest