ScriptBlock and SessionState in PowerShell

ScriptBlock and SessionState in PowerShell

How they work together

·

6 min read

Do you know the difference between these two ScriptBlocks? One is directly defined in a script, and the other is created from a string.

$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')

The difference I talk about here can be described like this:

$sb1 is bound to the SessionState that the ScriptBlock is created in. $sb2 is not bound to any SessionState when it's created and is bound to the SessionState where it's invoked.

Let's see what this means.

What is SessionState?

A PowerShell process has the default Runspace which is the operating environment for the commands invoked by a host. In a Runspace, there is the global SessionState object. When modules are imported to the global SessionState, a new SessionState object is created for each module.

A SessionState object holds the state of a PowerShell session or a module. Most importantly, the SessionState manages variables, functions and cmdlets by holding a stack of Scopes. ScriptBlocks use the SessionState object to look for variables and functions based on the scoping rule.

The point is that a ScriptBlock needs a SessionState object to run, and there are multiple SessionState objects in a PowerShell session. In some cases, we need to be careful about which SessionState a ScriptBlock uses.

Bound and Unbound ScriptBlocks

Let's assume that we create a module that has a script scope variable $var and a function to invoke a ScriptBlock.

# MyModule.psm1
$var = 'Module Var'
function Run($scriptBlock) {
    $scriptBlock.Invoke()
}

In the terminal, we create the two types of ScriptBlocks that just return a variable $var, and we pass them to the Run function of the module.

# On the terminal
Import-Module .\MyModule.psm1

$sb1 = {$var}
$sb2 = [ScriptBlock]::Create('$var')

$var = 'Global Var'
Run $sb1 # Global Var
Run $sb2 # Module Var

$sb1 sees the global $var, while $sb2 sees the script variable in the module.

When you define a ScriptBlock by writing braces directly, the ScriptBlock is bound to the SessionState where it's defined, the global SessionState in this case. Let's call this a Bound ScriptBlock. Bound ScriptBlock always uses the bound SessionState no matter where it's invoked. That's why $sb1 sees the global $var.

If you create a ScriptBlock from a string, it doesn't hold any SessionState. It grabs the SessionState when it's invoked. Because modules have their own SessionState, and $sb2 uses the module's SessionState where it's invoked, it sees the variable inside the module. Let's call this an Unbound ScriptBlock.

Note that if modules are not involved and the ScriptBlocks run in a single SessionState, you don't see any difference between bound and unbound ScriptBlocks.

Dot Sourcing and SessionState

In terms of scoping, there are two ways to invoke a ScriptBlock: the Call operator and Dot Sourcing. The call operator creates a new scope, while dot sourcing does not create a new one.

# Create a new scope.
& $scriptBlock
$scriptBlock.Invoke()
Invoke-Command -ScriptBlock $scriptBlock

# Don't create a new scope.
. $scriptBlock
Invoke-Command -ScriptBlock $scriptBlock -NoNewScope

In the previous example for the Bound and Unbound ScriptBlocks, I used ScriptBlock.Invoke() that created a new scope, but Bound/Unbound also makes a difference in the dot sourcing case. When you dot source a ScriptBlock, it runs in the current scope of the SessionState that the ScriptBlock is bound to, not the SessionState where the ScriptBlock is invoked.

Let's modify the previous module so that it uses dot sourcing.

# MyModule.psm1
function Run($scriptBlock) {
    . $scriptBlock # [1]
    if ($var) {
        "MyModule: $var"
    }
}

Outside the module, we define a bound and an unbound ScriptBlock and pass them to the module.

Import-Module .\MyModule.psm1

$sb1 = {$var = 'var from [Bound] ScriptBlock'}
$sb2 = [ScriptBlock]::Create('$var = "var from [Unbound] ScriptBlock"')

function CallModule($scriptBlock) {
    Run $scriptBlock # [2]
    if ($var) {
        "Global: $var"
    }
}

CallModule $sb1 # Global: var from [Bound] ScriptBlock
CallModule $sb2 # MyModule: var from [Unbound] ScriptBlock

You might have an impression that dot sourcing always injects variables into the place where we write the dot source operator (line [1]), but it is not correct. $sb1 runs in the current scope of the bound SessionState, the global SessionState in this case (line [2]). $sb2 grabs the SessionState when it's dot sourced, the module's SessionState in this case, so it runs in the scope where we wrote the dot source operator (line [1]).

What about GetNewClosure?

I think you know that ScriptBlock.GetNewClosure() copies local variables to the ScriptBlock at the time when GetNewClosure() is called, but there is another not well-known behavior. It creates a dynamic module for the ScriptBlock.

What it means is that the ScriptBlock created by GetNewClosure() is bound to the SessionState of the module that is created for the ScriptBlock. It is a kind of Bound ScriptBlocks but its SessionState is isolated from others. Therefore, when it's invoked, it does not see any variables outside of the ScriptBlock except the global variables and even dot sourcing cannot change the state outside of the dynamic module.

# Script.ps1
$sb = {
    $var
    $var = 'Modified'
}.GetNewClosure()

$var = 'Script Var'
& $sb
. $sb
& $sb # Modified
$var # Script Var

Multi-Runspace Scenario

There is another case where you need to be aware of the bound and unbound ScriptBlocks: multi-threading using multiple Runspaces. We'll take ThreadJob as an example.

ThreadJob creates a new Runspace to process commands on another thread, and the Runspace has its own SessionState. When you pass a ScriptBlock to the ScriptBlock parameter of the Start-ThreadJob function, it is converted to a string internally using ToString(), and a new unbound ScriptBlock is created. It's invoked with the ThreadJob's SessionState, so this one has no problem.

However, if you pass a ScriptBlock by the ArgumentList parameter or the using scope modifier, you can invoke the ScriptBlock directly on the thread. If it's a bound ScriptBlock, there is a possibility that the caller's SessionState is modified at the same time from multiple threads (Not always though). This is not safe and leads to state corruption.

$sb = {
    $var = 1
    foreach ($i in 1..10000) {
        $var = [Math]::Pow($var, 3);
    }
}

# Unsafe!
# Passing a bound ScriptBlock to another thread as an argument
$job = Start-ThreadJob -ScriptBlock {
    while ($true) {
        $args[0].Invoke()
    }
} -ArgumentList $sb

while ($true) {
    Start-Sleep -Milliseconds 500
    Write-Host 'Hi'
}

Running this code throws a strange error:

Passing an unbound ScriptBlock is safe on the other hand, so remember, you should never pass a bound ScriptBlock to a ThreadJob.

Making ScriptBlock Unbound

Like in the case of multi-threading, there might be a situation where you want to get an unbound ScriptBlock from a bound ScriptBlock. One easy way is to use ScriptBlock.Ast.GetScriptBlock().

$bound = {$var}
$unbound = $bound.Ast.GetScriptBlock()

You can also use ScriptBlock.ToString() and create an unbound ScriptBlock from that string, but Ast.GetScriptBlock() should be more efficient.

Conclusion

In this article, I explained how ScriptBlocks and SessionStates work together. I would summarize the key points below:

  • Bound and Unbound ScriptBlocks behave differently when they are invoked in a module.

  • Dot Sourcing and GetNewClosure() sometimes behave confusingly, but knowing the underlying SessionState mechanism helps you understand the behavior.

  • Do not pass a ScriptBlock that is bound to a Runspace to another Runspace (Thread).

References