Skip to content

Commit 8d7e88f

Browse files
authored
Add pathMappings option to debugger (#2251)
Adds the `pathMappings` option to the debugger that can be used to map a local to remote path and vice versa. This is useful if the local environment has a checkout of the files being run on a remote target but at a different path. The mappings are used to translate the paths that will the breakpoint will be set to in the target PowerShell instance. It is also used to update the stack trace paths received from the remote.
1 parent 6f76c6e commit 8d7e88f

File tree

14 files changed

+571
-81
lines changed

14 files changed

+571
-81
lines changed

module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ function Start-DebugAttachSession {
4343
[string]
4444
$WindowActionOnEnd,
4545

46+
[Parameter()]
47+
[IDictionary[]]
48+
$PathMapping,
49+
4650
[Parameter()]
4751
[switch]
4852
$AsJob
@@ -110,11 +114,21 @@ function Start-DebugAttachSession {
110114
return
111115
}
112116

113-
$configuration.name = "Attach Process $ProcessId"
117+
if ($Name) {
118+
$configuration.name = $Name
119+
}
120+
else {
121+
$configuration.name = "Attach Process $ProcessId"
122+
}
114123
$configuration.processId = $ProcessId
115124
}
116125
elseif ($CustomPipeName) {
117-
$configuration.name = "Attach Pipe $CustomPipeName"
126+
if ($Name) {
127+
$configuration.name = $Name
128+
}
129+
else {
130+
$configuration.name = "Attach Pipe $CustomPipeName"
131+
}
118132
$configuration.customPipeName = $CustomPipeName
119133
}
120134
else {
@@ -136,6 +150,10 @@ function Start-DebugAttachSession {
136150
$configuration.temporaryConsoleWindowActionOnDebugEnd = $WindowActionOnEnd.ToLowerInvariant()
137151
}
138152

153+
if ($PathMapping) {
154+
$configuration.pathMappings = $PathMapping
155+
}
156+
139157
# https://microsoft.github.io/debug-adapter-protocol/specification#Reverse_Requests_StartDebugging
140158
$resp = $debugServer.SendRequest(
141159
'startDebugging',

module/docs/Start-DebugAttachSession.md

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ Starts a new debug session attached to the specified PowerShell instance.
1616
### ProcessId (Default)
1717
```
1818
Start-DebugAttachSession [-Name <String>] [-ProcessId <Int32>] [-RunspaceName <String>] [-RunspaceId <Int32>]
19-
[-ComputerName <String>] [-WindowActionOnEnd {Close | Hide | Keep}] [-AsJob] [<CommonParameters>]
19+
[-ComputerName <String>] [-WindowActionOnEnd {Close | Hide | Keep}] [-PathMapping <IDictionary[]>] [-AsJob]
20+
[<CommonParameters>]
2021
```
2122

2223
### CustomPipeName
2324
```
2425
Start-DebugAttachSession [-Name <String>] [-CustomPipeName <String>] [-RunspaceName <String>]
25-
[-RunspaceId <Int32>] [-ComputerName <String>] [-WindowActionOnEnd {Close | Hide | Keep}] [-AsJob]
26-
[<CommonParameters>]
26+
[-RunspaceId <Int32>] [-ComputerName <String>] [-WindowActionOnEnd {Close | Hide | Keep}]
27+
[-PathMapping <IDictionary[]>] [-AsJob] [<CommonParameters>]
2728
```
2829

2930
## DESCRIPTION
@@ -75,6 +76,27 @@ Write-Host "Test $a - $PID"
7576

7677
Launches a new PowerShell process with a custom pipe and starts a new attach configuration that will debug the new process under a child debugging session. The caller waits until the new process ends before ending the parent session.
7778

79+
### -------------------------- EXAMPLE 2 --------------------------
80+
81+
```powershell
82+
$attachParams = @{
83+
ComputerName = 'remote-windows'
84+
ProcessId = $remotePid
85+
RunspaceId = 1
86+
PathMapping = @(
87+
@{
88+
localRoot = 'C:\local\path\to\scripts\'
89+
remoteRoot = 'C:\remote\path\on\remote-windows\'
90+
}
91+
)
92+
}
93+
Start-DebugAttachSession @attachParams
94+
```
95+
96+
Attaches to a remote PSSession through the WSMan parameter and maps the remote path running the script in the PSSession to the same copy of files locally. For example `remote-windows` is running the script `C:\remote\path\on\remote-windows\script.ps1` but the same script(s) are located locally on the current host `C:\local\path\to\scripts\script.ps1`.
97+
98+
The debug client can see the remote files as local when setting breakpoints and inspecting the callstack with this mapped path.
99+
78100
## PARAMETERS
79101

80102
### -AsJob
@@ -143,6 +165,24 @@ Accept pipeline input: False
143165
Accept wildcard characters: False
144166
```
145167

168+
### -PathMapping
169+
170+
An array of dictionaries with the keys `localRoot` and `remoteRoot` that maps a local and remote path root to each other. This option is useful when attaching to a PSSession running a script that is not accessible locally but can be found under a different path.
171+
172+
It is a good idea to ensure the `localRoot` and `remoteRoot` entries are either the absolute path to a script or ends with the trailing directory separator if specifying a directory. A path can also be mapped from a Windows and non-Windows path, just ensure the correct directory separators are used for each OS type. For example `/` for non-Windows and `\` for Windows.
173+
174+
```yaml
175+
Type: IDictionary[]
176+
Parameter Sets: (All)
177+
Aliases:
178+
179+
Required: False
180+
Position: Named
181+
Default value: None
182+
Accept pipeline input: False
183+
Accept wildcard characters: False
184+
```
185+
146186
### -ProcessId
147187

148188
The ID of the PowerShell host process that should be attached. This option is mutually exclusive with `-CustomPipeName`.

src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnl
195195
// path which may or may not exist.
196196
psCommand
197197
.AddScript(_setPSBreakpointLegacy, useLocalScope: true)
198-
.AddParameter("Script", breakpoint.Source)
198+
.AddParameter("Script", breakpoint.MappedSource ?? breakpoint.Source)
199199
.AddParameter("Line", breakpoint.LineNumber);
200200

201201
// Check if the user has specified the column number for the breakpoint.
@@ -219,7 +219,16 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnl
219219
IEnumerable<Breakpoint> setBreakpoints = await _executionService
220220
.ExecutePSCommandAsync<Breakpoint>(psCommand, CancellationToken.None)
221221
.ConfigureAwait(false);
222-
configuredBreakpoints.AddRange(setBreakpoints.Select((breakpoint) => BreakpointDetails.Create(breakpoint)));
222+
223+
int bpIdx = 0;
224+
foreach (Breakpoint setBp in setBreakpoints)
225+
{
226+
BreakpointDetails setBreakpoint = BreakpointDetails.Create(
227+
setBp,
228+
sourceBreakpoint: breakpoints[bpIdx]);
229+
configuredBreakpoints.Add(setBreakpoint);
230+
bpIdx++;
231+
}
223232
}
224233
return configuredBreakpoints;
225234
}

src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
1616
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
1717
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility;
18-
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
1918
using Microsoft.PowerShell.EditorServices.Utility;
2019

2120
namespace Microsoft.PowerShell.EditorServices.Services
@@ -87,6 +86,11 @@ public bool IsDebuggingRemoteRunspace
8786
set => _debugContext.IsDebuggingRemoteRunspace = value;
8887
}
8988

89+
/// <summary>
90+
/// Gets or sets an array of path mappings for the current debug session.
91+
/// </summary>
92+
public PathMapping[] PathMappings { get; set; } = [];
93+
9094
#endregion
9195

9296
#region Constructors
@@ -123,22 +127,22 @@ public DebugService(
123127
/// <summary>
124128
/// Sets the list of line breakpoints for the current debugging session.
125129
/// </summary>
126-
/// <param name="scriptFile">The ScriptFile in which breakpoints will be set.</param>
130+
/// <param name="scriptPath">The path in which breakpoints will be set.</param>
127131
/// <param name="breakpoints">BreakpointDetails for each breakpoint that will be set.</param>
128132
/// <param name="clearExisting">If true, causes all existing breakpoints to be cleared before setting new ones.</param>
133+
/// <param name="skipRemoteMapping">If true, skips the remote file manager mapping of the script path.</param>
129134
/// <returns>An awaitable Task that will provide details about the breakpoints that were set.</returns>
130135
public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
131-
ScriptFile scriptFile,
136+
string scriptPath,
132137
IReadOnlyList<BreakpointDetails> breakpoints,
133-
bool clearExisting = true)
138+
bool clearExisting = true,
139+
bool skipRemoteMapping = false)
134140
{
135141
DscBreakpointCapability dscBreakpoints = await _debugContext.GetDscBreakpointCapabilityAsync().ConfigureAwait(false);
136142

137-
string scriptPath = scriptFile.FilePath;
138-
139143
_psesHost.Runspace.ThrowCancelledIfUnusable();
140144
// Make sure we're using the remote script path
141-
if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null)
145+
if (!skipRemoteMapping && _psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null)
142146
{
143147
if (!_remoteFileManager.IsUnderRemoteTempPath(scriptPath))
144148
{
@@ -162,7 +166,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
162166
{
163167
if (clearExisting)
164168
{
165-
await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false);
169+
await _breakpointService.RemoveAllBreakpointsAsync(scriptPath).ConfigureAwait(false);
166170
}
167171

168172
return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false);
@@ -603,6 +607,49 @@ public VariableScope[] GetVariableScopes(int stackFrameId)
603607
};
604608
}
605609

610+
internal bool TryGetMappedLocalPath(string remotePath, out string localPath)
611+
{
612+
foreach (PathMapping mapping in PathMappings)
613+
{
614+
if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot))
615+
{
616+
// If either path mapping is null, we can't map the path.
617+
continue;
618+
}
619+
620+
if (remotePath.StartsWith(mapping.RemoteRoot, StringComparison.OrdinalIgnoreCase))
621+
{
622+
localPath = mapping.LocalRoot + remotePath.Substring(mapping.RemoteRoot.Length);
623+
return true;
624+
}
625+
}
626+
627+
localPath = null;
628+
return false;
629+
}
630+
631+
internal bool TryGetMappedRemotePath(string localPath, out string remotePath)
632+
{
633+
foreach (PathMapping mapping in PathMappings)
634+
{
635+
if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot))
636+
{
637+
// If either path mapping is null, we can't map the path.
638+
continue;
639+
}
640+
641+
if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase))
642+
{
643+
// If the local path starts with the local path mapping, we can replace it with the remote path.
644+
remotePath = mapping.RemoteRoot + localPath.Substring(mapping.LocalRoot.Length);
645+
return true;
646+
}
647+
}
648+
649+
remotePath = null;
650+
return false;
651+
}
652+
606653
#endregion
607654

608655
#region Private Methods
@@ -873,14 +920,19 @@ private async Task FetchStackFramesAsync(string scriptNameOverride)
873920
StackFrameDetails stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables);
874921
string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath;
875922

876-
if (scriptNameOverride is not null
877-
&& string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
923+
bool isNoScriptPath = string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath);
924+
if (scriptNameOverride is not null && isNoScriptPath)
878925
{
879926
stackFrameDetailsEntry.ScriptPath = scriptNameOverride;
880927
}
928+
else if (TryGetMappedLocalPath(stackFrameScriptPath, out string localMappedPath)
929+
&& !isNoScriptPath)
930+
{
931+
stackFrameDetailsEntry.ScriptPath = localMappedPath;
932+
}
881933
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
882934
&& _remoteFileManager is not null
883-
&& !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
935+
&& !isNoScriptPath)
884936
{
885937
stackFrameDetailsEntry.ScriptPath =
886938
_remoteFileManager.GetMappedPath(stackFrameScriptPath, _psesHost.CurrentRunspace);
@@ -981,9 +1033,13 @@ await _executionService.ExecutePSCommandAsync<PSObject>(
9811033
// Begin call stack and variables fetch. We don't need to block here.
9821034
StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null);
9831035

1036+
if (!noScriptName && TryGetMappedLocalPath(e.InvocationInfo.ScriptName, out string mappedLocalPath))
1037+
{
1038+
localScriptPath = mappedLocalPath;
1039+
}
9841040
// If this is a remote connection and the debugger stopped at a line
9851041
// in a script file, get the file contents
986-
if (_psesHost.CurrentRunspace.IsOnRemoteMachine
1042+
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
9871043
&& _remoteFileManager is not null
9881044
&& !noScriptName)
9891045
{
@@ -1034,8 +1090,12 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e)
10341090
{
10351091
// TODO: This could be either a path or a script block!
10361092
string scriptPath = lineBreakpoint.Script;
1037-
if (_psesHost.CurrentRunspace.IsOnRemoteMachine
1038-
&& _remoteFileManager is not null)
1093+
if (TryGetMappedLocalPath(scriptPath, out string mappedLocalPath))
1094+
{
1095+
scriptPath = mappedLocalPath;
1096+
}
1097+
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
1098+
&& _remoteFileManager is not null)
10391099
{
10401100
string mappedPath = _remoteFileManager.GetMappedPath(scriptPath, _psesHost.CurrentRunspace);
10411101

src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ public static Breakpoint SetBreakpoint(Debugger debugger, BreakpointDetailsBase
136136
{
137137
BreakpointDetails lineBreakpoint => SetLineBreakpointDelegate(
138138
debugger,
139-
lineBreakpoint.Source,
139+
lineBreakpoint.MappedSource ?? lineBreakpoint.Source,
140140
lineBreakpoint.LineNumber,
141141
lineBreakpoint.ColumnNumber ?? 0,
142142
actionScriptBlock,

src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ internal sealed class BreakpointDetails : BreakpointDetailsBase
2424
/// </summary>
2525
public string Source { get; private set; }
2626

27+
/// <summary>
28+
/// Gets the source where the breakpoint is mapped to, will be null if no mapping exists. Used only for debug purposes.
29+
/// </summary>
30+
public string MappedSource { get; private set; }
31+
2732
/// <summary>
2833
/// Gets the line number at which the breakpoint is set.
2934
/// </summary>
@@ -50,14 +55,16 @@ private BreakpointDetails()
5055
/// <param name="condition"></param>
5156
/// <param name="hitCondition"></param>
5257
/// <param name="logMessage"></param>
58+
/// <param name="mappedSource"></param>
5359
/// <returns></returns>
5460
internal static BreakpointDetails Create(
5561
string source,
5662
int line,
5763
int? column = null,
5864
string condition = null,
5965
string hitCondition = null,
60-
string logMessage = null)
66+
string logMessage = null,
67+
string mappedSource = null)
6168
{
6269
Validate.IsNotNullOrEmptyString(nameof(source), source);
6370

@@ -69,7 +76,8 @@ internal static BreakpointDetails Create(
6976
ColumnNumber = column,
7077
Condition = condition,
7178
HitCondition = hitCondition,
72-
LogMessage = logMessage
79+
LogMessage = logMessage,
80+
MappedSource = mappedSource
7381
};
7482
}
7583

@@ -79,10 +87,12 @@ internal static BreakpointDetails Create(
7987
/// </summary>
8088
/// <param name="breakpoint">The Breakpoint instance from which details will be taken.</param>
8189
/// <param name="updateType">The BreakpointUpdateType to determine if the breakpoint is verified.</param>
90+
/// /// <param name="sourceBreakpoint">The breakpoint source from the debug client, if any.</param>
8291
/// <returns>A new instance of the BreakpointDetails class.</returns>
8392
internal static BreakpointDetails Create(
8493
Breakpoint breakpoint,
85-
BreakpointUpdateType updateType = BreakpointUpdateType.Set)
94+
BreakpointUpdateType updateType = BreakpointUpdateType.Set,
95+
BreakpointDetails sourceBreakpoint = null)
8696
{
8797
Validate.IsNotNull(nameof(breakpoint), breakpoint);
8898

@@ -96,10 +106,11 @@ internal static BreakpointDetails Create(
96106
{
97107
Id = breakpoint.Id,
98108
Verified = updateType != BreakpointUpdateType.Disabled,
99-
Source = lineBreakpoint.Script,
109+
Source = sourceBreakpoint?.MappedSource is not null ? sourceBreakpoint.Source : lineBreakpoint.Script,
100110
LineNumber = lineBreakpoint.Line,
101111
ColumnNumber = lineBreakpoint.Column,
102-
Condition = lineBreakpoint.Action?.ToString()
112+
Condition = lineBreakpoint.Action?.ToString(),
113+
MappedSource = sourceBreakpoint?.MappedSource,
103114
};
104115

105116
if (lineBreakpoint.Column > 0)

0 commit comments

Comments
 (0)