5

I've created a Windows form where you can click a button that starts a backup process (Using Start-Job) of about 15 minutes. I used Start-Job in order to keep the form responsive during the backup process (By responsive I mean you can move it around, minimize it and so on). However, I would like the form to pop up a message box once the job is completed and I can't manage to get to the right result.

At first I tried a While loop that checks every 10 seconds if the job is completed:

$BackupButton.Add_Click( {

        $BackupJob = Start-Job -ScriptBlock { ... }
        $Completed = $false
        while (!($Completed)) {
            if ($BackupJob.State -ne "Running") {
                $Completed = $true
            }
            Start-Sleep -Seconds 10
        }
        [System.Windows.Forms.MessageBox]::Show('Successfully completed the backup process.', 'Backup Tool', 'OK', 'Info')
    })

This gave me the message box after the job completed but the form was unresponsive during the process, probably because it was still using the thread's resources for the While loop.

Then, I tried using Register-ObjectEvent to call the message box to show when the job's state has changed:

$BackupButton.Add_Click( {

        $BackupJob = Start-Job -ScriptBlock { ... }
        Register-ObjectEvent $BackupJob StateChanged -Action {
            [System.Windows.Forms.MessageBox]::Show('Successfully completed the backup process.', 'Backup Tool', 'OK', 'Info')
        }
    })

This option did keep the form responsive during the process, but the message box (The event's action block) never started, until the moment I closed the Windows form.

Is there any option that will both make the message box appear on time (Not when the form closes) and not use the form's thread (Keep it responsive)?

Edit: Alternatively, is there a way to control my form from the background job? I tried to send the form's buttons/controls as arguments to the job and then control the form's events from the job but it didn't work. If there's a way to somehow access the form from the background job, this will also solve my problem.

Thanks in advance.

NightJob
  • 53
  • 4

2 Answers2

3

The Start-Sleep cmdlet makes your form unresponsive. To overcome that, use a System.Windows.Forms.Timer object instead.

Something like:

$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = 1000   # for demo 1 second
$timer.Enabled = $false  # disabled at first
$timer.Add_Tick({
    # check every 'Interval' milliseconds to see if the backup job is still running
    # if not, stop the timer (this will set the Enabled property to $false)
    if ($script:BackupJob.State -ne "Running") { $timer.Stop() }
})

$BackupButton = New-Object System.Windows.Forms.Button
$BackupButton.Anchor = 'Top','Left'
$BackupButton.Size = [System.Drawing.Size]::new(120, 31)
$BackupButton.Location = [System.Drawing.Point]::new(($form.Width - $BackupButton.Width) / 2, 150)
$BackupButton.Text = 'Start Backup'

$BackupButton.Add_Click( {
    Write-Host "Job started"
    $this.Enabled = $false  # disable the button, to prevent multiple clicks

    # use the script: scope, otherwise the timer event will not have access to it
    # for demo, the job does nothing but wait..
    $script:BackupJob = Start-Job -ScriptBlock { Start-Sleep -Seconds 5 }
    $timer.Start()
    while ($timer.Enabled) {
        [System.Windows.Forms.Application]::DoEvents()
    }
    Write-Host "Job ended"
    # show the messagebox
    [System.Windows.Forms.MessageBox]::Show('Successfully completed the backup process.', 'Backup Tool', 'OK', 'Info')

    # and enable the button again
    $this.Enabled = $true
})

Hope that helps

Theo
  • 57,719
  • 8
  • 24
  • 41
  • 1
    Thank you Theo. I actually found a similar solution yesterday just a few hours before your comment. I solved it with adding this after the ``Start-Job`` command: ``Do { [System.Windows.Forms.Application]::DoEvents() } Until ($BackupJob.State -eq "Completed")`` Thank you! – NightJob Jun 15 '20 at 08:11
0

Theo's helpful answer explains the problem with your approach and shows an effective solution (which you yourself improved on by noting that use of a [System.Windows.Forms.Timer] instance isn't actually necessary).

