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