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"

 

 

 

Launch a Process using PowerShell and Start-Process

This blog post explains how we can launch a process using PowerShell and Start-Process.

In its most simplistic form, we can launch a process and essentially forget about it.  In this example we’re going to launch Google Chrome.  You will note by reading the output that it will launch Chrome and carry on with the rest of our script without waiting.

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
write-host "Launching $pathToChrome"
Start-Process -FilePath $pathToChrome
write-host "Finished"

Maybe we want some user input to complete a form?  So we need to specify an argument to the executable we are launching.  In this example, we are passing a web address to Chrome so the browser opens on our website:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
Start-Process -FilePath $pathToChrome -ArgumentList $arguments
write-host "Finished"

You’ll notice that script execution continues after we launch Chrome.  But what if we want to wait for the user to complete a web-based form and close the browser?  We can simply add the -Wait parameter to Start-Process like so:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
Start-Process -FilePath $pathToChrome -ArgumentList $arguments -Wait
write-host "Finished"

And since the -ArgumentList parameter is an array of strings, we can pass multiple arguments to our executable.  In this example we are opening the Alkane Solutions website in full screen mode:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "--start-fullscreen","https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
Start-Process -FilePath $pathToChrome -ArgumentList $arguments -Wait
write-host "Finished"

In many instances we want to launch and executable and check for its exit code – usually an exit code of 0 represents a success, and any other exit code represents a failure of some sort.

To retrieve the exit code of our launched process, we must use the -Wait and the -PassThru parameter like so:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "--start-fullscreen","https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
$exitCodeObject = Start-Process -FilePath $pathToChrome -ArgumentList $arguments -Wait -PassThru
$exitCode = $exitCodeObject.ExitCode
write-host "Exit code is $exitCode"
write-host "Finished"

We also posted an example previously of how we can install and uninstall windows installer MSI files using Start-Process in a similar way.

We can also request that the executable is “Run As Administrator” by specifying the RunAs verb.

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "--start-fullscreen","https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
$exitCodeObject = Start-Process -FilePath $pathToChrome -ArgumentList $arguments -Wait -PassThru -Verb RunAs
$exitCode = $exitCodeObject.ExitCode
write-host "Exit code is $exitCode"
write-host "Finished"

If you launch it from a “normal” user account you will be prompted to enter credentials.  To find out which verbs you can use when running a process, you can utilise the following command:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$startExe = New-Object System.Diagnostics.ProcessStartInfo -Args $pathToChrome
$startExe.verbs

Finally, we can request that a process is launched as another user.  To do this, we need to prompt the user for credentials by using Get-Credential.  And we can pass the output of this to Start-Process using the -Credential parameter like so:

$pathToChrome = "$env:ProgramFiles\Google\Chrome\Application\Chrome.exe"
$arguments = "--start-fullscreen","https://www.alkanesolutions.co.uk"

write-host "Launching $pathToChrome"
write-host "Requesting credentials"
$cred = Get-Credential
if ($cred -ne $null) {
    $exitCodeObject = Start-Process -FilePath $pathToChrome -ArgumentList $arguments -Wait -PassThru -Credential $cred
    $exitCode = $exitCodeObject.ExitCode
    write-host "Exit code is $exitCode"
}
write-host "Finished"

 

Installing and Uninstalling an MSI using PowerShell

This blog post shows code that can be used when installing and uninstalling an MSI using PowerShell. You can adapt the code to add more parameters (public properties etc) as necessary.

You can use this chunk of PowerShell code to install or uninstall an MSI:

$MSIArguments = @(
    "/i"
    "`"C:\path with spaces\alkane.msi`""
    "/qb"
    "/norestart"
    "/l*v"
    "`"C:\path with spaces\alkane.log`""
)

Start-Process "msiexec.exe" -ArgumentList $MSIArguments -Wait -NoNewWindow

Start an App-V 5 Process Immediately After Publishing

I have a piece of software that acts as an ‘agent’ sitting in the background (in the System Tray) waiting to intercept print requests.  It needs to be running all the time since it intercepts print requests from ANY desktop application.  So this blog posts explains how to start an App-V 5 process immediately after publishing.

Start an App-V 5 Process Immediately After Publishing

The target platforms are a combination of standard desktops and non-persistent VDI desktops.  Making it work with standard desktops wasn’t really the problem – by default the software placed a shortcut in the Startup folder and after publishing, logging off and logging in the background process started.

The trouble with a non-persistent VDI is that when a user logs off and logs back in, they get a completely fresh desktop experience and all the apps get published again.  As a result of this we can’t just lump some logic in a Run key or the Startup folder since the application is published after these features are processed.

