Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve debugger's variable population mechanism #1620

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
147 changes: 91 additions & 56 deletions src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging;
using System.Collections;

namespace Microsoft.PowerShell.EditorServices.Services
{
Expand Down Expand Up @@ -45,6 +46,7 @@ internal class DebugService
private List<VariableDetailsBase> variables;
private VariableContainerDetails globalScopeVariables;
private VariableContainerDetails scriptScopeVariables;
private VariableContainerDetails localScopeVariables;
private StackFrameDetails[] stackFrameDetails;
private readonly PropertyInfo invocationTypeScriptPositionProperty;

Expand Down Expand Up @@ -445,11 +447,6 @@ public async Task<string> SetVariableAsync(int variableContainerReferenceId, str
for (int i = 0; i < stackFrames.Length; i++)
{
var stackFrame = stackFrames[i];
if (stackFrame.LocalVariables.ContainsVariable(variable.Id))
{
scope = i.ToString();
break;
}
}
}

Expand Down Expand Up @@ -626,13 +623,12 @@ internal async Task<StackFrameDetails[]> GetStackFramesAsync(CancellationToken c
public VariableScope[] GetVariableScopes(int stackFrameId)
{
var stackFrames = this.GetStackFrames();
int localStackFrameVariableId = stackFrames[stackFrameId].LocalVariables.Id;
int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id;

return new VariableScope[]
{
new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName),
new VariableScope(localStackFrameVariableId, VariableContainerDetails.LocalScopeName),
new VariableScope(this.localScopeVariables.Id, VariableContainerDetails.LocalScopeName),
new VariableScope(this.scriptScopeVariables.Id, VariableContainerDetails.ScriptScopeName),
new VariableScope(this.globalScopeVariables.Id, VariableContainerDetails.GlobalScopeName),
};
Expand All @@ -655,30 +651,27 @@ private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride)
new VariableDetails("Dummy", null)
};

// Must retrieve global/script variales before stack frame variables
// as we check stack frame variables against globals.
await FetchGlobalAndScriptVariablesAsync().ConfigureAwait(false);

// Must retrieve in order of broadest to narrowest scope for efficient deduplication: global, script, local
this.globalScopeVariables =
await FetchVariableContainerAsync(VariableContainerDetails.GlobalScopeName).ConfigureAwait(false);

this.scriptScopeVariables =
await FetchVariableContainerAsync(VariableContainerDetails.ScriptScopeName).ConfigureAwait(false);

this.localScopeVariables =
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
await FetchVariableContainerAsync(VariableContainerDetails.LocalScopeName).ConfigureAwait(false);

await FetchStackFramesAsync(scriptNameOverride).ConfigureAwait(false);

}
finally
{
this.debugInfoHandle.Release();
}
}

private async Task FetchGlobalAndScriptVariablesAsync()
{
// Retrieve globals first as script variable retrieval needs to search globals.
this.globalScopeVariables =
await FetchVariableContainerAsync(VariableContainerDetails.GlobalScopeName, null).ConfigureAwait(false);

this.scriptScopeVariables =
await FetchVariableContainerAsync(VariableContainerDetails.ScriptScopeName, null).ConfigureAwait(false);
}

