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 356885d7b1d4f1cca11dd515cbfd47f798568c75 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 6 Aug 2025 10:27:21 +1000 Subject: [PATCH 2/2] Delay launch/attach until after config done Updates the sequence of launching and attaching scripts until after configuration done is received. This aligns the behaviour of starting a debug session with other debug adapters. A side effect of this change is that it is now possible to set breakpoints and do other actions in an attached runspace target during the initialisation. Also adds a new option `NotifyOnAttach` for an attach request which will create the `PSES.Attached` event before calling `Debug-Runspace` allowing attached scripts to wait for an attach event. --- .../DebugAdapter/BreakpointService.cs | 200 +++++++++++++++++- .../DebugAdapter/DebugEventHandlerService.cs | 11 - .../DebugAdapter/DebugStateService.cs | 79 ++++++- .../Handlers/ConfigurationDoneHandler.cs | 138 +----------- .../Handlers/LaunchAndAttachHandler.cs | 153 ++++++++++++-- .../DebugAdapterProtocolMessageTests.cs | 173 +++++++++++++-- .../Debugging/DebugServiceTests.cs | 18 +- 7 files changed, 568 insertions(+), 204 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 6d7e0c31a..578cf1f70 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -18,6 +18,154 @@ namespace Microsoft.PowerShell.EditorServices.Services { internal class BreakpointService { + private const string _getPSBreakpointLegacy = @" + [CmdletBinding()] + param ( + [Parameter()] + [string] + $Script, + + [Parameter()] + [int] + $RunspaceId = [Runspace]::DefaultRunspace.Id + ) + + $runspace = if ($PSBoundParameters.ContainsKey('RunspaceId')) { + Get-Runspace -Id $RunspaceId + $null = $PSBoundParameters.Remove('RunspaceId') + } + else { + [Runspace]::DefaultRunspace + } + + $debugger = $runspace.Debugger + $getBreakpointsMeth = $debugger.GetType().GetMethod( + 'GetBreakpoints', + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@(), + $null) + + $runspaceIdProp = [System.Management.Automation.PSNoteProperty]::new( + 'RunspaceId', + $runspaceId) + + @( + if (-not $getBreakpointsMeth) { + if ($RunspaceId -ne [Runspace]::DefaultRunspace.Id) { + throw 'Failed to find GetBreakpoints method on Debugger.' + } + + Microsoft.PowerShell.Utility\Get-PSBreakpoint @PSBoundParameters + } + else { + $getBreakpointsMeth.Invoke($debugger, @()) | Where-Object { + if ($Script) { + $_.Script -eq $Script + } + else { + $true + } + } + } + ) | ForEach-Object { + $_.PSObject.Properties.Add($runspaceIdProp) + $_ + } + "; + + private const string _removePSBreakpointLegacy = @" + [CmdletBinding(DefaultParameterSetName = 'Breakpoint')] + param ( + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Breakpoint')] + [System.Management.Automation.Breakpoint[]] + $Breakpoint, + + [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Id')] + [int[]] + $Id, + + [Parameter(ParameterSetName = 'Id')] + [int] + $RunspaceId = [Runspace]::DefaultRunspace.Id + ) + + begin { + $removeBreakpointMeth = [Runspace]::DefaultRunspace.Debugger.GetType().GetMethod( + 'RemoveBreakpoint', + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([System.Management.Automation.Breakpoint]), + $null) + $getBreakpointMeth = [Runspace]::DefaultRunspace.Debugger.GetType().GetMethod( + 'GetBreakpoint', + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([int]), + $null) + + $breakpointCollection = [System.Collections.Generic.List[System.Management.Automation.Breakpoint]]::new() + } + + process { + if ($PSCmdlet.ParameterSetName -eq 'Id') { + $runspace = Get-Runspace -Id $RunspaceId + $runspaceProp = [System.Management.Automation.PSNoteProperty]::new( + 'Runspace', + $Runspace) + + $breakpoints = if ($getBreakpointMeth) { + foreach ($breakpointId in $Id) { + $getBreakpointMeth.Invoke($runspace.Debugger, @($breakpointId)) + } + } + elseif ($runspace -eq [Runspace]::DefaultRunspace) { + Microsoft.PowerShell.Utility\Get-PSBreakpoint -Id $Id + } + else { + throw 'Failed to find GetBreakpoint method on Debugger.' + } + + $breakpoints | ForEach-Object { + $_.PSObject.Properties.Add($runspaceProp) + $breakpointCollection.Add($_) + } + } + else { + foreach ($b in $Breakpoint) { + # RunspaceId may be set by _getPSBreakpointLegacy when + # targeting a breakpoint in a specific runspace. + $runspace = if ($b.PSObject.Properties.Match('RunspaceId')) { + Get-Runspace -Id $b.RunspaceId + } + else { + [Runspace]::DefaultRunspace + } + + $b.PSObject.Properties.Add( + [System.Management.Automation.PSNoteProperty]::new('Runspace', $runspace)) + $breakpointCollection.Add($b) + } + } + } + + end { + foreach ($b in $breakpointCollection) { + if ($removeBreakpointMeth) { + $removeBreakpointMeth.Invoke($b.Runspace.Debugger, @($b)) + } + elseif ($b.Runspace -eq [Runspace]::DefaultRunspace) { + # If we don't have the method, we can only remove breakpoints + # from the default runspace using Remove-PSBreakpoint. + $b | Microsoft.PowerShell.Utility\Remove-PSBreakpoint + } + else { + throw 'Failed to find RemoveBreakpoint method on Debugger.' + } + } + } + "; + /// /// Code used on WinPS 5.1 to set breakpoints without Script path validation. /// It uses reflection because the APIs were not public until 7.0 but just in @@ -45,7 +193,11 @@ internal class BreakpointService [Parameter(ParameterSetName = 'Command', Mandatory = $true)] [string] - $Command + $Command, + + [Parameter()] + [int] + $RunspaceId ) if ($Script) { @@ -65,6 +217,9 @@ internal class BreakpointService $null) if (-not $cmdCtor) { + if ($PSBoundParameters.ContainsKey('RunspaceId')) { + throw 'Failed to find constructor for CommandBreakpoint.' + } Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters return } @@ -82,6 +237,9 @@ internal class BreakpointService $null) if (-not $lineCtor) { + if ($PSBoundParameters.ContainsKey('RunspaceId')) { + throw 'Failed to find constructor for LineBreakpoint.' + } Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters return } @@ -89,8 +247,14 @@ internal class BreakpointService $b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action)) } - [Runspace]::DefaultRunspace.Debugger.SetBreakpoints( - [System.Management.Automation.Breakpoint[]]@($b)) + $runspace = if ($PSBoundParameters.ContainsKey('RunspaceId')) { + Get-Runspace -Id $RunspaceId + } + else { + [Runspace]::DefaultRunspace + } + + $runspace.Debugger.SetBreakpoints([System.Management.Automation.Breakpoint[]]@($b)) $b "; @@ -128,10 +292,14 @@ public async Task> GetBreakpointsAsync() } // Legacy behavior - PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); + PSCommand psCommand = new PSCommand().AddScript(_getPSBreakpointLegacy, useLocalScope: true); + if (_debugStateService.RunspaceId is not null) + { + psCommand.AddParameter("RunspaceId", _debugStateService.RunspaceId.Value); + } return await _executionService - .ExecutePSCommandAsync(psCommand, CancellationToken.None) - .ConfigureAwait(false); + .ExecutePSCommandAsync(psCommand, CancellationToken.None) + .ConfigureAwait(false); } public async Task> SetBreakpointsAsync(IReadOnlyList breakpoints) @@ -211,6 +379,11 @@ public async Task> SetBreakpointsAsync(IReadOnl { psCommand.AddParameter("Action", actionScriptBlock); } + + if (_debugStateService.RunspaceId is not null) + { + psCommand.AddParameter("RunspaceId", _debugStateService.RunspaceId.Value); + } } // If no PSCommand was created then there are no breakpoints to set. @@ -335,14 +508,17 @@ public async Task RemoveAllBreakpointsAsync(string scriptPath = null) } // Legacy behavior - PSCommand psCommand = new PSCommand().AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); - + PSCommand psCommand = new PSCommand().AddScript(_getPSBreakpointLegacy, useLocalScope: true); + if (_debugStateService.RunspaceId is not null) + { + psCommand.AddParameter("RunspaceId", _debugStateService.RunspaceId.Value); + } if (!string.IsNullOrEmpty(scriptPath)) { psCommand.AddParameter("Script", scriptPath); } - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); + psCommand.AddScript(_removePSBreakpointLegacy, useLocalScope: true); await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); } catch (Exception e) @@ -378,8 +554,12 @@ public async Task RemoveBreakpointsAsync(IEnumerable breakpoints) if (breakpointIds.Any()) { PSCommand psCommand = new PSCommand() - .AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint") + .AddScript(_removePSBreakpointLegacy, useLocalScope: true) .AddParameter("Id", breakpoints.Select(b => b.Id).ToArray()); + if (_debugStateService.RunspaceId is not null) + { + psCommand.AddParameter("RunspaceId", _debugStateService.RunspaceId.Value); + } await _executionService.ExecutePSCommandAsync(psCommand, CancellationToken.None).ConfigureAwait(false); } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs index e3237a3b0..c887b02ac 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugEventHandlerService.cs @@ -94,17 +94,6 @@ private void OnRunspaceChanged(object sender, RunspaceChangedEventArgs e) { switch (e.ChangeAction) { - case RunspaceChangeAction.Enter: - if (_debugStateService.WaitingForAttach - && e.NewRunspace.RunspaceOrigin == RunspaceOrigin.DebuggedRunspace) - { - // Sends the InitializedEvent so that the debugger will continue - // sending configuration requests - _debugStateService.WaitingForAttach = false; - _debugStateService.ServerStarted.TrySetResult(true); - } - return; - case RunspaceChangeAction.Exit: if (_debugContext.IsStopped) { diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs index 9736b3e85..164ea44ca 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs @@ -4,27 +4,24 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.JsonRpc; namespace Microsoft.PowerShell.EditorServices.Services { internal class DebugStateService { private readonly SemaphoreSlim _setBreakpointInProgressHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + private readonly SemaphoreSlim _inLaunchOrAttachHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + private TaskCompletionSource _waitForConfigDone; internal bool NoDebug { get; set; } - internal string[] Arguments { get; set; } - internal bool IsRemoteAttach { get; set; } internal int? RunspaceId { get; set; } internal bool IsAttachSession { get; set; } - internal bool WaitingForAttach { get; set; } - - internal string ScriptToLaunch { get; set; } - internal bool ExecutionCompleted { get; set; } internal bool IsInteractiveDebugSession { get; set; } @@ -34,13 +31,79 @@ internal class DebugStateService internal bool IsUsingTempIntegratedConsole { get; set; } - internal string ExecuteMode { get; set; } - // This gets set at the end of the Launch/Attach handler which set debug state. internal TaskCompletionSource ServerStarted { get; set; } internal int ReleaseSetBreakpointHandle() => _setBreakpointInProgressHandle.Release(); internal Task WaitForSetBreakpointHandleAsync() => _setBreakpointInProgressHandle.WaitAsync(); + + /// + /// Sends the InitializedEvent and waits for the configuration done + /// event to be sent by the client. + /// + /// The action being performed, either "attach" or "launch". + /// A cancellation token to cancel the operation. + /// A launch or attach request is already in progress + internal async Task WaitForConfigurationDoneAsync( + string action, + CancellationToken cancellationToken) + { + Task waitForConfigDone; + + // First check we are not already running a launch or attach request. + await _inLaunchOrAttachHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_waitForConfigDone is not null) + { + // If we are already waiting for a configuration done, then we cannot start another + // launch or attach request. + throw new RpcErrorException(0, null, $"Cannot start a new {action} request when one is already in progress."); + } + + _waitForConfigDone = new TaskCompletionSource(); + waitForConfigDone = _waitForConfigDone.Task; + } + finally + { + _inLaunchOrAttachHandle.Release(); + } + + using CancellationTokenRegistration _ = cancellationToken.Register(_waitForConfigDone.SetCanceled); + + // Sends the InitializedEvent so that the debugger will continue + // sending configuration requests before the final configuration + // done. + ServerStarted.TrySetResult(true); + await waitForConfigDone.ConfigureAwait(false); + } + + /// + /// Sets the configuration done task to complete, indicating that the + /// client has sent all the initial configuration information and the + /// debugger is ready to start. + /// + /// A cancellation token to cancel the operation. + internal async Task SetConfigurationDoneAsync( + CancellationToken cancellationToken) + { + await _inLaunchOrAttachHandle.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_waitForConfigDone is null) + { + // If we are not waiting for a configuration done, then we cannot set it. + throw new RpcErrorException(0, null, "Unexpected configuration done request when server is not expecting it."); + } + + _waitForConfigDone.TrySetResult(true); + _waitForConfigDone = null; + } + finally + { + _inLaunchOrAttachHandle.Release(); + } + } } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 146bbeae0..747b2d2c0 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -1,161 +1,35 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Management.Automation; -using System.Management.Automation.Language; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Services; -using Microsoft.PowerShell.EditorServices.Services.PowerShell; -using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; -using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; -using Microsoft.PowerShell.EditorServices.Services.TextDocument; -using Microsoft.PowerShell.EditorServices.Utility; -using OmniSharp.Extensions.DebugAdapter.Protocol.Events; using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; -using OmniSharp.Extensions.DebugAdapter.Protocol.Server; namespace Microsoft.PowerShell.EditorServices.Handlers { internal class ConfigurationDoneHandler : IConfigurationDoneHandler { - // TODO: We currently set `WriteInputToHost` as true, which writes our debugged commands' - // `GetInvocationText` and that reveals some obscure implementation details we should - // instead hide from the user with pretty strings (or perhaps not write out at all). - // - // This API is mostly used for F5 execution so it requires the foreground. - private static readonly PowerShellExecutionOptions s_debuggerExecutionOptions = new() - { - RequiresForeground = true, - WriteInputToHost = true, - WriteOutputToHost = true, - ThrowOnError = false, - AddToHistory = true, - }; - - private readonly ILogger _logger; - private readonly IDebugAdapterServerFacade _debugAdapterServer; private readonly DebugService _debugService; private readonly DebugStateService _debugStateService; - private readonly DebugEventHandlerService _debugEventHandlerService; - private readonly IInternalPowerShellExecutionService _executionService; - private readonly WorkspaceService _workspaceService; - private readonly IPowerShellDebugContext _debugContext; - // TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified - // (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`). public ConfigurationDoneHandler( - ILoggerFactory loggerFactory, - IDebugAdapterServerFacade debugAdapterServer, DebugService debugService, - DebugStateService debugStateService, - DebugEventHandlerService debugEventHandlerService, - IInternalPowerShellExecutionService executionService, - WorkspaceService workspaceService, - IPowerShellDebugContext debugContext) + DebugStateService debugStateService) { - _logger = loggerFactory.CreateLogger(); - _debugAdapterServer = debugAdapterServer; _debugService = debugService; _debugStateService = debugStateService; - _debugEventHandlerService = debugEventHandlerService; - _executionService = executionService; - _workspaceService = workspaceService; - _debugContext = debugContext; } - public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) + public async Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) { _debugService.IsClientAttached = true; - if (!string.IsNullOrEmpty(_debugStateService.ScriptToLaunch)) - { - // NOTE: This is an unawaited task because responding to "configuration done" means - // setting up the debugger, and in our case that means starting the script but not - // waiting for it to finish. - Task _ = LaunchScriptAsync(_debugStateService.ScriptToLaunch).HandleErrorsAsync(_logger); - } - - if (_debugStateService.IsInteractiveDebugSession && _debugService.IsDebuggerStopped) - { - if (_debugService.CurrentDebuggerStoppedEventArgs is not null) - { - // If this is an interactive session and there's a pending breakpoint, send that - // information along to the debugger client. - _debugEventHandlerService.TriggerDebuggerStopped(_debugService.CurrentDebuggerStoppedEventArgs); - } - else - { - // If this is an interactive session and there's a pending breakpoint that has - // not been propagated through the debug service, fire the debug service's - // OnDebuggerStop event. - _debugService.OnDebuggerStopAsync(null, _debugContext.LastStopEventArgs); - } - } - - return Task.FromResult(new ConfigurationDoneResponse()); - } - - // NOTE: We test this function in `DebugServiceTests` so it both needs to be internal, and - // use conditional-access on `_debugStateService` and `_debugAdapterServer` as its not set - // by tests. - internal async Task LaunchScriptAsync(string scriptToLaunch) - { - PSCommand command; - if (System.IO.File.Exists(scriptToLaunch)) - { - // For a saved file we just execute its path (after escaping it), with the configured operator - // (which can't be called that because it's a reserved keyword in C#). - string executeMode = _debugStateService?.ExecuteMode == "Call" ? "&" : "."; - command = PSCommandHelpers.BuildDotSourceCommandWithArguments( - PSCommandHelpers.EscapeScriptFilePath(scriptToLaunch), _debugStateService?.Arguments, executeMode); - } - else // It's a URI to an untitled script, or a raw script. - { - bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); - if (isScriptFile) - { - // Parse untitled files with their `Untitled:` URI as the filename which will - // cache the URI and contents within the PowerShell parser. By doing this, we - // light up the ability to debug untitled files with line breakpoints. - ScriptBlockAst ast = Parser.ParseInput( - untitledScript.Contents, - untitledScript.DocumentUri.ToString(), - out Token[] _, - out ParseError[] _); - - // In order to use utilize the parser's cache (and therefore hit line - // breakpoints) we need to use the AST's `ScriptBlock` object. Due to - // limitations in PowerShell's public API, this means we must use the - // `PSCommand.AddArgument(object)` method, hence this hack where we dot-source - // `$args[0]. Fortunately the dot-source operator maintains a stack of arguments - // on each invocation, so passing the user's arguments directly in the initial - // `AddScript` surprisingly works. - command = PSCommandHelpers - .BuildDotSourceCommandWithArguments("$args[0]", _debugStateService?.Arguments) - .AddArgument(ast.GetScriptBlock()); - } - else - { - // Without the new APIs we can only execute the untitled script's contents. - // Command breakpoints and `Wait-Debugger` will work. We must wrap the script - // with newlines so that any included comments don't break the command. - command = PSCommandHelpers.BuildDotSourceCommandWithArguments( - string.Concat( - "{" + System.Environment.NewLine, - isScriptFile ? untitledScript.Contents : scriptToLaunch, - System.Environment.NewLine + "}"), - _debugStateService?.Arguments); - } - } - - await _executionService.ExecutePSCommandAsync( - command, - CancellationToken.None, - s_debuggerExecutionOptions).ConfigureAwait(false); + // Tells the attach/launch request handler that the config is done + // and it can continue starting the script. + await _debugStateService.SetConfigurationDoneAsync(cancellationToken).ConfigureAwait(false); - _debugAdapterServer?.SendNotification(EventNames.Terminated); + return new ConfigurationDoneResponse(); } } } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 13bbb3445..5d7947b3f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Management.Automation; +using System.Management.Automation.Language; using System.Management.Automation.Remoting; using System.Threading; using System.Threading.Tasks; @@ -17,6 +18,7 @@ using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.DebugAdapter.Protocol.Events; using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; using OmniSharp.Extensions.DebugAdapter.Protocol.Server; @@ -99,10 +101,47 @@ internal record PsesAttachRequestArguments : AttachRequestArguments /// Gets or sets the path mappings for the remote debugging session. /// public PathMapping[] PathMappings { get; set; } = []; + + /// Gets or sets a boolean value that determines whether to write the + /// PSES.Attached event to the target runspace after attaching. + /// + public bool NotifyOnAttach { get; set; } } internal class LaunchAndAttachHandler : ILaunchHandler, IAttachHandler, IOnDebugAdapterServerStarted { + private const string _newAttachEventScript = @" + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [int] + $RunspaceId + ) + + $ErrorActionPreference = 'Stop' + + $runspace = Get-Runspace -Id $RunspaceId + $runspace.Events.GenerateEvent( + 'PSES.Attached', + 'PSES', + @(), + $null) + "; + + // TODO: We currently set `WriteInputToHost` as true, which writes our debugged commands' + // `GetInvocationText` and that reveals some obscure implementation details we should + // instead hide from the user with pretty strings (or perhaps not write out at all). + // + // This API is mostly used for F5 execution so it requires the foreground. + private static readonly PowerShellExecutionOptions s_debuggerExecutionOptions = new() + { + RequiresForeground = true, + WriteInputToHost = true, + WriteOutputToHost = true, + ThrowOnError = false, + AddToHistory = true, + }; + private static readonly int s_currentPID = System.Diagnostics.Process.GetCurrentProcess().Id; private static readonly Version s_minVersionForCustomPipeName = new(6, 2); private readonly ILogger _logger; @@ -110,6 +149,7 @@ internal class LaunchAndAttachHandler : ILaunchHandler(); + // DebugServiceTests will call this with a null DebugStateService. + if (_debugStateService is not null) + { + _debugStateService.ServerStarted = new TaskCompletionSource(); + } _remoteFileManagerService = remoteFileManagerService; } @@ -230,10 +276,7 @@ await _executionService.ExecutePSCommandAsync( // Store the launch parameters so that they can be used later _debugStateService.NoDebug = request.NoDebug; - _debugStateService.ScriptToLaunch = request.Script; - _debugStateService.Arguments = request.Args; _debugStateService.IsUsingTempIntegratedConsole = request.CreateTemporaryIntegratedConsole; - _debugStateService.ExecuteMode = request.ExecuteMode; if (request.CreateTemporaryIntegratedConsole && !string.IsNullOrEmpty(request.Script) @@ -244,31 +287,39 @@ await _executionService.ExecutePSCommandAsync( // If the current session is remote, map the script path to the remote // machine if necessary - if (_debugStateService.ScriptToLaunch != null + string requestScript = request.Script; + if (requestScript != null && _runspaceContext.CurrentRunspace.IsOnRemoteMachine) { - if (_debugService.TryGetMappedRemotePath(_debugStateService.ScriptToLaunch, out string remoteMappedPath)) + if (_debugService.TryGetMappedRemotePath(requestScript, out string remoteMappedPath)) { - _debugStateService.ScriptToLaunch = remoteMappedPath; + requestScript = remoteMappedPath; } else { // If the script is not mapped, we will map it to the remote path // using the RemoteFileManagerService. - _debugStateService.ScriptToLaunch = + requestScript = _remoteFileManagerService.GetMappedPath( - _debugStateService.ScriptToLaunch, + requestScript, _runspaceContext.CurrentRunspace); } } // If no script is being launched, mark this as an interactive // debugging session - _debugStateService.IsInteractiveDebugSession = string.IsNullOrEmpty(_debugStateService.ScriptToLaunch); + _debugStateService.IsInteractiveDebugSession = string.IsNullOrEmpty(requestScript); // Sends the InitializedEvent so that the debugger will continue // sending configuration requests - _debugStateService.ServerStarted.TrySetResult(true); + await _debugStateService.WaitForConfigurationDoneAsync("launch", cancellationToken).ConfigureAwait(false); + + if (!_debugStateService.IsInteractiveDebugSession) + { + // NOTE: This is an unawaited task because we are starting the script but not + // waiting for it to finish. + Task _ = LaunchScriptAsync(requestScript, request.Args, request.ExecuteMode).HandleErrorsAsync(_logger); + } return new LaunchResponse(); } @@ -389,16 +440,90 @@ void RunspaceChangedHandler(object s, RunspaceChangedEventArgs _) // Clear any existing breakpoints before proceeding await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); - _debugStateService.WaitingForAttach = true; + // The debugger is now ready to receive breakpoint requests. We do + // this before running Debug-Runspace so the runspace is not busy + // and can set breakpoints before the final configuration done. + await _debugStateService.WaitForConfigurationDoneAsync("attach", cancellationToken).ConfigureAwait(false); + + if (request.NotifyOnAttach) + { + // This isn't perfect as there is still a race condition + // this and Debug-Runspace setting up the debugger below but it + // is the best we can do without changes to PowerShell. + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddScript(_newAttachEventScript, useLocalScope: true) + .AddParameter("RunspaceId", _debugStateService.RunspaceId), + cancellationToken).ConfigureAwait(false); + } + Task nonAwaitedTask = _executionService .ExecutePSCommandAsync(debugRunspaceCmd, CancellationToken.None, PowerShellExecutionOptions.ImmediateInteractive) .ContinueWith(OnExecutionCompletedAsync, TaskScheduler.Default); - _debugStateService.ServerStarted.TrySetResult(true); - return new AttachResponse(); } + // NOTE: We test this function in `DebugServiceTests` so it both needs to be internal, and + // use conditional-access on `_debugStateService` and `_debugAdapterServer` as its not set + // by tests. + internal async Task LaunchScriptAsync(string scriptToLaunch, string[] arguments, string requestExecuteMode) + { + PSCommand command; + if (File.Exists(scriptToLaunch)) + { + // For a saved file we just execute its path (after escaping it), with the configured operator + // (which can't be called that because it's a reserved keyword in C#). + string executeMode = requestExecuteMode == "Call" ? "&" : "."; + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + PSCommandHelpers.EscapeScriptFilePath(scriptToLaunch), arguments, executeMode); + } + else // It's a URI to an untitled script, or a raw script. + { + bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); + if (isScriptFile) + { + // Parse untitled files with their `Untitled:` URI as the filename which will + // cache the URI and contents within the PowerShell parser. By doing this, we + // light up the ability to debug untitled files with line breakpoints. + ScriptBlockAst ast = Parser.ParseInput( + untitledScript.Contents, + untitledScript.DocumentUri.ToString(), + out Token[] _, + out ParseError[] _); + + // In order to use utilize the parser's cache (and therefore hit line + // breakpoints) we need to use the AST's `ScriptBlock` object. Due to + // limitations in PowerShell's public API, this means we must use the + // `PSCommand.AddArgument(object)` method, hence this hack where we dot-source + // `$args[0]. Fortunately the dot-source operator maintains a stack of arguments + // on each invocation, so passing the user's arguments directly in the initial + // `AddScript` surprisingly works. + command = PSCommandHelpers + .BuildDotSourceCommandWithArguments("$args[0]", arguments) + .AddArgument(ast.GetScriptBlock()); + } + else + { + // Without the new APIs we can only execute the untitled script's contents. + // Command breakpoints and `Wait-Debugger` will work. We must wrap the script + // with newlines so that any included comments don't break the command. + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + string.Concat( + "{" + Environment.NewLine, + isScriptFile ? untitledScript.Contents : scriptToLaunch, + Environment.NewLine + "}"), + arguments); + } + } + + await _executionService.ExecutePSCommandAsync( + command, + CancellationToken.None, + s_debuggerExecutionOptions).ConfigureAwait(false); + + _debugAdapterServer?.SendNotification(EventNames.Terminated); + } + private async Task AttachToComputer(string computerName, CancellationToken cancellationToken) { _debugStateService.IsRemoteAttach = true; diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index 1d8259ae5..ceed8a333 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -67,6 +68,12 @@ public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsync /// private readonly TaskCompletionSource terminatedTcs = new(); + private readonly TaskCompletionSource serverInitializedTcs = new(); + /// + /// This task is useful for waiting until the server has sent the Initialized event. + /// + private Task serverInitialized => serverInitializedTcs.Task; + public async Task InitializeAsync() { // Cleanup testScriptLogPath if it exists due to an interrupted previous run @@ -130,6 +137,10 @@ send until a launch is sent. terminatedTcs.SetResult(e); return Task.CompletedTask; }) + .OnDebugAdapterInitialized((initEvent) => + { + serverInitializedTcs.SetResult(initEvent); + }) ; }); @@ -263,9 +274,11 @@ public void CanInitializeWithCorrectServerSettings() public async Task UsesDotSourceOperatorAndQuotesAsync() { string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); - await client.LaunchScript(filePath); + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + await launchTask; string actual = await ReadScriptLogLineAsync(); Assert.StartsWith(". '", actual); @@ -275,9 +288,11 @@ public async Task UsesDotSourceOperatorAndQuotesAsync() public async Task UsesCallOperatorWithSettingAsync() { string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); - await client.LaunchScript(filePath, executeMode: "Call"); + Task launchTask = client.LaunchScript(filePath, executeMode: "Call"); + await serverInitialized; ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + await launchTask; string actual = await ReadScriptLogLineAsync(); Assert.StartsWith("& '", actual); @@ -288,10 +303,12 @@ public async Task CanLaunchScriptWithNoBreakpointsAsync() { string filePath = NewTestFile(GenerateLoggingScript("works")); - await client.LaunchScript(filePath); + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + await launchTask; string actual = await ReadScriptLogLineAsync(); Assert.Equal("works", actual); @@ -309,7 +326,8 @@ public async Task CanSetBreakpointsAsync() "after breakpoint" )); - await client.LaunchScript(filePath); + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; // {"command":"setBreakpoints","arguments":{"source":{"name":"dfsdfg.ps1","path":"/Users/tyleonha/Code/PowerShell/Misc/foo/dfsdfg.ps1"},"lines":[2],"breakpoints":[{"line":2}],"sourceModified":false},"type":"request","seq":3} SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments @@ -327,6 +345,8 @@ public async Task CanSetBreakpointsAsync() ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + await launchTask; + // Wait until we hit the breakpoint StoppedEvent stoppedEvent = await nextStopped; Assert.Equal("breakpoint", stoppedEvent.Reason); @@ -370,8 +390,10 @@ await client.SetBreakpoints( ); // Signal to start the script + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; await client.RequestConfigurationDone(new ConfigurationDoneArguments()); - await client.LaunchScript(filePath); + await launchTask; // Try to get the stacktrace, which should throw as we are not currently at a breakpoint. await Assert.ThrowsAsync(() => client.RequestStackTrace( @@ -390,7 +412,8 @@ public async Task SendsInitialLabelBreakpointForPerformanceReasons() )); // Request a launch. Note that per DAP spec, launch doesn't actually begin until ConfigDone finishes. - await client.LaunchScript(filePath); + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments { @@ -406,6 +429,8 @@ public async Task SendsInitialLabelBreakpointForPerformanceReasons() _ = client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await launchTask; + // Wait for the breakpoint to be hit StoppedEvent stoppedEvent = await nextStopped; Assert.Equal("breakpoint", stoppedEvent.Reason); @@ -452,7 +477,8 @@ public async Task CanStepPastSystemWindowsForms() "Write-Host $form" })); - await client.LaunchScript(filePath); + Task launchTask = client.LaunchScript(filePath); + await serverInitialized; SetFunctionBreakpointsResponse setBreakpointsResponse = await client.SetFunctionBreakpoints( new SetFunctionBreakpointsArguments @@ -466,6 +492,8 @@ public async Task CanStepPastSystemWindowsForms() ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + await launchTask; + await Task.Delay(5000); VariablesResponse variablesResponse = await client.RequestVariables( @@ -490,9 +518,11 @@ public async Task CanLaunchScriptWithCommentedLastLineAsync() // PsesLaunchRequestArguments.Script, which is then assigned to // DebugStateService.ScriptToLaunch in that handler, and finally used by the // ConfigurationDoneHandler in LaunchScriptAsync. - await client.LaunchScript(script); + Task launchTask = client.LaunchScript(script); + await serverInitialized; _ = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await launchTask; // We can check that the script was invoked as expected, which is to dot-source a script // block with the contents surrounded by newlines. While we can't check that the last @@ -539,8 +569,10 @@ public async Task CanRunPesterTestFile() } }", isPester: true); - await client.LaunchScript($"Invoke-Pester -Script '{pesterTest}'"); + Task launchTask = client.LaunchScript($"Invoke-Pester -Script '{pesterTest}'"); + await serverInitialized; await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await launchTask; Assert.Equal("pester", await ReadScriptLogLineAsync()); } @@ -573,8 +605,10 @@ public async Task CanLaunchScriptWithNewChildAttachSession( terminatedTcs.TrySetCanceled(); }); - await client.LaunchScript(script); + Task launchTask = client.LaunchScript(script); + await serverInitialized; await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await launchTask; StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; Assert.Equal("attach", attachRequest.Request); @@ -607,8 +641,10 @@ public async Task CanLaunchScriptWithNewChildAttachSessionAsJob() terminatedTcs.TrySetCanceled(); }); - await client.LaunchScript(script); + Task launchTask = client.LaunchScript(script); + await serverInitialized; await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await launchTask; StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; Assert.Equal("attach", attachRequest.Request); @@ -621,6 +657,82 @@ public async Task CanLaunchScriptWithNewChildAttachSessionAsJob() await terminatedTcs.Task; } + [SkippableFact] + public async Task CanAttachScriptWithEventWait() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "Breakpoints can't be set in Constrained Language Mode."); + + string[] logStatements = ["before breakpoint", "after breakpoint"]; + + await RunWithAttachableProcess(logStatements, async (filePath, processId, runspaceId) => + { + Task nextStoppedTask = nextStopped; + + Task attachTask = client.Attach( + new PsesAttachRequestArguments + { + ProcessId = processId, + RunspaceId = runspaceId, + NotifyOnAttach = true, + }); + + await serverInitialized; + + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments + { + Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, + Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, + SourceModified = false, + }); + + Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); + Assert.True(breakpoint.Verified); + Assert.NotNull(breakpoint.Source); + Assert.Equal(filePath, breakpoint.Source.Path, ignoreCase: s_isWindows); + Assert.Equal(2, breakpoint.Line); + + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.NotNull(configDoneResponse); + + AttachResponse attachResponse = await attachTask; + Assert.NotNull(attachResponse); + + StoppedEvent stoppedEvent; + if (PsesStdioLanguageServerProcessHost.IsWindowsPowerShell) + { + // WinPS will break on first statement when Debug-Runspace + // is called. This does not happen in pwsh 7. + stoppedEvent = await nextStoppedTask; + Assert.Equal("step", stoppedEvent.Reason); + Assert.NotNull(stoppedEvent.ThreadId); + + nextStoppedTask = nextStopped; + + 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 nextStopped; + Assert.Equal("breakpoint", stoppedEvent.Reason); + + // The code before the breakpoint should have already run + Assert.Equal("before breakpoint", await ReadScriptLogLineAsync()); + + // Assert that the stopped breakpoint is the one we set + StackTraceResponse stackTraceResponse = await client.RequestStackTrace(new StackTraceArguments { ThreadId = 1 }); + DapStackFrame? stoppedTopFrame = stackTraceResponse.StackFrames?.First(); + Assert.NotNull(stoppedTopFrame); + Assert.Equal(2, stoppedTopFrame.Line); + + _ = await client.RequestContinue(new ContinueArguments { ThreadId = 1 }); + + string afterBreakpointActual = await ReadScriptLogLineAsync(); + Assert.Equal("after breakpoint", afterBreakpointActual); + }, waitForNotifyEvent: true); + } + [SkippableFact] public async Task CanAttachScriptWithPathMappings() { @@ -638,7 +750,7 @@ await RunWithAttachableProcess(logStatements, async (filePath, processId, runspa Task nextStoppedTask = nextStopped; - AttachResponse attachResponse = await client.Attach( + Task attachTask = client.Attach( new PsesAttachRequestArguments { ProcessId = processId, @@ -650,8 +762,9 @@ await RunWithAttachableProcess(logStatements, async (filePath, processId, runspa RemoteRoot = Path.GetDirectoryName(filePath) + Path.DirectorySeparatorChar } ] - }) ?? throw new Exception("Attach response was null."); - Assert.NotNull(attachResponse); + }); + + await serverInitialized; SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments { @@ -669,6 +782,9 @@ await RunWithAttachableProcess(logStatements, async (filePath, processId, runspa ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); + AttachResponse attachResponse = await attachTask; + Assert.NotNull(attachResponse); + // Wait-Debugger stop StoppedEvent stoppedEvent = await nextStoppedTask; Assert.Equal("step", stoppedEvent.Reason); @@ -710,7 +826,7 @@ await RunWithAttachableProcess(logStatements, async (filePath, processId, runspa }); } - private async Task RunWithAttachableProcess(string[] logStatements, Func action) + private async Task RunWithAttachableProcess(string[] logStatements, Func action, bool waitForNotifyEvent = false) { /* There is no public API in pwsh to wait for an attach event. We @@ -727,7 +843,7 @@ once that is merged and we are running against that version but WinPS will always need this. */ string scriptEntrypoint = @" - param([string]$TestScript) + param([string]$TestScript, [string]$WaitScript) $debugRunspaceCmd = Get-Command Debug-Runspace -Module Microsoft.PowerShell.Utility $runspaceBase = [PSObject].Assembly.GetType( @@ -742,9 +858,12 @@ WinPS will always need this. $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() + if ($WaitScript) { + $null = $ps.AddScript($WaitScript, $true).AddStatement() + } + else { + $null = $ps.AddCommand('Wait-Debugger').AddStatement() + } $null = $ps.AddCommand($TestScript) # Let the runner know what Runspace to attach to and that it @@ -778,7 +897,21 @@ WinPS will always need this. "; string filePath = NewTestFile(GenerateLoggingScript(logStatements)); - string encArgs = CreatePwshEncodedArgs(filePath); + + List args = [filePath]; + if (waitForNotifyEvent) + { + args.Add(@" + $e = Wait-Event -SourceIdentifier PSES.Attached -Timeout 10 + if ($e) { + $e | Remove-Event -Force + } + else { + throw 'Timed out waiting for PSES.Attached event.' + } + "); + } + string encArgs = CreatePwshEncodedArgs(args.ToArray()); string encCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptEntrypoint)); ProcessStartInfo psi = new ProcessStartInfo diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 3ba16008d..ab4f4ee49 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -552,10 +552,10 @@ await debugService.SetLineBreakpointsAsync( new[] { BreakpointDetails.Create(scriptPath, 1) }); } - ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + LaunchAndAttachHandler launchAndAttachHandler = new( + NullLoggerFactory.Instance, null, null, null, debugService, psesHost, psesHost, workspace, null, null); - Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); + Task _ = launchAndAttachHandler.LaunchScriptAsync(scriptPath, [], "DotSource"); await AssertDebuggerStopped(scriptPath, 1); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.CommandVariablesName); @@ -574,9 +574,9 @@ await debugService.SetLineBreakpointsAsync( [Fact] public async Task RecordsF5CommandInPowerShellHistory() { - ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); - await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); + LaunchAndAttachHandler launchAndAttachHandler = new( + NullLoggerFactory.Instance, null, null, null, debugService, psesHost, psesHost, workspace, null, null); + await launchAndAttachHandler.LaunchScriptAsync(debugScriptFile.FilePath, [], "DotSource"); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( new PSCommand().AddScript("(Get-History).CommandLine"), @@ -614,9 +614,9 @@ public async Task RecordsF8CommandInHistory() [Fact] public async Task OddFilePathsLaunchCorrectly() { - ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); - await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); + LaunchAndAttachHandler launchAndAttachHandler = new( + NullLoggerFactory.Instance, null, null, null, debugService, psesHost, psesHost, workspace, null, null); + await launchAndAttachHandler.LaunchScriptAsync(oddPathScriptFile.FilePath, [], "DotSource"); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( new PSCommand().AddScript("(Get-History).CommandLine"),