Michael Sorens
Michael Sorens

Reputation: 36738

When is a ScriptBlock not a ScriptBlock?

I do not mean to sound too cute with the question, but that really is the question at hand. Consider the following two functions defined in a PowerShell module Test.psm1 installed under $env:PSModulePath:

function Start-TestAsync
{
    [CmdletBinding()]
    param([ScriptBlock]$Block, [string]$Name = '')   
    Start-Job { Start-Test -Name $using:Name -Block $using:Block }
}

function Start-Test
{
    [CmdletBinding()]
    param([ScriptBlock]$Block, [string]$Name = '')
    # do some work here, including this:
    Invoke-Command -ScriptBlock $Block
}

After importing the module, I can then run the synchronous function...

PS> Start-Test -Name "My Test" -Block { ps | select -first 9 }

...and it displays appropriate output from Get-Process.

However, when I attempt to run the asynchronous version...

PS> $testJob=Start-TestAsync -Name "My Test" -Block { ps | select -first 9 }

...and then review its output...

PS> Receive-Job $testJob

... it fails at just bringing in the parameter to the Start-Test function, reporting it cannot convert a String to a ScriptBlock. Thus, -Block $using:Block is passing a String rather than a ScriptBlock!

After some experimentation, I did find a workaround. If I modify Start-Test so that the type of the $Block parameter is [string] instead of [ScriptBlock] -- and then convert that string back to a block to feed to Invoke-Command...

function Start-Test
{
    [CmdletBinding()]
    param([string]$Block, [string]$Name = '')
    $myBlock = [ScriptBlock]::Create($Block)
    Invoke-Command -ScriptBlock $myBlock
}

I then obtain the correct result when I run the same commands from above:

PS> $testJob=Start-TestAsync -Name "My Test" -Block { ps | select -first 9 }
PS> Receive-Job $testJob

Is the using scope working correctly in my initial example (converting a ScriptBlock to a string)? The limited documentation on it (about_Remote_Variables, about_Scopes) offers little guidance. Ultimately, is there a way to make Start-Test work when its $Block parameter is typed as a [ScriptBlock]?

Upvotes: 3

Views: 1048

Answers (4)

Keith Hill
Keith Hill

Reputation: 201832

This is apparently by design: https://connect.microsoft.com/PowerShell/feedback/details/685749/passing-scriptblocks-to-the-job-as-an-argument-cannot-process-argument-transformation-on-parameter

The workaround (from the link above) is to use [ScriptBlock]::Create():

This happens because the $ScriptToNest scriptblock is getting converted into a string because of how PowerShell serialization works. You can work around this by explicitly creating the scriptblock. Replace the param() block in your $OuterScriptblock with the following ($ip is the input):

[scriptblock]$OuterScriptblock = {
param($ip)
[ScriptBlock]$ScriptToRun = [ScriptBlock]::Create($ip)

This would be your work-around (as you've found):

function Start-TestAsync
{
    [CmdletBinding()]
    param([ScriptBlock]$Block, [string]$Name = '')
    Start-Job { Start-Test -Name $using:Name -Block $using:Block }
}

function Start-Test
{
    [CmdletBinding()]
    param($Block, [string]$Name = '')
    # do some work here, including this:
    $sb = [ScriptBlock]::Create($Block)
    Invoke-Command -ScriptBlock $sb
}

Upvotes: 2

Michael Sorens
Michael Sorens

Reputation: 36738

While it is useful to know (thanks, @KeithHill) that what I was seeing was a known issue--sorry, I meant "by design"--my real question had not been answered ("Ultimately, is there a way to make Start-Test work when its $Block parameter is typed as a [ScriptBlock]?")

The answer came to me suddenly last night:

function Start-TestAsync
{
    [CmdletBinding()]
    param([ScriptBlock]$Block, [string]$Name = '')   
    Start-Job {
        $myBlock = [ScriptBlock]::Create($using:Block);
        Start-Test -Name $using:Name -Block $myBlock  }
}

function Start-Test
{
    [CmdletBinding()]
    param([ScriptBlock]$Block, [string]$Name = '')   
    # do some work here, including this:
    Invoke-Command -ScriptBlock $Block
}

Notice that in Start-TestAsync I internally allow the serialization to occur ($using:Block), converting the ScriptBlock to a String, then immediately re-convert it (Create) to a ScriptBlock, and can then safely pass that on to Start-Test as a genuine ScriptBlock. To me, this is a significant improvement over the workaround in my question because now the public APIs on both functions are correct.

Upvotes: 0

mjolinor
mjolinor

Reputation: 68301

I believe the reason you're seeing this is that the purpose of $using is to expand the values of of local variables inside the script block prior to it being used on the remote system - it doesn't actually create those variables in the the remote session. The closest thing a scriptblock has to a value is it's command text.

Upvotes: 0

briantist
briantist

Reputation: 47832

I realize this doesn't quite answer your question, but I think you could simplify this a lot by putting it into one function:

function Start-Test
{
    [CmdletBinding()]
    param(
        [ScriptBlock]$Block, 
        [string]$Name = '',
        [Switch]$Async
    )
    # do some work here, including this:
    Invoke-Command -ScriptBlock $Block -AsJob:$Async
}

Since Invoke-Command can already start a job for you, you can have your function accept an -Async switch, then pass its value to the -AsJob switch.

Invoke Synchronous

Start-Test -Block { ps | select -first 9 }

Invoke Asynchronous

Start-Test -Block { ps | select -first 9 } -Async

Speculation

As for why that actual thing you're seeing is happening, I'm not certain, but I think it might be related to nesting the script blocks, though I'm unable to do a proper test at the moment.

Upvotes: 0

Related Questions