Private/Start-StepWatchdog.ps1

function Start-StepWatchdog {
    <#
        .SYNOPSIS
        Arms a timer that stops a step's child processes when the step runs long.
 
        .DESCRIPTION
        Steps run in this process and close over the run's variables and this
        module's private functions, so a step cannot be moved somewhere it could
        be stopped from outside. What can run elsewhere is a watchdog: a second
        runspace on its own thread that sleeps for the budget and, if it wakes
        before Stop-StepWatchdog is called, kills every process descended from
        this one. The hung native command -- winget, msiexec, npm -- then returns,
        and the step fails with the reason instead of the run being stopped at
        the task's execution time limit with no summary.
 
        A step that hangs inside a cmdlet in this process, such as a Windows
        Update scan that never returns, has no child to kill and is not helped.
 
        Returns an object with State (Fired, Killed), or nothing when a runspace
        could not be created, in which case the step runs unguarded.
 
        .PARAMETER TimeoutSeconds
        How long the step may run before its children are stopped.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Arms a timer in this process. What it may stop later is the step the caller is already running, and the step reports it.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateRange(1, 86400)]
        [int] $TimeoutSeconds
    )

    # Synchronized, because the watchdog thread writes and the step thread reads.
    $state = [hashtable]::Synchronized(@{
        Fired  = $false
        Killed = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
        Error  = $null
    })

    # Process ids from CIM are UInt32 and the parent's is Int32; compared as
    # they arrive, nothing matches and nothing is stopped.
    $script = {
        param($ParentPid, $TimeoutSeconds, $State)
        try {
            Start-Sleep -Seconds $TimeoutSeconds
            $State.Fired = $true
            $all = @(Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId, Name -ErrorAction Stop)
            $seen = @{}
            $frontier = @([int] $ParentPid)
            $targets = @()
            while ($frontier.Count) {
                $next = @($all | Where-Object { [int] $_.ParentProcessId -in $frontier -and -not $seen.ContainsKey([int] $_.ProcessId) })
                foreach ($p in $next) { $seen[[int] $p.ProcessId] = $true }
                $targets += $next
                $frontier = @($next | ForEach-Object { [int] $_.ProcessId })
            }
            foreach ($t in $targets) {
                try {
                    Stop-Process -Id $t.ProcessId -Force -ErrorAction Stop
                    $null = $State.Killed.Add("$($t.Name) ($($t.ProcessId))")
                } catch {
                    $null = $State.Killed.Add("$($t.Name) ($($t.ProcessId)) would not stop: $($_.Exception.Message)")
                }
            }
        } catch {
            $State.Error = $_.Exception.Message
        }
    }

    try {
        $runspace = [runspacefactory]::CreateRunspace()
        $runspace.Open()
        $powershell = [powershell]::Create()
        $powershell.Runspace = $runspace
        $null = $powershell.AddScript($script.ToString()).AddArgument($PID).AddArgument($TimeoutSeconds).AddArgument($state)
        $handle = $powershell.BeginInvoke()
    } catch {
        Write-Verbose "Could not start the step watchdog: $($_.Exception.Message)"
        return
    }

    [pscustomobject]@{
        State      = $state
        PowerShell = $powershell
        Runspace   = $runspace
        Handle     = $handle
        Seconds    = $TimeoutSeconds
    }
}