Skip to content

Commit f052fa5

Browse files
committed
Add Breakpoint label frame to optimize debug stepping performance
1 parent fec1f3a commit f052fa5

File tree

5 files changed

+124
-134
lines changed

5 files changed

+124
-134
lines changed

src/PowerShellEditorServices/Server/PsesDebugServer.cs

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public async Task StartAsync()
117117
response.SupportsHitConditionalBreakpoints = true;
118118
response.SupportsLogPoints = true;
119119
response.SupportsSetVariable = true;
120+
response.SupportsDelayedStackTraceLoading = true;
120121

121122
return Task.CompletedTask;
122123
});

src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs

+8-58
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
using System.Collections.Generic;
77
using System.Linq;
88
using System.Management.Automation;
9-
using System.Management.Automation.Language;
10-
using System.Reflection;
119
using System.Threading;
1210
using System.Threading.Tasks;
1311
using Microsoft.Extensions.Logging;
@@ -50,7 +48,6 @@ internal class DebugService
5048
private VariableContainerDetails scriptScopeVariables;
5149
private VariableContainerDetails localScopeVariables;
5250
private StackFrameDetails[] stackFrameDetails;
53-
private readonly PropertyInfo invocationTypeScriptPositionProperty;
5451

5552
private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore();
5653
#endregion
@@ -75,6 +72,12 @@ internal class DebugService
7572
/// </summary>
7673
public DebuggerStoppedEventArgs CurrentDebuggerStoppedEventArgs { get; private set; }
7774

75+
/// <summary>
76+
/// Returns a task that completes when script frames and variables have completed population
77+
/// </summary>
78+
public Task StackFramesAndVariablesFetched { get; private set; }
79+
80+
7881
/// <summary>
7982
/// Tracks whether we are running <c>Debug-Runspace</c> in an out-of-process runspace.
8083
/// </summary>
@@ -111,12 +114,6 @@ public DebugService(
111114
_debugContext.DebuggerResuming += OnDebuggerResuming;
112115
_debugContext.BreakpointUpdated += OnBreakpointUpdated;
113116
_remoteFileManager = remoteFileManager;
114-
115-
invocationTypeScriptPositionProperty =
116-
typeof(InvocationInfo)
117-
.GetProperty(
118-
"ScriptPosition",
119-
BindingFlags.NonPublic | BindingFlags.Instance);
120117
}
121118

122119
#endregion
@@ -981,8 +978,8 @@ await _executionService.ExecutePSCommandAsync<PSObject>(
981978
}
982979
}
983980

984-
// Get call stack and variables.
985-
await FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null).ConfigureAwait(false);
981+
// Begin call stack and variables fetch. We don't need to block here.
982+
StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null);
986983

987984
// If this is a remote connection and the debugger stopped at a line
988985
// in a script file, get the file contents
@@ -996,53 +993,6 @@ await _remoteFileManager.FetchRemoteFileAsync(
996993
_psesHost.CurrentRunspace).ConfigureAwait(false);
997994
}
998995

999-
if (stackFrameDetails.Length > 0)
1000-
{
1001-
// Augment the top stack frame with details from the stop event
1002-
if (invocationTypeScriptPositionProperty.GetValue(e.InvocationInfo) is IScriptExtent scriptExtent)
1003-
{
1004-
StackFrameDetails targetFrame = stackFrameDetails[0];
1005-
1006-
// Certain context changes (like stepping into the default value expression
1007-
// of a parameter) do not create a call stack frame. In order to represent
1008-
// this context change we create a fake call stack frame.
1009-
if (!string.IsNullOrEmpty(scriptExtent.File)
1010-
&& !PathUtils.IsPathEqual(scriptExtent.File, targetFrame.ScriptPath))
1011-
{
1012-
await debugInfoHandle.WaitAsync().ConfigureAwait(false);
1013-
try
1014-
{
1015-
targetFrame = new StackFrameDetails
1016-
{
1017-
ScriptPath = scriptExtent.File,
1018-
// Just use the last frame's variables since we don't have a
1019-
// good way to get real values.
1020-
AutoVariables = targetFrame.AutoVariables,
1021-
CommandVariables = targetFrame.CommandVariables,
1022-
// Ideally we'd get a real value here but since there's no real
1023-
// call stack frame for this, we'd need to replicate a lot of
1024-
// engine code.
1025-
FunctionName = "<ScriptBlock>",
1026-
};
1027-
1028-
StackFrameDetails[] newFrames = new StackFrameDetails[stackFrameDetails.Length + 1];
1029-
newFrames[0] = targetFrame;
1030-
stackFrameDetails.CopyTo(newFrames, 1);
1031-
stackFrameDetails = newFrames;
1032-
}
1033-
finally
1034-
{
1035-
debugInfoHandle.Release();
1036-
}
1037-
}
1038-
1039-
targetFrame.StartLineNumber = scriptExtent.StartLineNumber;
1040-
targetFrame.EndLineNumber = scriptExtent.EndLineNumber;
1041-
targetFrame.StartColumnNumber = scriptExtent.StartColumnNumber;
1042-
targetFrame.EndColumnNumber = scriptExtent.EndColumnNumber;
1043-
}
1044-
}
1045-
1046996
CurrentDebuggerStoppedEventArgs =
1047997
new DebuggerStoppedEventArgs(
1048998
e,

src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ScopesHandler.cs

+8-5
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ internal class ScopesHandler : IScopesHandler
2020

2121
public Task<ScopesResponse> Handle(ScopesArguments request, CancellationToken cancellationToken)
2222
{
23-
VariableScope[] variableScopes =
24-
_debugService.GetVariableScopes(
25-
(int)request.FrameId);
23+
//We have an artificial breakpoint label, so just copy the stacktrace from the first stack entry for this.
24+
int frameId = request.FrameId == 0 ? 0 : (int)request.FrameId - 1;
25+
26+
VariableScope[] variableScopes = _debugService.GetVariableScopes(frameId);
2627

2728
return Task.FromResult(new ScopesResponse
2829
{
29-
Scopes = new Container<Scope>(variableScopes
30-
.Select(LspDebugUtils.CreateScope))
30+
Scopes = new Container<Scope>(
31+
variableScopes
32+
.Select(LspDebugUtils.CreateScope)
33+
)
3134
});
3235
}
3336
}
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,132 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
3+
#nullable enable
34

