windowspowershellterminatesigint

How can I configure a PowerShell session to gracefully and reliably end after a request to terminate from another process?


Consider the following background:

  1. None of the obvious methods of programmatically stopping PowerShell from another process result in clean{} running.
  2. Stop-Process "jumps straight to kill" without even trying the standardized but unreliable methods of requesting termination like WM_CLOSE and GenerateConsoleCtrlEvent as described here.
  3. There are countless clues that ported programs that successfully use POSIX signals like SIGTERM on other platforms don't succeed at doing the same on Windows. Perhaps this comment most succinctly summarizes what that situation appears to be.

Now consider a PowerShell session running some of my code. I can press CTRL+C at the console, the clean{} blocks are executed, a cascade of Dispose() calls occurs through any active Cmdlet's and other object trees, and the process gracefully stops after all that cleanup is complete. I would like to invoke a similar degree of orderly stopping from another process.

How can I achieve that in a manner that is reliable?


Solution

  • As discussed extensively in comments from your own answer, an approach that would work consistently for a PowerShell process (including Jobs) that would allow to invoke their clean block is via connecting to the process using a NamedPipeConnectionInfo and calling CloseAsync() on the process runspaces that are not the DefaultRunspace.

    This seem to work consistently however it's important to note that if the process is invoking a .NET method that doesn't allow interrupts (i.e.: Thread.Sleep(-1)) then this fails.

    using namespace System.Management.Automation
    
    function Stop-PowerShell {
        param(
            [Parameter(Mandatory, ValueFromPipeline)]
            [System.Diagnostics.Process] $Process,
    
            [Parameter()]
            [ValidateRange(1, [int]::MaxValue)]
            [int] $TimeoutSeconds = 10)
    
        process {
            try {
                $pipe = [Runspaces.NamedPipeConnectionInfo]::new($Process.Id)
                $rs = [runspacefactory]::CreateRunspace($pipe)
                $rs.Open()
                $ps = [powershell]::Create($rs).AddScript({
                    Get-Runspace |
                        Where-Object { $_ -ne [runspace]::DefaultRunspace } |
                        ForEach-Object CloseAsync
                })
                $null = $ps.InvokeAsync()
                if (-not $Process.WaitForExit([timespan]::FromSeconds($TimeoutSeconds))) {
                    $PSCmdlet.WriteError([ErrorRecord]::new(
                        "Failed to stop PowerShell with Process Id '$($Process.Id)'.",
                        'StopFailed',
                        [ErrorCategory]::OperationTimeout,
                        $null))
                }
            }
            catch {
                $PSCmdlet.WriteError($_)
            }
            finally {
                ${ps}?.Dispose()
                ${rs}?.Dispose()
            }
        }
    }
    

    A variant of this approach could be via reflection, by GetCurrentlyRunningPipeline() and StopAsync():

    $ps = [powershell]::Create($rs).AddScript({
        $method = [runspace].GetMethod(
            'GetCurrentlyRunningPipeline',
            [System.Reflection.BindingFlags] 'NonPublic, Instance')
    
        Get-Runspace |
            Where-Object { $_ -ne [runspace]::DefaultRunspace } |
            ForEach-Object {
                if ($pipeline = $method.Invoke($_, $null)) {
                    $pipeline.StopAsync()
                }
            }
    })