1

Consider the following

Invoke-Command {
    'a'; Write-Verbose 'a' -Verbose
    'b'; Write-Verbose 'b' -Verbose
} |
. { process { Write-Verbose "downstream_$_" -Verbose } }

which outputs

VERBOSE: downstream_a
VERBOSE: a
VERBOSE: downstream_b
VERBOSE: b

Note that process{} of the second scriptblock is invoked immediately when the first emits an object.

On the other hand, consider a similar construction except replacing Invoke-Command with a custom compiled Cmdlet:

Add-Type       `
    -PassThru  `
    -TypeDefinition @'
using System.Management.Automation;

namespace n {
    [Cmdlet(VerbsLifecycle.Invoke,"SomeScriptBlockThing")]
    public class InvokeSomeScriptBlockThing : Cmdlet
    {
        [Parameter(Mandatory=true,Position=1)]
        public ScriptBlock ScriptBlock { get; set; }
        protected override void EndProcessing() {
            var output = ScriptBlock.Invoke("some_fancy_argument");
            foreach (var o in output) {
                WriteObject(o);
            }
        }
    }
}
'@             |
    % Assembly |
    Import-Module

Invoke-SomeScriptBlockThing {
    param($SomeFancyArgument)
    'a'; Write-Verbose 'a' -Verbose
    'b'; Write-Verbose 'b' -Verbose
} |
    . { process { Write-Verbose "downstream_$_" -Verbose } }

which outputs

VERBOSE: a
VERBOSE: b
VERBOSE: downstream_a
VERBOSE: downstream_b

Note that, in this case, all of the objects output from the first scriptblock are accumulated in output until the first scriptblock completes, then all objects are output all at once.

The real challenge I'm solving here is to obtain a CancellationToken using the technique demonstrated here. For that technique, the invocation of the Cmdlet must outlive the token. The natural way to ensure that is to have the Cmdlet make the token available (where $SomeFancyArgument appears here) to a scriptblock it invokes. But with the implementation shown, the Cmdlet doesn't emit anything until the scriptblock completes.

How can I make the execution order of my Cmdlet match that of the first example? That is, how can I make my Cmdlet output each object as it is produced by the scriptblock it invokes?


Note that this old question and answer is, arguably, a duplicate but it predates open-sourcing of PowerShell and so didn't have the advantages of seeing how, for example, Invoke-Command Cmdlet achieves this goal. The only answer to that question accomplishes the goal by converting the scriptblock to a string and spinning up an entirely separate PowerShell instance which, among other things, invokes the script in an entirely separate SessionState without access to any of the parent scopes. It's the same question, but from another era.

