C. McCoy IV
C. McCoy IV

Reputation: 897

How to run a progress bar asynchronously while performing silent installs?

The Goal:

I want to asynchronously run a progress bar (which shows elapsed time/estimated time) while waiting for silent installs. For instance,

RunWithProgressBar "cmd /c """" /Wait ""Setup.exe""" $(New-Timespan -Minutes 5)

Closest I've Come:

## Functions

function global:BottleOfBeer {
  $beer = $args[0]
  if ($beer -eq 1) {
    echo "$beer beer on the wall.";
  } elseif ($beer -eq 0) {
    echo "No beer left on the wall!";
  } else {
    echo "$beer beers left on the wall.";
  }
  sleep 1
}

function global:BeersOnTheWall {
  $NumBeers = $args[0]
  for ($i=$NumBeers; $i -ge 0; $i--) {
    BottleOfBeer $i
  }
}

function global:Install {
    cmd /c @"
        "AutoHotkey112306_Install.exe" /S /D="%cd%"
"@
}

function global:Uninstall {
    cmd /c start "" /wait "Installer.ahk" /Uninstall
}

####START Progress Bar Stuff
function global:DisplayProgress {
  $display = $args[0]
  Write-Progress    -Activity "Running..." -Status "$display"
}

function global:FormatDisplay {
  $StartTime = $args[0]
  $RunningTime = ($args[1]).Elapsed
  $EstimatedTime = $args[2]
  $RunningTimeDisplay = $([string]::Format("{0:d2}:{1:d2}:{2:d2}",
    $RunningTime.hours, 
    $RunningTime.minutes, 
    $RunningTime.seconds))
  $EstimatedEnd = $StartTime + $EstimatedTime
  return $([string]::Format("(Start: {0}) (Elapsed/Estimated: {1}/{2}) (EstimatedEnd: {3})",
    $StartTime.ToShortTimeString(), 
    $RunningTimeDisplay, 
    $EstimatedTime,
    $EstimatedEnd.ToShortTimeString()))
}

function global:TearDownProgressBar {
  $job = $args[0]
  $event = $args[1]
  $job,$event | Stop-Job -PassThru | Remove-Job #stop the job and event listener
  Write-Progress -Activity "Working..." -Completed -Status "All done."
}

function RunWithProgressBar {
  $Payload = $args[0]
  $EstimatedTime = $args[1]

  $global:StartTime = Get-Date
  $global:RunningTime = [System.Diagnostics.Stopwatch]::StartNew()
  $global:EstimatedTime = $EstimatedTime

  $progressTask = {
    while($true) {
      Register-EngineEvent -SourceIdentifier MyNewMessage -Forward
      $null = New-Event -SourceIdentifier MyNewMessage -MessageData "Pingback from job."
      Start-Sleep -Seconds 1
    }
  }

  $job = Start-Job -ScriptBlock $progressTask
  $event = Register-EngineEvent -SourceIdentifier MyNewMessage -Action {
    DisplayProgress $(FormatDisplay $global:StartTime $global:RunningTime $global:EstimatedTime)
  }

  try {
    sleep 1
    Invoke-Expression $Payload
  } finally {
    TearDownProgressBar $job $event
  }
}
####END Progress Bar Stuff

## MAIN

RunWithProgressBar "BeersOnTheWall 2"  $(New-Timespan -Seconds 3)
RunWithProgressBar "Install"  $(New-Timespan -Seconds 30)
RunWithProgressBar "Uninstall"  $(New-Timespan -Seconds 5)
RunWithProgressBar "BeersOnTheWall 2"  $(New-Timespan -Seconds 3)

The Problem

Although the above implementation runs as intended, whenever the payload argument of RunWithProgressBar is an install the event which updates the progress bar stops getting triggered.

What I'm Looking For:

How to modify my current implementation to update the progress bar every second, even while performing an install?

Upvotes: 2

Views: 3360

Answers (2)

C. McCoy IV
C. McCoy IV

Reputation: 897

While the solution provided earned the bounty, it's not what I ended up doing. Here's the final version of RunWithProgressBar():

param ( 
    [alias("IM")]
    [bool]$IgnoreMain = $false
)

function RunAsBAT([string]$commands) {
    # Write commands to bat file
    $tempFile = $global:scriptDir + '\TemporaryBatFile.bat'
    $commands = "@echo off `n" + $commands
    Out-File -InputObject $commands -FilePath $tempFile -Encoding ascii
    # Wait for bat file to run
    & $tempFile
    # Delete bat file
    Remove-Item -Path $tempFile
}

function DisplayProgress([string]$display) {
    Write-Progress  -Activity "Running..." -Status "$display"
}

