From 23ef91b72e9226ae90171f39c5c32be4fdb42a88 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 30 Jul 2025 13:01:12 +1000 Subject: [PATCH 1/2] Add pathMappings option to debugger 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. For a launch scenario, the path mappings are also used when launching a script if the integrated terminal has entered a remote runspace. --- .../DebugAdapter/BreakpointService.cs | 13 +- .../Services/DebugAdapter/DebugService.cs | 94 +++++- .../Debugging/BreakpointApiUtils.cs | 2 +- .../Debugging/BreakpointDetails.cs | 21 +- .../Handlers/BreakpointHandlers.cs | 13 +- .../Handlers/DisconnectHandler.cs | 1 + .../Handlers/LaunchAndAttachHandler.cs | 45 ++- .../Handlers/StackTraceHandler.cs | 11 +- .../Services/DebugAdapter/PathMapping.cs | 24 ++ .../Debugging/DscBreakpointCapability.cs | 8 +- .../DebugAdapterProtocolMessageTests.cs | 282 +++++++++++++++++- .../Debugging/DebugServiceTests.cs | 42 +-- 12 files changed, 495 insertions(+), 61 deletions(-) create mode 100644 src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 007b75d49..6d7e0c31a 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -195,7 +195,7 @@ public async Task> SetBreakpointsAsync(IReadOnl // path which may or may not exist. psCommand .AddScript(_setPSBreakpointLegacy, useLocalScope: true) - .AddParameter("Script", breakpoint.Source) + .AddParameter("Script", breakpoint.MappedSource ?? breakpoint.Source) .AddParameter("Line", breakpoint.LineNumber); // Check if the user has specified the column number for the breakpoint. @@ -219,7 +219,16 @@ public async Task> SetBreakpointsAsync(IReadOnl IEnumerable setBreakpoints = await _executionService .ExecutePSCommandAsync(psCommand, CancellationToken.None) .ConfigureAwait(false); - configuredBreakpoints.AddRange(setBreakpoints.Select((breakpoint) => BreakpointDetails.Create(breakpoint))); + + int bpIdx = 0; + foreach (Breakpoint setBp in setBreakpoints) + { + BreakpointDetails setBreakpoint = BreakpointDetails.Create( + setBp, + sourceBreakpoint: breakpoints[bpIdx]); + configuredBreakpoints.Add(setBreakpoint); + bpIdx++; + } } return configuredBreakpoints; } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index fd7090df4..645e8a858 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -15,7 +15,6 @@ using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility; -using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Utility; namespace Microsoft.PowerShell.EditorServices.Services @@ -49,6 +48,7 @@ internal class DebugService private VariableContainerDetails scriptScopeVariables; private VariableContainerDetails localScopeVariables; private StackFrameDetails[] stackFrameDetails; + private PathMapping[] _pathMappings; private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore(); #endregion @@ -123,22 +123,22 @@ public DebugService( /// /// Sets the list of line breakpoints for the current debugging session. /// - /// The ScriptFile in which breakpoints will be set. + /// The path in which breakpoints will be set. /// BreakpointDetails for each breakpoint that will be set. /// If true, causes all existing breakpoints to be cleared before setting new ones. + /// If true, skips the remote file manager mapping of the script path. /// An awaitable Task that will provide details about the breakpoints that were set. public async Task> SetLineBreakpointsAsync( - ScriptFile scriptFile, + string scriptPath, IReadOnlyList breakpoints, - bool clearExisting = true) + bool clearExisting = true, + bool skipRemoteMapping = false) { DscBreakpointCapability dscBreakpoints = await _debugContext.GetDscBreakpointCapabilityAsync().ConfigureAwait(false); - string scriptPath = scriptFile.FilePath; - _psesHost.Runspace.ThrowCancelledIfUnusable(); // Make sure we're using the remote script path - if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null) + if (!skipRemoteMapping && _psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null) { if (!_remoteFileManager.IsUnderRemoteTempPath(scriptPath)) { @@ -162,7 +162,7 @@ public async Task> SetLineBreakpointsAsync( { if (clearExisting) { - await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false); + await _breakpointService.RemoveAllBreakpointsAsync(scriptPath).ConfigureAwait(false); } return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false); @@ -603,6 +603,59 @@ public VariableScope[] GetVariableScopes(int stackFrameId) }; } + internal void SetPathMappings(PathMapping[] pathMappings) => _pathMappings = pathMappings; + + internal void UnsetPathMappings() => _pathMappings = null; + + internal bool TryGetMappedLocalPath(string remotePath, out string localPath) + { + if (_pathMappings is not null) + { + foreach (PathMapping mapping in _pathMappings) + { + if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot)) + { + // If either path mapping is null, we can't map the path. + continue; + } + + if (remotePath.StartsWith(mapping.RemoteRoot, StringComparison.OrdinalIgnoreCase)) + { + localPath = mapping.LocalRoot + remotePath.Substring(mapping.RemoteRoot.Length); + return true; + } + } + } + + localPath = null; + return false; + } + + internal bool TryGetMappedRemotePath(string localPath, out string remotePath) + { + if (_pathMappings is not null) + { + foreach (PathMapping mapping in _pathMappings) + { + if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot)) + { + // If either path mapping is null, we can't map the path. + continue; + } + + if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase)) + { + // If the local path starts with the local path mapping, we can replace it with the remote path. + remotePath = mapping.RemoteRoot + localPath.Substring(mapping.LocalRoot.Length); + return true; + } + } + } + + remotePath = null; + return false; + } + #endregion #region Private Methods @@ -873,14 +926,19 @@ private async Task FetchStackFramesAsync(string scriptNameOverride) StackFrameDetails stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables); string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath; - if (scriptNameOverride is not null - && string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) + bool isNoScriptPath = string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath); + if (scriptNameOverride is not null && isNoScriptPath) { stackFrameDetailsEntry.ScriptPath = scriptNameOverride; } + else if (TryGetMappedLocalPath(stackFrameScriptPath, out string localMappedPath) + && !isNoScriptPath) + { + stackFrameDetailsEntry.ScriptPath = localMappedPath; + } else if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null - && !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) + && !isNoScriptPath) { stackFrameDetailsEntry.ScriptPath = _remoteFileManager.GetMappedPath(stackFrameScriptPath, _psesHost.CurrentRunspace); @@ -981,9 +1039,13 @@ await _executionService.ExecutePSCommandAsync( // Begin call stack and variables fetch. We don't need to block here. StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null); + if (!noScriptName && TryGetMappedLocalPath(e.InvocationInfo.ScriptName, out string mappedLocalPath)) + { + localScriptPath = mappedLocalPath; + } // If this is a remote connection and the debugger stopped at a line // in a script file, get the file contents - if (_psesHost.CurrentRunspace.IsOnRemoteMachine + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null && !noScriptName) { @@ -1034,8 +1096,12 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) { // TODO: This could be either a path or a script block! string scriptPath = lineBreakpoint.Script; - if (_psesHost.CurrentRunspace.IsOnRemoteMachine - && _remoteFileManager is not null) + if (TryGetMappedLocalPath(scriptPath, out string mappedLocalPath)) + { + scriptPath = mappedLocalPath; + } + else if (_psesHost.CurrentRunspace.IsOnRemoteMachine + && _remoteFileManager is not null) { string mappedPath = _remoteFileManager.GetMappedPath(scriptPath, _psesHost.CurrentRunspace); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs index 7884fbda5..ebb0646d2 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs @@ -136,7 +136,7 @@ public static Breakpoint SetBreakpoint(Debugger debugger, BreakpointDetailsBase { BreakpointDetails lineBreakpoint => SetLineBreakpointDelegate( debugger, - lineBreakpoint.Source, + lineBreakpoint.MappedSource ?? lineBreakpoint.Source, lineBreakpoint.LineNumber, lineBreakpoint.ColumnNumber ?? 0, actionScriptBlock, diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs index 0a1c268b9..4177b3816 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs @@ -24,6 +24,11 @@ internal sealed class BreakpointDetails : BreakpointDetailsBase /// public string Source { get; private set; } + /// + /// Gets the source where the breakpoint is mapped to, will be null if no mapping exists. Used only for debug purposes. + /// + public string MappedSource { get; private set; } + /// /// Gets the line number at which the breakpoint is set. /// @@ -50,6 +55,7 @@ private BreakpointDetails() /// /// /// + /// /// internal static BreakpointDetails Create( string source, @@ -57,7 +63,8 @@ internal static BreakpointDetails Create( int? column = null, string condition = null, string hitCondition = null, - string logMessage = null) + string logMessage = null, + string mappedSource = null) { Validate.IsNotNullOrEmptyString(nameof(source), source); @@ -69,7 +76,8 @@ internal static BreakpointDetails Create( ColumnNumber = column, Condition = condition, HitCondition = hitCondition, - LogMessage = logMessage + LogMessage = logMessage, + MappedSource = mappedSource }; } @@ -79,10 +87,12 @@ internal static BreakpointDetails Create( /// /// The Breakpoint instance from which details will be taken. /// The BreakpointUpdateType to determine if the breakpoint is verified. + /// /// The breakpoint source from the debug client, if any. /// A new instance of the BreakpointDetails class. internal static BreakpointDetails Create( Breakpoint breakpoint, - BreakpointUpdateType updateType = BreakpointUpdateType.Set) + BreakpointUpdateType updateType = BreakpointUpdateType.Set, + BreakpointDetails sourceBreakpoint = null) { Validate.IsNotNull(nameof(breakpoint), breakpoint); @@ -96,10 +106,11 @@ internal static BreakpointDetails Create( { Id = breakpoint.Id, Verified = updateType != BreakpointUpdateType.Disabled, - Source = lineBreakpoint.Script, + Source = sourceBreakpoint?.MappedSource is not null ? sourceBreakpoint.Source : lineBreakpoint.Script, LineNumber = lineBreakpoint.Line, ColumnNumber = lineBreakpoint.Column, - Condition = lineBreakpoint.Action?.ToString() + Condition = lineBreakpoint.Action?.ToString(), + MappedSource = sourceBreakpoint?.MappedSource, }; if (lineBreakpoint.Column > 0) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 68d23c966..1c26c48de 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -79,6 +79,11 @@ public async Task Handle(SetBreakpointsArguments request } // At this point, the source file has been verified as a PowerShell script. + string mappedSource = null; + if (_debugService.TryGetMappedRemotePath(scriptFile.FilePath, out string remoteMappedPath)) + { + mappedSource = remoteMappedPath; + } IReadOnlyList breakpointDetails = request.Breakpoints .Select((srcBreakpoint) => BreakpointDetails.Create( scriptFile.FilePath, @@ -86,7 +91,8 @@ public async Task Handle(SetBreakpointsArguments request srcBreakpoint.Column, srcBreakpoint.Condition, srcBreakpoint.HitCondition, - srcBreakpoint.LogMessage)).ToList(); + srcBreakpoint.LogMessage, + mappedSource: mappedSource)).ToList(); // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. IReadOnlyList updatedBreakpointDetails = breakpointDetails; @@ -98,8 +104,9 @@ public async Task Handle(SetBreakpointsArguments request { updatedBreakpointDetails = await _debugService.SetLineBreakpointsAsync( - scriptFile, - breakpointDetails).ConfigureAwait(false); + mappedSource ?? scriptFile.FilePath, + breakpointDetails, + skipRemoteMapping: mappedSource is not null).ConfigureAwait(false); } catch (Exception e) { diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs index 798ccc621..7ca10ffce 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -50,6 +50,7 @@ public async Task Handle(DisconnectArguments request, Cancel // We should instead ensure that the debugger is in some valid state, lock it and then tear things down _debugEventHandlerService.UnregisterEventHandlers(); + _debugService.UnsetPathMappings(); if (!_debugStateService.ExecutionCompleted) { diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 4201cc5e6..13bbb3445 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -75,6 +75,12 @@ internal record PsesLaunchRequestArguments : LaunchRequestArguments /// properties of the 'environmentVariables' are used as key/value pairs. /// public Dictionary Env { get; set; } + + /// + /// Gets or sets the path mappings for the debugging session. This is + /// only used when the current runspace is remote. + /// + public PathMapping[] PathMappings { get; set; } = []; } internal record PsesAttachRequestArguments : AttachRequestArguments @@ -88,6 +94,11 @@ internal record PsesAttachRequestArguments : AttachRequestArguments public string RunspaceName { get; set; } public string CustomPipeName { get; set; } + + /// + /// Gets or sets the path mappings for the remote debugging session. + /// + public PathMapping[] PathMappings { get; set; } = []; } internal class LaunchAndAttachHandler : ILaunchHandler, IAttachHandler, IOnDebugAdapterServerStarted @@ -128,6 +139,20 @@ public LaunchAndAttachHandler( } public async Task Handle(PsesLaunchRequestArguments request, CancellationToken cancellationToken) + { + _debugService.SetPathMappings(request.PathMappings); + try + { + return await HandleImpl(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _debugService.UnsetPathMappings(); + throw; + } + } + + public async Task HandleImpl(PsesLaunchRequestArguments request, CancellationToken cancellationToken) { // The debugger has officially started. We use this to later check if we should stop it. ((PsesInternalHost)_executionService).DebugContext.IsActive = true; @@ -222,10 +247,19 @@ await _executionService.ExecutePSCommandAsync( if (_debugStateService.ScriptToLaunch != null && _runspaceContext.CurrentRunspace.IsOnRemoteMachine) { - _debugStateService.ScriptToLaunch = - _remoteFileManagerService.GetMappedPath( - _debugStateService.ScriptToLaunch, - _runspaceContext.CurrentRunspace); + if (_debugService.TryGetMappedRemotePath(_debugStateService.ScriptToLaunch, out string remoteMappedPath)) + { + _debugStateService.ScriptToLaunch = remoteMappedPath; + } + else + { + // If the script is not mapped, we will map it to the remote path + // using the RemoteFileManagerService. + _debugStateService.ScriptToLaunch = + _remoteFileManagerService.GetMappedPath( + _debugStateService.ScriptToLaunch, + _runspaceContext.CurrentRunspace); + } } // If no script is being launched, mark this as an interactive @@ -250,11 +284,13 @@ public async Task Handle(PsesAttachRequestArguments request, Can _debugService.IsDebuggingRemoteRunspace = true; try { + _debugService.SetPathMappings(request.PathMappings); return await HandleImpl(request, cancellationToken).ConfigureAwait(false); } catch { _debugService.IsDebuggingRemoteRunspace = false; + _debugService.UnsetPathMappings(); throw; } } @@ -486,6 +522,7 @@ private async Task OnExecutionCompletedAsync(Task executeTask) _debugEventHandlerService.UnregisterEventHandlers(); _debugService.IsDebuggingRemoteRunspace = false; + _debugService.UnsetPathMappings(); if (!isRunspaceClosed && _debugStateService.IsAttachSession) { diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs index 858f6a815..735d672d1 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/StackTraceHandler.cs @@ -38,7 +38,12 @@ public async Task Handle(StackTraceArguments request, Cancel InvocationInfo invocationInfo = debugService.CurrentDebuggerStoppedEventArgs?.OriginalEvent?.InvocationInfo ?? throw new RpcErrorException(0, null!, "InvocationInfo was not available on CurrentDebuggerStoppedEvent args. This is a bug and you should report it."); - StackFrame breakpointLabel = CreateBreakpointLabel(invocationInfo); + string? scriptNameOverride = null; + if (debugService.TryGetMappedLocalPath(invocationInfo.ScriptName, out string mappedLocalPath)) + { + scriptNameOverride = mappedLocalPath; + } + StackFrame breakpointLabel = CreateBreakpointLabel(invocationInfo, scriptNameOverride: scriptNameOverride); 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. { @@ -116,13 +121,13 @@ public static StackFrame CreateStackFrame(StackFrameDetails stackFrame, long id) }; } - public static StackFrame CreateBreakpointLabel(InvocationInfo invocationInfo, int id = 0) => new() + public static StackFrame CreateBreakpointLabel(InvocationInfo invocationInfo, int id = 0, string? scriptNameOverride = null) => new() { Name = "", Id = id, Source = new() { - Path = invocationInfo.ScriptName + Path = scriptNameOverride ?? invocationInfo.ScriptName }, Line = invocationInfo.ScriptLineNumber, Column = invocationInfo.OffsetInLine, diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs b/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs new file mode 100644 index 000000000..6bf6a6ad9 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/PathMapping.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace Microsoft.PowerShell.EditorServices.Services; + +/// +/// Used for attach requests to map a local and remote path together. +/// +internal record PathMapping +{ + /// + /// Gets or sets the local root of this mapping entry. + /// + public string? LocalRoot { get; set; } + + /// + /// Gets or sets the remote root of this mapping entry. + /// + public string? RemoteRoot { get; set; } +} + +#nullable disable diff --git a/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs index 763094be4..344b798f7 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Debugging/DscBreakpointCapability.cs @@ -91,10 +91,12 @@ public static async Task GetDscCapabilityAsync( .AddCommand(@"Microsoft.PowerShell.Core\Import-Module") .AddParameter("Name", "PSDesiredStateConfiguration") .AddParameter("PassThru") - .AddParameter("ErrorAction", ActionPreference.Ignore); + .AddParameter("ErrorAction", ActionPreference.Ignore) + .AddCommand(@"Microsoft.PowerShell.Utility\Select-Object") + .AddParameter("ExpandProperty", "Name"); - IReadOnlyList dscModule = - await executionService.ExecutePSCommandAsync( + IReadOnlyList dscModule = + await executionService.ExecutePSCommandAsync( psCommand, CancellationToken.None, new PowerShellExecutionOptions { ThrowOnError = false }) diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index b51f3d8dd..1d8259ae5 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -2,12 +2,14 @@ // Licensed under the MIT License. using System; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using Microsoft.PowerShell.EditorServices.Handlers; using Nerdbank.Streams; using OmniSharp.Extensions.DebugAdapter.Client; @@ -105,12 +107,18 @@ send until a launch is sent. .WithInput(psesStream) .WithOutput(psesStream) // The "early" return mentioned above - .OnInitialized(async (dapClient, _, _, _) => initializedLanguageClientTcs.SetResult(dapClient)) + .OnInitialized((dapClient, _, _, _) => + { + initializedLanguageClientTcs.SetResult(dapClient); + return Task.CompletedTask; + }) // This TCS is useful to wait for a breakpoint to be hit - .OnStopped(async (StoppedEvent e) => + .OnStopped((StoppedEvent e) => { - nextStoppedTcs.SetResult(e); + TaskCompletionSource currentStoppedTcs = nextStoppedTcs; nextStoppedTcs = new(); + + currentStoppedTcs.SetResult(e); }) .OnRequest("startDebugging", (StartDebuggingAttachRequestArguments request) => { @@ -613,8 +621,272 @@ public async Task CanLaunchScriptWithNewChildAttachSessionAsJob() await terminatedTcs.Task; } - private record StartDebuggingAttachRequestArguments(PsesAttachRequestArguments Configuration, string Request); + [SkippableFact] + public async Task CanAttachScriptWithPathMappings() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + string[] logStatements = ["$PSCommandPath", "after breakpoint"]; + + await RunWithAttachableProcess(logStatements, async (filePath, processId, runspaceId) => + { + string localParent = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string localScriptPath = Path.Combine(localParent, Path.GetFileName(filePath)); + Directory.CreateDirectory(localParent); + File.Copy(filePath, localScriptPath); + + Task nextStoppedTask = nextStopped; + + AttachResponse attachResponse = await client.Attach( + new PsesAttachRequestArguments + { + ProcessId = processId, + RunspaceId = runspaceId, + PathMappings = [ + new() + { + LocalRoot = localParent + Path.DirectorySeparatorChar, + RemoteRoot = Path.GetDirectoryName(filePath) + Path.DirectorySeparatorChar + } + ] + }) ?? throw new Exception("Attach response was null."); + Assert.NotNull(attachResponse); + + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(localScriptPath), Path = localScriptPath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, + SourceModified = false, + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + Assert.NotNull(breakpoint.Source); + Assert.Equal(localScriptPath, breakpoint.Source.Path, ignoreCase: s_isWindows); + Assert.Equal(2, breakpoint.Line); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + // Wait-Debugger stop + StoppedEvent stoppedEvent = await nextStoppedTask; + Assert.Equal("step", stoppedEvent.Reason); + Assert.NotNull(stoppedEvent.ThreadId); + + nextStoppedTask = nextStopped; + + // It is important we wait for the stack trace before continue. + // The stopped event starts to get the stack trace info in the + // background and requesting the stack trace is the only way to + // ensure it is done and won't conflict with the continue request. + await client.RequestStackTrace(new StackTraceArguments { ThreadId = (int)stoppedEvent.ThreadId }); + await client.RequestContinue(new ContinueArguments { ThreadId = (int)stoppedEvent.ThreadId }); + + // Wait until we hit the breakpoint + stoppedEvent = await nextStoppedTask; + Assert.Equal("breakpoint", stoppedEvent.Reason); + Assert.NotNull(stoppedEvent.ThreadId); + + // The code before the breakpoint should have already run + // It will contain the actual script being run + string beforeBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal(filePath, beforeBreakpointActual); + + // Assert that the stopped breakpoint is the one we set + StackTraceResponse stackTraceResponse = await client.RequestStackTrace(new StackTraceArguments { ThreadId = (int)stoppedEvent.ThreadId }); + DapStackFrame? stoppedTopFrame = stackTraceResponse.StackFrames?.First(); + + // The top frame should have a source path of our local script. + Assert.NotNull(stoppedTopFrame); + Assert.Equal(2, stoppedTopFrame.Line); + Assert.NotNull(stoppedTopFrame.Source); + Assert.Equal(localScriptPath, stoppedTopFrame.Source.Path, ignoreCase: s_isWindows); + + await client.RequestContinue(new ContinueArguments { ThreadId = 1 }); + + string afterBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal("after breakpoint", afterBreakpointActual); + }); + } + + private async Task RunWithAttachableProcess(string[] logStatements, Func action) + { + /* + There is no public API in pwsh to wait for an attach event. We + use reflection to wait until the AvailabilityChanged event is + subscribed to by Debug-Runspace as a marker that it is ready to + continue. + + We also run the test script in another runspace as WinPS' + Debug-Runspace will break on the first statement after the + attach and we want that to be the Wait-Debugger call. + + We can use https://github.com/PowerShell/PowerShell/pull/25788 + once that is merged and we are running against that version but + WinPS will always need this. + */ + string scriptEntrypoint = @" + param([string]$TestScript) + + $debugRunspaceCmd = Get-Command Debug-Runspace -Module Microsoft.PowerShell.Utility + $runspaceBase = [PSObject].Assembly.GetType( + 'System.Management.Automation.Runspaces.RunspaceBase') + $availabilityChangedField = $runspaceBase.GetField( + 'AvailabilityChanged', + [System.Reflection.BindingFlags]'NonPublic, Instance') + if (-not $availabilityChangedField) { + throw 'Failed to get AvailabilityChanged event field' + } + + $ps = [PowerShell]::Create() + $runspace = $ps.Runspace + + # Wait-Debugger is needed in WinPS to sync breakpoints before + # running the script. + $null = $ps.AddCommand('Wait-Debugger').AddStatement() + $null = $ps.AddCommand($TestScript) + + # Let the runner know what Runspace to attach to and that it + # is ready to run. + 'RID: {0}' -f $runspace.Id + + $start = Get-Date + while ($true) { + $subscribed = $availabilityChangedField.GetValue($runspace) | + Where-Object Target -is $debugRunspaceCmd.ImplementingType + if ($subscribed) { + break + } + + if (((Get-Date) - $start).TotalSeconds -gt 10) { + throw 'Timeout waiting for Debug-Runspace to be subscribed.' + } + } + + $ps.Invoke() + foreach ($e in $ps.Streams.Error) { + Write-Error -ErrorRecord $e + } -#nullable disable + # Keep running until the runner has deleted the test script to + # ensure the process doesn't finish before the test does in + # normal circumstances. + while (Test-Path -LiteralPath $TestScript) { + Start-Sleep -Seconds 1 + } + "; + + string filePath = NewTestFile(GenerateLoggingScript(logStatements)); + string encArgs = CreatePwshEncodedArgs(filePath); + string encCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptEntrypoint)); + + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = PsesStdioLanguageServerProcessHost.PwshExe, + Arguments = $"-NoLogo -NoProfile -EncodedCommand {encCommand} -EncodedArguments {encArgs}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.EnvironmentVariables["TERM"] = "dumb"; // Avoids color/VT sequences in test output. + + TaskCompletionSource ridOutput = new(); + + // Task shouldn't take longer than 30 seconds to complete. + using CancellationTokenSource debugTaskCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using CancellationTokenRegistration _ = debugTaskCts.Token.Register(ridOutput.SetCanceled); + using Process? psProc = Process.Start(psi); + try + { + Assert.NotNull(psProc); + psProc.OutputDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + if (args.Data.StartsWith("RID: ")) + { + int rid = int.Parse(args.Data.Substring(5)); + ridOutput.SetResult(rid); + } + + output.WriteLine("STDOUT: {0}", args.Data); + } + }; + psProc.ErrorDataReceived += (sender, args) => + { + if (!string.IsNullOrEmpty(args.Data)) + { + output.WriteLine("STDERR: {0}", args.Data); + } + }; + psProc.EnableRaisingEvents = true; + psProc.BeginOutputReadLine(); + psProc.BeginErrorReadLine(); + + Task procExited = psProc.WaitForExitAsync(debugTaskCts.Token); + Task waitRid = ridOutput.Task; + + // Wait for the process to fail or the script to start. + Task finishedTask = await Task.WhenAny(waitRid, procExited); + if (finishedTask == procExited) + { + await procExited; + Assert.Fail("The attached process exited before the PowerShell entrypoint could start."); + } + int rid = await waitRid; + + Task debugTask = action(filePath, psProc.Id, rid); + finishedTask = await Task.WhenAny(procExited, debugTask); + if (finishedTask == procExited) + { + await procExited; + Assert.Fail("Attached process exited before the script could start."); + } + + await debugTask; + + File.Delete(filePath); + psProc.Kill(); + await procExited; + } + catch + { + if (psProc is not null && !psProc.HasExited) + { + psProc.Kill(); + } + + throw; + } + } + + private static string CreatePwshEncodedArgs(params string[] args) + { + // Only way to pass args to -EncodedCommand is to use CLIXML with + // -EncodedArguments. Not pretty but the structure isn't too + // complex and saves us trying to embed/escape strings in a script. + string clixmlNamespace = "http://schemas.microsoft.com/powershell/2004/04"; + string clixml = new XDocument( + new XDeclaration("1.0", "utf-16", "yes"), + new XElement(XName.Get("Objs", clixmlNamespace), + new XAttribute("Version", "1.1.0.1"), + new XElement(XName.Get("Obj", clixmlNamespace), + new XAttribute("RefId", "0"), + new XElement(XName.Get("TN", clixmlNamespace), + new XAttribute("RefId", "0"), + new XElement(XName.Get("T", clixmlNamespace), "System.Collections.ArrayList"), + new XElement(XName.Get("T", clixmlNamespace), "System.Object") + ), + new XElement(XName.Get("LST", clixmlNamespace), + args.Select(s => new XElement(XName.Get("S", clixmlNamespace), s)) + ) + ))).ToString(SaveOptions.DisableFormatting); + + return Convert.ToBase64String(Encoding.Unicode.GetBytes(clixml)); + } + + private record StartDebuggingAttachRequestArguments(PsesAttachRequestArguments Configuration, string Request); } } diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 03690ec21..3ba16008d 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -201,7 +201,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task DebuggerAcceptsScriptArgs(string[] args) { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - oddPathScriptFile, + oddPathScriptFile.FilePath, new[] { BreakpointDetails.Create(oddPathScriptFile.FilePath, 3) }); Assert.Single(breakpoints); @@ -310,7 +310,7 @@ public async Task DebuggerSetsAndClearsLineBreakpoints() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 10) @@ -323,7 +323,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(10, breakpoints[1].LineNumber); breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 2) }); confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -331,7 +331,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(2, breakpoints[0].LineNumber); await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, Array.Empty()); IReadOnlyList remainingBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -342,7 +342,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerStopsOnLineBreakpoints() { await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 7) @@ -361,7 +361,7 @@ public async Task DebuggerStopsOnConditionalBreakpoints() const int breakpointValue2 = 20; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 7, null, $"$i -eq {breakpointValue1} -or $i -eq {breakpointValue2}"), }); @@ -397,7 +397,7 @@ public async Task DebuggerStopsOnHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, null, $"{hitCount}"), }); @@ -420,7 +420,7 @@ public async Task DebuggerStopsOnConditionalAndHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, "$i % 2 -eq 0", $"{hitCount}") }); Task _ = ExecuteDebugFileAsync(); @@ -441,7 +441,7 @@ public async Task DebuggerProvidesMessageForInvalidConditionalBreakpoint() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { // TODO: Add this breakpoint back when it stops moving around?! The ordering // of these two breakpoints seems to do with which framework executes the @@ -469,7 +469,7 @@ public async Task DebuggerFindsParsableButInvalidSimpleBreakpointConditions() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5, column: null, condition: "$i == 100"), BreakpointDetails.Create(debugScriptFile.FilePath, 7, column: null, condition: "$i > 100") @@ -548,7 +548,7 @@ await debugService.SetCommandBreakpointsAsync( else { await debugService.SetLineBreakpointsAsync( - scriptFile, + scriptFile.FilePath, new[] { BreakpointDetails.Create(scriptPath, 1) }); } @@ -630,7 +630,7 @@ public async Task OddFilePathsLaunchCorrectly() public async Task DebuggerVariableStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 8) }); Task _ = ExecuteVariableScriptFileAsync(); @@ -648,7 +648,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerGetsVariables() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 21) }); Task _ = ExecuteVariableScriptFileAsync(); @@ -698,7 +698,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesNoConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); Task _ = ExecuteVariableScriptFileAsync(); @@ -751,7 +751,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesWithConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); // Execute the script and wait for the breakpoint to be hit @@ -807,7 +807,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableEnumDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 15) }); // Execute the script and wait for the breakpoint to be hit @@ -827,7 +827,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableHashtableDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 11) }); // Execute the script and wait for the breakpoint to be hit @@ -860,7 +860,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableNullStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 16) }); // Execute the script and wait for the breakpoint to be hit @@ -880,7 +880,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariablePSObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 17) }); // Execute the script and wait for the breakpoint to be hit @@ -1076,7 +1076,7 @@ await GetVariables(VariableContainerDetails.ScriptScopeName), public async Task DebuggerVariablePSCustomObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 18) }); // Execute the script and wait for the breakpoint to be hit @@ -1105,7 +1105,7 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableProcessObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 19) }); // Execute the script and wait for the breakpoint to be hit From ba8d36c437839c66316623de4695613ea9b3159a Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Tue, 19 Aug 2025 08:10:52 +1000 Subject: [PATCH 2/2] Expose pathMappings to Start-DebugAttachSession and fixup Name --- .../Public/Start-DebugAttachSession.ps1 | 22 +++++++++- module/docs/Start-DebugAttachSession.md | 44 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 index a3df340d2..3cdf7bdd8 100644 --- a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 @@ -38,6 +38,10 @@ function Start-DebugAttachSession { [string] $ComputerName, + [Parameter()] + [IDictionary[]] + $PathMapping, + [Parameter()] [switch] $AsJob @@ -105,11 +109,21 @@ function Start-DebugAttachSession { return } - $configuration.name = "Attach Process $ProcessId" + if ($Name) { + $configuration.name = $Name + } + else { + $configuration.name = "Attach Process $ProcessId" + } $configuration.processId = $ProcessId } elseif ($CustomPipeName) { - $configuration.name = "Attach Pipe $CustomPipeName" + if ($Name) { + $configuration.name = $Name + } + else { + $configuration.name = "Attach Pipe $CustomPipeName" + } $configuration.customPipeName = $CustomPipeName } else { @@ -127,6 +141,10 @@ function Start-DebugAttachSession { $configuration.runspaceName = $RunspaceName } + if ($PathMapping) { + $configuration.pathMappings = $PathMapping + } + # https://microsoft.github.io/debug-adapter-protocol/specification#Reverse_Requests_StartDebugging $resp = $debugServer.SendRequest( 'startDebugging', diff --git a/module/docs/Start-DebugAttachSession.md b/module/docs/Start-DebugAttachSession.md index 1fa7bf785..214e6e9c9 100644 --- a/module/docs/Start-DebugAttachSession.md +++ b/module/docs/Start-DebugAttachSession.md @@ -16,13 +16,14 @@ Starts a new debug session attached to the specified PowerShell instance. ### ProcessId (Default) ``` Start-DebugAttachSession [-Name ] [-ProcessId ] [-RunspaceName ] [-RunspaceId ] - [-ComputerName ] [-AsJob] [] + [-ComputerName ] [-PathMapping ] [-AsJob] [] ``` ### CustomPipeName ``` Start-DebugAttachSession [-Name ] [-CustomPipeName ] [-RunspaceName ] - [-RunspaceId ] [-ComputerName ] [-AsJob] [] + [-RunspaceId ] [-ComputerName ] [-PathMapping ] [-AsJob] + [] ``` ## DESCRIPTION @@ -74,6 +75,27 @@ Write-Host "Test $a - $PID" 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. +### -------------------------- EXAMPLE 2 -------------------------- + +```powershell +$attachParams = @{ + ComputerName = 'remote-windows' + ProcessId = $remotePid + RunspaceId = 1 + PathMapping = @( + @{ + localRoot = 'C:\local\path\to\scripts\' + remoteRoot = 'C:\remote\path\on\remote-windows\' + } + ) +} +Start-DebugAttachSession @attachParams +``` + +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`. + +The debug client can see the remote files as local when setting breakpoints and inspecting the callstack with this mapped path. + ## PARAMETERS ### -AsJob @@ -142,6 +164,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -PathMapping + +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. + +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. + +```yaml +Type: IDictionary[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProcessId The ID of the PowerShell host process that should be attached. This option is mutually exclusive with `-CustomPipeName`.