45
using System;
56
using System.Collections.Generic;
67
using System.Threading;
78
using System.Threading.Tasks;
9+
using System.Management.Automation;
810
using Microsoft.PowerShell.EditorServices.Services;
9-
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
10-
using Microsoft.PowerShell.EditorServices.Utility;
1111
using OmniSharp.Extensions.DebugAdapter.Protocol.Models;
1212
using OmniSharp.Extensions.DebugAdapter.Protocol.Requests;
13+
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
14+
using System.Linq;
15+
16+
namespace Microsoft.PowerShell.EditorServices.Handlers;
1317

14-
namespace Microsoft.PowerShell.EditorServices.Handlers
18+
internal class StackTraceHandler(DebugService debugService) : IStackTraceHandler
1519
{
16-
internal class StackTraceHandler : IStackTraceHandler
20+
/// <summary>
21+
/// 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.
22+
/// </summary>
23+
private const int INITIAL_PAGE_SIZE = 20;
24+
25+
public async Task<StackTraceResponse> Handle(StackTraceArguments request, CancellationToken cancellationToken)
1726
{
18-
private readonly DebugService _debugService;
27+
if (!debugService.IsDebuggerStopped)
28+
{
29+
throw new NotSupportedException("Stacktrace was requested while we are not stopped at a breakpoint.");
30+
}
1931

20-
public StackTraceHandler(DebugService debugService) => _debugService = debugService;
32+
// 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...
33+
int skip = Convert.ToInt32(request.StartFrame ?? 0);
34+
int take = Convert.ToInt32(request.Levels ?? 0);
2135

22-
public async Task<StackTraceResponse> Handle(StackTraceArguments request, CancellationToken cancellationToken)
23-
{
24-
StackFrameDetails[] stackFrameDetails = await _debugService.GetStackFramesAsync(cancellationToken).ConfigureAwait(false);
36+
// We generate a label for the breakpoint and can return that immediately if the client is supporting DelayedStackTraceLoading.
37+
InvocationInfo invocationInfo = debugService.CurrentDebuggerStoppedEventArgs?.OriginalEvent?.InvocationInfo
38+
?? throw new NotSupportedException("InvocationInfo was not available on CurrentDebuggerStoppedEvent args. This is a bug.");
2539

26-
// Handle a rare race condition where the adapter requests stack frames before they've
27-
// begun building.
28-
if (stackFrameDetails is null)
29-
{
30-
return new StackTraceResponse
31-
{
32-
StackFrames = Array.Empty<StackFrame>(),
33-
TotalFrames = 0
34-
};
35-
}
36-
37-
List<StackFrame> newStackFrames = new();
38-
39-
long startFrameIndex = request.StartFrame ?? 0;
40-
long maxFrameCount = stackFrameDetails.Length;
41-
42-
// If the number of requested levels == 0 (or null), that means get all stack frames
43-
// after the specified startFrame index. Otherwise get all the stack frames.
44-
long requestedFrameCount = request.Levels ?? 0;
45-
if (requestedFrameCount > 0)
46-
{
47-
maxFrameCount = Math.Min(maxFrameCount, startFrameIndex + requestedFrameCount);
48-
}
40+
StackFrame breakpointLabel = CreateBreakpointLabel(invocationInfo);
4941

50-
for (long i = startFrameIndex; i < maxFrameCount; i++)
42+
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 subsequent requests.
43+
{
44+
return new StackTraceResponse()
5145
{
52-
// Create the new StackFrame object with an ID that can
53-
// be referenced back to the current list of stack frames
54-
newStackFrames.Add(LspDebugUtils.CreateStackFrame(stackFrameDetails[i], id: i));
55-
}
46+
StackFrames = new StackFrame[] { breakpointLabel },
47+
TotalFrames = INITIAL_PAGE_SIZE //Indicate to the client that there are more frames available
48+
};
49+
}
50+
51+
// Wait until the stack frames and variables have been fetched.
52+
await debugService.StackFramesAndVariablesFetched.ConfigureAwait(false);
53+
54+
StackFrameDetails[] stackFrameDetails = await debugService.GetStackFramesAsync(cancellationToken)
55+
.ConfigureAwait(false);
5656

57+
// Handle a rare race condition where the adapter requests stack frames before they've
58+
// begun building.
59+
if (stackFrameDetails is null)
60+
{
5761
return new StackTraceResponse
5862
{
59-
StackFrames = newStackFrames,
60-
TotalFrames = newStackFrames.Count
63+
StackFrames = Array.Empty<StackFrame>(),
64+
TotalFrames = 0
65+
};
66+
}
67+
68+
List<StackFrame> newStackFrames = new();
69+
if (skip == 0)
70+
{
71+
newStackFrames.Add(breakpointLabel);
72+
}
73+
74+
newStackFrames.AddRange(
75+
stackFrameDetails
76+
.Skip(skip != 0 ? skip - 1 : skip)
77+
.Take(take != 0 ? take - 1 : take)
78+
.Select((frame, index) => CreateStackFrame(frame, index + 1))
79+
);
80+
81+
return new StackTraceResponse
82+
{
83+
StackFrames = newStackFrames,
84+
TotalFrames = newStackFrames.Count
85+
};
86+
}
87+
88+
public static StackFrame CreateStackFrame(StackFrameDetails stackFrame, long id)
89+
{
90+
SourcePresentationHint sourcePresentationHint =
91+
stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal;
92+
93+
// When debugging an interactive session, the ScriptPath is <No File> which is not a valid source file.
94+
// We need to make sure the user can't open the file associated with this stack frame.
95+
// It will generate a VSCode error in this case.
96+
Source? source = null;
97+
if (!stackFrame.ScriptPath.Contains("<"))
98+
{
99+
source = new Source
100+
{
101+
Path = stackFrame.ScriptPath,
102+
PresentationHint = sourcePresentationHint
61103
};
62104
}
105+
106+
return new StackFrame
107+
{
108+
Id = id,
109+
Name = (source is not null) ? stackFrame.FunctionName : "Interactive Session",
110+
Line = (source is not null) ? stackFrame.StartLineNumber : 0,
111+
EndLine = stackFrame.EndLineNumber,
112+
Column = (source is not null) ? stackFrame.StartColumnNumber : 0,
113+
EndColumn = stackFrame.EndColumnNumber,
114+
Source = source
115+
};
63116
}
117+
118+
public static StackFrame CreateBreakpointLabel(InvocationInfo invocationInfo, int id = 0) => new()
119+
{
120+
Name = "<Breakpoint>",
121+
Id = id,
122+
Source = new()
123+
{
124+
Path = invocationInfo.ScriptName
125+
},
126+
Line = invocationInfo.ScriptLineNumber,
127+
Column = invocationInfo.OffsetInLine,
128+
PresentationHint = StackFramePresentationHint.Label
129+
};
130+
64131
}
132+

