Modules/DotForge.Catalog/Private/Invoke-DFBoundedProcess.ps1

#Requires -Version 7.0

function Invoke-DFBoundedProcess {
    <#
    .SYNOPSIS
        Runs an executable with a time limit and returns its output lines and exit code.
    .DESCRIPTION
        For outside programs that can hang (winget waiting on a source update or
        a stuck network call). Standard output is captured and split into lines
        the way PowerShell splits a native command's output; standard error is
        discarded. A process still running after -TimeoutSeconds is killed with
        its child processes, and the call throws, naming the command.
        Output is decoded with [Console]::OutputEncoding, as `& exe` would.
    .PARAMETER FilePath
        The executable's full path.
    .PARAMETER ArgumentList
        Arguments, each passed as one argument (no quoting needed).
    .PARAMETER TimeoutSeconds
        How long to wait before killing the process.
    .OUTPUTS
        pscustomobject: Lines (string[]), ExitCode (int).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FilePath,
        [string[]]$ArgumentList = @(),
        [Parameter(Mandatory)][ValidateRange(1, 3600)][int]$TimeoutSeconds
    )
    $startInfo = [System.Diagnostics.ProcessStartInfo]::new($FilePath)
    foreach ($argument in $ArgumentList) { $startInfo.ArgumentList.Add($argument) }
    $startInfo.UseShellExecute = $false
    $startInfo.CreateNoWindow = $true
    $startInfo.RedirectStandardInput = $true
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $startInfo.StandardOutputEncoding = [Console]::OutputEncoding

    $process = [System.Diagnostics.Process]::Start($startInfo)
    try {
        $process.StandardInput.Close()
        # Read both streams asynchronously: a full, unread pipe would block the child.
        $stdout = $process.StandardOutput.ReadToEndAsync()
        $null = $process.StandardError.ReadToEndAsync()
        if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
            try { $process.Kill($true) } catch { Write-Verbose "DotForge: couldn't kill '$FilePath': $($_.Exception.Message)" }
            $command = (@([System.IO.Path]::GetFileName($FilePath)) + $ArgumentList) -join ' '
            throw "DotForge: '$command' did not finish within $TimeoutSeconds s and was stopped."
        }
        $process.WaitForExit()   # lets the redirected streams drain
        $text = $stdout.GetAwaiter().GetResult()
        $lines = @($text -split '\r?\n')
        if ($lines.Count -and $lines[-1] -eq '') { $lines = @($lines | Select-Object -SkipLast 1) }
        [pscustomobject]@{ Lines = [string[]]$lines; ExitCode = $process.ExitCode }
    } finally {
        $process.Dispose()
    }
}