diff --git a/module/PowerShellEditorServices/Commands/PowerShellEditorServices.Commands.psd1 b/module/PowerShellEditorServices/Commands/PowerShellEditorServices.Commands.psd1 index 69347beab..2ef9b51d0 100644 --- a/module/PowerShellEditorServices/Commands/PowerShellEditorServices.Commands.psd1 +++ b/module/PowerShellEditorServices/Commands/PowerShellEditorServices.Commands.psd1 @@ -79,7 +79,8 @@ FunctionsToExport = @('Register-EditorCommand', 'Test-ScriptExtent', 'Open-EditorFile', 'New-EditorFile', - 'Clear-Host') + 'Clear-Host', + 'Start-DebugAttachSession') # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() diff --git a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 new file mode 100644 index 000000000..a3df340d2 --- /dev/null +++ b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +using namespace System.Collections +using namespace System.Management.Automation +using namespace System.Reflection +using namespace System.Threading +using namespace System.Threading.Tasks + +function Start-DebugAttachSession { + <# + .EXTERNALHELP ..\PowerShellEditorServices.Commands-help.xml + #> + [OutputType([System.Management.Automation.Job2])] + [CmdletBinding(DefaultParameterSetName = 'ProcessId')] + param( + [Parameter()] + [string] + $Name, + + [Parameter(ParameterSetName = 'ProcessId')] + [int] + $ProcessId, + + [Parameter(ParameterSetName = 'CustomPipeName')] + [string] + $CustomPipeName, + + [Parameter()] + [string] + $RunspaceName, + + [Parameter()] + [int] + $RunspaceId, + + [Parameter()] + [string] + $ComputerName, + + [Parameter()] + [switch] + $AsJob + ) + + $ErrorActionPreference = 'Stop' + + try { + if ($PSBoundParameters.ContainsKey('RunspaceId') -and $RunspaceName) { + $err = [ErrorRecord]::new( + [ArgumentException]::new("Cannot specify both RunspaceId and RunspaceName parameters"), + "InvalidRunspaceParameters", + [ErrorCategory]::InvalidArgument, + $null) + $err.ErrorDetails = [ErrorDetails]::new("") + $err.ErrorDetails.RecommendedAction = 'Specify only one of RunspaceId or RunspaceName.' + $PSCmdlet.WriteError($err) + return + } + + # Var will be set by PSES in configurationDone before launching script + $debugServer = Get-Variable -Name __psEditorServices_DebugServer -ValueOnly -ErrorAction Ignore + if (-not $debugServer) { + $err = [ErrorRecord]::new( + [Exception]::new("Cannot start a new attach debug session unless running in an existing launch debug session not in a temporary console"), + "NoDebugSession", + [ErrorCategory]::InvalidOperation, + $null) + $err.ErrorDetails = [ErrorDetails]::new("") + $err.ErrorDetails.RecommendedAction = 'Launch script with debugging to ensure the debug session is available.' + $PSCmdlet.WriteError($err) + return + } + + if ($AsJob -and -not (Get-Command -Name Start-ThreadJob -ErrorAction Ignore)) { + $err = [ErrorRecord]::new( + [Exception]::new("Cannot use the -AsJob parameter unless running on PowerShell 7+ or the ThreadJob module is present"), + "NoThreadJob", + [ErrorCategory]::InvalidArgument, + $null) + $err.ErrorDetails = [ErrorDetails]::new("") + $err.ErrorDetails.RecommendedAction = 'Install the ThreadJob module or run on PowerShell 7+.' + $PSCmdlet.WriteError($err) + return + } + + $configuration = @{ + type = 'PowerShell' + request = 'attach' + # A temp console is also needed as the current one is busy running + # this code. Failing to set this will cause a deadlock. + createTemporaryIntegratedConsole = $true + } + + if ($ProcessId) { + if ($ProcessId -eq $PID) { + $err = [ErrorRecord]::new( + [ArgumentException]::new("PSES does not support attaching to the current editor process"), + "AttachToCurrentProcess", + [ErrorCategory]::InvalidArgument, + $PID) + $err.ErrorDetails = [ErrorDetails]::new("") + $err.ErrorDetails.RecommendedAction = 'Specify a different process id.' + $PSCmdlet.WriteError($err) + return + } + + $configuration.name = "Attach Process $ProcessId" + $configuration.processId = $ProcessId + } + elseif ($CustomPipeName) { + $configuration.name = "Attach Pipe $CustomPipeName" + $configuration.customPipeName = $CustomPipeName + } + else { + $configuration.name = 'Attach Session' + } + + if ($ComputerName) { + $configuration.computerName = $ComputerName + } + + if ($PSBoundParameters.ContainsKey('RunspaceId')) { + $configuration.runspaceId = $RunspaceId + } + elseif ($RunspaceName) { + $configuration.runspaceName = $RunspaceName + } + + # https://microsoft.github.io/debug-adapter-protocol/specification#Reverse_Requests_StartDebugging + $resp = $debugServer.SendRequest( + 'startDebugging', + @{ + configuration = $configuration + request = 'attach' + } + ) + + # PipelineStopToken added in pwsh 7.6 + $cancelToken = if ($PSCmdlet.PipelineStopToken) { + $PSCmdlet.PipelineStopToken + } + else { + [CancellationToken]::new($false) + } + + # There is no response for a startDebugging request + $task = $resp.ReturningVoid($cancelToken) + + $waitTask = { + [CmdletBinding()] + param ([Parameter(Mandatory)][Task]$Task) + + while (-not $Task.AsyncWaitHandle.WaitOne(300)) {} + $null = $Task.GetAwaiter().GetResult() + } + + if ($AsJob) { + # Using the Ast to build the scriptblock allows the job to inherit + # the using namespace entries and include the proper line/script + # paths in any error traces that are emitted. + Start-ThreadJob -ScriptBlock { + & ($args[0]).Ast.GetScriptBlock() $args[1] + } -ArgumentList $waitTask, $task + } + else { + & $waitTask $task + } + } + catch { + $PSCmdlet.WriteError($_) + return + } +} \ No newline at end of file diff --git a/module/docs/PowerShellEditorServices.Commands.md b/module/docs/PowerShellEditorServices.Commands.md index ca417c173..017432f8c 100644 --- a/module/docs/PowerShellEditorServices.Commands.md +++ b/module/docs/PowerShellEditorServices.Commands.md @@ -46,6 +46,10 @@ The Set-ScriptExtent function can insert or replace text at a specified position You can use the Find-Ast function to easily find the desired extent. +### [Start-DebugAttachSession](Start-DebugAttachSession.md) + +The Start-DebugAttachSession function can start a new debug session that is attached to the specified PowerShell instance. + ### [Test-ScriptExtent](Test-ScriptExtent.md) The Test-ScriptExtent function can be used to determine if a ScriptExtent object is before, after, or inside another ScriptExtent object. You can also test for any combination of these with separate ScriptExtent objects to test against. diff --git a/module/docs/Start-DebugAttachSession.md b/module/docs/Start-DebugAttachSession.md new file mode 100644 index 000000000..1fa7bf785 --- /dev/null +++ b/module/docs/Start-DebugAttachSession.md @@ -0,0 +1,216 @@ +--- +external help file: PowerShellEditorServices.Commands-help.xml +Module Name: PowerShellEditorServices.Commands +online version: https://github.com/PowerShell/PowerShellEditorServices/tree/main/module/docs/Start-DebugAttachSession.md +schema: 2.0.0 +--- + +# Start-DebugAttachSession + +## SYNOPSIS + +Starts a new debug session attached to the specified PowerShell instance. + +## SYNTAX + +### ProcessId (Default) +``` +Start-DebugAttachSession [-Name ] [-ProcessId ] [-RunspaceName ] [-RunspaceId ] + [-ComputerName ] [-AsJob] [] +``` + +### CustomPipeName +``` +Start-DebugAttachSession [-Name ] [-CustomPipeName ] [-RunspaceName ] + [-RunspaceId ] [-ComputerName ] [-AsJob] [] +``` + +## DESCRIPTION + +The Start-DebugAttachSession function can be used to start a new debug session that is attached to the specified PowerShell instance. The caller must be running in an existing launched debug session, the launched session is not running in a temporary console, and the launched session is not entered into a remote PSSession. If the callers script ends before the new debug session is completed, the debug session for the child will also end. + +The function will return once the attach response was received by the debug server. For an example, an attach request will return once PowerShell has attached to the process and has called `Debug-Runspace`. If you need to return early use the `-AsJob` parameter to return a `Job` object immediately that can be used to wait for the response at a later time. + +If `-ProcessId` or `-CustomPipeName` is not specified, the debug client will prompt for process to connect to. If `-RunspaceId` or `-RunspaceName` is not specified, the debug client will prompt for which runspace to connect to. + +## EXAMPLES + +### -------------------------- EXAMPLE 1 -------------------------- + +```powershell +$pipeName = "TestPipe-$(New-Guid)" +$procParams = @{ + FilePath = 'pwsh' + ArgumentList = ('-CustomPipeName {0} -File other-script.ps1' -f $pipeName) + PassThru = $true +} +$proc = Start-Process @procParams + +Start-DebugAttachSession -CustomPipeName $pipeName -RunspaceId 1 +$proc | Wait-Process + + +<# The contents of `other-script.ps1` is #> +# Waits until PowerShell has attached +$runspaces = Get-Runspace +while ($true) { + if (Get-Runspace | Where-Object { $_.Id -notin $runspaces.Id }) { + break + } + Start-Sleep -Seconds 1 +} + +# WinPS will only have breakpoints synced once the debugger has been hit. +if ($PSVersionTable.PSVersion -lt '6.0') { + Wait-Debugger +} + +# Place breakpoint below or use Wait-Debugger +# to have the attach debug session break. +$a = 'abc' +$b = '' +Write-Host "Test $a - $PID" +``` + +Launches a new PowerShell process with a custom pipe and starts a new attach configuration that will debug the new process under a child debugging session. The caller waits until the new process ends before ending the parent session. + +## PARAMETERS + +### -AsJob + +Instead of waiting for the start debugging response before returning, the `-AsJob` parameter will output a job immediately after sending the request that waits for the job. This is useful if further work is needed for a debug session to successfully attach and start debugging the target runspace. + +This is only supported when the calling script is running on PowerShell 7+ or the `ThreadJob` module is present. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ComputerName + +The computer name to which a remote session will be established before attaching to the target runspace. If specified, the temporary console will run `Enter-PSSession -ComputerName ...` to connect to a host over WSMan before attaching to the requested PowerShell instance. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -CustomPipeName + +The custom pipe name of the PowerShell host process to attach to. This option is mutually exclusive with `-ProcessId`. + +```yaml +Type: String +Parameter Sets: CustomPipeName +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Name + +The name of the debug session to show in the debug client. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProcessId + +The ID of the PowerShell host process that should be attached. This option is mutually exclusive with `-CustomPipeName`. + +```yaml +Type: Int32 +Parameter Sets: ProcessId +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -RunspaceId + +The ID of the runspace to debug in the attached process. This option is mutually exclusive with `-RunspaceName`. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -RunspaceName + +The name of the runspace to debug in the attached process. This option is mutually exclusive with `-RunspaceId`. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +You can't pipe objects to this function. + +## OUTPUTS + +### None + +By default, this function returns no output. + +### System.Management.Automation.Job2 + +When you use the `-AsJob` parameter, this function returns the `Job` object that is waiting for the response. + +## NOTES + +The function will fail if the caller is not running under a debug session or was started through an attach request. + +## RELATED LINKS diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index 195d0508b..cc38c7c70 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -29,6 +29,7 @@ internal class DebugService #region Fields internal const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; + internal const string PsesGlobalVariableDebugServerName = $"{PsesGlobalVariableNamePrefix}DebugServer"; private const string TemporaryScriptFileName = "Script Listing.ps1"; private readonly ILogger _logger; diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs index fef2b107c..798ccc621 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -56,7 +56,16 @@ public async Task Handle(DisconnectArguments request, Cancel _debugStateService.ExecutionCompleted = true; _debugService.Abort(); - if (_debugStateService.IsInteractiveDebugSession && _debugStateService.IsAttachSession) + if (!_debugStateService.IsAttachSession && !_debugStateService.IsUsingTempIntegratedConsole) + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Remove-Variable") + .AddParameter("Name", DebugService.PsesGlobalVariableDebugServerName) + .AddParameter("Force", true), + cancellationToken).ConfigureAwait(false); + } + + if (_debugStateService.IsInteractiveDebugSession && _debugStateService.IsRemoteAttach) { // Pop the sessions if (_runspaceContext.CurrentRunspace.RunspaceOrigin == RunspaceOrigin.EnteredProcess) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index 67b2c1021..4201cc5e6 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -178,6 +178,23 @@ public async Task Handle(PsesLaunchRequestArguments request, Can } _logger.LogTrace("Working dir " + (string.IsNullOrEmpty(workingDir) ? "not set." : $"set to '{workingDir}'")); + + if (!request.CreateTemporaryIntegratedConsole) + { + // Start-DebugAttachSession attaches in a new temp console + // so we cannot set this var if already running in that + // console. + PSCommand setVariableCmd = new PSCommand().AddCommand("Set-Variable") + .AddParameter("Name", DebugService.PsesGlobalVariableDebugServerName) + .AddParameter("Value", _debugAdapterServer) + .AddParameter("Description", "DO NOT USE: for internal use only.") + .AddParameter("Scope", "Global") + .AddParameter("Option", "ReadOnly"); + + await _executionService.ExecutePSCommandAsync( + setVariableCmd, + cancellationToken).ConfigureAwait(false); + } } // Prepare arguments to the script - if specified diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index 881e55884..b51f3d8dd 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -6,7 +6,9 @@ using System.Linq; using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Handlers; using Nerdbank.Streams; using OmniSharp.Extensions.DebugAdapter.Client; using OmniSharp.Extensions.DebugAdapter.Protocol.Client; @@ -53,6 +55,16 @@ public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsync /// private Task nextStopped => nextStoppedTcs.Task; + /// + /// This task is useful for waiting until a StartDebuggingAttachRequest is received. + /// + private readonly TaskCompletionSource startDebuggingAttachRequestTcs = new(); + + /// + /// This task is useful for waiting until the debug session has terminated. + /// + private readonly TaskCompletionSource terminatedTcs = new(); + public async Task InitializeAsync() { // Cleanup testScriptLogPath if it exists due to an interrupted previous run @@ -100,6 +112,16 @@ send until a launch is sent. nextStoppedTcs.SetResult(e); nextStoppedTcs = new(); }) + .OnRequest("startDebugging", (StartDebuggingAttachRequestArguments request) => + { + startDebuggingAttachRequestTcs.SetResult(request); + return Task.CompletedTask; + }) + .OnTerminated((TerminatedEvent e) => + { + terminatedTcs.SetResult(e); + return Task.CompletedTask; + }) ; }); @@ -513,5 +535,86 @@ public async Task CanRunPesterTestFile() await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.Equal("pester", await ReadScriptLogLineAsync()); } + +#nullable enable + [InlineData("", null, null, 0, 0, null)] + [InlineData("-ProcessId 1234 -RunspaceId 5678", null, null, 1234, 5678, null)] + [InlineData("-ProcessId 1234 -RunspaceId 5678 -ComputerName comp", "comp", null, 1234, 5678, null)] + [InlineData("-CustomPipeName testpipe -RunspaceName rs-name", null, "testpipe", 0, 0, "rs-name")] + [SkippableTheory] + public async Task CanLaunchScriptWithNewChildAttachSession( + string paramString, + string? expectedComputerName, + string? expectedPipeName, + int expectedProcessId, + int expectedRunspaceId, + string? expectedRunspaceName) + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "PowerShellEditorServices.Command is not signed to run FLM in Constrained Language Mode."); + + string script = NewTestFile($"Start-DebugAttachSession {paramString}"); + + using CancellationTokenSource timeoutCts = new(30000); + using CancellationTokenRegistration _ = timeoutCts.Token.Register(() => + { + startDebuggingAttachRequestTcs.TrySetCanceled(); + }); + using CancellationTokenRegistration _2 = timeoutCts.Token.Register(() => + { + terminatedTcs.TrySetCanceled(); + }); + + await client.LaunchScript(script); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; + Assert.Equal("attach", attachRequest.Request); + Assert.Equal(expectedComputerName, attachRequest.Configuration.ComputerName); + Assert.Equal(expectedPipeName, attachRequest.Configuration.CustomPipeName); + Assert.Equal(expectedProcessId, attachRequest.Configuration.ProcessId); + Assert.Equal(expectedRunspaceId, attachRequest.Configuration.RunspaceId); + Assert.Equal(expectedRunspaceName, attachRequest.Configuration.RunspaceName); + + await terminatedTcs.Task; + } + + [SkippableFact] + public async Task CanLaunchScriptWithNewChildAttachSessionAsJob() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "PowerShellEditorServices.Command is not signed to run FLM in Constrained Language Mode."); + Skip.If(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, + "WinPS does not have ThreadJob, needed by -AsJob, present by default."); + + string script = NewTestFile("Start-DebugAttachSession -AsJob | Receive-Job -Wait -AutoRemoveJob"); + + using CancellationTokenSource timeoutCts = new(30000); + using CancellationTokenRegistration _1 = timeoutCts.Token.Register(() => + { + startDebuggingAttachRequestTcs.TrySetCanceled(); + }); + using CancellationTokenRegistration _2 = timeoutCts.Token.Register(() => + { + terminatedTcs.TrySetCanceled(); + }); + + await client.LaunchScript(script); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequestTcs.Task; + Assert.Equal("attach", attachRequest.Request); + Assert.Null(attachRequest.Configuration.ComputerName); + Assert.Null(attachRequest.Configuration.CustomPipeName); + Assert.Equal(0, attachRequest.Configuration.ProcessId); + Assert.Equal(0, attachRequest.Configuration.RunspaceId); + Assert.Null(attachRequest.Configuration.RunspaceName); + + await terminatedTcs.Task; + } + + private record StartDebuggingAttachRequestArguments(PsesAttachRequestArguments Configuration, string Request); + +#nullable disable } }