function FormatDisplay([System.DateTime]$StartTime, [System.TimeSpan]$RunningTime, [System.TimeSpan]$EstimatedTime) {
    $RunningTimeDisplay = $([string]::Format("{0:d2}:{1:d2}:{2:d2}",
        $RunningTime.hours, 
        $RunningTime.minutes, 
        $RunningTime.seconds))
    $EstimatedEnd = $StartTime + $EstimatedTime
    return $([string]::Format("(Start: {0}) (Elapsed/Estimated: {1}/{2}) (EstimatedEnd: {3})",
        $StartTime.ToShortTimeString(), 
        $RunningTimeDisplay, 
        $EstimatedTime,
        $EstimatedEnd.ToShortTimeString()))
}

function RunWithProgressBar([scriptblock]$payload, [System.TimeSpan]$EstimatedTime) {
    $global:StartTime = Get-Date
    $global:RunningTime = [System.Diagnostics.Stopwatch]::StartNew()
    $global:EstimatedTime = $EstimatedTime

    try {
        $logFile = $global:scriptDir + '\TemporaryLogFile.txt'
        $StartInfo = New-Object System.Diagnostics.ProcessStartInfo -Property @{
                        FileName = 'Powershell'
                        # load this script but don't run MAIN (to expose functions/variables); 
                        # run the payload (and also log to file);
                        # if error, pause (so the window stays open to display the error)
                        Arguments = ". $global:scriptPath -IM 1; & $payload | Tee-Object -file $logFile;" + ' if ( $LastExitCode -ne 0 ) { cmd /c pause }'
                        UseShellExecute = $true
                    }
        $Process = New-Object System.Diagnostics.Process
        $Process.StartInfo = $StartInfo
        [void]$Process.Start()

        do
        {
            DisplayProgress $(FormatDisplay $global:StartTime ($global:RunningTime).Elapsed $global:EstimatedTime)
            Start-Sleep -Seconds 1
        }
        while (!$Process.HasExited)

    }
    finally {
        if (Test-Path $logFile) {
            Get-Content -Path $logFile
            Remove-Item -Path $logFile
        } else {
            Write-Host "No output was logged..."
        }
        Write-Progress  -Activity "Working..." -Completed -Status "All done."
    }
}