As shown, in order to keep a form responsive while performing other tasks you must keep calling [System.Windows.Forms.Application]::DoEvents() in a loop, and that loop's body must execute quickly overall; in other words: you can only perform polling-like activities inside the loop.

  • Note: [System.Windows.Forms.Application]::DoEvents() can be problematic in general (it is essentially what the blocking .ShowDialog() call does behind the scenes), but in this constrained scenario (assuming only one form is to be shown) it should be fine. See this answer for background information.

An alternative to running the DoEvents() loop from inside a given control's event handler is to instead show the form non-modally - with .Show() rather than .ShowDialog() - and then placing the DoEvents() loop directly after this call, in the script's main scope.

The advantage of this approach is:

  • You only ever need a single DoEvents loop, whose placement in the main script scope also helps make the overall flow of control clearer.

  • This enables re-entrant job creation (if you want to allow starting another backup before a running one has completed) and generally makes creating concurrent background jobs via multiple controls easier, due to only a single loop for monitoring all of them being required.

Here's a self-contained example (requires PowerShell version 5 or higher); it creates a form with a single Start Job button and a status label that reflects the job's state:

  • Initial state:

    • enter image description here
  • While running (after clicking Start Job; the form is still responsive in this state - you can move it around, for instance, and if there were other (enabled) controls, you could click them):

    • enter image description here
  • Completed:

    • enter image description here
# PSv5+
using namespace System.Windows.Forms
using namespace System.Drawing

Add-Type -AssemblyName System.Windows.Forms

# Create a sample form.
$form = [Form] @{ 
  Text = 'Form with Background Job'
  ClientSize = [Point]::new(200, 80)
  FormBorderStyle = 'FixedToolWindow'
}

# Create the controls and add them to the form.
$form.Controls.AddRange(@(

    ($btnStartJob = [Button] @{
        Text     = "Start Job"
        Location = [Point]::new(10, 10)
      })
    
    [Label] @{
      Text     = "Status:"
      AutoSize = $true
      Location = [Point]::new(10, 40)
      Font     = [Font]::new('Microsoft Sans Serif', 10)
    }

    ($lblStatus = [Label] @{
        Text     = "(Not started)"
        AutoSize = $true
        Location = [Point]::new(80, 40)
        Font     = [Font]::new('Microsoft Sans Serif', 10)
      })

  ))

# The script-level variable that receives the job-info
# object when the user clicks the job-starting button. 
$job = $null

# Add an event handler to the button that starts 
# the background job.
$btnStartJob.add_Click( {
    $this.Enabled = $false # To prevent re-entry while the job is still running.
    # Signal the status.
    $lblStatus.Text = 'Running...'
    $form.Refresh() # Update the UI.
    # Start the job, and store the job-info object in 
    # the *script-level* $job variable.
    # The sample job simply sleeps for 3 seconds.
    $script:job = Start-Job { Start-Sleep -Seconds 3 }
  })

# Show the form *non*-modally.
# That is, this statement is *not* blocking and execution continues below.
$form.Show()

# While the form is visible, process events.
while ($form.Visible) {
  [Application]::DoEvents() # Process form (UI) events.
  # Check if the job has terminated, for whatever reason.
  if ($job.State -in 'Completed', 'Failed', 'Stopped', 'Suspended', 'Disconnected') {
    # Show the termination state in the label.
    $lblStatus.Text = $job.State
    # Re-enable the button.
    $btnStartJob.Enabled = $true
  }
  # Sleep a little.
  # IMPORTANT: Do NOT use Start-Sleep, as certain events - notably reactivating a minimized
  # window from the taskbar - then do not work.
  [Threading.Thread]::Sleep(100) 
}

For a variation of this solution that collects a job's output on an ongoing basis and displays it in a multiline text box, see this answer.

mklement0
  • 382,024
  • 64
  • 607
  • 775