Combined with this there were another 2 applications that needed connection-grouping with this agent application.

Unless there’s a more elegant way of achieving this that I’m not aware of, I present my solution forthwith:

Start an App-V 5 Process Using PublishPackage

Firstly, since we’re publishing to users, I have added a user script at publish time in the AppxManifest.xml file (or you could use the UserConfig.xml file – your choice depending on which version of App-V you’re using):

		
<appv:UserScripts>
        <appv:PublishPackage>
            <appv:Path>cmd.exe</appv:Path>
            <appv:Arguments>/c START "" "powershell.exe" -ExecutionPolicy ByPass -WindowStyle Hidden -File "[{AppVPackageRoot}]\..\Scripts\runProcess.ps1"</appv:Arguments>
            <appv:Wait RollbackOnError="false" />
        </appv:PublishPackage>
    </appv:UserScripts>
	

What’s important to note is that we can’t just point at our background process for 2 reasons:

  1. Because the application is not yet published at this point, and so we can’t access/run it
  2. Even if we could access/run it, the publishing process would stick at running the .exe since the .exe will always be running and the PublishPackage action will never release the handle (unless we manually kill it).  And hence the package would never publish!

Instead we’ve called cmd.exe with the START command.  What this does is launch powershell.exe as a separate process and does NOT wait for it to complete.  Hence the publishing of the app will complete as normal.  Meanwhile in the background, powershell.exe runs a script that I’ve placed in the Scripts folder in the background.  So what does the script do?  In essence it:

  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to get the package (Get-AppvClientPackage) and ensure it is published to the user (IsPublishedToUser).  We cannot run the process if the package is not published.
  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to get the connection group (Get-AppvClientConnectionGroup) and ensure it is enabled (IsEnabledToUser).  If using a connection group, we cannot enable a connection group if a package is in use!  So we need to wait until the connection group is enabled before we attempt to start the process.
  • Loops 10 times maximum, with a 5 second delay between each loop, and attempts to start the process (Start-Process).  There’s no reason why it shouldn’t start first time, but I still add in some retries just in case.

Here is the script (this supports Connection Groups – see below if you are not using a connection group)

#variable to store the current script folder (in case, for ease, we want to put our exe's in the same folder as the script)
$ScriptFolder = $PSScriptRoot

#path that points to the 'Root' folder inside an app-v package
$appvRoot = split-path $ScriptFolder -parent
$appvRoot = $appvRoot + "\Root"

#default number of attempts
$attempts = 10
#path to exe inside app-v package
$processPath = "$appvRoot\VFS\ProgramFilesX86\Equitrac\Express\Client\EQMsgClient.exe"
#process name without exe (used with Get-Process)
$processName = "EQMsgClient"
#name of App-V package
$packageName = "Nuance_EquitracExpressClient_5.4.23.4801"
#name of connection group
$connectionGroupName = "Equitrac Express"

#initialise
$packageObject = $false
$connectionGroupObject = $false
$newProcess = $null

$eventMessage = "Attempting to run: " + $processPath
write-host $eventMessage


#wait until package is published
$packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue

while (($packageObject -eq $null -Or (!($packageObject.IsPublishedToUser))) -And $attempts -gt 0)
{   
    Start-Sleep -s 5
    $attempts = $attempts - 1

    $eventMessage = "Checking if the following package is published: " + $packageName + " (Attempts remaining: " + $attempts + ")"
    write-host $eventMessage  

    $packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue  
}


