Last active
September 27, 2025 04:05
-
-
Save YoraiLevi/d0d95011bed792dff57a301dbc2780ec to your computer and use it in GitHub Desktop.
Starts a process with optional redirected stdout and stderr streams for better output handling. Allow to wait for the process to exit or forcefully kill it with timeout.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
function Invoke-Process { | |
<# | |
.SYNOPSIS | |
Starts a process with optional redirected stdout and stderr streams for better output handling. | |
Allow to wait for the process to exit or forcefully kill it with timeout. | |
.DESCRIPTION | |
This function creates and starts a new process with optional standard output and error streams | |
redirected to enable capture and processing. It provides various waiting options | |
including timeout and TimeSpan timeout support. | |
.PARAMETER FilePath | |
The path to the executable file to run. | |
.PARAMETER ArgumentList | |
Arguments to pass to the executable. | |
.PARAMETER WorkingDirectory | |
The working directory for the process. | |
.PARAMETER Wait | |
Wait for the process to exit without timeout. | |
.PARAMETER Timeout | |
Wait for the process to exit with a timeout in milliseconds. | |
.PARAMETER TimeSpan | |
Wait for the process to exit with a TimeSpan timeout. | |
.PARAMETER TimeoutAction | |
Action to take when wait operations timeout. Valid values are 'Continue', 'Inquire', 'SilentlyContinue', 'Stop'. | |
.PARAMETER RedirectOutput | |
Redirect stdout and stderr streams. When false, uses Start-Process for normal console output. | |
It is Recommended to use the PassThru switch to access the redirected output through the returned process object | |
You're welcome to think of a better solution to this. | |
.PARAMETER PassThru | |
Return the process object. | |
.EXAMPLE | |
# Basic usage without waiting - starts process and control returns immediately | |
Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "10" | |
.EXAMPLE | |
# Basic usage with timeout - starts process and control returns immediately, the process is killed after 3 seconds | |
Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "10" -Timeout 3 | |
.EXAMPLE | |
# Wait for process to complete | |
Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "4" -Wait | |
.EXAMPLE | |
# Wait with timeout (3 seconds), after 3 seconds the process is killed | |
Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "10" -Wait -Timeout 3 | |
.EXAMPLE | |
# Wait with TimeSpan timeout and custom timeout action, after 3 an inquire is shown asking what to do | |
Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "10" -Wait -TimeSpan (New-TimeSpan -Seconds 3) -TimeoutAction Inquire | |
.EXAMPLE | |
# Redirect output and get process object | |
$process = Invoke-Process -FilePath "ping.exe" -ArgumentList "google.com", "-n", "10" -TimeSpan (New-TimeSpan -Seconds 3) -TimeoutAction Stop -RedirectOutput -PassThru | |
$output = $process.StandardOutput.ReadToEnd() | |
$errors = $process.StandardError.ReadToEnd() | |
.LINK | |
https://gist.github.com/YoraiLevi/d0d95011bed792dff57a301dbc2780ec | |
.LINK | |
https://stackoverflow.com/a/66700583/12603110 | |
.LINK | |
https://stackoverflow.com/q/36933527/12603110 | |
.LINK | |
https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.5#parameters | |
.LINK | |
https://github.com/PowerShell/PowerShell/blob/d8b1cc55332079d2be94cc266891c85e57d88c55/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs#L1597 | |
#> | |
[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'NoWait')] | |
param | |
( | |
[Parameter(Mandatory, Position = 0)] | |
[ValidateNotNullOrEmpty()] | |
[Alias('PSPath', 'Path')] | |
[string]$FilePath, | |
[Parameter(Position = 1)] | |
[string[]]$ArgumentList = @(), | |
[ValidateNotNullOrEmpty()] | |
[string]$WorkingDirectory, | |
[Parameter(ParameterSetName = 'WithTimeout')] | |
[Parameter(ParameterSetName = 'WithTimeSpan')] | |
[Parameter(Mandatory, ParameterSetName = 'WaitExit')] | |
[switch]$Wait, | |
[Parameter(Mandatory, ParameterSetName = 'WithTimeout')] | |
[int]$Timeout, | |
[Parameter(Mandatory, ParameterSetName = 'WithTimeSpan')] | |
[System.TimeSpan]$TimeSpan, | |
[Parameter(ParameterSetName = 'WithTimeout')] | |
[Parameter(ParameterSetName = 'WithTimeSpan')] | |
[ValidateSet('Continue', 'Inquire', 'SilentlyContinue', 'Stop')] | |
[string]$TimeoutAction = 'Stop', | |
[switch]$RedirectOutput, | |
[switch]$PassThru, | |
# Consider adding support for the other Start-Process parameters and make this into a drop in replacement for Start-Process: | |
# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.5#parameters | |
# partial eg: | |
# [-Verb <string>] | |
# [-WindowStyle <ProcessWindowStyle>] | |
[hashtable]$Environment, | |
[switch]$UseNewEnvironment | |
) | |
$ErrorActionPreference = 'Stop' | |
$command = Get-Command $FilePath -CommandType Application -ErrorAction SilentlyContinue | |
$resolvedFilePath = if ($command) { | |
$command.Source | |
} | |
else { | |
$FilePath | |
} | |
$argumentString = if ($ArgumentList -and $ArgumentList.Count -gt 0) { | |
" " + ($ArgumentList -join " ") | |
} | |
else { | |
"" | |
} | |
$target = "$resolvedFilePath$argumentString" | |
if ($PSCmdlet.ShouldProcess($target, $MyInvocation.MyCommand)) { | |
if (($TimeoutAction -eq 'Inquire') -and -not $Wait) { | |
throw "TimeoutAction 'Inquire' and 'Wait' switch are not compatible" | |
} | |
class Process : System.Diagnostics.Process { | |
[void] WaitForExit() { | |
$this.StandardOutput.ReadToEnd() | |
$this.StandardError.ReadToEnd() | |
([System.Diagnostics.Process]$this).WaitForExit() | |
} | |
} | |
function InvokeTimeoutAction { | |
param( | |
[string]$TimeoutAction, | |
[System.Diagnostics.Process]$Process | |
) | |
switch ($TimeoutAction) { | |
'Continue' { | |
Write-Debug "Waiting action: Continue" | |
Write-Warning "Process may still be running. Continuing..." | |
} | |
'Inquire' { | |
Write-Debug "Waiting action: Inquire" | |
$choice = Read-Host "Process is still running. What would you like to do? (K)ill, (W)ait" | |
switch ($choice.ToLower()) { | |
'k' { | |
if (!$Process.HasExited) { | |
$Process.Kill() | |
} | |
} | |
'w' { | |
$Process.WaitForExit() | |
} | |
default { | |
Write-Warning "Invalid choice. Process will continue running." | |
} | |
} | |
} | |
'SilentlyContinue' { | |
Write-Debug "Waiting action: SilentlyContinue" | |
# No action - let process continue running | |
} | |
'Stop' { | |
Write-Debug "Waiting action: Stop" | |
if (!$Process.HasExited) { | |
$Process.Kill() | |
} | |
} | |
default { | |
Write-Debug "Waiting action: Default, should never happen" | |
# Unreachable code | |
Write-Error "Invalid wait action: $WaitAction" | |
} | |
} | |
} | |
$script_block = { param($Id, $Timeout) | |
$function:InvokeTimeoutAction = $using:function:InvokeTimeoutAction; | |
$TimeoutAction = $using:TimeoutAction; | |
Write-Host "TimeoutAction: $TimeoutAction, Id: $Id, Timeout: $Timeout" | |
$p = Wait-Process -Id $Id -Timeout $Timeout -PassThru; | |
if ($TimeoutAction) { | |
InvokeTimeoutAction -TimeoutAction $TimeoutAction -Process $p | |
} | |
} | |
$p = $null | |
if ($RedirectOutput) { | |
$pinfo = New-Object System.Diagnostics.ProcessStartInfo | |
$pinfo.FileName = $FilePath | |
$pinfo.RedirectStandardError = $true | |
$pinfo.RedirectStandardOutput = $true | |
$pinfo.UseShellExecute = $false | |
$pinfo.WindowStyle = 'Hidden' | |
$pinfo.CreateNoWindow = $true | |
$pinfo.Arguments = $ArgumentList | |
if ($WorkingDirectory) { | |
$pinfo.WorkingDirectory = $WorkingDirectory | |
} | |
function LoadEnvironmentVariable { | |
# https://github.com/PowerShell/PowerShell/blob/d8b1cc55332079d2be94cc266891c85e57d88c55/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs#L2231C24-L2231C335 | |
param( | |
[System.Diagnostics.ProcessStartInfo]$ProcessStartInfo, | |
[System.Collections.IDictionary]$EnvironmentVariables | |
) | |
$processEnvironment = $ProcessStartInfo.EnvironmentVariables | |
foreach ($entry in $EnvironmentVariables.GetEnumerator()) { | |
if ($processEnvironment.ContainsKey($entry.Key)) { | |
$processEnvironment.Remove($entry.Key) | |
} | |
if ($null -ne $entry.Value) { | |
if ($entry.Key -eq "PATH") { | |
if ($IsWindows) { | |
$machinePath = [System.Environment]::GetEnvironmentVariable($entry.Key, [System.EnvironmentVariableTarget]::Machine) | |
$userPath = [System.Environment]::GetEnvironmentVariable($entry.Key, [System.EnvironmentVariableTarget]::User) | |
$combinedPath = $entry.Value + [System.IO.Path]::PathSeparator + $machinePath + [System.IO.Path]::PathSeparator + $userPath | |
$processEnvironment.Add($entry.Key, $combinedPath) | |
} | |
else { | |
$processEnvironment.Add($entry.Key, $entry.Value) | |
} | |
} | |
else { | |
$processEnvironment.Add($entry.Key, $entry.Value) | |
} | |
} | |
} | |
} | |
# https://github.com/PowerShell/PowerShell/blob/d8b1cc55332079d2be94cc266891c85e57d88c55/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs#L1954 | |
if ($UseNewEnvironment) { | |
$pinfo.EnvironmentVariables.Clear() | |
LoadEnvironmentVariable -ProcessStartInfo $pinfo -EnvironmentVariables ([System.Environment]::GetEnvironmentVariables([System.EnvironmentVariableTarget]::Machine)) | |
LoadEnvironmentVariable -ProcessStartInfo $pinfo -EnvironmentVariables ([System.Environment]::GetEnvironmentVariables([System.EnvironmentVariableTarget]::User)) | |
} | |
if ($Environment) { | |
LoadEnvironmentVariable -ProcessStartInfo $pinfo -EnvironmentVariables $Environment | |
} | |
$p = New-Object Process | |
$p.StartInfo = $pinfo | |
$p.Start() | Out-Null | |
} | |
else { | |
$startProcessParams = @{ | |
FilePath = $FilePath | |
ArgumentList = $ArgumentList | |
PassThru = $true | |
NoNewWindow = $true | |
} | |
if ($WorkingDirectory) { | |
$startProcessParams.WorkingDirectory = $WorkingDirectory | |
} | |
if ($Environment) { | |
$startProcessParams.Environment = $Environment | |
} | |
if ($UseNewEnvironment) { | |
$startProcessParams.UseNewEnvironment = $UseNewEnvironment | |
} | |
$p = Start-Process @startProcessParams -Confirm:$false | |
} | |
Write-Debug "Process started: $target" | |
Write-Debug "Waiting Mode: $($PSCmdlet.ParameterSetName)" | |
if ($Wait) { | |
switch ($PSCmdlet.ParameterSetName) { | |
'WaitExit' { | |
Write-Debug "Waiting for process to exit..." | |
$p.WaitForExit() | Out-Null | |
} | |
'WithTimeout' { | |
Write-Debug "Waiting for process to exit with timeout..." | |
$p.WaitForExit($Timeout * 1000) | Out-Null | |
InvokeTimeoutAction -TimeoutAction $TimeoutAction -Process $p | |
} | |
'WithTimeSpan' { | |
Write-Debug "Waiting for process to exit with timespan..." | |
$p.WaitForExit($TimeSpan) | Out-Null | |
InvokeTimeoutAction -TimeoutAction $TimeoutAction -Process $p | |
} | |
default { | |
Write-Error "Invalid parameter set: $($PSCmdlet.ParameterSetName)" | |
} | |
} | |
} | |
else { | |
switch ($PSCmdlet.ParameterSetName) { | |
'WithTimeout' { | |
Start-Job -ScriptBlock $script_block -ArgumentList $p.Id, $Timeout | Out-Null | |
Write-Debug "Letting process run in background with timeout..." | |
} | |
'WithTimeSpan' { | |
Start-Job -ScriptBlock $script_block -ArgumentList $p.Id, $TimeSpan.TotalSeconds | Out-Null | |
Write-Debug "Letting process run in background with timespan..." | |
} | |
'NoWait' { | |
Write-Debug "Letting process run in background..." | |
} | |
default { | |
Write-Error "Invalid parameter set: $($PSCmdlet.ParameterSetName)" | |
} | |
} | |
} | |
if ($PassThru) { | |
Write-Debug "Returning process object" | |
return $p | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment