Search code examples
powershellrunspace

PowerShell Runspace and EndInvoke


I've cobbled together an example of a runspace popping up a Windows form and a timer closing it after 3 seconds. See this example:

$runspace = [runspacefactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$PowerShell = [PowerShell]::Create().AddScript({
    $objForm = New-Object System.Windows.Forms.Form

    $Timer = New-Object System.Windows.Forms.Timer
    $Timer.Interval = 1000
    $script:countdown = 3
            
    $Timer.Add_Tick({             
        --$script:countdown
        if ($script:countdown -lt 0)
        {               
            $Timer.Dispose();    
            $objForm.Dispose();                
        }
     })

    $Timer.Start()        
    $objForm.ShowDialog() | Out-Null                 
})

$PowerShell.Runspace = $runspace

$null = Register-ObjectEvent -InputObject $PowerShell -EventName InvocationStateChanged -Action { 
    param([System.Management.Automation.PowerShell] $ps)

    $state = $EventArgs.InvocationStateInfo.State
         
    if ($state -in 'Completed', 'Failed', 'Stopped') {  
        write-host $state          
        $ps.Runspace.Dispose() 
        [GC]::Collect()
    }      
}
            
$AsyncHandle = $PowerShell.BeginInvoke()  

$PowerShell.EndInvoke($AsyncHandle)
$PowerShell.Dispose()

write-host "Continue with the rest of my script"

The idea is to open the form in a separate runspace and continue with my script processing. However, it doesn't continue the script unless i remove the lines:

$PowerShell.EndInvoke($AsyncHandle)
$PowerShell.Dispose()

But if I remove these lines, the script will currently leave a handle open to the runspace.

My question is, can I auto-cleanup the runspace when the runspace is complete somewhere here?

if ($state -in 'Completed', 'Failed', 'Stopped') {  
        #call EndInvoke on this instance??          
        $ps.Runspace.Dispose() 
        [GC]::Collect()
}   

Thanks.

UPDATE - Attempt 1

$runspace = [runspacefactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$PowerShell = [PowerShell]::Create().AddScript({
    $objForm = New-Object System.Windows.Forms.Form

    $Timer = New-Object System.Windows.Forms.Timer
    $Timer.Interval = 1000
    $script:countdown = 3
        
    $Timer.Add_Tick({             
        --$script:countdown
        if ($script:countdown -lt 0)
        {               
            $Timer.Dispose();    
            $objForm.Dispose();                
        }
     })

    $Timer.Start()        
    $objForm.ShowDialog() | Out-Null                 
})

$PowerShell.Runspace = $runspace

$null = Register-ObjectEvent -InputObject $PowerShell -EventName InvocationStateChanged -Action { 
    param([System.Management.Automation.PowerShell] $ps)

   if($EventArgs.InvocationStateInfo.State -in 'Completed', 'Failed', 'Stopped') {
            $state['Instance'].EndInvoke($state['Handle'])
            $state['Instance'].Runspace.Dispose()
            $state['Instance'].Dispose()
   }      
}
        
$state = @{
        Instance = $PowerShell
        Handle   = $PowerShell.BeginInvoke()
}

write-host "Continue with the rest of my script"
$state['Instance'].Runspace.RunspaceStateInfo.State

start-sleep -Seconds 10

$state['Instance'].Runspace.RunspaceStateInfo.State

UPDATE - Attempt 2

function Show-Form() {

$runspace = [runspacefactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$PowerShell = [PowerShell]::Create().AddScript({
    $objForm = New-Object System.Windows.Forms.Form

    $Timer = New-Object System.Windows.Forms.Timer
    $Timer.Interval = 1000
    $script:countdown = 3
    
    $Timer.Add_Tick({             
        --$script:countdown
        if ($script:countdown -lt 0)
        {               
            $Timer.Dispose();    
            $objForm.Dispose();                
        }
     })

    $Timer.Start()        
    $objForm.ShowDialog() | Out-Null                 
})

$PowerShell.Runspace = $runspace    

$null = Register-ObjectEvent -InputObject $PowerShell -EventName InvocationStateChanged -Action { 
    param([System.Management.Automation.PowerShell] $ps)

   if($EventArgs.InvocationStateInfo.State -in 'Completed', 'Failed', 'Stopped') {
            $global:state['Instance'].EndInvoke($global:state['Handle'])
            $global:state['Instance'].Runspace.Dispose()
            $global:state['Instance'].Dispose()
   }      
}
   
$global:state = @{
        Instance = $PowerShell
        Handle   = $PowerShell.BeginInvoke()
}

}

Show-Form

#do other stuff

do {
    #write-host 
    $global:state['Instance'].Runspace.RunspaceStateInfo.State
    start-sleep 1
   } while($global:state['Instance'].Runspace.RunspaceStateInfo.State -ne 'closed')

Attempt 3

function Show-Form() {

$runspace = [runspacefactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "UseNewThread"
$runspace.Open()

$PowerShell = [PowerShell]::Create().AddScript({
    $objForm = New-Object System.Windows.Forms.Form
    $objForm.ShowDialog() | Out-Null                 
})
$PowerShell.Runspace = $runspace    
  
$state = @{
        Instance = $PowerShell
        Handle   = $PowerShell.BeginInvoke()
}

$Jobs.Add($state) | Out-Null
  
$null = Register-ObjectEvent -InputObject $state.Instance -MessageData $state.Handle -EventName InvocationStateChanged -Action { 
    param([System.Management.Automation.PowerShell] $ps)

    if($ps.InvocationStateInfo.State -in 'Completed', 'Failed', 'Stopped') {
            write-host "closing instance " $ps
            write-host "closing handle " $Event.MessageData
            
           $ps.Runspace.Close()
           $ps.Runspace.Dispose()
            $ps.EndInvoke($Event.MessageData)
            $ps.Dispose()
             
            [GC]::Collect()
    }       
    write-host "finish"
}

}

Show-Form
Show-Form

write-host "other stuff"

Solution

  • Looks like your original code is based on this answer, which calls $ps.Runspace.Dispose() from inside the -Action script block, as in your original attempt. However, the enclosing [powershell] instance is retained there, for a later (blocking) call to .EndInvoke() from the main thread, and for potential later reuse for additional parallel tasks. (In the end, that main thread should call .Dispose() on the [powershell] instance, and I've updated the linked answer to make that clear.)

    Per your feedback, the problem you ran into was that not fully disposing of all the PowerShell SDK-related objects caused you script to hang when compiled into an .exe file with ps2exe.ps1.

    While you have found a solution, let me offer a simpler one, which:

    • Uses the default runspace that is used when you create a [powershell] instance without assigning an externally created runspace instance to its .Runspace property.

    • In that case, the only cleanup needed is to call .Dispose() on that [powershell] instance from inside the -Action script block.

    Add-Type -AssemblyName System.Windows.Forms
    
    function Show-Form() {
      
      # Create a PowerShell instance with a default runspace
      # and add code for showing a WinForms form modally.
      $PowerShell = [PowerShell]::Create().AddScript({
          $objForm = New-Object System.Windows.Forms.Form
          $null = $objForm.ShowDialog()
        })
      
      # Start the runspace task asynchronously, i.e. display the form and move on.
      # Since the task produces no output, there's *no* need to save the async
      # handle returned and to call .EndInvoke() before calling .Dispose()
      $null = $PowerShell.BeginInvoke()
      
      # Register for the PowerShell instance's invocation-state-changed event.
      $null = Register-ObjectEvent -InputObject $PowerShell -EventName InvocationStateChanged -Action { 
        param([System.Management.Automation.PowerShell] $ps)
      
        if ($ps.InvocationStateInfo.State -in 'Completed', 'Failed', 'Stopped') {
          # Disposing of the PowerShell object as a whole is seemingly sufficient,
          # if its default runspace was used, which is then *implicitly* disposed too.
          $ps.Dispose()
        }       
      }
      
    }
      
    # Create two forms running in a separate thread (runspace) each.
    Show-Form
    Show-Form
      
    # You're free to do other things here, including exiting the
    # script right away.
    # If you exit before the forms were closed, they will stay open, and
    # cleanup will not happen until they're closed.
    write-host "other stuff"