src/PowerShellEditorServices/Utility/LspDebugUtils.cs

-32
Original file line numberDiff line numberDiff line change
@@ -56,38 +56,6 @@ public static Breakpoint CreateBreakpoint(
5656
};
5757
}
5858

59-
public static StackFrame CreateStackFrame(
60-
StackFrameDetails stackFrame,
61-
long id)
62-
{
63-
SourcePresentationHint sourcePresentationHint =
64-
stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal;
65-
66-
// When debugging an interactive session, the ScriptPath is <No File> which is not a valid source file.
67-
// We need to make sure the user can't open the file associated with this stack frame.
68-
// It will generate a VSCode error in this case.
69-
Source source = null;
70-
if (!stackFrame.ScriptPath.Contains("<"))
71-
{
72-
source = new Source
73-
{
74-
Path = stackFrame.ScriptPath,
75-
PresentationHint = sourcePresentationHint
76-
};
77-
}
78-
79-
return new StackFrame
80-
{
81-
Id = id,
82-
Name = (source != null) ? stackFrame.FunctionName : "Interactive Session",
83-
Line = (source != null) ? stackFrame.StartLineNumber : 0,
84-
EndLine = stackFrame.EndLineNumber,
85-
Column = (source != null) ? stackFrame.StartColumnNumber : 0,
86-
EndColumn = stackFrame.EndColumnNumber,
87-
Source = source
88-
};
89-
}
90-
9159
public static Scope CreateScope(VariableScope scope)
9260
{
9361
return new Scope

0 commit comments

Comments
 (0)