Skip to content

Engine event processing bypasses "ShouldQueueAndProcessInExecutionThread" causing state corruption and crash due to Runspace affinity #4003

@PaulHigin

Description

@PaulHigin

This was found while investigating a PowerShell class static/instance method concurrency bug.

Background

When invoking a script block using InvokeWithPipe, if the script block is bound to a different Runspace (e.g. created in a different Runspace), then the script block will be marshaled to that Runspace using the EventManager and is supposed to be executed by the main pipeline thread of that Runspace.

This is how it's done:

  1. The thread that is calling InvokeWithPipe finds that the script block is bound to another Runsapce and needs to run in that Runspace. (code here)
  2. The thread queues an event on the EventManager of the target Runspace, wishing the main pipeline thread of the target Runspace would pick up the event and run the script block. (code here)
  3. The thread waits for the main pipeline thread of the target Runspace to finish running the script block. (code here)

Issues

The problem is that, to avoid a possible hang, the thread, which waits for the main pipeline thread of the target Runspace, will pick up the event and execute it (not the required main pipeline thread) after waiting for 250 mSec (See the code here). This will result in 2 threads running in the same Runspace and changing its state simultaneously. This would cause:

  1. Deadlock.
  2. Runspace state corruption and process crash.

Repro Steps

Deadlock

Both arguments for '-sb' and '-arg' are script blocks that are created in the powershell console session. So when bar runs $sb.InvokeReturnAsIs($arg) in a new Runspace, it needs to marshal it back to the powershell console session. This is what happens:

  1. The new Runspace thread (aka. requesting thread) cannot execute the script block because it has to run in the powershell console session (aka. target Runspace) which is bound with the script block, so it queues an event for the pipeline thread of the target Runspace to run the script block;
  2. However, the target Runspace is completely unresponsive because it’s blocked on $ps.Invoke();
  3. So, after 250ms, the requesting thread go ahead to process the event itself to run the script block. Note that – an event is now executing;
  4. Again, the requesting thread finds it cannot run $arg and goes back to step (1). However, this time when it comes to step (3), an event is already executing. So this.ProcessPendingActions() will immediately return, and the requesting thread will be stuck in the while loop.
## The deadlock happens on PowerShell Core RC builds
$ps = [powershell]::Create()
$ps.AddScript('function bar { param([scriptblock]$sb, [scriptblock]$arg) $sb.InvokeReturnAsIs($arg) }').Invoke()
$ps.Commands.Clear()
$ps.AddCommand("bar").AddParameter('sb', {param([scriptblock] $s) $s.InvokeReturnAsIs()}).AddParameter('arg', {[Console]::WriteLine("blah")}) > $null
$ps.Invoke()

Runspace state corruption and process crash

Note: this doesn't repro on latest PowerShell Core anymore because we have fixed the PowerShell class static method to not route method execution to other Runspaces incorrectly. But the underlying problem in EventManager is still there. You can run this repro in Windows PowerShell v5.1 to see the results.

This repro creates a script DoInvokeInParallel.ps1 that dot-sources an Invoker.ps1 file which defines a class with a static method. Run DoInvokeInParallel.ps1 in multiple Runspaces via a RunspacePool to use the class static method concurrently.

Invoker.ps1 file

class Invoker
{
    static [object[]] Invoke(
        [scriptblock] $scriptToInvoke,
        [object[]] $args)
    {
        return $scriptToInvoke.Invoke($args)
    }
}

DoInvokeInParallel.ps1 file

$invokerPath = Join-Path $PSScriptRoot Invoker.ps1
. $invokerPath

$rsp = [runspacefactory]::CreateRunspacePool(1, 10, $host)
$rsp.Open()

$scriptTemplate = @'
    . {0}
    1..100 | foreach {{
        $results = [Invoker]::Invoke({{ "RS_{1} Loop $_ `r`n" }}, $null)
        Write-Output $results
    }}
'@

class Task
{
    [powershell] $powershell
    [System.IAsyncResult] $Async
}

$tasks = @()

1..10 | foreach {
    $task = [Task]::new()
    $script = $scriptTemplate -f $invokerPath,$_
    $task.powershell = [powershell]::Create()
    $null = $task.powershell.AddScript($script)
    $task.powershell.RunspacePool = $rsp
    $task.Async = $task.powershell.BeginInvoke()
    $tasks += $task
}

foreach ($task in $tasks)
{
    $results = $task.powershell.EndInvoke($task.Async)
    Write-Host $results
    Write-Host $task.powershell.Streams.Error
    $task.powershell.Dispose()
}

$rsp.Dispose()

Run DoInvokeInParallel.ps1. The result is multiple "invalid session state" asserts if you are using a debug flavor Windows PowerShell. Eventually, the process will crash.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-BugIssue has been identified as a bug in the productResolution-No ActivityIssue has had no activity for 6 months or moreSize-WeekWG-Enginecore PowerShell engine, interpreter, and runtime

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions