PowerShell: Taking Control of CTRL-C.

Have you ever been in the middle of running a PowerShell script and hit CTRL-C to stop it, and realize you just lost the tracking of all work performed and/or information gathered up to that point? It’s even worse when you are using PowerShell jobs for multi-threading processing, or remote computer sessions, because you potentially just orphaned all those jobs. Wouldn’t it have been nice to first be able to report out what work had been performed, or been able to gracefully shut those jobs down and remove them? If so, then this blog post is for you.

NOTE: Thanks to Murat Yıldırımoğlu's testing of this code, he identified that it doesn't work in the PowerShell ISE. That's because the ISE doesn't use the true PowerShell Console (engine), but rather it's own which has a history of subtle differences. In particular to this code example, we are making changes to the "[Console]" environment, which as I mentioned isn't used by the ISE. These differences are why I almost never run code in the ISE even when I use it to briefly tweak some code. There may be a ISE equivalent to the code below but I don't know what it is off the top of my head (feel free to share in comments if you know what it is).

FYI if you weren't aware, the PowerShell Product Group is no long actively developing the ISE (it's still supported just not getting new features) as they have switched their efforts for the cross-platform Visual Studio Code. You can configure VS Code to use the true PowerShell Console as the run time environment, so if you want to keep running codes from a GUI but want a true PowerShell experience, now is a great time to switch to VS Code.

Background

This all started when I was writing the version 2.0 of my Exchange Message Profile Generator script, where I had added multi-threading in the form of background PowerShell jobs. During the addition of the multi-threading capability I had thought to myself that if someone were to cancel the running script, I wouldn’t want to leave the jobs running orphaned in the background. So I set about researching and eventually adding that capability of gracefully shutting down background jobs to the script as a part of the new v2.0 version. Here is an example of that script in action when CTRL-C is pressed during the messaging tracking log data gathering phase:

You might be asking yourself why did Dan use PowerShell jobs that have been around since version 2.0 versus Runspaces or some other multi-threading mechanism. Well I originally tried Runspaces because of their low overhead, but I ended up abandoning them when I couldn’t suppress the progress bar from Get-MessageTrackingLog (it’s physically impossible to do because it’s hard coded in the cmdlet to always show a progress bar no matter what you do). If I hadn’t, you would have seen multiple progress bars fighting with each other for screen dominance which would not have been a good end user experience. Switching to jobs allowed me to encapsulate/hide the progress bars and only show them briefly when I imported those jobs back into the main PowerShell window. But I digress… 😊

The PowerShell Magic Code

Without further ado, here is a slightly modified snippet from my Message Profile Generator script that shows you how I suppress the default action of “CTRL-C” when PowerShell is running, and instead intercept it so I can choose what action to take as a part of that exit process:

 

# Change the default behavior of CTRL-C so that the script can intercept and use it versus just terminating the script.
[Console]::TreatControlCAsInput = $True
# Sleep for 1 second and then flush the key buffer so any previously pressed keys are discarded and the loop can monitor for the use of
#   CTRL-C. The sleep command ensures the buffer flushes correctly.
Start-Sleep -Seconds 1
$Host.UI.RawUI.FlushInputBuffer()

# Continue to loop while there are pending or currently executing jobs.
While ($PendingJobs -or $CurrentJobCount) {
    # If a key was pressed during the loop execution, check to see if it was CTRL-C (aka "3"), and if so exit the script after clearing
    #   out any running jobs and setting CTRL-C back to normal.
    If ($Host.UI.RawUI.KeyAvailable -and ($Key = $Host.UI.RawUI.ReadKey("AllowCtrlC,NoEcho,IncludeKeyUp"))) {
        If ([Int]$Key.Character -eq 3) {
            Write-Host ""
            Write-Warning "CTRL-C was used - Shutting down any running jobs before exiting the script."
            Get-Job | Where-Object {$_.Name -like "MessageProfile*"} | Remove-Job -Force -Confirm:$False
            [Console]::TreatControlCAsInput = $False
            _Exit-Script -HardExit $True
        }
        # Flush the key buffer again for the next loop.
        $Host.UI.RawUI.FlushInputBuffer()
    }

    # Perform other work here such as process pending jobs or process out current jobs.
}

To quickly summarize what’s happening above, the code does the following:

  1. The PowerShell Console is told to treat CTRL-C as input versus a terminating action.
  2. A sleep of 1 second is performed (to ensure everything typed in the keyboard is accounted for) followed by a keyboard buffer flush just to make sure no keys pressed prior to executing the While loop are considered.
  3. A While loop construct begins, where you can put whatever conditions you want to check in your script, where the script checks to see if there are any pending or currently running jobs. If there aren’t then the script exits naturally.
  4. The first part of each While loop iteration is to check to see if any keys were pressed (and released), and if there were it checks to see if it was the character “3” which is the equivalent of CTRL-C.
  5. If it was then the script informs the user that it sees that action performed and then proceeds to gracefully shut down any running jobs.
  6. After the jobs are shut down, the PowerShell Console is told to treat CRTL-C like normal and then a function I wrote to gracefully exit the script while performing other cleanup is called.

You can take this code and substitute a While loop for any loop construct you want, use whatever conditions you want to keep the loop running, and use the CTRL-C detection to perform whatever cleanup actions you want. For example, you could use a ForEach loop construct to process 100K mailboxes in some fashion, and if CTRL-C is pressed you can dump out to a file what work was performed up until the point where CTRL-C was pressed, versus losing it all which is the default action of PowerShell.

To be clear this code method isn’t Exchange specific, I'm just using the example from an Exchange large data gathering script I first created this for.

Closing Thoughts

Hopefully this little tidbit on PowerShell Console control helps you with any big/long running scripts you have where you don’t want to dump everything gathered/performed/running in the background - part way through the script’s execution.

I don’t use this in many of my scripts that are short running, but I am going back an re-evaluating previously published scripts to see if they could benefit from this type of control.

Please feel free to leave me comments here if you wish, I promise I will try to respond to each in kind.

Thanks!
Dan Sheehan
Senior Premier Field Engineer