4
  • 1
    I think you might still consider to use PowerShell for this and throw a StopUpstreamCommandsException, see this helpful answer from @Mathias R. Jessen. Commented Dec 6, 2024 at 12:00
  • 1
    Not sure were you exactly want to output to (just the screen?) and looking for (I am not very much into C#) but from a PowerShell view, you could also consider multiple pipelines by Mastering the (steppable) pipeline. Commented Dec 6, 2024 at 13:16
  • 1
    @iRon that's a very good point, didnt consider it at first but definitely makes it easier Commented Dec 8, 2024 at 16:23
  • 1
    @SantiagoSquarzon, happy to see that it worked out. Needless to say, even I brought up the idea from a PowerShell/.net view, the C# code is above my head 🤪, I can hardly read it, let alone write something like you did... Commented Dec 8, 2024 at 18:30

3 Answers 3

3

You're essentially asking for what's known as streaming, if you're invoking the scriptblock synchronously it isn't very hard to implement, you will need to subscribe to the DataAdding event from each of the PowerShell.Streams and pass-in your output (success) stream as the as the TOutput.

NOTE

using System.Threading;
using System.Management.Automation;
using System;

[Cmdlet(VerbsLifecycle.Invoke, "SomeScriptBlockThing")]
public sealed class InvokeSomeScriptBlockThing : PSCmdlet, IDisposable
{
    private readonly CancellationTokenSource _src = new();

    [Parameter(Mandatory = true, Position = 0)]
    public ScriptBlock ScriptBlock { get; set; }

    protected override void EndProcessing()
    {
        using PSDataCollection<PSObject> output = [];
        using PowerShell ps = PowerShell
            .Create(RunspaceMode.CurrentRunspace)
            .AddScript(ScriptBlock.ToString(), useLocalScope: true)
            .AddArgument(_src.Token);

        ps.Streams.Verbose.DataAdding += (s, e) => WriteVerbose(((VerboseRecord)e.ItemAdded).Message);
        output.DataAdding += (s, e) => WriteObject(e.ItemAdded);
        ps.Invoke(null, output); // hook output and null input
    }

    protected override void StopProcessing() => _src.Cancel();

    public void Dispose()
    {
        _src.Dispose();
        GC.SuppressFinalize(this);
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

I see. Do you see a way to accomplish that without turning the scriptblock into a string? The downside is that the error position information gets stripped. It seems like that must be possible since Invoke-Command is able to do so...but I'm still trying to make sense of how that's done
no, i dont see it via public APIs @alx9r
maybe you can do ps.AddScript("param($sb, $token) & $sb $token).AddArgument(ScriptBlock).AddArgument(_src.Token). I don't like it and wouldn't recommend it.
2

There is another much easier option to make a binary cmdlet that can stream output when invoking a scriptblock synchronously, that is using a SteppablePipeline as iRon points out in his comment.

using System;
using System.Management.Automation;
using System.Threading;

[Cmdlet(VerbsLifecycle.Invoke, "SomeScriptBlockThing")]
public sealed class InvokeSomeScriptBlockThing : PSCmdlet, IDisposable
{
    private SteppablePipeline _pipe;

    private readonly CancellationTokenSource _src = new CancellationTokenSource();

    [Parameter(ValueFromPipeline = true)]
    public object InputObject { get; set; }

    [Parameter(Mandatory = true, Position = 0)]
    public ScriptBlock ScriptBlock { get; set; }

    [Parameter]
    public SwitchParameter UseLocalScope { get; set; }

    protected override void BeginProcessing()
    {
        _pipe = ScriptBlock
            .Create(
                string.Format(
                    "param($__sb, $__token) {0} $__sb $__token",
                    UseLocalScope.IsPresent ? "." : "&"))
            .GetSteppablePipeline(
                CommandOrigin.Internal,
                new object[] { ScriptBlock, _src.Token });

        _pipe.Begin(this);
    }

    protected override void ProcessRecord()
    {
        _pipe.Process(InputObject);
    }

    protected override void EndProcessing()
    {
        _pipe.End();
    }

    protected override void StopProcessing()
    {
        _src.Cancel();
    }

    public void Dispose()
    {
        _src.Dispose();
        _pipe.Dispose();
        GC.SuppressFinalize(this);
    }
}

Then using this cmdlet you can pass-in a CancellationToken to your .NET method and it will respond correctly to CTRL + C:

Invoke-SomeScriptBlockThing {
    param($Token)

    [System.Threading.Tasks.Task]::Delay(-1, $Token).GetAwaiter().GetResult()
}

9 Comments

Thank you Santiago. I was wondering if steppable pipeline could be made to work this way.
@alx9r im learning something new here too :p. definitely much easier. the -UseLocalScope means that it will dot-source the sb (you can update outside scope variables and all variables defined in the scriptblock become part of the local scope essentially)
Yup. Me too. I think this may well solve that other question I was struggling to ask the other day.
@alx9r i made this, didnt add it here because it isnt relevant to the question being asked but this is the best version i can think of for c# using: gist.github.com/santisq/…
That looks promising. Isn't that part of an answer to this question? I think with that implementation of using it's possible to ensure disposal in any advanced function.
|
0

I confess I got lost tracing through Invoke-Command, and I'm not currently set up to step through with a debugger. I did come up with this hybrid approach which works to solve the real challenge, but it doesn't cover the case where the compiled Cmdlet needs access to the output of the scriptblock. For that, I think the technique from Santiago's answer might be the better option.

The hybrid approach is shown below. It outputs the following:

VERBOSE: downstream_
VERBOSE: downstream_System.Threading.CancellationToken
VERBOSE: downstream_parent
VERBOSE: downstream_a
VERBOSE: a
VERBOSE: downstream_b
VERBOSE: b
Write-Error: C:\test.ps1:41
Line |
  41 |                  $_input | & $ScriptBlock -CancellationToken $_
     |                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | some error

which demonstrates the following:

  • downstream streaming occurs
  • variables are available from the parent scope
  • the CancellationToken is available from the compiled Cmdlet
  • the location of the error message is preserved
function Using-CancellationToken {
    param (
        [Parameter(ValueFromPipeline)]
        $InputObject,

        [Parameter(Mandatory,Position=1)]
        [scriptblock]
        $private:ScriptBlock
    )
    $private:_input = $input
    Add-Type      `
        -PassThru `
        -TypeDefinition @'
using System.Threading;
using System.Management.Automation;

namespace n {
    [Cmdlet("Using","CancellationTokenImpl")]
    [OutputType(typeof(CancellationToken))]
    public class UsingCancellationTokenImplCommand : Cmdlet
    {
        CancellationTokenSource cts;
        protected override void BeginProcessing() {
            cts = new CancellationTokenSource();
        }
        protected override void EndProcessing() {
            WriteObject(cts.Token);
        }
        protected override void StopProcessing () {
            cts.Cancel();
        }
    }
}
'@ |
    % Assembly |
    Import-Module -WarningAction SilentlyContinue

    Using-CancellationTokenImpl |
        . {
            process {
                $_input | & $ScriptBlock -CancellationToken $_
            }
        }
}

try {
    $parent = 'parent'
    'a','b' |
        Using-CancellationToken {
            param($CancellationToken)
            begin {
                $ScriptBlock
                $CancellationToken
                $parent
            }
            process {
                $_
                Write-Verbose $_ -Verbose
            }
            end {
                Write-Error 'some error'
            }
        } |
        . { process { Write-Verbose "downstream_$_" -Verbose } }
}
catch {
    $_
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.