Setting a Timeout in PowerShell Scripts to Prevent Hanging

This blog explains setting a timeout in PowerShell scripts to prevent hanging.

There’s nothing worse than iterating through a list of commands and the whole workflow grinds to a halt because one command is hanging.  But resolving the issue is not as trivial as it first seems.

“Use a PowerShell job!” is what I hear you shout.  But this method exposes a couple of issues.  The first is that if the job spawns another process (notepad.exe for example) and the job times out after the specified timeout period, we can remove the job but the notepad.exe process stays running!  And we can’t get the process ID to kill it because to keep the job running, we generally need to use Start-Process with the -Wait and -PassThru parameters, and because the process hasn’t ended it won’t return the process object (which includes the Process ID and the Exit Code)!

The second issue is that if we don’t use the -wait parameter, the job will end instantly and it will return the process object where we can obtain the running process ID, but it won’t return an exit code because the process is still running!

There were other annoying complexities I found along the way too, but thankfully after hours of head scratching I found a solution.

Use PowerShell Script Blocks and Wait-Process for PowerShell Script Timeout

Do not use PowerShell jobs if you want to use timeouts in your PowerShell scripts.  Use processes instead which invoke script blocks.

Below we provide an example of how we can invoke script blocks that will provide valid return codes (-1 for a timeout) and not leave any redundant processes running!

#function returns -1 if it times out.  Otherwise the exit code of the scriptblock.

function Invoke-ScriptBlock {
    param([string]$scriptBlock,[int]$timeoutSeconds)

    #encode script to obfuscate
    $encodedScriptBlock = [convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($scriptBlock))
    $Arguments =   "-Noexit", "-NoLogo", "–NoProfile", "-ExecutionPolicy Bypass", "-EncodedCommand $encodedScriptBlock"   

    $ProcessStartInfoParam = [ordered]@{
        Arguments       = $Arguments -join " "
        FileName        = "powershell.exe"
        LoadUserProfile = $false
        UseShellExecute = $false   
        CreateNoWindow = $true
    }

    $ProcessStartInfo = New-Object -TypeName "System.Diagnostics.ProcessStartInfo" -Property $ProcessStartInfoParam
    $proc = New-Object "System.Diagnostics.Process"
    $proc.StartInfo = $ProcessStartInfo
    $proc.Start() | Out-Null
       
    $timedOut = $null
    $proc | Wait-Process -Timeout $timeoutSeconds -ErrorAction SilentlyContinue -ErrorVariable timedOut

    if ($timedOut)
    {
        #timed out
        $parentProcessId = $proc.Id
        $parentProcessName = $proc.Name       

        foreach ($childProcess in (gwmi win32_process -Filter "ParentProcessId='$parentProcessId'")) {
            $childProcessId = $childprocess.processid
            $childProcessName = $childProcess.Name            
            & taskkill /PID $childProcessId /f /t /fi "STATUS eq RUNNING" 2>&1>$null
        }
        return -1
    }
    else
    {   
        return $proc.ExitCode
    }
}

#EXAMPLE 1 - SPAWNING A PROCESS
#Launch msiexec.exe.  If we close it within 5 seconds it will return a 1639 exit code.
#If we leave the dialog open, it will time out and return -1 
$sb = { 
        exit (start-process msiexec.exe -passthru -wait).ExitCode
}

$exitCode = Invoke-ScriptBlock $sb 5
write-host "Exit code was $exitCode"

#EXAMPLE 2 - RUNNING A CMDLET
#Sleep for 6 seconds
$sb = {
    try {
        start-sleep -seconds 6
        exit 0
    } catch {
        exit -999
    }
}

#change timeout to 5 seconds (less than sleep duration) to return -1 and a timeout
#change timeout to 7 seconds (more than the sleep duration) to return 0 and a success
$exitCode = Invoke-ScriptBlock $sb 7
write-host "Exit code was $exitCode"

#EXAMPLE 3 - PASS PARAMETERS TO SCRIPT BLOCK
#(Check output in c:\Alkane\Alkane.txt)

$first = "Alkane"
$last = "Solutions"

$params = -join ("Param(",                                             
"[string]`$first = '$first',",
"[string]`$last = '$last'",
")")

$sb = $params + { 
    try {
        "$first $last" | Out-File c:\Alkane\Alkane.txt
        exit 0
    } catch {
        exit -999
    }
}

$exitCode = Invoke-ScriptBlock $sb 5
write-host "Exit code was $exitCode"