if ($packageObject.IsPublishedToUser)
{
    $eventMessage = "The following package is published: " + $packageName
    write-host $eventMessage

    #now we need to check that the connection group has applied and is published!
    $connectionGroupObject = Get-AppvClientConnectionGroup -Name $connectionGroupName -ErrorAction SilentlyContinue
    
    #reset attempts
    $attempts = 10

    while (($connectionGroupObject -eq $null -Or (!($connectionGroupObject.IsEnabledToUser))) -And $attempts -gt 0)
    {   
        Start-Sleep -s 5
        $attempts = $attempts - 1

        $eventMessage = "Checking if the following connection group is enabled: " + $connectionGroupName + " (Attempts remaining: " + $attempts + ")"
        write-host $eventMessage

        $connectionGroupObject = Get-AppvClientConnectionGroup -Name $connectionGroupName -ErrorAction SilentlyContinue
    }

    
    if ($connectionGroupObject.IsEnabledToUser)
    {
        $eventMessage = "The following connection group is enabled: " + $connectionGroupName
        write-host $eventMessage

        #reset attempts
        $attempts = 10

        #now try and start our process
        while (((Get-Process -Name $processName -ErrorAction SilentlyContinue) -eq $null) -And $attempts -gt 0)
        {
            if (Test-Path $processPath)
            {
                $newProcess = Start-Process -FilePath $processPath -PassThru -NoNewWindow -ErrorAction SilentlyContinue       
            }
            else
            {
                $eventMessage = "Process path : " + $processPath + " was not found.  Unable to start process."
                write-host $eventMessage
                break
            }
            $attempts = $attempts - 1

            $eventMessage = "Trying to start process: " + $processPath + " (Attempts remaining: " + $attempts + ")"
            write-host $eventMessage

            Start-Sleep -s 5
        }
        
        if ($newProcess -eq $null)
        {
            $eventMessage = "Process : " + $processPath + " could not be launched."
            write-host $eventMessage
        }
        else
        {
            $eventMessage = "Process : " + $processPath + " was launched successfully."
            write-host $eventMessage
        }
        
    }
    else
    {
        $eventMessage = "Connection Group : " + $connectionGroupName + " was not enabled.  Unable to start process."
        write-host $eventMessage
    }
}
else
{
    $eventMessage = "Package : " + $packageName + " was not published.  Unable to start process."
    write-host $eventMessage
}

Here is the same script (without Connection Group logic)

#now start SAP process
#variable to store the current script folder (in case, for ease, we want to put our exe's in the same folder as the script)
$ScriptFolder = $PSScriptRoot

#path that points to the 'Root' folder inside an app-v package
$appvRoot = split-path $ScriptFolder -parent
$appvRoot = $appvRoot + "\Root"

#default number of attempts
$attempts = 10
#path to exe inside app-v package
$processPath = "$appvRoot\VFS\ProgramFilesX86\SAP\FrontEnd\SecureLogin\bin\sbus.exe"
#process name without exe (used with Get-Process)
$processName = "sbus"
#name of App-V package
$packageName = "W10_SAP_SAPBusinessUser_7.50_V_P1"

#initialise
$packageObject = $false
$newProcess = $null

$eventMessage = "Attempting to run: " + $processPath
write-host $eventMessage

#wait until package is published
$packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue

while (($packageObject -eq $null -Or (!($packageObject.IsPublishedToUser))) -And $attempts -gt 0)
{   
    Start-Sleep -s 5
    $attempts = $attempts - 1

    $eventMessage = "Checking if the following package is published: " + $packageName + " (Attempts remaining: " + $attempts + ")"
    write-host $eventMessage  

    $packageObject = Get-AppvClientPackage -Name $packageName -ErrorAction SilentlyContinue  
}


if ($packageObject.IsPublishedToUser)
{
    $eventMessage = "The following package is published: " + $packageName
    write-host $eventMessage
   
    #now try and start our process
    while (((Get-Process -Name $processName -ErrorAction SilentlyContinue) -eq $null) -And $attempts -gt 0)
    {
        if (Test-Path $processPath)
        {
            $newProcess = Start-Process -FilePath $processPath -PassThru -NoNewWindow -ErrorAction SilentlyContinue       
        }
        else
        {
            $eventMessage = "Process path : " + $processPath + " was not found.  Unable to start process."
            write-host $eventMessage
            break
        }
        $attempts = $attempts - 1

        $eventMessage = "Trying to start process: " + $processPath + " (Attempts remaining: " + $attempts + ")"
        write-host $eventMessage

        Start-Sleep -s 5
    }
       write-host $newProcess
	   
    if ($newProcess -eq $null)
    {
        $eventMessage = "Process : " + $processPath + " could not be launched."
        write-host $eventMessage
    }
    else
    {
        $eventMessage = "Process : " + $processPath + " was launched successfully."
        write-host $eventMessage
    }
		
}
else
{
    $eventMessage = "Package : " + $packageName + " was not published.  Unable to start process."
    write-host $eventMessage
}

Things to tweak

The variables at the top (package names etc).  Also remember this targets packages published to the user, not the machine.  So you’ll need to tweak things like ‘isEnabledToUser’ and ‘isPublishedToUser’ etc.  Also you may not be using a connection group like me, so you could chop that logic out too.

A note before you commence…

Since the background process is always running, it obviously can’t be unpublished instantly (because the package will be in use).  Luckily we’re running the App-V 5 SP2 client which will set up a pending task to un-publish it when the user logs off and back in.  You may want to consider this before implementing it.