powershellconcurrencyrunspacescriptblock

How can I detect that a scriptblock is bound to the SessionState of another Runspace?


Consider the following code

foreach ($i in 1..100) {
    $sb = {'bound_to_parent_runspace'}
    . $sb | Out-Null

    1..2 |
        ForEach-Object {
            Start-ThreadJob `
                -ArgumentList ([pscustomobject]@{ScriptBlock = $sb}) `
                -ScriptBlock {
                    param($arg1)
                    'child_runspace'
                    . $arg1.ScriptBlock
                }                     |
                Receive-Job           `
                    -Wait             `
                    -ErrorAction Stop
        }
}

which occasionally (at least on my computer) causes the fatal error

An error has occurred that was not properly handled. Additional information is shown below. The PowerShell process will exit.
Unhandled exception. System.InvalidOperationException: Stack empty.
   at System.Collections.Generic.Stack`1.ThrowForEmptyStack()
   at System.Collections.Generic.Stack`1.Pop()
   at System.Management.Automation.DlrScriptCommandProcessor.OnRestorePreviousScope()
   at System.Management.Automation.CommandProcessorBase.RestorePreviousScope()
   at System.Management.Automation.CommandProcessorBase.DoComplete()
   at System.Management.Automation.Internal.PipelineProcessor.DoCompleteCore(CommandProcessorBase commandRequestingUpstreamCommandsToStop)
   at System.Management.Automation.Internal.PipelineProcessor.SynchronousExecuteEnumerate(Object input)
   at System.Management.Automation.Runspaces.LocalPipeline.InvokeHelper()
   at System.Management.Automation.Runspaces.LocalPipeline.InvokeThreadProc()
   at System.Management.Automation.Runspaces.PipelineThread.WorkerProc()
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)
--- End of stack trace from previous location ---
   at System.Threading.ExecutionContext.RunInternal(ExecutionContext executionContext, ContextCallback callback, Object state)

This is, I think, an example of the following issues:

Those issues are now six and seven years old. Around that time Jason Shirk made the following comment about what looks to me like a similar manifestation:

Scriptblocks have runspace affinity. ...I think this is an architectural flaw, but it's not easy to fix without requiring locks throughout the engine.

I am assuming that this problem is unlikely to be resolved by the PowerShell engine anytime soon. I'm happy to work around this limitation wherever I have multiple Runspaces. But that internal stacktrace error is rather unhelpful at finding where the offending ScriptBlock has arisen from.

Instead I would like to be able to guard against invoking such a ScriptBlock by detecting it beforehand so that I can raise an actionable error. With that error message the underlying bug can be tracked down. Without that error message, all I know is that some scriptblock, anywhere in the entire process, was invoked in a Runspace to which it does not belong.

In other words I would like to be able to write a statement like

Assert-ScriptBlockAffinity -CurrentOrNoRunspace -ScriptBlock $arg1.ScriptBlock

which would output something like

ScriptBlockAffinityException: C:\demo.ps1:1
Line |
   1 |  Assert-ScriptBlockAffinity -CurrentOrNoRunspace -ScriptBlock $arg1.ScriptBlockwith a …
     |  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | ScriptBlock detected in Runspace Id 1 with affinity to a different Runspace.

Such an error would give me at least the Runspace Id and source code location which would go a long way to narrowing in on the culprit.

How can I detect such an offending ScriptBlock?

Ideally this could be done with public APIs, but considering this should really only arise during development, an implementation using private APIs would be nearly as useful, I think.


Solution

  • Indeed, it is safe to invoke [scriptblock] instances if and only if:

    Unfortunately, there are no public APIs to determine a given script block's runspace affinity, if any, but you can solve the problem via non-public APIs, using .NET reflection, which comes with the usual caveat:

    The following custom function, Assert-Invokable, asserts that a given [scriptblock] instance can be safely invoked by the caller, and reports a statement-terminating error (exception) otherwise.

    function Assert-Invokable {
      param(
        [Parameter(Mandatory)]
        [scriptblock] $ScriptBlock
      )
     
      # Get the runspace associated with the given script block, if any.
      $rs = & {
        # Binding flags for finding non-public instance members via reflection.
        $bindingFlags = [System.Reflection.BindingFlags] 'NonPublic, Instance'
        
        # Get the non-public [System.Management.Automation.SessionStateInternal] instance
        # associated with the script block, via the non-public .SessionStateInternal property.
        $ssInternal = 
          [scriptblock].GetProperty('SessionStateInternal', $bindingFlags).GetValue($ScriptBlock)
    
        if ($ssInternal) {
          # From the [System.Management.Automation.SessionStateInternal] instance,
          # obtain the associated non-public [System.Management.Automation.ExecutionContext] instance.
          # via the non-public .ExecutionContext property.
          $ec = 
            $ssInternal.GetType().GetProperty('ExecutionContext', $bindingFlags).GetValue($ssInternal)
          
          # From the [System.Management.Automation.ExecutionContext] instance, obtain the associated
          # [runspace] instance (which itself is public), via the non-public .CurrentRunspace property.
          ($rs = $ec.GetType().GetProperty('CurrentRunspace', $bindingFlags).GetValue($ec))
          Write-Verbose ('Script block is bound to runspace {0} ({1})' -f $rs.Name, $rs.Id)
        }
        else {
          # Implies that the given script is *unbound*, i.e. doesn't have any
          # runspace affinity.
          Write-Verbose 'Script block is unbound.'
        }    
      }
    
      # $rs being $null implies that the script block is *unbound*, which means that
      # it can be invoked from *any* runspace; if it is *bound*, it can only be safely
      # invoked if it is bound to the *caller's* runspace.
      if (-not ($null -eq $rs -or $rs -eq [runspace]::DefaultRunspace)) {
        $PSCmdlet.ThrowTerminatingError(
          [System.Management.Automation.ErrorRecord]::new(
            [System.ArgumentException]::new(('Cannot call the given script block from runsapce {0} ({1}), because it is bound to a different one, {2} ({3})' -f [runspace]::DefaultRunspace.Name, [runspace]::DefaultRunspace.Id, $rs.Name, $rs.Id)),
            'ScriptBlockRunspaceMismatch',
            [System.Management.Automation.ErrorCategory]::InvalidArgument,
            $ScriptBlock
          )
        )
      }
    
    }
    

    Note: