Skip to content

Support breakpoints in untitled files in WinPS #2248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,74 @@ namespace Microsoft.PowerShell.EditorServices.Services
{
internal class BreakpointService
{
/// <summary>
/// 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.
/// </summary>
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(
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The embedded PowerShell script uses reflection to access internal constructors which makes the code brittle to PowerShell internal API changes. Consider adding error handling for cases where these constructors might not be available in future versions.

Copilot uses AI. Check for mistakes.

[System.Reflection.BindingFlags]'NonPublic, Public, Instance',
$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')
$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)

if (-not $lineCtor) {
Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters
return
}

$b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action))
}

[Runspace]::DefaultRunspace.Debugger.SetBreakpoints(
[System.Management.Automation.Breakpoint[]]@($b))

$b
";

private readonly ILogger<BreakpointService> _logger;
private readonly IInternalPowerShellExecutionService _executionService;
private readonly PsesInternalHost _editorServicesHost;
Expand Down Expand Up @@ -114,8 +182,10 @@ public async Task<IReadOnlyList<BreakpointDetails>> 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)
Copy link
Preview

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using reflection to access non-public constructors and executing dynamic PowerShell scripts could introduce security risks. Consider adding input validation or restricting the scope where this custom script can be executed.

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

@jborean93 jborean93 Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case the code is only run on WinPS 5.1 which is mostly set in stone. PowerShell 7+ which is a rolling target uses a newer public API not available in the older version.

If for whatever reason WinPS is updated and the reflection code is unable to find the ctors, the latest commit has it falling back to Set-PSBreakpoint which is the original code anyway.

.AddParameter("Script", escapedScriptPath)
.AddParameter("Line", breakpoint.LineNumber);

Expand Down Expand Up @@ -184,7 +254,7 @@ public async Task<IReadOnlyList<CommandBreakpointDetails>> SetCommandBreakpoints
}

psCommand
.AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint")
.AddScript(_setPSBreakpointLegacy, useLocalScope: true)
.AddParameter("Command", breakpoint.Name);

// Check if this is a "conditional" line breakpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<BreakpointHandlers>();
_debugService = debugService;
_debugStateService = debugStateService;
_workspaceService = workspaceService;
_runspaceContext = runspaceContext;
}

public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -182,12 +178,11 @@ public Task<SetExceptionBreakpointsResponse> 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`).
Expand All @@ -56,8 +53,7 @@ public ConfigurationDoneHandler(
DebugEventHandlerService debugEventHandlerService,
IInternalPowerShellExecutionService executionService,
WorkspaceService workspaceService,
IPowerShellDebugContext debugContext,
IRunspaceContext runspaceContext)
IPowerShellDebugContext debugContext)
{
_logger = loggerFactory.CreateLogger<ConfigurationDoneHandler>();
_debugAdapterServer = debugAdapterServer;
Expand All @@ -67,7 +63,6 @@ public ConfigurationDoneHandler(
_executionService = executionService;
_workspaceService = workspaceService;
_debugContext = debugContext;
_runspaceContext = runspaceContext;
}

public Task<ConfigurationDoneResponse> Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -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(),
Expand Down
26 changes: 18 additions & 8 deletions test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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);
Expand All @@ -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<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
Expand Down Expand Up @@ -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<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
Expand Down