Powershell GUI Freezing, even with runspace
Asked Answered
L

3

5

I am creating a powershell script with a GUI, that copies user profiles from a selected source disk to a destination disk. I've created the GUI in XAML, with VS Community 2019. The script works like this : you select the source disk, the destination disk, the user profile and the folders you want to copy. When you press the button "Start", it calls a function called Backup_data, where a runspace is created. In this runspace, there's just a litte Copy-Item, with as arguments what you've selected.

The script works fine, all the wanted items are correctly copied. The problem is that the GUI is freezing during the copy (no "not responding" message or whatever, it's just completly freezed ; can't click anywhere, can't move the window). I've seen that using runspaces would fix this problem, but it doesn't to me. Am I missing something ?

Here's the function Backup_Data:

Function BackupData {  
  ##CREATE RUNSPACE
  $PowerShell = [powershell]::Create()
  [void]$PowerShell.AddScript( {
      Param ($global:ReturnedDiskSource, $global:SelectedUser, $global:SelectedFolders, $global:ReturnedDiskDestination)
      ##SCRIPT BLOCK
      foreach ($item in $global:SelectedFolders) {
        Copy-Item -Path "$global:ReturnedDiskSource\Users\$global:SelectedUser\$item" -Destination "$global:ReturnedDiskDestination\Users\$global:SelectedUser\$item" -Force -Recurse
      }
    }).AddArgument($global:ReturnedDiskSource).AddArgument($global:SelectedUser).AddArgument($global:SelectedFolders).AddArgument($global:ReturnedDiskDestination)
  #Invoke the command
  $PowerShell.Invoke()
  $PowerShell.Dispose()
}
Lysin answered 1/1, 2021 at 14:47 Comment(1)
As an aside: the param(...) block declares scope-local variables defining the parameters, so the $global: scope specifier shouldn't be used.Batiste
B
8

The PowerShell SDK's PowerShell.Invoke() method is synchronous and therefore by design blocks while the script in the other runspace (thread) runs.

You must use the asynchronous PowerShell.BeginInvoke() method instead.

Simple example without WPF in the picture (see the bottom section for a WPF solution):

$ps = [powershell]::Create()

# Add the script and invoke it *asynchronously*
$asyncResult = $ps.AddScript({ Start-Sleep 3; 'done' }).BeginInvoke()

# Wait in a loop and check periodically if the script has completed.
Write-Host -NoNewline 'Doing other things..'
while (-not $asyncResult.IsCompleted) {
  Write-Host -NoNewline .
  Start-Sleep 1
}
Write-Host

# Get the script's success output.
"result: " + $ps.EndInvoke($asyncResult)

$ps.Dispose()

Note that there's a simpler alternative to using the PowerShell SDK: the ThreadJob module's Start-ThreadJob cmdlet, a thread-based alternative to the child-process-based regular background jobs started with Start-Job, that is compatible with all the other *-Job cmdlets.

Start-ThreadJob comes with PowerShell [Core] 7+, and can be installed from the PowerShell Gallery in Windows PowerShell (Install-Module ThreadJob).

  • Update: It appears that the module has been re-released under a new name and that development is now focused on the latter: Microsoft.PowerShell.ThreadJob. As of PowerShell 7.3.4, it is still the old module that is bundled; clarifying the relationship between these two modules and the best way forward is the subject of GitHub issue #19742.
# Requires module ThreadJob (preinstalled in v6+)

# Start the thread job, always asynchronously.
$threadJob = Start-ThreadJob { Start-Sleep 3; 'done' }

# Wait in a loop and check periodically if the job has terminated.
Write-Host -NoNewline 'Doing other things..'
while ($threadJob.State -notin 'Completed', 'Failed') {
  Write-Host -NoNewline .
  Start-Sleep 1
}
Write-Host

# Get the job's success output.
"result: " + ($threadJob | Receive-Job -Wait -AutoRemoveJob)

Complete example with WPF:

If, as in your case, the code needs to run from an event handler attached to a control in a WPF window, more work is needed, because Start-Sleep can not be used, since it blocks processing of GUI events and therefore freezes the window.

Unlike WinForms, which has a built-in method for processing pending GUI events on demand ([System.Windows.Forms.Application]::DoEvents(), WPF has no equivalent method, but it can be added manually, as shown in the DispatcherFrame documentation.

The following example:

  • Creates a window with two background-operation-launching buttons and corresponding status text boxes.

  • Uses the button-click event handlers to launch the background operations via Start-ThreadJob:

    • Note: Start-Job would work too, but that would run the code in a child process rather than a thread, which is much slower and has other important ramifications.

    • It also wouldn't be hard to adapt the example to use of the PowerShell SDK ([powershell]), but thread jobs are more PowerShell-idiomatic and are easier to manage, via the regular *-Job cmdlets.

  • Displays the WPF window non-modally and enters a custom event loop:

    • A custom DoEvents()-like function, DoWpfEvents, adapted from the DispatcherFrame documentation is called in each loop operation for GUI event processing.

      • Note: For WinForms code, you could simply call [System.Windows.Forms.Application]::DoEvents() - and, in fact, you can use the latter directly, if you don't mind loading both the WPF and WinForms assemblies.
    • Additionally, the progress of the background thread jobs is monitored and output received is appended to the job-specific status text box. Completed jobs are cleaned up.

Note: Just as it would if you invoked the window modally (with .ShowModal()), the foreground thread and therefore the console session is blocked while the window is being displayed. The simplest way to avoid this is to run the code in a hidden child process instead; assuming that the code is in script wpfDemo.ps1:

# In PowerShell [Core] 7+, use `pwsh` instead of `powershell`
Start-Process -WindowStyle Hidden powershell '-noprofile -file wpfDemo.ps1'

You could also do this via the SDK, which would be faster, but it's much more verbose and cumbersome:
$runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'STA'; $runspace.Open(); $ps = [powershell]::Create(); $ps.Runspace = $runspace; $null = $ps.AddScript((Get-Content -Raw wpfDemo.ps1)).BeginInvoke()

Screenshot:

This sample screen shot shows one completed background operation, and one ongoing one (running them in parallel is supported); note how the button that launched the ongoing operation is disabled for the duration of the operation, to prevent re-entry:

WPF background operations screenshot

Source code:

using namespace System.Windows
using namespace System.Windows.Threading

# Load WPF assemblies.
Add-Type -AssemblyName PresentationCore, PresentationFramework

# Define the XAML document, containing a pair of background-operation-launching
# buttons plus associated status text boxes.
[xml] $xaml = @"
<Window
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:Test"
        Title="MainWindow" Height="220" Width="600">
    <Grid>
        <TextBox x:Name="Status1" Height="140" Width="280" Margin="10,10" TextWrapping="Wrap" VerticalAlignment="Top" HorizontalAlignment="Left" AcceptsReturn="True" AcceptsTab="True" Padding="4" VerticalScrollBarVisibility="Auto" />
        <TextBox x:Name="Status2" Height="140" Width="280" Margin="10,10" TextWrapping="Wrap" VerticalAlignment="Top" HorizontalAlignment="Right" AcceptsReturn="True" AcceptsTab="True" Padding="4" VerticalScrollBarVisibility="Auto" />
        <Button x:Name="DoThing1" Content="Do Thing 1" HorizontalAlignment="Left" VerticalAlignment="Bottom" Width="100" Height="22" Margin="10,5" IsDefault="True" />
        <Button x:Name="DoThing2" Content="Do Thing 2" HorizontalAlignment="Right" VerticalAlignment="Bottom" Width="100" Height="22" Margin="10,5" />
    </Grid>
</Window>
"@

# Parse the XAML, which returns a [System.Windows.Window] instance.
$Window = [Markup.XamlReader]::Load((New-Object System.Xml.XmlNodeReader $xaml))

# Save the window's relevant controls in PowerShell variables.
# Background-operation-launching buttons.
$btns = $Window.FindName('DoThing1'), $Window.FindName('DoThing2')

# Use a [hashtable] to map the buttons to the associated status text boxes.
$txtBoxes = @{
  $btns[0] = $Window.FindName('Status1')
  $btns[1] = $Window.FindName('Status2')
}
# Use a [hashtable] to map the buttons to the associated background
# operations, defined as script blocks to be passed to Start-ThreadJob later.
# The sample operations here run for a few seconds, 
# emitting '.' every second and a message on completion.
$scriptBlocks = @{
  $btns[0] = 
    {
      1..3 | ForEach-Object { '.'; Start-Sleep 1 }
      'Thing 1 is done.'
    }
  $btns[1] = 
    {
      1..2 | ForEach-Object { '.'; Start-Sleep 1 }
      'Thing 2 is done.'
    }
}

# Attach the button-click event handlers that
# launch the background operations (thread jobs).
foreach ($btn in $btns) {

  $btn.Add_Click({

    # Temporarily disable this button to prevent re-entry.
    $this.IsEnabled = $false

    # Show a status message in the associated text box.
    $txtBoxes[$this].Text = "Started thing $($this.Name -replace '\D') at $(Get-Date -Format T)."

    # Asynchronously start a background thread job named for this button.
    # Note: Would work with Start-Job too, but that runs the code in *child process*, 
    #       which is much slower and has other implications.
    $null = Start-ThreadJob -Name $this.Name $scriptBlocks[$this]

  })

}

# Define a custom DoEvents()-like function that processes GUI WPF events and can be 
# called in a custom event loop in the foreground thread.
# Adapted from: https://learn.microsoft.com/en-us/dotnet/api/system.windows.threading.dispatcherframe
function DoWpfEvents {
  [DispatcherFrame] $frame = [DispatcherFrame]::new($True)
  $null = [Dispatcher]::CurrentDispatcher.BeginInvoke(
    'Background', 
    [DispatcherOperationCallback] {
      param([object] $f)
      ($f -as [DispatcherFrame]).Continue = $false
      return $null
    }, 
    $frame)
  [Dispatcher]::PushFrame($frame)
}


# Finally, display the window NON-modally...
$Window.Show() 
$null = $Window.Activate() # Ensures that the window gets the focus.
# ... and enter a custom event loop based on calling the custom .DoEvents() method
while ($Window.IsVisible) {

  # Process GUI events.
  DoWpfEvents

  # Process pending background (thread) jobs, if any.
  Get-Job | ForEach-Object {
    
    # Get the originating button via the job name.
    $btn = $Window.FindName($_.Name)
    # Get the corresponding status text box.
    $txtBox = $txtBoxes[$btn]

    # Test if the job has terminated.
    $completed = $_.State -in 'Completed', 'Failed', 'Stopped'

    # Append any new results to the respective status text boxes.
    # Note the use of redirection *>&1 to capture ALL streams, notably including the error stream.
    if ($data = Receive-Job $_ *>&1) {
      $txtBox.Text += "`n" + ($data -join "`n")
    }

    # Clean up, if the job is completed.
    if ($completed) {
      Remove-Job $_
      $btn.IsEnabled = $true # re-enable the button.
      $txtBox.Text += "`nJob terminated on: $(Get-Date -Format T); status: $($_.State)."
    }

  }

  # Note: If there are no GUI events pending, this loop will cycle very rapidly.
  #       To mitigate this, we *also* sleep a little, but short enough to 
  #       still keep the GUI responsive.
  # IMPORTANT: Do NOT use Start-Sleep, as certain events 
  #            - notably reactivating a minimized window from the taskbar - 
  #            then do not work.
  [Threading.Thread]::Sleep(50)

}

# Window was closed; clean up:
# If the window was closed before all jobs completed, 
# get the incomplete jobs' remaining output, wait for them to finish, and delete them.
Get-Job | Receive-Job -Wait -AutoRemoveJob
Batiste answered 1/1, 2021 at 16:23 Comment(3)
Problem is, that the WPF app I'm supposed to create will be used by techs to copy user profiles in my company. And I know powershell isn't at his highest version..Lysin
@OscarLoret, it doesn't have to be at its highest version: You have 3 options:: (a) Install the ThreadModule module, which is possible down to v3 (although it's easy only in v5.1) (b) Given the nature of your task simply replace Start-ThreadJob with the built-in Start-Job, which works too - it's slower, but with a long-running copy operation that won't matter. (c) adapt the example to use the PowerShell SDK: instead of creating jobs, add the async result returned from .BeginInvoke() to a synchronized script-level collection and process that in the custom event loop.Batiste
*ThreadJob module.Batiste
L
2

I've been searching for a solution all day and I've finally found one, so I'm gonna post it there for those who have the same problem.

First, check this article : https://smsagent.blog/2015/09/07/powershell-tip-utilizing-runspaces-for-responsive-wpf-gui-applications/ It's well explained and shows you how to correctly use runspaces with a WPF GUI. You just have to replace your $Window variable by $Synchhash.Window :

$syncHash = [hashtable]::Synchronized(@{})
$reader = (New-Object System.Xml.XmlNodeReader $xaml)
$syncHash.window = [Windows.Markup.XamlReader]::Load( $reader )

Insert a runspace function with your code :

function RunspaceBackupData {
$Runspace = [runspacefactory]::CreateRunspace()
$Runspace.ApartmentState = "STA"
$Runspace.ThreadOptions = "ReuseThread"
$Runspace.Open()
$Runspace.SessionStateProxy.SetVariable("syncHash",$syncHash)
$Runspace.SessionStateProxy.SetVariable("SelectedFolders",$global:SelectedFolders)
$Runspace.SessionStateProxy.SetVariable("SelectedUser",$global:SelectedUser)
$Runspace.SessionStateProxy.SetVariable("ReturnedDiskSource",$global:ReturnedDiskSource)
$Runspace.SessionStateProxy.SetVariable("ReturnedDiskDestination",$global:ReturnedDiskDestination)
$code = {
    foreach ($item in $global:SelectedFolders) {
        copy-item -Path "$global:ReturnedDiskSource\Users\$global:SelectedUser\$item" -Destination "$global:ReturnedDiskDestination\Users\$global:SelectedUser\$item" -Force -Recurse
        }
}
$PSinstance = [powershell]::Create().AddScript($Code)
$PSinstance.Runspace = $Runspace
$job = $PSinstance.BeginInvoke()
}

And call it in the event-handler you want with the parameters you've indicated :

$var_btnStart.Add_Click( {
    RunspaceBackupData -syncHash $syncHash -SelectedFolders $global:SelectedFolders -SelectedUser $global:SelectedUser -ReturnedDiskSource $global:ReturnedDiskSource -ReturnedDiskDestination $global:ReturnedDiskDestination 
})

Don't forget to end your runspace :

$syncHash.window.ShowDialog()
$Runspace.Close()
$Runspace.Dispose()
Lysin answered 2/1, 2021 at 10:49 Comment(3)
It would be better to show improved code instead of describing what it should look like.Fructose
The linked article uses advanced PowerShell SDK techniques, which is impressive, but also problematic due to its complexity. I've improved my original approach to perform all background processing in a custom event loop after showing the window non-modally. Combined with the use of Start-ThreadJob, the sample code doesn't require the SDK (though it could be adapted to the use the SDK instead, but thread jobs are preferable), and it shows running two background operations independently. You should have no problem adapting the sample to your use case.Batiste
I totally agree this solution isn't the easiest. Especially when you have to change most of your script to put all of your events-handler into the synchash variable.. I'm gonna check for the Start-ThreadJob cmdlet todayLysin
U
1

There is also another way to do that.

The handler for the button's Click event starts a separate thread within the local process. Do any time-consuming actions there. If any UI actions are required from that thread, just use Window's dispatcher. It is also possible to pass the reference to the button in ArgumentList, but in that case, one is restricting oneself from accessing any control within Window.

Add-Type -AssemblyName PresentationFramework

[xml]$xaml = @"
<Window
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  x:Name="Window" Width="400" Height="200">
    <StackPanel>
        <Button x:Name="RunButton" Content="Run" Width="100" Height="50"></Button>
        <Button x:Name="TestButton" Content="Test" Width="100" Height="50"></Button>
    </StackPanel>
</Window>
"@

$reader = (New-Object System.Xml.XmlNodeReader $xaml)
$window = [Windows.Markup.XamlReader]::Load($reader)

$runButton = $window.FindName("RunButton")
$testButton = $window.FindName("TestButton")

$runButton.Add_Click( {
    
    Write-Host "RunButton clicked!"

    $runButton.IsEnabled = $False
    
    Start-ThreadJob -ScriptBlock {
        param($windowHandle)

        try {
            Start-Sleep -Seconds 5
        }
        catch {
            #Add-Content C:\Temp\Error.txt ($Error | Format-List | Out-String)
        }
        finally {
            $windowHandle.Dispatcher.Invoke({
                $controlHandle = $windowHandle.FindName("RunButton")
                $controlHandle.IsEnabled = $True
            })
        }

    } -ArgumentList $window
      
})

$testButton.Add_Click( {
    
    Write-Host "TestButton clicked!"
    
})

$window.ShowDialog() | Out-Null

In the above example, clicking the Run button disables it for 5 seconds, while clicking the Test button is possible all the time.

Unearthly answered 28/9, 2023 at 11:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.