diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs index 31f6a988f..6fbecce9c 100644 --- a/src/PowerShellEditorServices/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -117,6 +117,7 @@ public async Task StartAsync() response.SupportsHitConditionalBreakpoints = true; response.SupportsLogPoints = true; response.SupportsSetVariable = true; + response.SupportsDelayedStackTraceLoading = true; return Task.CompletedTask; }); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 667a26bdd..94a957cf9 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -6,8 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation; -using System.Management.Automation.Language; -using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -50,7 +48,6 @@ internal class DebugService private VariableContainerDetails scriptScopeVariables; private VariableContainerDetails localScopeVariables; private StackFrameDetails[] stackFrameDetails; - private readonly PropertyInfo invocationTypeScriptPositionProperty; private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore(); #endregion @@ -75,6 +72,12 @@ internal class DebugService /// public DebuggerStoppedEventArgs CurrentDebuggerStoppedEventArgs { get; private set; } + /// + /// Returns a task that completes when script frames and variables have completed population + /// + public Task StackFramesAndVariablesFetched { get; private set; } + + /// /// Tracks whether we are running Debug-Runspace in an out-of-process runspace. /// @@ -111,12 +114,6 @@ public DebugService( _debugContext.DebuggerResuming += OnDebuggerResuming; _debugContext.BreakpointUpdated += OnBreakpointUpdated; _remoteFileManager = remoteFileManager; - - invocationTypeScriptPositionProperty = - typeof(InvocationInfo) - .GetProperty( - "ScriptPosition", - BindingFlags.NonPublic | BindingFlags.Instance); } #endregion @@ -981,8 +978,8 @@ await _executionService.ExecutePSCommandAsync( } } - // Get call stack and variables. - await FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null).ConfigureAwait(false); + // Begin call stack and variables fetch. We don't need to block here. + StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null); // If this is a remote connection and the debugger stopped at a line // in a script file, get the file contents @@ -996,53 +993,6 @@ await _remoteFileManager.FetchRemoteFileAsync( _psesHost.CurrentRunspace).ConfigureAwait(false); } - if (stackFrameDetails.Length > 0) - { - // Augment the top stack frame with details from the stop event - if (invocationTypeScriptPositionProperty.GetValue(e.InvocationInfo) is IScriptExtent scriptExtent) - { - StackFrameDetails targetFrame = stackFrameDetails[0]; - - // Certain context changes (like stepping into the default value expression - // of a parameter) do not create a call stack frame. In order to represent - // this context change we create a fake call stack frame. - if (!string.IsNullOrEmpty(scriptExtent.File) - && !PathUtils.IsPathEqual(scriptExtent.File, targetFrame.ScriptPath)) - { - await debugInfoHandle.WaitAsync().ConfigureAwait(false); - try - { - targetFrame = new StackFrameDetails - { - ScriptPath = scriptExtent.File, - // Just use the last frame's variables since we don't have a - // good way to get real values. - AutoVariables = targetFrame.AutoVariables, - CommandVariables = targetFrame.CommandVariables, - // Ideally we'd get a real value here but since there's no real - // call stack frame for this, we'd need to replicate a lot of - // engine code. - FunctionName = "", - }; - - StackFrameDetails[] newFrames = new StackFrameDetails[stackFrameDetails.Length + 1]; - newFrames[0] = targetFrame; - stackFrameDetails.CopyTo(newFrames, 1); - stackFrameDetails = newFrames; - } - finally - { - debugInfoHandle.Release(); - } - } - - targetFrame.StartLineNumber = scriptExtent.StartLineNumber; - targetFrame.EndLineNumber = scriptExtent.EndLineNumber; - targetFrame.StartColumnNumber = scriptExtent.StartColumnNumber; - targetFrame.EndColumnNumber = scriptExtent.EndColumnNumber; - } - } - CurrentDebuggerStoppedEventArgs = new DebuggerStoppedEventArgs( e, diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs index 04adf185e..e74bd1eb9 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs @@ -20,14 +20,17 @@ internal class ScopesHandler : IScopesHandler public Task Handle(ScopesArguments request, CancellationToken cancellationToken) { - VariableScope[] variableScopes = - _debugService.GetVariableScopes( - (int)request.FrameId); + //We have an artificial breakpoint label, so just copy the stacktrace from the first stack entry for this. + int frameId = request.FrameId == 0 ? 0 : (int)request.FrameId - 1; + + VariableScope[] variableScopes = _debugService.GetVariableScopes(frameId); return Task.FromResult(new ScopesResponse { - Scopes = new Container(variableScopes - .Select(LspDebugUtils.CreateScope)) + Scopes = new Container( + variableScopes + .Select(LspDebugUtils.CreateScope) + ) }); } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs index f53e094e7..560e18733 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs @@ -1,64 +1,132 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#nullable enable using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using System.Management.Automation; using Microsoft.PowerShell.EditorServices.Services; -using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; -using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.DebugAdapter.Protocol.Models; using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; +using System.Linq; + +namespace Microsoft.PowerShell.EditorServices.Handlers; -namespace Microsoft.PowerShell.EditorServices.Handlers +internal class StackTraceHandler(DebugService debugService) : IStackTraceHandler { - internal class StackTraceHandler : IStackTraceHandler + /// + /// Because we don't know the size of the stacktrace beforehand, we will tell the client that there are more frames available, this is effectively a paging size, as the client should request this many frames after the first one. + /// + private const int INITIAL_PAGE_SIZE = 20; + + public async Task Handle(StackTraceArguments request, CancellationToken cancellationToken) { - private readonly DebugService _debugService; + if (!debugService.IsDebuggerStopped) + { + throw new NotSupportedException("Stacktrace was requested while we are not stopped at a breakpoint."); + } - public StackTraceHandler(DebugService debugService) => _debugService = debugService; + // Adapting to int to let us use LINQ, realistically if you have a stacktrace larger than this that the client is requesting, you have bigger problems... + int skip = Convert.ToInt32(request.StartFrame ?? 0); + int take = Convert.ToInt32(request.Levels ?? 0); - public async Task Handle(StackTraceArguments request, CancellationToken cancellationToken) - { - StackFrameDetails[] stackFrameDetails = await _debugService.GetStackFramesAsync(cancellationToken).ConfigureAwait(false); + // We generate a label for the breakpoint and can return that immediately if the client is supporting DelayedStackTraceLoading. + InvocationInfo invocationInfo = debugService.CurrentDebuggerStoppedEventArgs?.OriginalEvent?.InvocationInfo + ?? throw new NotSupportedException("InvocationInfo was not available on CurrentDebuggerStoppedEvent args. This is a bug."); - // Handle a rare race condition where the adapter requests stack frames before they've - // begun building. - if (stackFrameDetails is null) - { - return new StackTraceResponse - { - StackFrames = Array.Empty(), - TotalFrames = 0 - }; - } - - List newStackFrames = new(); - - long startFrameIndex = request.StartFrame ?? 0; - long maxFrameCount = stackFrameDetails.Length; - - // If the number of requested levels == 0 (or null), that means get all stack frames - // after the specified startFrame index. Otherwise get all the stack frames. - long requestedFrameCount = request.Levels ?? 0; - if (requestedFrameCount > 0) - { - maxFrameCount = Math.Min(maxFrameCount, startFrameIndex + requestedFrameCount); - } + StackFrame breakpointLabel = CreateBreakpointLabel(invocationInfo); - for (long i = startFrameIndex; i < maxFrameCount; i++) + if (skip == 0 && take == 1) // This indicates the client is doing an initial fetch, so we want to return quickly to unblock the UI and wait on the remaining stack frames for the susequent requests. + { + return new StackTraceResponse() { - // Create the new StackFrame object with an ID that can - // be referenced back to the current list of stack frames - newStackFrames.Add(LspDebugUtils.CreateStackFrame(stackFrameDetails[i], id: i)); - } + StackFrames = new StackFrame[] { breakpointLabel }, + TotalFrames = INITIAL_PAGE_SIZE //Indicate to the client that there are more frames available + }; + } + + // Wait until the stack frames and variables have been fetched. + await debugService.StackFramesAndVariablesFetched.ConfigureAwait(false); + + StackFrameDetails[] stackFrameDetails = await debugService.GetStackFramesAsync(cancellationToken) + .ConfigureAwait(false); + // Handle a rare race condition where the adapter requests stack frames before they've + // begun building. + if (stackFrameDetails is null) + { return new StackTraceResponse { - StackFrames = newStackFrames, - TotalFrames = newStackFrames.Count + StackFrames = Array.Empty(), + TotalFrames = 0 + }; + } + + List newStackFrames = new(); + if (skip == 0) + { + newStackFrames.Add(breakpointLabel); + } + + newStackFrames.AddRange( + stackFrameDetails + .Skip(skip != 0 ? skip - 1 : skip) + .Take(take != 0 ? take - 1 : take) + .Select((frame, index) => CreateStackFrame(frame, index + 1)) + ); + + return new StackTraceResponse + { + StackFrames = newStackFrames, + TotalFrames = newStackFrames.Count + }; + } + + public static StackFrame CreateStackFrame(StackFrameDetails stackFrame, long id) + { + SourcePresentationHint sourcePresentationHint = + stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal; + + // When debugging an interactive session, the ScriptPath is which is not a valid source file. + // We need to make sure the user can't open the file associated with this stack frame. + // It will generate a VSCode error in this case. + Source? source = null; + if (!stackFrame.ScriptPath.Contains("<")) + { + source = new Source + { + Path = stackFrame.ScriptPath, + PresentationHint = sourcePresentationHint }; } + + return new StackFrame + { + Id = id, + Name = (source is not null) ? stackFrame.FunctionName : "Interactive Session", + Line = (source is not null) ? stackFrame.StartLineNumber : 0, + EndLine = stackFrame.EndLineNumber, + Column = (source is not null) ? stackFrame.StartColumnNumber : 0, + EndColumn = stackFrame.EndColumnNumber, + Source = source + }; } + + public static StackFrame CreateBreakpointLabel(InvocationInfo invocationInfo, int id = 0) => new() + { + Name = "", + Id = id, + Source = new() + { + Path = invocationInfo.ScriptName + }, + Line = invocationInfo.ScriptLineNumber, + Column = invocationInfo.OffsetInLine, + PresentationHint = StackFramePresentationHint.Label + }; + } + diff --git a/src/PowerShellEditorServices/Utility/LspDebugUtils.cs b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs index 115689586..36677743c 100644 --- a/src/PowerShellEditorServices/Utility/LspDebugUtils.cs +++ b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs @@ -56,38 +56,6 @@ public static Breakpoint CreateBreakpoint( }; } - public static StackFrame CreateStackFrame( - StackFrameDetails stackFrame, - long id) - { - SourcePresentationHint sourcePresentationHint = - stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal; - - // When debugging an interactive session, the ScriptPath is which is not a valid source file. - // We need to make sure the user can't open the file associated with this stack frame. - // It will generate a VSCode error in this case. - Source source = null; - if (!stackFrame.ScriptPath.Contains("<")) - { - source = new Source - { - Path = stackFrame.ScriptPath, - PresentationHint = sourcePresentationHint - }; - } - - return new StackFrame - { - Id = id, - Name = (source != null) ? stackFrame.FunctionName : "Interactive Session", - Line = (source != null) ? stackFrame.StartLineNumber : 0, - EndLine = stackFrame.EndLineNumber, - Column = (source != null) ? stackFrame.StartColumnNumber : 0, - EndColumn = stackFrame.EndColumnNumber, - Source = source - }; - } - public static Scope CreateScope(VariableScope scope) { return new Scope