From 7f62aac4b120637e5f548de1536be4e25ca4305e Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 24 Jul 2025 14:36:28 +1000 Subject: [PATCH 1/4] Support breakpoints in untitled files in WinPS Adds support for setting breakpoints in untitled/unsaved files for Windows PowerShell 5.1. This aligns the breakpoint validation behaviour with the PowerShell 7.x API so that a breakpoint can be set for any ScriptBlock with a filename if it aligns with the client's filename. --- .../DebugAdapter/BreakpointService.cs | 57 ++++++++++++++++++- .../Handlers/BreakpointHandlers.cs | 11 +--- .../Handlers/ConfigurationDoneHandler.cs | 13 +---- .../Debugging/DebugServiceTests.cs | 26 ++++++--- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 748e21c6d..15a7aece4 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -18,6 +18,57 @@ namespace Microsoft.PowerShell.EditorServices.Services { internal class BreakpointService { + private const string _setPSBreakpointLegacy = @" + [CmdletBinding(DefaultParameterSetName = 'Line')] + param ( + [Parameter()] + [ScriptBlock] + $Action, + + [Parameter(ParameterSetName = 'Command')] + [Parameter(ParameterSetName = 'Line', Mandatory = $true)] + [string] + $Script, + + [Parameter(ParameterSetName = 'Line')] + [int] + $Line, + + [Parameter(ParameterSetName = 'Line')] + [int] + $Column, + + [Parameter(ParameterSetName = 'Command', Mandatory = $true)] + [string] + $Command + ) + + if ($PSCmdlet.ParameterSetName -eq 'Command') { + $cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([string], [System.Management.Automation.WildcardPattern], [string], [ScriptBlock]), + $null) + $pattern = [System.Management.Automation.WildcardPattern]::Get( + $Command, + [System.Management.Automation.WildcardOptions]'Compiled, IgnoreCase') + $b = $cmdCtor.Invoke(@($Script, $pattern, $Command, $Action)) + } + else { + $lineCtor = [System.Management.Automation.LineBreakpoint].GetConstructor( + [System.Reflection.BindingFlags]'NonPublic, Public, Instance', + $null, + [type[]]@([string], [int], [int], [ScriptBlock]), + $null) + $b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action)) + } + + [Runspace]::DefaultRunspace.Debugger.SetBreakpoints( + [System.Management.Automation.Breakpoint[]]@($b)) + + $b + "; + private readonly ILogger _logger; private readonly IInternalPowerShellExecutionService _executionService; private readonly PsesInternalHost _editorServicesHost; @@ -114,8 +165,10 @@ public async Task> SetBreakpointsAsync(string e psCommand.AddStatement(); } + // Don't use Set-PSBreakpoint as that will try and validate the Script + // path which may or may not exist. psCommand - .AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint") + .AddScript(_setPSBreakpointLegacy, useLocalScope: true) .AddParameter("Script", escapedScriptPath) .AddParameter("Line", breakpoint.LineNumber); @@ -184,7 +237,7 @@ public async Task> SetCommandBreakpoints } psCommand - .AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint") + .AddScript(_setPSBreakpointLegacy, useLocalScope: true) .AddParameter("Command", breakpoint.Name); // Check if this is a "conditional" line breakpoint. diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 4c99ff747..68d23c966 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -11,7 +11,6 @@ using Microsoft.PowerShell.EditorServices.Logging; using Microsoft.PowerShell.EditorServices.Services; using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; -using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.DebugAdapter.Protocol.Models; @@ -31,20 +30,17 @@ internal class BreakpointHandlers : ISetFunctionBreakpointsHandler, ISetBreakpoi private readonly DebugService _debugService; private readonly DebugStateService _debugStateService; private readonly WorkspaceService _workspaceService; - private readonly IRunspaceContext _runspaceContext; public BreakpointHandlers( ILoggerFactory loggerFactory, DebugService debugService, DebugStateService debugStateService, - WorkspaceService workspaceService, - IRunspaceContext runspaceContext) + WorkspaceService workspaceService) { _logger = loggerFactory.CreateLogger(); _debugService = debugService; _debugStateService = debugStateService; _workspaceService = workspaceService; - _runspaceContext = runspaceContext; } public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) @@ -182,12 +178,11 @@ public Task Handle(SetExceptionBreakpointsArgum Task.FromResult(new SetExceptionBreakpointsResponse()); - private bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile) + private static bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile) { - // PowerShell 7 and above support breakpoints in untitled files if (ScriptFile.IsUntitledPath(requestedPath)) { - return BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace); + return true; } if (string.IsNullOrEmpty(resolvedScriptFile?.FilePath)) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 038f61955..146bbeae0 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -7,11 +7,9 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Services; -using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; 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.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.DebugAdapter.Protocol.Events; @@ -44,7 +42,6 @@ internal class ConfigurationDoneHandler : IConfigurationDoneHandler private readonly IInternalPowerShellExecutionService _executionService; private readonly WorkspaceService _workspaceService; private readonly IPowerShellDebugContext _debugContext; - private readonly IRunspaceContext _runspaceContext; // TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified // (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`). @@ -56,8 +53,7 @@ public ConfigurationDoneHandler( DebugEventHandlerService debugEventHandlerService, IInternalPowerShellExecutionService executionService, WorkspaceService workspaceService, - IPowerShellDebugContext debugContext, - IRunspaceContext runspaceContext) + IPowerShellDebugContext debugContext) { _logger = loggerFactory.CreateLogger(); _debugAdapterServer = debugAdapterServer; @@ -67,7 +63,6 @@ public ConfigurationDoneHandler( _executionService = executionService; _workspaceService = workspaceService; _debugContext = debugContext; - _runspaceContext = runspaceContext; } public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) @@ -119,13 +114,11 @@ internal async Task LaunchScriptAsync(string scriptToLaunch) else // It's a URI to an untitled script, or a raw script. { bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript); - if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)) + 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. This is - // only possible with PowerShell 7's new breakpoint APIs since the old API, - // Set-PSBreakpoint, validates that the given path points to a real file. + // light up the ability to debug untitled files with line breakpoints. ScriptBlockAst ast = Parser.ParseInput( untitledScript.Contents, untitledScript.DocumentUri.ToString(), diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index d9656c855..03690ec21 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -527,10 +527,11 @@ await debugService.SetCommandBreakpointsAsync( Assert.Equal("True > ", prompt.ValueString); } - [SkippableFact] - public async Task DebuggerBreaksInUntitledScript() + [Theory] + [InlineData("Command")] + [InlineData("Line")] + public async Task DebuggerBreaksInUntitledScript(string breakpointType) { - Skip.IfNot(VersionUtils.PSEdition == "Core", "Untitled script breakpoints only supported in PowerShell Core"); const string contents = "Write-Output $($MyInvocation.Line)"; const string scriptPath = "untitled:Untitled-1"; Assert.True(ScriptFile.IsUntitledPath(scriptPath)); @@ -539,11 +540,20 @@ public async Task DebuggerBreaksInUntitledScript() Assert.Equal(contents, scriptFile.Contents); Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _)); - await debugService.SetCommandBreakpointsAsync( - new[] { CommandBreakpointDetails.Create("Write-Output") }); + if (breakpointType == "Command") + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Output") }); + } + else + { + await debugService.SetLineBreakpointsAsync( + scriptFile, + new[] { BreakpointDetails.Create(scriptPath, 1) }); + } ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); await AssertDebuggerStopped(scriptPath, 1); @@ -565,7 +575,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task RecordsF5CommandInPowerShellHistory() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -605,7 +615,7 @@ public async Task RecordsF8CommandInHistory() public async Task OddFilePathsLaunchCorrectly() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( From 6691f08f3f406e3fd18273a4d1e6e2977678ed4f Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 25 Jul 2025 04:40:03 +1000 Subject: [PATCH 2/4] Add fallback to Set-PSBreakpoint in case reflection can't find ctor --- .../Services/DebugAdapter/BreakpointService.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 15a7aece4..80798c352 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -18,6 +18,11 @@ namespace Microsoft.PowerShell.EditorServices.Services { internal class BreakpointService { + /// + /// 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 + /// case something changes it has a fallback to Set-PSBreakpoint. + /// private const string _setPSBreakpointLegacy = @" [CmdletBinding(DefaultParameterSetName = 'Line')] param ( @@ -49,6 +54,12 @@ internal class BreakpointService $null, [type[]]@([string], [System.Management.Automation.WildcardPattern], [string], [ScriptBlock]), $null) + + if (-not $cmdCtor) { + Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters + return + } + $pattern = [System.Management.Automation.WildcardPattern]::Get( $Command, [System.Management.Automation.WildcardOptions]'Compiled, IgnoreCase') @@ -60,6 +71,12 @@ internal class BreakpointService $null, [type[]]@([string], [int], [int], [ScriptBlock]), $null) + + if (-not $lineCtor) { + Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters + return + } + $b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action)) } From 8d17b3b9a174a2cf57d32db9523bfa292e3e4dc1 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Wed, 30 Jul 2025 14:30:06 +1000 Subject: [PATCH 3/4] Fix up wildcard handling --- .../Services/DebugAdapter/BreakpointService.cs | 9 +++++++-- .../Services/DebugAdapter/DebugService.cs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index 80798c352..ecd8f9bc0 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -48,6 +48,11 @@ internal class BreakpointService $Command ) + if ($Script) { + # If using Set-PSBreakpoint we need to escape any wildcard patterns. + $PSBoundParameters['Script'] = [WildcardPattern]::Escape($Script) + } + if ($PSCmdlet.ParameterSetName -eq 'Command') { $cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor( [System.Reflection.BindingFlags]'NonPublic, Public, Instance', @@ -125,7 +130,7 @@ public async Task> GetBreakpointsAsync() .ConfigureAwait(false); } - public async Task> SetBreakpointsAsync(string escapedScriptPath, IReadOnlyList breakpoints) + public async Task> SetBreakpointsAsync(IReadOnlyList breakpoints) { if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace)) { @@ -186,7 +191,7 @@ public async Task> SetBreakpointsAsync(string e // path which may or may not exist. psCommand .AddScript(_setPSBreakpointLegacy, useLocalScope: true) - .AddParameter("Script", escapedScriptPath) + .AddParameter("Script", breakpoint.Source) .AddParameter("Line", breakpoint.LineNumber); // Check if the user has specified the column number for the breakpoint. diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 195d0508b..c0029fd42 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -164,7 +164,7 @@ public async Task> SetLineBreakpointsAsync( await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false); } - return await _breakpointService.SetBreakpointsAsync(escapedScriptPath, breakpoints).ConfigureAwait(false); + return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false); } return await dscBreakpoints From b3ea78cc357994be5e42c87e99d09d42ab6b0f62 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 1 Aug 2025 12:25:12 +1000 Subject: [PATCH 4/4] Fix up command breakpoints for WinPS --- .../Services/DebugAdapter/BreakpointService.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs index ecd8f9bc0..007b75d49 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -52,6 +52,10 @@ internal class BreakpointService # If using Set-PSBreakpoint we need to escape any wildcard patterns. $PSBoundParameters['Script'] = [WildcardPattern]::Escape($Script) } + else { + # WinPS must use null for the Script if unset. + $Script = [NullString]::Value + } if ($PSCmdlet.ParameterSetName -eq 'Command') { $cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor(