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.

    .OUTPUTS
        pscustomobject with FileName, ArgumentList, ExitCode, StdOut, StdErr,
        Duration, 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
    )

    Set-StrictMode -Version 3.0
    $ErrorActionPreference = 'Stop'

    if (-not $WorkingDirectory) {
        $WorkingDirectory = (Get-Location).ProviderPath
    }

    $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
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
    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)
        }

        # Capture stdout / stderr by reading each stream to end on its own
        # asynchronous task. A single reader per stream preserves the exact
        # order of the child's output. The previous Register-ObjectEvent
        # approach dispatched OutputDataReceived callbacks through the runspace
        # event queue, which reordered rapid multi-line bursts (e.g. terraform's
        # `validate -json` payload) and corrupted the captured text because the
        # shared StringBuilder was appended from multiple job threads. Using one
        # task per stream also avoids the full-buffer deadlock that a single
        # synchronous ReadToEnd would risk when a child writes heavily to both
        # streams at once.
        $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-Verbose "Failed to kill timed-out process: $($_.Exception.Message)" }
            }
        }
        # Block until the process has fully exited (also after a kill) so the
        # exit code is readable and the async stream tasks reach EOF.
        $process.WaitForExit()
    }
    finally {
        $stopwatch.Stop()
    }

    # 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) {
        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) {
        throw [System.TimeoutException]::new(
            "Process '$FilePath' did not exit within $TimeoutSec seconds; killed.")
    }

    $result = [pscustomobject][ordered]@{
        FileName     = $FilePath
        ArgumentList = $ArgumentList
        ExitCode     = $exitCode
        StdOut       = $stdOut
        StdErr       = $stdErr
        Duration     = $stopwatch.Elapsed
        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
}