diff --git a/src/SharpDbg.Application/DebugAdapter.cs b/src/SharpDbg.Application/DebugAdapter.cs index 4b335ca..91a1c16 100644 --- a/src/SharpDbg.Application/DebugAdapter.cs +++ b/src/SharpDbg.Application/DebugAdapter.cs @@ -187,6 +187,7 @@ protected override InitializeResponse HandleInitializeRequest(InitializeArgument SupportsConditionalBreakpoints = true, SupportsHitConditionalBreakpoints = true, SupportsEvaluateForHovers = true, + SupportsExceptionInfoRequest = true, SupportsStepBack = false, SupportsSetVariable = false, SupportsRestartFrame = false, @@ -352,6 +353,37 @@ protected override StackTraceResponse HandleStackTraceRequest(StackTraceArgument }); } + protected override ExceptionInfoResponse HandleExceptionInfoRequest(ExceptionInfoArguments arguments) + { + return ExecuteWithExceptionHandling(() => + { + if (arguments == null || arguments.ThreadId == 0) + { + throw new ProtocolException("Missing thread id"); + } + var threadId = arguments.ThreadId; + var ex = _debugger.GetExceptionInfoForThread(threadId); + if (ex is null) + { + var id = $"ex-{threadId}"; + var resp = new ExceptionInfoResponse(id, ExceptionBreakMode.Unhandled); + resp.Description = $"Exception on thread {threadId}"; + return resp; + } + var details = new ExceptionDetails(); + details.Message = ex.Message ?? string.Empty; + details.TypeName = ex.TypeName ?? ex.FullTypeName ?? "Exception"; + details.FullTypeName = ex.FullTypeName ?? ex.TypeName ?? string.Empty; + details.EvaluateName = ex.EvaluateName ?? null; + details.StackTrace = ex.StackTrace ?? string.Empty; + details.FormattedDescription = ex.Message ?? string.Empty; + var resp2 = new ExceptionInfoResponse(ex.ExceptionId, ExceptionBreakMode.Unhandled); + resp2.Description = ex.Message ?? string.Empty; + resp2.Details = details; + return resp2; + }); + } + protected override ScopesResponse HandleScopesRequest(ScopesArguments arguments) { return ExecuteWithExceptionHandling(() => diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger.cs index be46827..b108e6c 100644 --- a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger.cs +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger.cs @@ -69,6 +69,7 @@ private void OnAnyEvent(object? sender, CorDebugManagedCallbackEventArgs e) case StepCompleteCorDebugManagedCallbackEventArgs a: HandleStepComplete(sender, a); break; case BreakCorDebugManagedCallbackEventArgs a: HandleBreak(sender, a); break; case ExceptionCorDebugManagedCallbackEventArgs a: HandleException(sender, a); break; + case Exception2CorDebugManagedCallbackEventArgs a: HandleException2(sender, a); break; case EvalCompleteCorDebugManagedCallbackEventArgs or EvalExceptionCorDebugManagedCallbackEventArgs: break; // don't continue on these, as they are being used for expression evaluation default: e.Controller.Continue(false); break; } diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs index 2da0105..b94cd9a 100644 --- a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_EventHandlers.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Reflection.PortableExecutable; using ClrDebug; using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator; using SharpDbg.Infrastructure.Debugger.ExpressionEvaluator.Interpreter; @@ -254,14 +255,76 @@ private void HandleBreak(object? sender, OnStopped?.Invoke(corThread.Id, "pause"); } - private void HandleException(object? sender, ExceptionCorDebugManagedCallbackEventArgs exceptionCorDebugManagedCallbackEventArgs) + private void HandleException(object? sender, ExceptionCorDebugManagedCallbackEventArgs ev) { if (EvalStatus.IsRunning) { ContinueProcess(); return; } - var corThread = exceptionCorDebugManagedCallbackEventArgs.Thread; + + var corThread = ev.Thread; + IsRunning = false; + _asyncStepper?.Disable(); + if (_stepper is not null) + { + _stepper.Deactivate(); + _stepper = null; + } + + try + { + var frames = GetStackTrace(corThread.Id); + var stackTrace = string.Join("\n", frames.Select(f => f.Name + (f.Source != null ? $" at {f.Source}:{f.Line}" : string.Empty))); + + CorDebugValue? exceptionValue = null; + string? typeName = null; + string? fullTypeName = null; + string? message = null; + + try + { + exceptionValue = corThread.CurrentException; + } + catch (Exception ex2) + { + _logger?.Invoke($"Error getting current exception: {ex2.Message}"); + } + + if (exceptionValue is not null) + { + try + { + var objectValue = exceptionValue.UnwrapDebugValueToObject(); + fullTypeName = GetCorDebugTypeFriendlyName(objectValue.ExactType); + var lastDot = fullTypeName.LastIndexOf('.'); + typeName = lastDot >= 0 ? fullTypeName.Substring(lastDot + 1) : fullTypeName; + message = TryReadExceptionMessage(objectValue); + } + catch (Exception ex3) + { + _logger?.Invoke($"Error reading exception details: {ex3.Message}"); + } + } + + if (fullTypeName != null) + _logger?.Invoke($"Unhandled exception: {fullTypeName}{(message != null ? $": {message}" : "")}"); + _logger?.Invoke($"Exception call stack:\n{stackTrace}"); + + StoreExceptionForThread(corThread.Id, message, typeName, fullTypeName, $"$exception{corThread.Id}", stackTrace, exceptionValue); + } + catch (Exception ex) + { + _logger?.Invoke($"Error capturing exception info: {ex.Message}"); + StoreExceptionForThread(corThread.Id, null, null, null, null, null, null); + } + + OnStopped?.Invoke(corThread.Id, "exception"); + } + + private void HandleException2(object? sender, Exception2CorDebugManagedCallbackEventArgs ev) + { + var corThread = ev.Thread; IsRunning = false; _asyncStepper?.Disable(); if (_stepper is not null) @@ -270,6 +333,76 @@ private void HandleException(object? sender, ExceptionCorDebugManagedCallbackEve _stepper = null; } + try + { + var frames = GetStackTrace(corThread.Id); + var stackTrace = string.Join("\n", frames.Select(f => f.Name + (f.Source != null ? $" at {f.Source}:{f.Line}" : string.Empty))); + + CorDebugValue? exceptionValue = null; + string? typeName = null; + string? fullTypeName = null; + string? message = null; + + try + { + exceptionValue = corThread.CurrentException; + } + catch (Exception ex2) + { + _logger?.Invoke($"Error getting current exception: {ex2.Message}"); + } + + if (exceptionValue is not null) + { + try + { + var objectValue = exceptionValue.UnwrapDebugValueToObject(); + fullTypeName = GetCorDebugTypeFriendlyName(objectValue.ExactType); + var lastDot = fullTypeName.LastIndexOf('.'); + typeName = lastDot >= 0 ? fullTypeName.Substring(lastDot + 1) : fullTypeName; + message = TryReadExceptionMessage(objectValue); + } + catch (Exception ex3) + { + _logger?.Invoke($"Error reading exception details: {ex3.Message}"); + } + } + + if (fullTypeName != null) + _logger?.Invoke($"Unhandled exception: {fullTypeName}{(message != null ? $": {message}" : "")} ({ev.EventType})"); + _logger?.Invoke($"Exception call stack:\n{stackTrace}"); + + StoreExceptionForThread(corThread.Id, message, typeName, fullTypeName, $"$exception{corThread.Id}", stackTrace, exceptionValue); + } + catch (Exception ex) + { + _logger?.Invoke($"Error capturing exception info (Exception2): {ex.Message}"); + StoreExceptionForThread(corThread.Id, null, null, null, null, null, null); + } + OnStopped?.Invoke(corThread.Id, "exception"); } + + private static string? TryReadExceptionMessage(CorDebugObjectValue exObj) + { + var currentType = exObj.ExactType; + while (currentType?.Class != null) + { + try + { + var metadataImport = currentType.Class.Module.GetMetaDataInterface().MetaDataImport; + var fieldDef = metadataImport.EnumFieldsWithName(currentType.Class.Token, "_message").FirstOrDefault(); + if (!fieldDef.IsNil) + { + var fieldValue = exObj.GetFieldValue(currentType.Class.Raw, fieldDef); + var unwrapped = fieldValue.UnwrapDebugValue(); + if (unwrapped is CorDebugStringValue sv) + return sv.GetStringWithoutBug(sv.Length + 1); + } + } + catch { } + currentType = currentType.Base; + } + return null; + } } diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_ExceptionInfo.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_ExceptionInfo.cs new file mode 100644 index 0000000..460bf1e --- /dev/null +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_ExceptionInfo.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ClrDebug; + +namespace SharpDbg.Infrastructure.Debugger; + +public partial class ManagedDebugger +{ + // Minimal per-thread exception state stored when an exception callback occurs. + private readonly Dictionary _threadExceptions = new(); + + public record ExceptionState(string ExceptionId, string? Message, string? TypeName, string? FullTypeName, string? EvaluateName, string? StackTrace, CorDebugValue? ExceptionValue); + + public ExceptionState? GetExceptionInfoForThread(int threadId) + { + lock (_threadExceptions) + { + _threadExceptions.TryGetValue(threadId, out var state); + return state; + } + } + + private void StoreExceptionForThread(int threadId, string? message, string? typeName, string? fullTypeName, string? evaluateName, string? stackTrace, CorDebugValue? exceptionValue) + { + var id = Guid.NewGuid().ToString(); + var state = new ExceptionState(id, message, typeName, fullTypeName, evaluateName, stackTrace, exceptionValue); + lock (_threadExceptions) + { + _threadExceptions[threadId] = state; + } + + // Log stored exception metadata for diagnostics + try + { + _logger?.Invoke($"Stored exception for thread {threadId}: hasValue={(exceptionValue is not null)}, isHandle={(exceptionValue is CorDebugHandleValue)}"); + } + catch + { + // ignore logging failures + } + } + + private void ClearAllThreadExceptions() + { + lock (_threadExceptions) + { + // Dispose any handle values if necessary + foreach (var s in _threadExceptions.Values) + { + if (s.ExceptionValue is CorDebugHandleValue hv) + { + try { hv.Dispose(); } catch { } + } + } + _threadExceptions.Clear(); + } + } +} diff --git a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs index 405235b..3a55f44 100644 --- a/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs +++ b/src/SharpDbg.Infrastructure/Debugger/ManagedDebugger_RequestHandlers.cs @@ -56,6 +56,16 @@ private void PerformLaunch() _pendingLaunchArgs = null; _pendingLaunchWorkingDirectory = null; + // On non-Windows, .dll/.exe assemblies cannot be exec'd directly; wrap with dotnet host + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) is false && + (program.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + program.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))) + { + _logger?.Invoke($"Non-Windows: prepending dotnet host for assembly launch"); + args = args.Length > 0 ? [program, ..args] : [program]; + program = "dotnet"; + } + // Build command line: "program" "arg1" "arg2" ... var commandLine = new StringBuilder(); commandLine.Append('"').Append(program).Append('"'); @@ -68,14 +78,24 @@ private void PerformLaunch() // Initialize DbgShim var dbgShimPath = DbgShimResolver.Resolve(); + _logger?.Invoke($"DbgShim path: {dbgShimPath}"); var dbgshim = new DbgShim(NativeLibrary.Load(dbgShimPath)); // Create process suspended - var result = dbgshim.CreateProcessForLaunch( - commandLine.ToString(), - bSuspendProcess: true, - lpEnvironment: IntPtr.Zero, // TODO: support environment variables - lpCurrentDirectory: workingDirectory); + CreateProcessForLaunchResult result; + try + { + result = dbgshim.CreateProcessForLaunch( + commandLine.ToString(), + bSuspendProcess: true, + lpEnvironment: IntPtr.Zero, // TODO: support environment variables + lpCurrentDirectory: workingDirectory); + } + catch (Exception ex) + { + _logger?.Invoke($"CreateProcessForLaunch failed: {ex.GetType().Name}: {ex.Message}"); + throw; + } var processId = result.ProcessId; var resumeHandle = result.ResumeHandle; @@ -194,6 +214,9 @@ public void Continue() _logger?.Invoke("Continue"); if (_rawProcess != null) { + // Clear any cached exception info when execution is resumed + ClearAllThreadExceptions(); + IsRunning = true; _variableManager.ClearAndDisposeHandleValues(); _rawProcess.Continue(false); @@ -483,6 +506,21 @@ public List GetScopes(int frameId) VariablesReference = localsRef, Expensive = false }); + + // If an exception was recorded for this thread, expose it as an "Exception" scope + var excState = GetExceptionInfoForThread(variablesReference.Value.ThreadId.Value); + if (excState is not null && excState.ExceptionValue is not null) + { + var excRef = _variableManager.CreateReference(new VariablesReference(StoredReferenceKind.StackVariable, excState.ExceptionValue, variablesReference.Value.ThreadId, variablesReference.Value.FrameStackDepth, null)); + _logger?.Invoke($"Created Exception scope variablesReference={excRef} for thread {variablesReference.Value.ThreadId.Value}"); + result.Add(new ScopeInfo + { + Name = "Exception", + VariablesReference = excRef, + Expensive = false + }); + } + return result; } @@ -560,6 +598,48 @@ public async Task> GetVariables(int variablesReferenceInt) public async Task<(string result, string? type, int variablesReference)> Evaluate(string expression, int? frameId) { _logger?.Invoke($"Evaluate: {expression}"); + + // Special-case: evaluate the stored exception object for a thread using the synthetic expression + if (!string.IsNullOrEmpty(expression) && expression.StartsWith("$exception")) + { + // expression may be "$exception" or "$exception{threadId}" + var suffix = expression.AsSpan()["$exception".Length..]; + int parsedThreadId = 0; + if (suffix.IsEmpty is false && int.TryParse(suffix, out var tmp)) parsedThreadId = tmp; + + ExceptionState? state = null; + if (parsedThreadId != 0) + { + state = GetExceptionInfoForThread(parsedThreadId); + } + else if (frameId.HasValue && frameId.Value != 0) + { + var frameRef = _variableManager.GetReference(frameId.Value); + if (frameRef.HasValue && frameRef.Value.ThreadId.Value > 0) + { + state = GetExceptionInfoForThread(frameRef.Value.ThreadId.Value); + } + } + + if (state is not null && state.ExceptionValue is not null) + { + int targetThreadId = parsedThreadId; + if (targetThreadId == 0 && frameId.HasValue && frameId.Value != 0) + { + var frameRef = _variableManager.GetReference(frameId.Value); + if (frameRef.HasValue && frameRef.Value.ThreadId.Value > 0) + { + targetThreadId = frameRef.Value.ThreadId.Value; + } + } + var exceptionVariablesReference = GenerateUniqueVariableReference(state.ExceptionValue, new ThreadId(targetThreadId), new FrameStackDepth(0), null); + var exceptionFriendlyTypeName = state.FullTypeName ?? state.TypeName ?? "Exception"; + var resultString = state.Message ?? exceptionFriendlyTypeName; + return (resultString, exceptionFriendlyTypeName, exceptionVariablesReference); + } + return ("", null, 0); + } + if (frameId is null or 0) throw new InvalidOperationException("Frame ID is required for evaluation"); var variablesReference = _variableManager.GetReference(frameId.Value); diff --git a/tests/SharpDbg.Cli.Tests/Helpers/GitRoot.cs b/tests/SharpDbg.Cli.Tests/Helpers/GitRoot.cs index 8e4f3c8..d8e4a64 100644 --- a/tests/SharpDbg.Cli.Tests/Helpers/GitRoot.cs +++ b/tests/SharpDbg.Cli.Tests/Helpers/GitRoot.cs @@ -8,7 +8,7 @@ public static string GetGitRootPath() if (_gitRoot is not null) return _gitRoot; var currentDirectory = Directory.GetCurrentDirectory(); var gitRoot = currentDirectory; - while (!Directory.Exists(Path.Combine(gitRoot, ".git"))) + while (!Directory.Exists(Path.Combine(gitRoot, ".git")) && !File.Exists(Path.Combine(gitRoot, ".git"))) { gitRoot = Path.GetDirectoryName(gitRoot); // parent directory if (string.IsNullOrWhiteSpace(gitRoot))