Private/Invoke-Process.ps1

# The Invoke-Process function is loosely based on code from https://github.com/guitarrapc/PowerShellUtil/blob/master/Invoke-Process/Invoke-Process.ps1
function Invoke-Process {
    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $false, Position = 0)]
        [string]$FileName = "PowerShell.exe",

        [Parameter(Mandatory = $false, Position = 1)]
        [string[]]$Arguments = "",

        [Parameter(Mandatory = $false, Position = 3)]
        [Int]$TimeoutSeconds = 120,

        [Parameter(Mandatory = $false, Position = 4)]
        [String]$stdoutFile = $null,

        [Parameter(Mandatory = $false, Position = 5)]
        [String]$stderrFile = $null
    )

    end {
        $WorkingDirectory = if ($IsLinux -or $IsMacOS) { "/tmp" } else { $env:TEMP }
        try {
            # new Process
            if ($stdoutFile) {
                # new Process
                $process = NewProcess -FileName $FileName -Arguments $Arguments -WorkingDirectory $WorkingDirectory

                # Event Handler for Output
                $stdSb = New-Object -TypeName System.Text.StringBuilder
                $errorSb = New-Object -TypeName System.Text.StringBuilder
                $scripBlock =
                {
                    $x = $Event.SourceEventArgs.Data
                    if (-not [String]::IsNullOrEmpty($x)) {
                        $Event.MessageData.AppendLine($x)
                    }
                }
                $stdEvent = Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $scripBlock -MessageData $stdSb
                $errorEvent = Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $scripBlock -MessageData $errorSb

                # execution
                $process.Start() > $null
                $process.BeginOutputReadLine()
                $process.BeginErrorReadLine()
                # wait for complete
                $Timeout = [System.TimeSpan]::FromSeconds(($TimeoutSeconds))
                $isTimeout = $false
                if (-not $Process.WaitForExit($Timeout.TotalMilliseconds)) {
                    $isTimeout = $true
                    Invoke-KillProcessTree $process.id
                    Write-Host -ForegroundColor Red "Process Timed out after $TimeoutSeconds seconds, use '-TimeoutSeconds' to specify a different timeout"
                }
                $process.CancelOutputRead()
                $process.CancelErrorRead()

                # Unregister Event to recieve Asynchronous Event output (should be called before process.Dispose())
                Unregister-Event -SourceIdentifier $stdEvent.Name
                Unregister-Event -SourceIdentifier $errorEvent.Name

                $stdOutString = $stdSb.ToString().Trim()
                if ($stdOutString.Length -gt 0) {
                    Write-Host $stdOutString
                }

                $stdErrString = $errorSb.ToString().Trim()
                if ($stdErrString.Length -gt 0) {
                    Write-Host $stdErrString
                }

                # Get Process result
                return GetCommandResult -Process $process -StandardStringBuilder $stdSb -ErrorStringBuilder $errorSb -IsTimeOut $isTimeout
            }
            else {
                # This is the enitrety of the "old style" code, kept for interactive tests
                $process = Start-Process -FilePath $FileName -ArgumentList $Arguments -WorkingDirectory $WorkingDirectory -NoNewWindow -PassThru
                # cache process.Handle, otherwise ExitCode is null from powershell processes
                $handle = $process.Handle

                # wait for complete
                $Timeout = [System.TimeSpan]::FromSeconds(($TimeoutSeconds))
                if (-not $process.WaitForExit($Timeout.TotalMilliseconds)) {
                    Invoke-KillProcessTree $process.id

                    Write-Host -ForegroundColor Red "Process Timed out after $TimeoutSeconds seconds, use '-TimeoutSeconds' to specify a different timeout"
                    if ($stdoutFile) {
                        # Add a warning in stdoutFile in case of timeout
                        # problem: $stdoutFile was locked in writing by the process we just killed, sometimes it's too fast and the lock isn't released immediately
                        # solution: retry at most 10 times with 100ms between each attempt
                        For ($i = 0; $i -lt 10; $i++) {
                            try {
                                "<timeout>" | Out-File (Join-Path $WorkingDirectory $stdoutFile) -Append -Encoding ASCII
                                break # if we're here it means the file wasn't locked and Out-File worked, so we can leave the retry loop
                            }
                            catch {} # file is locked
                            Start-Sleep -m 100
                        }
                    }
                }

                if ($IsLinux -or $IsMacOS) {
                    Start-Sleep -Seconds 5 # On nix, the last 4 lines of stdout get overwritten upon return so pause for a bit to ensure user can view results
                }

                # Get Process result
                return [PSCustomObject]@{
                    StandardOutput = ""
                    ErrorOutput    = ""
                    ExitCode       = $process.ExitCode
                    ProcessId      = $Process.Id
                    IsTimeOut      = $IsTimeout
                }

            }

        }
        finally {
            if ($null -ne $process) { $process.Dispose() }
            if ($null -ne $stdEvent) { $stdEvent.StopJob(); $stdEvent.Dispose() }
            if ($null -ne $errorEvent) { $errorEvent.StopJob(); $errorEvent.Dispose() }
        }
    }

    begin {
        function NewProcess {
            [OutputType([System.Diagnostics.Process])]
            [CmdletBinding()]
            param
            (
                [parameter(Mandatory = $true)]
                [string]$FileName,

                [parameter(Mandatory = $false)]
                [string[]]$Arguments,

                [parameter(Mandatory = $false)]
                [string]$WorkingDirectory
            )

            # ProcessStartInfo
            $psi = New-object System.Diagnostics.ProcessStartInfo
            $psi.CreateNoWindow = $true
            $psi.UseShellExecute = $false
            $psi.RedirectStandardOutput = $true
            $psi.RedirectStandardError = $true
            $psi.FileName = $FileName
            $psi.Arguments += $Arguments
            $psi.WorkingDirectory = $WorkingDirectory

            # Set Process
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo = $psi
            $process.EnableRaisingEvents = $true
            return $process
        }

        function GetCommandResult {
            [OutputType([PSCustomObject])]
            [CmdletBinding()]
            param
            (
                [parameter(Mandatory = $true)]
                [System.Diagnostics.Process]$Process,

                [parameter(Mandatory = $true)]
                [System.Text.StringBuilder]$StandardStringBuilder,

                [parameter(Mandatory = $true)]
                [System.Text.StringBuilder]$ErrorStringBuilder,

                [parameter(Mandatory = $true)]
                [Bool]$IsTimeout
            )

            return [PSCustomObject]@{
                StandardOutput = $StandardStringBuilder.ToString().Trim()
                ErrorOutput    = $ErrorStringBuilder.ToString().Trim()
                ExitCode       = $Process.ExitCode
                ProcessId      = $Process.Id
                IsTimeOut      = $IsTimeout
            }
        }
    }
}