function TestBlockingCall {
    RunAsBAT(@"
        timeout 5
"@)
}

## MAIN

if (-Not $IgnoreMain) {
    RunWithProgressBar { TestBlockingCall } $(New-Timespan -Seconds 7)
}

Upvotes: 1

Martin Brandl
Martin Brandl

Reputation: 58931

I feel like you are trying to reinvent the wheel in your RunWithProgressBar function:

 $progressTask = {
    while($true) {
      Register-EngineEvent -SourceIdentifier MyNewMessage -Forward
      $null = New-Event -SourceIdentifier MyNewMessage -MessageData "Pingback from job."
      Start-Sleep -Seconds 1
    }
  }

  $job = Start-Job -ScriptBlock $progressTask
  $event = Register-EngineEvent -SourceIdentifier MyNewMessage -Action {
    DisplayProgress $(FormatDisplay $global:StartTime $global:RunningTime $global:EstimatedTime)
  }

This is basically a timer and can be refactored:

$timer = new-object timers.timer     
$action = {DisplayProgress $(FormatDisplay $global:StartTime $global:RunningTime $global:EstimatedTime)} 
$timer.Interval = 1000 
Register-ObjectEvent -InputObject $timer -EventName elapsed –SourceIdentifier thetimer -Action $action | out-null
$timer.Start()

However, to solve your problem, you could execute the task in a job and wait until they finish.

Instead of making the functions public, consider define them in a scriptblock:

$initializationScriptBlock = {
    $functions = @{        
        Install = {
            cmd /c '"AutoHotkey112306_Install.exe" /S /D="%cd%"'
        }

        BottleOfBeer = {
          $beer = $args[0]
          if ($beer -eq 1) {
            echo "$beer beer on the wall.";
          } elseif ($beer -eq 0) {
            echo "No beer left on the wall!";
          } else {
            echo "$beer beers left on the wall.";
          }
          sleep 1
        }

        BeersOnTheWall = {
            $NumBeers = $args[0]
            for ($i=$NumBeers; $i -ge 0; $i--) 
            {
                BottleOfBeer $i
            }
        }

        Uninstall =  {
            cmd /c start "" /wait "Installer.ahk" /Uninstall
        }
    }
}

Now, intead of Invoke-Expression $Payload you start a new job by passing the initializationScriptBlock and the actual function $functionToExecute as the scriptblock to execute:

$executingJob =  start-Job -InitializationScript $initializationScriptBlock -ScriptBlock $functionToExecute 
while ((Get-job $executingJob.Id).State -eq 'Running')
{
    sleep 1;
}

Your RunWithProgressBar function now using PowerShell-like function definition:

function RunWithProgressBar 
{
    Param(
        [Parameter(Mandatory=$true, Position=0)]
        [scriptblock]$functionToExecute,

        [Parameter(Mandatory=$true, Position=1)]
        [timespan]$EstimatedTime
    )
....
}

Please consider to also change the rest of the script for readability.

To invoke a function with a progressbar, you first have to load the initializationScriptBlock to the current runspace:

. $initializationScriptBlock

Now you are able to invoke the functions like this:

RunWithProgressBar $functions.BottleOfBeer (New-Timespan -Seconds 3)
RunWithProgressBar $functions.Install (New-Timespan -Seconds 30)

Your whole script now looks like this:

## Functions
$initializationScriptBlock = {
    $functions = @{

        Install = {
        Sleep 5
        <#cmd /c @"
        "AutoHotkey112306_Install.exe" /S /D="%cd%"
"@#>
        }

        BottleOfBeer = {
          $beer = $args[0]
          if ($beer -eq 1) {
            echo "$beer beer on the wall.";
          } elseif ($beer -eq 0) {
            echo "No beer left on the wall!";
          } else {
            echo "$beer beers left on the wall.";
          }
          sleep 1
        }

        BeersOnTheWall = {
            $NumBeers = $args[0]
            for ($i=$NumBeers; $i -ge 0; $i--) 
            {
                BottleOfBeer $i
            }
        }

        Uninstall =  {
            cmd /c start "" /wait "Installer.ahk" /Uninstall
        }

    }

}

####START Progress Bar Stuff
function global:DisplayProgress {
  $display = $args[0]
  Write-Progress    -Activity "Running..." -Status "$display"
}

function global:FormatDisplay {
  $StartTime = $args[0]
  $RunningTime = ($args[1]).Elapsed
  $EstimatedTime = $args[2]
  $RunningTimeDisplay = $([string]::Format("{0:d2}:{1:d2}:{2:d2}",
    $RunningTime.hours, 
    $RunningTime.minutes, 
    $RunningTime.seconds))
  $EstimatedEnd = $StartTime + $EstimatedTime
  return $([string]::Format("(Start: {0}) (Elapsed/Estimated: {1}/{2}) (EstimatedEnd: {3})",
    $StartTime.ToShortTimeString(), 
    $RunningTimeDisplay, 
    $EstimatedTime,
    $EstimatedEnd.ToShortTimeString()))
}


function RunWithProgressBar 
{
    Param(
        [Parameter(Mandatory=$true, Position=0)]
        [scriptblock]$functionToExecute,

        [Parameter(Mandatory=$true, Position=1)]
        [timespan]$EstimatedTime
    )

  $global:StartTime = Get-Date
  $global:RunningTime = [System.Diagnostics.Stopwatch]::StartNew()
  $global:EstimatedTime = $EstimatedTime


  $timer = new-object timers.timer 

    $action = {DisplayProgress $(FormatDisplay $global:StartTime $global:RunningTime $global:EstimatedTime)} 
    $timer.Interval = 1000  

    Register-ObjectEvent -InputObject $timer -EventName elapsed –SourceIdentifier thetimer -Action $action | out-null

    $timer.Start()

  try {
    $executingJob =  start-Job -InitializationScript $initializationScriptBlock -ScriptBlock $functionToExecute
    while ((Get-job $executingJob.Id).State -eq 'Running')
    {
        sleep 1;
    }

  } finally {
    $timer.stop() 

    Unregister-Event thetimer
  }
}
####END Progress Bar Stuff

## MAIN
. $initializationScriptBlock

RunWithProgressBar $functions.BottleOfBeer (New-Timespan -Seconds 3)
RunWithProgressBar $functions.Install (New-Timespan -Seconds 30)
RunWithProgressBar $functions.Uninstall (New-Timespan -Seconds 5)
RunWithProgressBar $functions.BeersOnTheWall (New-Timespan -Seconds 3)

Your script should work now even there are still a lot to refactor e. g. function names, scopes

Answer to your comment:

1.) You can use Receive-Job inside your loop to retrieve the output:

$executingJob =  start-Job -InitializationScript $initializationScriptBlock -ScriptBlock $functionToExecute
while ((Get-job $executingJob.Id).State -eq 'Running')
{
    Receive-Job $executingJob.Id
    sleep 1;
}
Receive-Job $executingJob.Id

2.) After you executed the $initializationScriptBlock using:

. $initializationScriptBlock

You can use any functions:

& $functions.BottleOfBeer

Upvotes: 3

Related Questions