From 9cf84d2dc9b057840e57ee646ee5d1f58167f6eb Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 25 Jul 2025 15:17:43 +1000 Subject: [PATCH 1/7] Add Start-DebugAttachSession function Adds the `Start-DebugAttachSession` cmdlet which can be used to launch a new attach session debug request inside an existing launched debug session. This allows a script being debugged to launch a child debugging session using more dynamic environment information without requiring end users to manually setup the launch.json configuration. --- .../PowerShellEditorServices.Commands.psd1 | 3 +- .../Public/Start-DebugAttachSession.ps1 | 174 ++++++++++++++ .../docs/PowerShellEditorServices.Commands.md | 4 + module/docs/Start-DebugAttachSession.md | 215 ++++++++++++++++++ .../Services/DebugAdapter/DebugService.cs | 1 + .../Handlers/DisconnectHandler.cs | 10 +- .../Handlers/LaunchAndAttachHandler.cs | 11 + 7 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 create mode 100644 module/docs/Start-DebugAttachSession.md 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..c99f6eb34 --- /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(Mandatory)] + [int] + $RunspaceId, + + [Parameter()] + [string] + $ComputerName, + + [Parameter()] + [switch] + $AsJob + ) + + $ErrorActionPreference = 'Stop' + + try { + if ($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 debug session"), + "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 ($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..9b68a7f9f --- /dev/null +++ b/module/docs/Start-DebugAttachSession.md @@ -0,0 +1,215 @@ +--- +external help file: PowerShellEditorServices.Commands-help.xml +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 and the newly attached session will be treated as a child debug session in a new temporary console. 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 reponse 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 with 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: True +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..789c8237f 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -56,7 +56,15 @@ public async Task Handle(DisconnectArguments request, Cancel _debugStateService.ExecutionCompleted = true; _debugService.Abort(); - if (_debugStateService.IsInteractiveDebugSession && _debugStateService.IsAttachSession) + if (!_debugStateService.IsAttachSession) + { + await _executionService.ExecutePSCommandAsync( + new PSCommand().AddCommand("Remove-Variable") + .AddParameter("Name", DebugService.PsesGlobalVariableDebugServerName) + .AddParameter("Force", true), + cancellationToken).ConfigureAwait(false); + } + else if (_debugStateService.IsInteractiveDebugSession) { // 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..395576c18 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -178,6 +178,17 @@ public async Task Handle(PsesLaunchRequestArguments request, Can } _logger.LogTrace("Working dir " + (string.IsNullOrEmpty(workingDir) ? "not set." : $"set to '{workingDir}'")); + + 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 From cd67c0e8bf391bafb258e6966d5ab8bffc57e121 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Sat, 26 Jul 2025 05:48:17 +1000 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Commands/Public/Start-DebugAttachSession.ps1 | 2 +- module/docs/Start-DebugAttachSession.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 index c99f6eb34..ba70862b9 100644 --- a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 @@ -120,7 +120,7 @@ function Start-DebugAttachSession { $configuration.computerName = $ComputerName } - if ($RunspaceId) { + if ($PSBoundParameters.ContainsKey('RunspaceId')) { $configuration.runspaceId = $RunspaceId } elseif ($RunspaceName) { diff --git a/module/docs/Start-DebugAttachSession.md b/module/docs/Start-DebugAttachSession.md index 9b68a7f9f..91522bb6a 100644 --- a/module/docs/Start-DebugAttachSession.md +++ b/module/docs/Start-DebugAttachSession.md @@ -8,7 +8,7 @@ schema: 2.0.0 ## SYNOPSIS -Starts a new debug session attached to the specified PowerShell instance.. +Starts a new debug session attached to the specified PowerShell instance. ## SYNTAX @@ -28,7 +28,7 @@ Start-DebugAttachSession [-Name ] [-CustomPipeName ] [-RunspaceN 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 and the newly attached session will be treated as a child debug session in a new temporary console. 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 reponse 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. +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. @@ -95,7 +95,7 @@ Accept wildcard characters: False ### -ComputerName -The computer name to which a remote session with 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. +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 From ac8be74086ef78aee45452a04471d3aabd66b411 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Sat, 26 Jul 2025 06:02:58 +1000 Subject: [PATCH 3/7] Remote mandatory param and update logic to exclude temp consoles --- .../Public/Start-DebugAttachSession.ps1 | 6 ++--- module/docs/Start-DebugAttachSession.md | 9 ++++--- .../Handlers/DisconnectHandler.cs | 5 ++-- .../Handlers/LaunchAndAttachHandler.cs | 26 ++++++++++++------- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 index ba70862b9..79948e33f 100644 --- a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 @@ -30,7 +30,7 @@ function Start-DebugAttachSession { [string] $RunspaceName, - [Parameter(Mandatory)] + [Parameter()] [int] $RunspaceId, @@ -46,7 +46,7 @@ function Start-DebugAttachSession { $ErrorActionPreference = 'Stop' try { - if ($RunspaceId -and $RunspaceName) { + if ($PSBoundParameters.ContainsKey('RunspaceId') -and $RunspaceName) { $err = [ErrorRecord]::new( [ArgumentException]::new("Cannot specify both RunspaceId and RunspaceName parameters"), "InvalidRunspaceParameters", @@ -62,7 +62,7 @@ function Start-DebugAttachSession { $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 debug session"), + [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) diff --git a/module/docs/Start-DebugAttachSession.md b/module/docs/Start-DebugAttachSession.md index 91522bb6a..1fa7bf785 100644 --- a/module/docs/Start-DebugAttachSession.md +++ b/module/docs/Start-DebugAttachSession.md @@ -1,5 +1,6 @@ --- 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 --- @@ -14,19 +15,19 @@ Starts a new debug session attached to the specified PowerShell instance. ### ProcessId (Default) ``` -Start-DebugAttachSession [-Name ] [-ProcessId ] [-RunspaceName ] -RunspaceId +Start-DebugAttachSession [-Name ] [-ProcessId ] [-RunspaceName ] [-RunspaceId ] [-ComputerName ] [-AsJob] [] ``` ### CustomPipeName ``` Start-DebugAttachSession [-Name ] [-CustomPipeName ] [-RunspaceName ] - -RunspaceId [-ComputerName ] [-AsJob] [] + [-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 and the newly attached session will be treated as a child debug session in a new temporary console. If the callers script ends before the new debug session is completed, the debug session for the child will also end. +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. @@ -166,7 +167,7 @@ Type: Int32 Parameter Sets: (All) Aliases: -Required: True +Required: False Position: Named Default value: None Accept pipeline input: False diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs index 789c8237f..798ccc621 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -56,7 +56,7 @@ public async Task Handle(DisconnectArguments request, Cancel _debugStateService.ExecutionCompleted = true; _debugService.Abort(); - if (!_debugStateService.IsAttachSession) + if (!_debugStateService.IsAttachSession && !_debugStateService.IsUsingTempIntegratedConsole) { await _executionService.ExecutePSCommandAsync( new PSCommand().AddCommand("Remove-Variable") @@ -64,7 +64,8 @@ await _executionService.ExecutePSCommandAsync( .AddParameter("Force", true), cancellationToken).ConfigureAwait(false); } - else if (_debugStateService.IsInteractiveDebugSession) + + 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 395576c18..4201cc5e6 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -179,16 +179,22 @@ public async Task Handle(PsesLaunchRequestArguments request, Can _logger.LogTrace("Working dir " + (string.IsNullOrEmpty(workingDir) ? "not set." : $"set to '{workingDir}'")); - 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); + 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 From 45e23dec4b77d6e14ce6c5d1899a75ff0f1ae09b Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Thu, 31 Jul 2025 20:33:41 +1000 Subject: [PATCH 4/7] Update module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 --- .../Commands/Public/Start-DebugAttachSession.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 index 79948e33f..a3df340d2 100644 --- a/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/Start-DebugAttachSession.ps1 @@ -129,7 +129,7 @@ function Start-DebugAttachSession { # https://microsoft.github.io/debug-adapter-protocol/specification#Reverse_Requests_StartDebugging $resp = $debugServer.SendRequest( - "startDebugging", + 'startDebugging', @{ configuration = $configuration request = 'attach' From 56e1ccb1d11db4a437c854ee4f79a9596fe57430 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 1 Aug 2025 14:19:09 +1000 Subject: [PATCH 5/7] Add startDebugging tests --- .../DebugAdapterProtocolMessageTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index 881e55884..2c4fa55f1 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Text; 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 +54,12 @@ public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsync /// private Task nextStopped => nextStoppedTcs.Task; + private readonly TaskCompletionSource startDebuggingAttachRequestTcs = new(); + /// + /// This task is useful for waiting until a StartDebuggingAttachRequest is received. + /// + private Task startDebuggingAttachRequest => startDebuggingAttachRequestTcs.Task; + public async Task InitializeAsync() { // Cleanup testScriptLogPath if it exists due to an interrupted previous run @@ -100,6 +107,11 @@ send until a launch is sent. nextStoppedTcs.SetResult(e); nextStoppedTcs = new(); }) + .OnRequest("startDebugging", (StartDebuggingAttachRequestArguments request) => + { + startDebuggingAttachRequestTcs.SetResult(request); + return Task.CompletedTask; + }) ; }); @@ -513,5 +525,37 @@ 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")] + [Theory] + public async Task CanLaunchScriptWithNewChildAttachSession( + string paramString, + string? expectedComputerName, + string? expectedPipeName, + int expectedProcessId, + int expectedRunspaceId, + string? expectedRunspaceName) + { + string script = NewTestFile($"Start-DebugAttachSession {paramString}"); + + await client.LaunchScript(script); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + + StartDebuggingAttachRequestArguments attachRequest = await startDebuggingAttachRequest; + 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); + } + + private record StartDebuggingAttachRequestArguments(PsesAttachRequestArguments Configuration, string Request); + +#nullable disable } } From d2eb4ac199281d4a81d21cf353d46cb4848a6793 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 1 Aug 2025 20:27:33 +1000 Subject: [PATCH 6/7] Skip test in CLM --- .../DebugAdapterProtocolMessageTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index 2c4fa55f1..15532c549 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -531,7 +531,7 @@ public async Task CanRunPesterTestFile() [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")] - [Theory] + [SkippableTheory] public async Task CanLaunchScriptWithNewChildAttachSession( string paramString, string? expectedComputerName, @@ -540,6 +540,9 @@ public async Task CanLaunchScriptWithNewChildAttachSession( 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}"); await client.LaunchScript(script); From 5e87464d35d234c284f5ae24ddcd175a138f919b Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Fri, 1 Aug 2025 20:58:46 +1000 Subject: [PATCH 7/7] Add test timeout and -AsJob test --- .../DebugAdapterProtocolMessageTests.cs | 62 ++++++++++++++++++- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index 15532c549..b51f3d8dd 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -6,6 +6,7 @@ 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; @@ -54,11 +55,15 @@ public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsync /// private Task nextStopped => nextStoppedTcs.Task; - private readonly TaskCompletionSource startDebuggingAttachRequestTcs = new(); /// /// This task is useful for waiting until a StartDebuggingAttachRequest is received. /// - private Task startDebuggingAttachRequest => startDebuggingAttachRequestTcs.Task; + 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() { @@ -112,6 +117,11 @@ send until a launch is sent. startDebuggingAttachRequestTcs.SetResult(request); return Task.CompletedTask; }) + .OnTerminated((TerminatedEvent e) => + { + terminatedTcs.SetResult(e); + return Task.CompletedTask; + }) ; }); @@ -545,16 +555,62 @@ public async Task CanLaunchScriptWithNewChildAttachSession( 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 startDebuggingAttachRequest; + 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);