Modules/businessdev.ALbuild.Core/Public/Invoke-ALbuildProcess.ps1
|
function Invoke-ALbuildProcess { <# .SYNOPSIS Runs an external process, capturing stdout/stderr/exit code, with optional retry. .DESCRIPTION A reliable wrapper around System.Diagnostics.Process that: * captures stdout and stderr without dead-locking (asynchronous reads); * treats a configurable set of exit codes as success; * optionally retries on failure with a fixed back-off (for transient I/O); * throws a terminating, descriptive error on final failure unless -PassThru is used. Argument handling is dual-target: ArgumentList is used on PowerShell 7+, with a quoted fallback for Windows PowerShell 5.1. .PARAMETER FilePath The executable to run. .PARAMETER Arguments Arguments passed to the executable (array form; no manual quoting required). .PARAMETER WorkingDirectory Working directory for the process. .PARAMETER SuccessExitCodes Exit codes considered successful. Default: 0. .PARAMETER RetryCount Number of additional attempts on failure. Default: 0 (no retry). .PARAMETER RetryDelaySeconds Delay between attempts. Default: 5. .PARAMETER PassThru Return the result object even on failure instead of throwing. .PARAMETER StreamOutput Echo the process's stdout to the host line-by-line as it is produced (still captured in the returned StdOut), so long-running children show live progress instead of appearing all at once when they exit. Stderr is captured but not echoed live. .EXAMPLE Invoke-ALbuildProcess -FilePath 'docker' -Arguments @('version','--format','{{.Server.Version}}') .OUTPUTS PSCustomObject with ExitCode, StdOut, StdErr, Success, Attempts. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string] $FilePath, [Parameter(Position = 1)] [string[]] $Arguments = @(), [string] $WorkingDirectory, [int[]] $SuccessExitCodes = @(0), [ValidateRange(0, [int]::MaxValue)] [int] $RetryCount = 0, [ValidateRange(0, [int]::MaxValue)] [int] $RetryDelaySeconds = 5, [switch] $PassThru, [switch] $StreamOutput ) $supportsArgumentList = $PSVersionTable.PSVersion.Major -ge 6 $attempt = 0 $result = $null while ($attempt -le $RetryCount) { $attempt++ $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = $FilePath $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true if ($WorkingDirectory) { $psi.WorkingDirectory = $WorkingDirectory } if ($supportsArgumentList) { foreach ($arg in $Arguments) { $psi.ArgumentList.Add([string]$arg) } } else { $psi.Arguments = ($Arguments | ForEach-Object { $a = [string]$_ if ($a -match '\s|"') { '"' + ($a -replace '"', '\"') + '"' } else { $a } }) -join ' ' } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $psi try { if (-not $process.Start()) { throw "Failed to start process '$FilePath'." } if ($StreamOutput) { # Read stdout in chunks (not whole lines) and echo it raw, so partial-line progress - # e.g. a heartbeat that appends '.' without a trailing newline - is shown live instead # of being withheld until the next newline. Flush each chunk so it reaches the console # immediately. Drain stderr async to avoid the full-buffer deadlock. The full stdout is # still captured for the caller. $stderrTask = $process.StandardError.ReadToEndAsync() $sb = [System.Text.StringBuilder]::new() $buffer = [char[]]::new(4096) while (($read = $process.StandardOutput.Read($buffer, 0, $buffer.Length)) -gt 0) { $chunk = [string]::new($buffer, 0, $read) [System.Console]::Out.Write($chunk) [System.Console]::Out.Flush() [void]$sb.Append($chunk) } $process.WaitForExit() $stdout = $sb.ToString() $stderr = $stderrTask.GetAwaiter().GetResult() $exit = $process.ExitCode } else { # Asynchronous reads avoid the classic full-buffer deadlock. $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.WaitForExit() $stdout = $stdoutTask.GetAwaiter().GetResult() $stderr = $stderrTask.GetAwaiter().GetResult() $exit = $process.ExitCode } } catch { $stdout = '' $stderr = $_.Exception.Message $exit = -1 } finally { $process.Dispose() } $success = $SuccessExitCodes -contains $exit $result = [PSCustomObject]@{ ExitCode = $exit StdOut = $stdout StdErr = $stderr Success = $success Attempts = $attempt } if ($success) { return $result } if ($attempt -le $RetryCount) { Write-ALbuildLog -Level Warning ("Process '$FilePath' failed (exit $exit), attempt $attempt of $($RetryCount + 1); retrying in $RetryDelaySeconds s...") if ($RetryDelaySeconds -gt 0) { Start-Sleep -Seconds $RetryDelaySeconds } } } if ($PassThru) { return $result } $detail = if ([string]::IsNullOrWhiteSpace($result.StdErr)) { $result.StdOut } else { $result.StdErr } $suffix = if ([string]::IsNullOrWhiteSpace($detail)) { '' } else { [Environment]::NewLine + $detail.Trim() } throw "Process '$FilePath' failed with exit code $($result.ExitCode) after $($result.Attempts) attempt(s).$suffix" } |