private async Task<VariableContainerDetails> FetchVariableContainerAsync(
string scope,
VariableContainerDetails autoVariables)
private async Task<VariableContainerDetails> FetchVariableContainerAsync(string scope)
{
PSCommand psCommand = new PSCommand()
.AddCommand("Get-Variable")
Expand All @@ -687,8 +680,20 @@ private async Task<VariableContainerDetails> FetchVariableContainerAsync(
var scopeVariableContainer = new VariableContainerDetails(this.nextVariableId++, "Scope: " + scope);
this.variables.Add(scopeVariableContainer);

IReadOnlyList<PSObject> results = await _executionService.ExecutePSCommandAsync<PSObject>(psCommand, CancellationToken.None)
.ConfigureAwait(false);
IReadOnlyList<PSObject> results;
try
{
results = await _executionService.ExecutePSCommandAsync<PSObject>(psCommand, CancellationToken.None)
.ConfigureAwait(false);
}
catch (CmdletInvocationException ex)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment as to why this exception is caught and eaten here, except for 'psargumentoutofrangeexception' types.

{
if (!ex.ErrorRecord.CategoryInfo.Reason.Equals("PSArgumentOutOfRangeException"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!ex.ErrorRecord.CategoryInfo.Reason.Equals("PSArgumentOutOfRangeException"))
// It's possible to be asked to run `Get-Variable -Scope N` where N is a number that
// exceeds the available scopes. In this case, the command throws this exception,
// but there's nothing we can do about it and we shouldn't crash the debugger, so we
// just return no results instead. All other exceptions should be thrown again.
if (!ex.ErrorRecord.CategoryInfo.Reason.Equals("PSArgumentOutOfRangeException"))

Suggested comment to resolve Paul's request.

{
throw;
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
}
results = null;
}

if (results != null)
{
Expand All @@ -704,11 +709,6 @@ private async Task<VariableContainerDetails> FetchVariableContainerAsync(
var variableDetails = new VariableDetails(psVariableObject) { Id = this.nextVariableId++ };
this.variables.Add(variableDetails);
scopeVariableContainer.Children.Add(variableDetails.Name, variableDetails);

if ((autoVariables != null) && AddToAutoVariables(psVariableObject, scope))
{
autoVariables.Children.Add(variableDetails.Name, variableDetails);
}
}
}

Expand Down Expand Up @@ -756,7 +756,7 @@ private bool AddToAutoVariables(PSObject psvariable, string scope)
// Some local variables, if they exist, should be displayed by default
if (psvariable.TypeNames[0].EndsWith("LocalVariable"))
{
if (variableName.Equals("_"))
if (variableName.Equals("PSItem") || variableName.Equals("_"))
{
return true;
}
Expand Down Expand Up @@ -792,55 +792,90 @@ private bool AddToAutoVariables(PSObject psvariable, string scope)
private async Task FetchStackFramesAsync(string scriptNameOverride)
{
PSCommand psCommand = new PSCommand();
// The serialization depth to retrieve variables from remote runspaces.
const int serializationDepth = 3;

// This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame
// objects (or "deserialized" CallStackFrames) when attached to a runspace in another
// process. Without the intermediate variable Get-PSCallStack inexplicably returns
// an array of strings containing the formatted output of the CallStackFrame list.
var callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack";
psCommand.AddScript($"{callStackVarName} = Get-PSCallStack; {callStackVarName}");
string callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be reviewed for possible injection opportunities, and ensure that arbitrary script content cannot be injected from external sources. ExecutePSCommandAsync appears to really be 'ExecuteScriptAsync' since it takes a general script string instead of command plus arguments.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While true, the only change here is from var to string. We can do this later.


string getPSCallStack = $"Get-PSCallStack | ForEach-Object {{ [void]{callStackVarName}.add(@($PSItem,$PSItem.GetFrameVariables())) }}";

// If we're attached to a remote runspace, we need to serialize the callstack prior to transport
// because the default depth is too shallow
bool isOnRemoteMachine = _psesHost.CurrentRunspace.IsOnRemoteMachine;
andyleejordan marked this conversation as resolved.
Show resolved Hide resolved
string returnSerializedIfOnRemoteMachine = isOnRemoteMachine

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing for future maintainers. Please add comments that clearly show the Get-PSCallStack script that is run for both local and remote (serialized results) cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
string returnSerializedIfOnRemoteMachine = isOnRemoteMachine
// The next command always calls `Get-PSCallStack`. On a local machine, we just return
// its results. On a remote machine we serialize it first and then later deserialize it.
string returnSerializedIfOnRemoteMachine = isOnRemoteMachine

Suggested comment to resolve Paul's request.

? $"[Management.Automation.PSSerializer]::Serialize({callStackVarName}, {serializationDepth})"
: callStackVarName;

// We have to deal with a shallow serialization depth with ExecutePSCommandAsync as well, hence the serializer to get full var information
psCommand.AddScript($"[Collections.ArrayList]{callStackVarName} = @(); {getPSCallStack}; {returnSerializedIfOnRemoteMachine}");

var results = await _executionService.ExecutePSCommandAsync<PSObject>(psCommand, CancellationToken.None).ConfigureAwait(false);

var callStackFrames = results.ToArray();
// PSObject is used here instead of the specific type because we get deserialized objects from remote sessions and want a common interface
IReadOnlyList<PSObject> results = await _executionService.ExecutePSCommandAsync<PSObject>(psCommand, CancellationToken.None).ConfigureAwait(false);

this.stackFrameDetails = new StackFrameDetails[callStackFrames.Length];
IEnumerable callStack = isOnRemoteMachine
? (PSSerializer.Deserialize(results[0].BaseObject as string) as PSObject).BaseObject as IList
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
: results;

for (int i = 0; i < callStackFrames.Length; i++)
List<StackFrameDetails> stackFrameDetailList = new List<StackFrameDetails>();
foreach (var callStackFrameItem in callStack)
{
VariableContainerDetails autoVariables =
new VariableContainerDetails(
this.nextVariableId++,
VariableContainerDetails.AutoVariablesName);
var callStackFrameComponents = (callStackFrameItem as PSObject).BaseObject as IList;
var callStackFrame = callStackFrameComponents[0] as PSObject;
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
IDictionary callStackVariables = isOnRemoteMachine
? (callStackFrameComponents[1] as PSObject).BaseObject as IDictionary
: callStackFrameComponents[1] as IDictionary;

this.variables.Add(autoVariables);
var autoVariables = new VariableContainerDetails(
nextVariableId++,
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
VariableContainerDetails.AutoVariablesName);

VariableContainerDetails localVariables =
await FetchVariableContainerAsync(i.ToString(), autoVariables).ConfigureAwait(false);
variables.Add(autoVariables);

// When debugging, this is the best way I can find to get what is likely the workspace root.
// This is controlled by the "cwd:" setting in the launch config.
string workspaceRootPath = _psesHost.InitialWorkingDirectory;
foreach (DictionaryEntry entry in callStackVariables)
{
// TODO: This should be deduplicated into a new function for the other variable handling as well
object psVarValue = isOnRemoteMachine
? (entry.Value as PSObject).Properties["Value"].Value
: (entry.Value as PSVariable).Value;
// The constructor we are using here does not automatically add the dollar prefix
string psVarName = VariableDetails.DollarPrefix + entry.Key.ToString();
var variableDetails = new VariableDetails(psVarName, psVarValue) { Id = nextVariableId++ };
variables.Add(variableDetails);

if (AddToAutoVariables(new PSObject(entry.Value), scope: null))
{
autoVariables.Children.Add(variableDetails.Name, variableDetails);
}
}

this.stackFrameDetails[i] =
StackFrameDetails.Create(callStackFrames[i], autoVariables, localVariables, workspaceRootPath);
var stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables);

string stackFrameScriptPath = this.stackFrameDetails[i].ScriptPath;
if (scriptNameOverride != null &&
string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath;
if (scriptNameOverride is not null &&
string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
{
this.stackFrameDetails[i].ScriptPath = scriptNameOverride;
stackFrameDetailsEntry.ScriptPath = scriptNameOverride;
}
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
&& this.remoteFileManager != null
else if (isOnRemoteMachine
&& remoteFileManager is not null
&& !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
{
this.stackFrameDetails[i].ScriptPath =
this.remoteFileManager.GetMappedPath(
stackFrameDetailsEntry.ScriptPath =
remoteFileManager.GetMappedPath(
stackFrameScriptPath,
_psesHost.CurrentRunspace);
}

stackFrameDetailList.Add(
stackFrameDetailsEntry);
}

stackFrameDetails = stackFrameDetailList.ToArray();
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
}

private static string TrimScriptListingLine(PSObject scriptLineObj, ref int prefixLength)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ internal class StackFrameDetails
/// </summary>
public VariableContainerDetails AutoVariables { get; private set; }

/// <summary>
/// Gets or sets the VariableContainerDetails that contains the local variables.
/// </summary>
public VariableContainerDetails LocalVariables { get; private set; }

#endregion

#region Constructors
Expand All @@ -93,14 +88,9 @@ internal class StackFrameDetails
/// <returns>A new instance of the StackFrameDetails class.</returns>
static internal StackFrameDetails Create(
PSObject callStackFrameObject,
VariableContainerDetails autoVariables,
VariableContainerDetails localVariables,
string workspaceRootPath = null)
VariableContainerDetails autoVariables)
{
string moduleId = string.Empty;
var isExternal = false;

var invocationInfo = callStackFrameObject.Properties["InvocationInfo"]?.Value as InvocationInfo;
JustinGrote marked this conversation as resolved.
Show resolved Hide resolved
string scriptPath = (callStackFrameObject.Properties["ScriptName"].Value as string) ?? NoFileScriptPath;
int startLineNumber = (int)(callStackFrameObject.Properties["ScriptLineNumber"].Value ?? 0);

Expand All @@ -122,7 +112,6 @@ static internal StackFrameDetails Create(
StartColumnNumber = 0, // Column number isn't given in PowerShell stack frames
EndColumnNumber = 0,
AutoVariables = autoVariables,
LocalVariables = localVariables,
IsExternalCode = isExternal
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ private VariableDetails[] GetChildren(object obj, ILogger logger)
childVariables.AddRange(
psObject
.Properties
.Where(p => p.MemberType == PSMemberTypes.NoteProperty)
.Where(p => (PSMemberTypes.Properties & p.MemberType) is not 0) // Filter out non-properties
.Select(p => new VariableDetails(p)));

obj = psObject.BaseObject;
Expand Down