Public/New-HDTProcessService.ps1

function New-HDTProcessService {
    <#
        .SYNOPSIS
            Creates the real IProcessService adapter, which starts a native
            process and returns its exit code and output.
 
        .DESCRIPTION
            The one place in HDT that starts a process. A native tool
            exit codes are checked explicitly; $LASTEXITCODE is never assumed to
            be zero" - so the exit code comes back as DATA on a result object
            rather than being left in an automatic variable somebody has to
            remember to read.
 
              Start($FilePath, $Argument, $WorkingDirectory, $TimeoutMillisecond)
                -> ExitCode, StandardOutput, StandardError, TimedOut, DurationMs
 
            A TimeoutMillisecond of 0 waits indefinitely. On timeout the process
            is KILLED, TimedOut is $true and ExitCode is -1: a step that hung must
            not leave the process behind for the next step to trip over.
 
            It is a ProcessStartInfo + WaitForExit(timeout) adapter and nothing
            more - no branching on what the tool was or what it returned, which
            is what keeps the untested surface bounded. Deciding
            whether an exit code means success belongs to the CommandLine step,
            which is unit tested against the fake.
 
            OUTPUT IS READ ASYNCHRONOUSLY, before WaitForExit. A process that
            fills the 4 KB pipe buffer while the parent waits on exit deadlocks -
            the classic redirect trap - so both streams are drained by handler
            into a builder as they arrive.
 
            It is a [pscustomobject] carrying ScriptMethod members rather than a
            PowerShell class: classes dot-sourced into the module are the known
            flaky path across -Force re-imports (see 01-03).
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with a Start
            ScriptMethod. Note that Get-Member -MemberType Method does NOT list a
            ScriptMethod - use -MemberType Method, ScriptMethod.
 
        .EXAMPLE
            $process = New-HDTProcessService
            $process.Start('cmd.exe', '/c echo hello', '', 30000)
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds a stateless service adapter object; it changes no state.')]
    [CmdletBinding()]
    [OutputType([object])]
    param()

    $service = [pscustomobject] @{
        ServiceName = 'ProcessService'
        Operations  = [System.Collections.ArrayList]::new()
        Journal     = $null
    }

    $service | Add-Member -MemberType ScriptMethod -Name Record -Value {
        param([string] $Operation, [object[]] $Argument)

        [void] $this.Operations.Add([pscustomobject] @{
                Sequence  = $this.Operations.Count + 1
                Operation = $Operation
                Arguments = $Argument
            })

        if ($null -ne $this.Journal) {
            [void] $this.Journal.Add([pscustomobject] @{
                    Sequence  = $this.Journal.Count + 1
                    Service   = $this.ServiceName
                    Operation = $Operation
                    Arguments = $Argument
                })
        }
    }

    $service | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value {
        return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation }))
    }

    $service | Add-Member -MemberType ScriptMethod -Name Start -Value {
        param([string] $FilePath, [string] $Argument, [string] $WorkingDirectory, [int] $TimeoutMillisecond)

        $this.Record('Start', @($FilePath, $Argument, $WorkingDirectory, $TimeoutMillisecond))

        $startInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
        $startInfo.FileName = $FilePath
        $startInfo.Arguments = $Argument
        $startInfo.UseShellExecute = $false
        $startInfo.RedirectStandardOutput = $true
        $startInfo.RedirectStandardError = $true
        $startInfo.CreateNoWindow = $true

        if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) {
            $startInfo.WorkingDirectory = $WorkingDirectory
        }

        $standardOutput = New-Object -TypeName System.Text.StringBuilder
        $standardError = New-Object -TypeName System.Text.StringBuilder

        $process = New-Object -TypeName System.Diagnostics.Process
        $process.StartInfo = $startInfo

        $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

        try {
            [void] $process.Start()

            # Drained asynchronously: waiting on exit while the child fills the
            # pipe buffer is the classic redirect deadlock.
            $outputTask = $process.StandardOutput.ReadToEndAsync()
            $errorTask = $process.StandardError.ReadToEndAsync()

            $timedOut = $false
            if ($TimeoutMillisecond -gt 0) {
                $timedOut = -not $process.WaitForExit($TimeoutMillisecond)
            } else {
                $process.WaitForExit()
            }

            if ($timedOut) {
                $process.Kill()
                [void] $process.WaitForExit(5000)
            }

            [void] $standardOutput.Append($outputTask.Result)
            [void] $standardError.Append($errorTask.Result)

            $exitCode = -1
            if (-not $timedOut) {
                $exitCode = $process.ExitCode
            }

            return [pscustomobject] @{
                ExitCode       = $exitCode
                StandardOutput = $standardOutput.ToString()
                StandardError  = $standardError.ToString()
                TimedOut       = $timedOut
                DurationMs     = [long] $stopwatch.ElapsedMilliseconds
            }
        } finally {
            $stopwatch.Stop()
            $process.Dispose()
        }
    }

    # A PROCESS NOBODY WAITS FOR, AND WITH A WINDOW. Start above is right for a
    # command line step: both pipes redirected, CreateNoWindow, wait for exit.
    # Every one of those is wrong for MDT's "Exit to Command Prompt" - an
    # interactive prompt has no output to capture, MUST have a window, and must
    # not block the thread that opened it.
    #
    # UseShellExecute = $true IS WHAT GIVES IT A CONSOLE. With it false the
    # child inherits this process's handles and, in WinPE, opens into the
    # console the wizard hid - a prompt the technician cannot see.
    #
    # SO IT IS A SEPARATE VERB RATHER THAN A FLAG ON Start. A caller cannot then
    # get an interactive prompt by passing the wrong timeout, and neither method
    # has to branch on which kind of process it is running.
    $service | Add-Member -MemberType ScriptMethod -Name StartInteractive -Value {
        param([string] $FilePath, [string] $Argument, [string] $WorkingDirectory)

        $this.Record('StartInteractive', @($FilePath, $Argument, $WorkingDirectory))

        $startInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo
        $startInfo.FileName = $FilePath
        $startInfo.Arguments = $Argument
        $startInfo.UseShellExecute = $true
        $startInfo.CreateNoWindow = $false

        if (-not [string]::IsNullOrWhiteSpace($WorkingDirectory)) {
            $startInfo.WorkingDirectory = $WorkingDirectory
        }

        $process = [System.Diagnostics.Process]::Start($startInfo)

        return [pscustomobject] @{
            ProcessId = [int] $process.Id
            FilePath  = $FilePath
        }
    }

    return $service
}