Private/Process/Invoke-AvmProcess.ps1
|
function Invoke-AvmProcess { <# .SYNOPSIS Run an external binary, capture stdout/stderr separately, and surface a structured result. The only subprocess primitive used by the CLI. .DESCRIPTION Wraps System.Diagnostics.Process with argv-array arguments (no shell, no quoting), separate stdout/stderr capture via per-stream asynchronous reads (ReadToEndAsync), and an optional timeout. Throws AvmProcessException on non-zero exit unless -IgnoreExitCode is supplied. On timeout the process tree is killed and a TimeoutException is thrown. Per spec section 9 the CLI never invokes a shell and never quotes arguments; every argument is passed verbatim through ProcessStartInfo.ArgumentList. .PARAMETER FilePath Absolute path to the executable. Callers are expected to resolve via the tool resolver or Get-Command before invoking. .PARAMETER ArgumentList Verbatim argv tokens. Empty array runs the binary with no args. .PARAMETER WorkingDirectory Working directory for the child process. Defaults to the current location's provider path. .PARAMETER EnvVars Per-invocation environment overrides. Existing parent-process vars are inherited; entries in this hashtable take precedence. A $null value removes the variable for the child. .PARAMETER TimeoutSec Maximum runtime in seconds. 0 (default) means no timeout. .PARAMETER IgnoreExitCode Suppress the AvmProcessException throw on non-zero exit. The exit code is still returned on the result object. .PARAMETER StreamOutput Narrate the invocation. Child output is always captured; it is emitted live only when verbose/debug logging is on or when running under GitHub Actions (where it is wrapped in a collapsed group). Otherwise the run is quiet with a periodic heartbeat, and the full captured output is replayed if the process fails. .PARAMETER Label Friendly name for the invocation used in narration. Defaults to the executable leaf plus its arguments. .PARAMETER OnStdOutLine Scriptblock invoked with each stdout line as it arrives. When supplied the caller owns stdout rendering, so raw stdout lines are not echoed even when live streaming is on, and the invocation is not wrapped in a collapsed GitHub Actions group: the caller's lines are already the curated progress view and must stay visible. Requires -StreamOutput. .PARAMETER SuccessExitCode Exit codes treated as success for narration and failure replay. Does not affect the AvmProcessException throw, which is governed by -IgnoreExitCode. .OUTPUTS pscustomobject with FileName, ArgumentList, ExitCode, StdOut, StdErr, Duration, DurationMs, StartTime, EndTime, TimedOut. .EXAMPLE PS> Invoke-AvmProcess -FilePath 'terraform' -ArgumentList @('version') #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [string] $FilePath, [string[]] $ArgumentList = @(), [string] $WorkingDirectory, [hashtable] $EnvVars, [int] $TimeoutSec = 0, [switch] $IgnoreExitCode, [switch] $StreamOutput, [string] $Label, [scriptblock] $OnStdOutLine, [int[]] $SuccessExitCode = @(0) ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' if (-not $WorkingDirectory) { $WorkingDirectory = (Get-Location).ProviderPath } $displayLabel = if (-not [string]::IsNullOrWhiteSpace($Label)) { $Label } else { $leaf = [System.IO.Path]::GetFileNameWithoutExtension($FilePath) $joined = if ($ArgumentList.Count -gt 0) { '{0} {1}' -f $leaf, ($ArgumentList -join ' ') } else { $leaf } if ($joined.Length -gt 140) { $joined.Substring(0, 137) + '...' } else { $joined } } $inActions = Test-AvmGitHubActionsContext $narrate = [bool]$StreamOutput $hasLineHook = $null -ne $OnStdOutLine $live = $narrate -and ($inActions -or (Test-AvmVerboseEnabled)) $grouped = $narrate -and $inActions -and -not $hasLineHook $heartbeatSeconds = 30 $nextHeartbeat = $heartbeatSeconds $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = $FilePath $psi.UseShellExecute = $false $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.RedirectStandardInput = $false $psi.CreateNoWindow = $true $psi.WorkingDirectory = $WorkingDirectory # Decode child stdout / stderr as UTF-8 without BOM so output from tools # like terraform and bicep round-trips cleanly even when the host console # is set to a legacy code page (typical on Windows). $utf8NoBom = [System.Text.UTF8Encoding]::new($false) $psi.StandardOutputEncoding = $utf8NoBom $psi.StandardErrorEncoding = $utf8NoBom foreach ($a in $ArgumentList) { $psi.ArgumentList.Add([string]$a) } if ($EnvVars) { foreach ($key in $EnvVars.Keys) { $value = $EnvVars[$key] if ($null -eq $value) { $null = $psi.Environment.Remove([string]$key) } else { $psi.Environment[[string]$key] = [string]$value } } } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $psi $process.EnableRaisingEvents = $false $started = $false $timedOut = $false $stdoutTask = $null $stderrTask = $null $stdoutBuilder = [System.Text.StringBuilder]::new() $stderrBuilder = [System.Text.StringBuilder]::new() $startTime = [datetime]::UtcNow $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() if ($grouped) { Enter-AvmLogGroup -Name $displayLabel } elseif ($narrate) { Write-AvmLog (' run: {0}' -f $displayLabel) -Level Info } try { try { $null = $process.Start() $started = $true } catch [System.ComponentModel.Win32Exception] { throw [AvmProcessException]::new( "Failed to start '$FilePath': $($_.Exception.Message)", $FilePath, $ArgumentList, -1, '', $_.Exception.Message) } if ($StreamOutput) { $stdoutTask = $process.StandardOutput.ReadLineAsync() $stderrTask = $process.StandardError.ReadLineAsync() while ($null -ne $stdoutTask -or $null -ne $stderrTask) { $pending = [System.Collections.Generic.List[System.Threading.Tasks.Task]]::new() if ($null -ne $stdoutTask) { $pending.Add($stdoutTask) } if ($null -ne $stderrTask) { $pending.Add($stderrTask) } if ($pending.Count -gt 0) { $null = [System.Threading.Tasks.Task]::WaitAny($pending.ToArray(), 100) } if ($null -ne $stdoutTask -and $stdoutTask.IsCompleted) { $line = $stdoutTask.GetAwaiter().GetResult() if ($null -eq $line) { $stdoutTask = $null } else { $null = $stdoutBuilder.AppendLine($line) if ($hasLineHook) { & $OnStdOutLine $line } elseif ($live) { Write-AvmLog $line -Level Info } $stdoutTask = $process.StandardOutput.ReadLineAsync() } } if ($null -ne $stderrTask -and $stderrTask.IsCompleted) { $line = $stderrTask.GetAwaiter().GetResult() if ($null -eq $line) { $stderrTask = $null } else { $null = $stderrBuilder.AppendLine($line) if ($live) { Write-AvmLog $line -Level Info } $stderrTask = $process.StandardError.ReadLineAsync() } } if (-not $live -and $stopwatch.Elapsed.TotalSeconds -ge $nextHeartbeat) { Write-AvmLog (' ... still running: {0} ({1})' -f $displayLabel, (Format-AvmDuration -Duration $stopwatch.Elapsed)) -Level Info $nextHeartbeat += $heartbeatSeconds } if ( $TimeoutSec -gt 0 -and -not $timedOut -and -not $process.HasExited -and $stopwatch.Elapsed.TotalSeconds -ge $TimeoutSec ) { $timedOut = $true try { $process.Kill($true) } catch { Write-AvmLog "Failed to kill timed-out process: $($_.Exception.Message)" -Level Verbose } } } } else { $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() if ($TimeoutSec -gt 0) { $exited = $process.WaitForExit([int]($TimeoutSec * 1000)) if (-not $exited) { $timedOut = $true try { $process.Kill($true) } catch { Write-AvmLog "Failed to kill timed-out process: $($_.Exception.Message)" -Level Verbose } } } } $process.WaitForExit() } finally { $stopwatch.Stop() if ($grouped) { Exit-AvmLogGroup } } $endTime = $startTime.AddMilliseconds($stopwatch.Elapsed.TotalMilliseconds) # Drain the async readers. After the process has exited (or been killed) the # child's pipe ends are closed, so these tasks complete with whatever was # buffered. Guard against a faulted task (e.g. a stream disposed abruptly on # kill) by falling back to an empty string. $stdOut = '' $stdErr = '' if ($started) { if ($StreamOutput) { $stdOut = $stdoutBuilder.ToString() $stdErr = $stderrBuilder.ToString() } else { try { $stdOut = $stdoutTask.GetAwaiter().GetResult() } catch { $stdOut = '' } try { $stdErr = $stderrTask.GetAwaiter().GetResult() } catch { $stdErr = '' } } } $exitCode = if ($started) { $process.ExitCode } else { -1 } $process.Dispose() if ($timedOut) { if ($narrate) { Write-AvmLog (' TIMEOUT: {0} (after {1})' -f $displayLabel, (Format-AvmDuration -Duration $stopwatch.Elapsed)) -Level Info } throw [System.TimeoutException]::new( "Process '$FilePath' did not exit within $TimeoutSec seconds; killed.") } $succeeded = $SuccessExitCode -contains $exitCode if ($narrate) { $suffix = '(exit {0}, {1})' -f $exitCode, (Format-AvmDuration -Duration $stopwatch.Elapsed) if ($succeeded) { Write-AvmLog (' done: {0} {1}' -f $displayLabel, $suffix) -Level Info } else { Write-AvmLog (' FAILED: {0} {1}' -f $displayLabel, $suffix) -Level Info if ($hasLineHook) { foreach ($replayLine in (Get-AvmProcessReplayLine -Label $displayLabel -StdErr $stdErr)) { Write-AvmLog $replayLine -Level Info } } elseif (-not $live) { foreach ($replayLine in (Get-AvmProcessReplayLine -Label $displayLabel -StdOut $stdOut -StdErr $stdErr)) { Write-AvmLog $replayLine -Level Info } } } } $result = [pscustomobject][ordered]@{ FileName = $FilePath ArgumentList = $ArgumentList ExitCode = $exitCode StdOut = $stdOut StdErr = $stdErr Duration = $stopwatch.Elapsed DurationMs = [int]$stopwatch.Elapsed.TotalMilliseconds StartTime = $startTime EndTime = $endTime TimedOut = $timedOut } if (-not $IgnoreExitCode -and $exitCode -ne 0) { $argDisplay = if ($ArgumentList.Count -gt 0) { ' ' + ($ArgumentList -join ' ') } else { '' } $message = "Process exited with code $exitCode`: $FilePath$argDisplay" throw [AvmProcessException]::new($message, $FilePath, $ArgumentList, $exitCode, $stdOut, $stdErr) } return $result } function Get-AvmProcessReplayLine { [CmdletBinding()] [OutputType([string[]])] param( [Parameter(Mandatory)] [string] $Label, [AllowEmptyString()] [string] $StdOut = '', [AllowEmptyString()] [string] $StdErr = '' ) $lines = [System.Collections.Generic.List[string]]::new() $body = [System.Collections.Generic.List[string]]::new() foreach ($chunk in @($StdOut, $StdErr)) { if ([string]::IsNullOrWhiteSpace($chunk)) { continue } foreach ($line in $chunk -split "`r?`n") { if ([string]::IsNullOrWhiteSpace($line)) { continue } $body.Add(' ' + $line) } } if ($body.Count -eq 0) { return @() } $lines.Add((' ---- captured output: {0} ----' -f $Label)) $lines.AddRange($body) $lines.Add(' ---- end captured output ----') return $lines.ToArray() } |