This guide assumes you've read the first compiled-module post, have a project targeting net10.0, and are comfortable with async/await in C#.

PSCmdlet is synchronous. Its lifecycle methods BeginProcessing, ProcessRecord, EndProcessing return void, and Powershell calls them on a single thread. That's at odds with every modern .NET API, which is async-first. This post is about how to bridge the two cleanly: no deadlocks, no zombie tasks, and Ctrl-C that actually cancels.

The Wrong Way (and Why You'll See It Anyway)

Three patterns to avoid:

// 1. Blocking on .Result - deadlocks under sync contexts that capture
var x = GetThingAsync().Result;

// 2. fire-and-forget Task - survives the cmdlet, no error reporting
Task.Run(async () => await DoWorkAsync(token));

// 3. async void overrides - eaten exceptions, no awaitability
protected override async void ProcessRecord() { ... }

The third one is the most insidious. The compiler accepts it because ProcessRecord returns void, but exceptions thrown after the first await will crash the host instead of becoming a proper ErrorRecord.

The Right Bridge

The cleanest pattern: write the cmdlet's logic as an async Task method, then drive it synchronously from the lifecycle override using GetAwaiter().GetResult():

[Cmdlet(VerbsCommon.Get, "RemoteWidget")]
[OutputType(typeof(Widget))]
public sealed class GetRemoteWidgetCmdlet : PSCmdlet, IDisposable
{
    [Parameter(Mandatory = true, ValueFromPipeline = true)]
    public string Url { get; set; } = string.Empty;

    private readonly CancellationTokenSource _cts = new();
    private HttpClient? _http;

    protected override void BeginProcessing()
    {
        _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
    }

    protected override void ProcessRecord()
    {
        try
        {
            var widget = GetAsync(Url, _cts.Token).GetAwaiter().GetResult();
            WriteObject(widget);
        }
        catch (OperationCanceledException)
        {
            // Cancellation is normal - don't surface as an error
        }
        catch (HttpRequestException ex)
        {
            WriteError(new ErrorRecord(ex, "RemoteFetchFailed", ErrorCategory.ConnectionError, Url));
        }
    }

    private async Task<Widget> GetAsync(string url, CancellationToken ct)
    {
        var json = await _http!.GetStringAsync(url, ct).ConfigureAwait(false);
        return JsonSerializer.Deserialize<Widget>(json)!;
    }

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

    public void Dispose()
    {
        _cts.Dispose();
        _http?.Dispose();
    }
}

Three things to notice:

  1. ConfigureAwait(false) everywhere in the async path. Powershell hosts may install a sync context; capturing it is the classic deadlock.
  2. StopProcessing() is your Ctrl-C handler. It runs on a different thread than ProcessRecord. Cancel the CancellationTokenSource and let your async code unwind cleanly.
  3. IDisposable on the cmdlet is honored by Powershell. Dispose your HttpClient and CancellationTokenSource here.

Ctrl-C, in Detail

Powershell signals cancellation by calling StopProcessing on a separate thread. Your ProcessRecord is still running. The contract is:

  • Your StopProcessing should return quickly (cancel a token, signal an event).
  • Your ProcessRecord is responsible for noticing and unwinding.

Catch OperationCanceledException and don't turn it into an error that's just noise:

protected override void ProcessRecord()
{
    try
    {
        DoLongThingAsync(_cts.Token).GetAwaiter().GetResult();
    }
    catch (OperationCanceledException) when (_cts.IsCancellationRequested)
    {
        // user pressed Ctrl-C - silent unwind
    }
}

If your async method calls something that doesn't accept a CancellationToken, you can't cancel it cooperatively. There is no safe Thread.Abort substitute on .NET Core/5+. The fix is to wrap the offender behind an interface and use a faster-failing alternative don't spin up a watchdog that calls Process.Kill on yourself.

Streaming Many Results

The WriteObject call must happen on the cmdlet's pipeline thread. That's the thread that called ProcessRecord. If you stream from IAsyncEnumerable<T>, do the awaiting on a worker, but write back on the cmdlet thread:

[Cmdlet(VerbsCommon.Get, "RemoteWidget")]
[OutputType(typeof(Widget))]
public sealed class StreamRemoteWidgetCmdlet : PSCmdlet
{
    [Parameter(Mandatory = true)]
    public string Url { get; set; } = string.Empty;

    private readonly CancellationTokenSource _cts = new();

    protected override void ProcessRecord()
    {
        try
        {
            var enumerable = StreamAsync(Url, _cts.Token);
            var enumerator = enumerable.GetAsyncEnumerator(_cts.Token);
            try
            {
                while (enumerator.MoveNextAsync().AsTask().GetAwaiter().GetResult())
                {
                    WriteObject(enumerator.Current);
                }
            }
            finally
            {
                enumerator.DisposeAsync().AsTask().GetAwaiter().GetResult();
            }
        }
        catch (OperationCanceledException) { }
    }

    private async IAsyncEnumerable<Widget> StreamAsync(
        string url,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
    {
        await foreach (var w in FetchAsync(url, ct).ConfigureAwait(false))
        {
            yield return w;
        }
    }

    protected override void StopProcessing() => _cts.Cancel();
}

The pattern is verbose, but the tradeoff is worth it: each result is yielded down the pipeline as soon as it's available, the user can pipe | Select-Object -First 5 and your producer cancels promptly, and there's no buffering.

The [EnumeratorCancellation] attribute is what wires the token passed to GetAsyncEnumerator() into your await foreach body. Without it, your inner code can't see the cancellation.

Parallel Work, Bounded

A common need: fetch N items concurrently with a cap. Parallel.ForEachAsync (introduced in .NET 6, improved in 10) is the right primitive easier than SemaphoreSlim plumbing:

protected override void ProcessRecord()
{
    var results = new ConcurrentBag<Widget>();
    var options = new ParallelOptions
    {
        MaxDegreeOfParallelism = 8,
        CancellationToken      = _cts.Token,
    };

    try
    {
        Parallel.ForEachAsync(_inputUrls, options, async (url, ct) =>
        {
            var w = await FetchAsync(url, ct).ConfigureAwait(false);
            results.Add(w);
        }).GetAwaiter().GetResult();
    }
    catch (OperationCanceledException) { return; }

    foreach (var w in results) WriteObject(w);
}

Don't WriteObject from inside the parallel body. WriteObject isn't thread-safe it must be called on the pipeline thread. Collect first, write second.

Producer/Consumer with Channel<T>

For long-running cmdlets that produce results from a background source (events, a polling loop, an IAsyncEnumerable from another library), Channel<T> is the cleanest bridge:

protected override void ProcessRecord()
{
    var channel = Channel.CreateBounded<Widget>(capacity: 64);

    var producer = Task.Run(async () =>
    {
        try
        {
            await foreach (var w in SourceAsync(_cts.Token).ConfigureAwait(false))
                await channel.Writer.WriteAsync(w, _cts.Token).ConfigureAwait(false);
        }
        finally { channel.Writer.TryComplete(); }
    });

    try
    {
        while (channel.Reader.WaitToReadAsync(_cts.Token).AsTask().GetAwaiter().GetResult())
        {
            while (channel.Reader.TryRead(out var w))
                WriteObject(w);
        }
        producer.GetAwaiter().GetResult();
    }
    catch (OperationCanceledException) { }
}

Bounded channels give you natural backpressure: if the consumer (Powershell pipeline) is slow, the producer waits, instead of buffering unbounded.

Progress Reporting

WriteProgress is the right way to surface "I'm 40% done" to the user. It's safe from the pipeline thread; from background work, marshal back through a captured progress callback:

private int _completed;
private int _total;

protected override void ProcessRecord()
{
    _total = _inputUrls.Count;
    _completed = 0;

    var progress = new Progress<int>(done =>
    {
        _completed = done;
        // can't WriteProgress here - Progress<T> may run on TP threads
    });

    var task = Task.Run(() => DoWorkAsync(progress, _cts.Token));

    while (!task.IsCompleted)
    {
        WriteProgress(new ProgressRecord(1, "Fetching", $"{_completed}/{_total}")
        {
            PercentComplete = _total == 0 ? 0 : (_completed * 100) / _total,
        });
        Thread.Sleep(200);
    }

    try { task.GetAwaiter().GetResult(); }
    catch (OperationCanceledException) { }

    WriteProgress(new ProgressRecord(1, "Fetching", "Done") { RecordType = ProgressRecordType.Completed });
}

The pattern: background task does the work and updates a counter; pipeline thread polls the counter every 200ms and emits ProgressRecord. WriteProgress from a non-pipeline thread sometimes works, sometimes throws don't.

Timeouts the Right Way

Always layer timeouts. HttpClient.Timeout is your hard cap; CancellationTokenSource.CancelAfter is your per-operation cap; StopProcessing is your user-cap. They compose:

using var op = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
op.CancelAfter(TimeSpan.FromSeconds(10));

try
{
    var w = await FetchAsync(url, op.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!_cts.IsCancellationRequested)
{
    // op timed out - different from user cancel
    WriteWarning($"Timeout fetching {url}");
}

The when clause distinguishes "user pressed Ctrl-C" from "this single fetch took too long" surface them differently.

A Reusable Base Class

The boilerplate (linked CTS, StopProcessing, exception mapping) is identical across cmdlets. Push it down:

public abstract class AsyncCmdlet : PSCmdlet, IDisposable
{
    protected CancellationTokenSource Cts { get; } = new();

    protected sealed override void ProcessRecord()
    {
        try
        {
            ProcessRecordAsync(Cts.Token).GetAwaiter().GetResult();
        }
        catch (OperationCanceledException) when (Cts.IsCancellationRequested) { }
        catch (Exception ex) when (TryMapException(ex, out var record))
        {
            WriteError(record);
        }
    }

    protected abstract Task ProcessRecordAsync(CancellationToken ct);

    protected virtual bool TryMapException(Exception ex, out ErrorRecord record)
    {
        record = new ErrorRecord(ex, ex.GetType().Name, ErrorCategory.NotSpecified, null);
        return true;
    }

    protected sealed override void StopProcessing() => Cts.Cancel();

    public void Dispose() => Cts.Dispose();
}

Then a subclass becomes:

[Cmdlet(VerbsCommon.Get, "RemoteWidget")]
public sealed class GetRemoteWidgetCmdlet : AsyncCmdlet
{
    [Parameter(Mandatory = true)] public string Url { get; set; } = "";

    protected override async Task ProcessRecordAsync(CancellationToken ct)
    {
        var json = await Http.GetStringAsync(Url, ct).ConfigureAwait(false);
        WriteObject(JsonSerializer.Deserialize<Widget>(json));
    }
}

That's the version most teams end up with. One base class, one well-tested cancellation story, every cmdlet stays readable.

What to Do Next

The Powershell pipeline isn't async-native, but a small bridging layer makes async-first .NET libraries feel native inside a cmdlet. Four habits hold the whole pattern up: ConfigureAwait(false) everywhere off the pipeline thread, cancel through a CancellationTokenSource driven by StopProcessing, never WriteObject from anywhere except the pipeline thread, and reach for Channel<T> or IAsyncEnumerable<T> when you need streaming with backpressure.

Three concrete moves the next time you write an async cmdlet:

  1. Wrap the async work in a base class once. A single AsyncCmdlet base that owns the CancellationTokenSource, the StopProcessing plumbing, and the queue-from-worker-thread-back-to-pipeline pattern means every cmdlet you write afterwards is just await DoTheThingAsync(token).
  2. Honour Ctrl+C end to end. Test it. Press Ctrl+C mid-pipeline and watch your network calls actually cancel rather than running to completion in the background while the prompt returns. If they don't, the token isn't threaded through.
  3. Bench the streaming version. When the upstream API supports IAsyncEnumerable<T> or paged callbacks, exposing the cmdlet as streaming-from-pipeline keeps memory flat and lets Select-Object -First N actually short-circuit.

Pairs naturally with the completers and dynamic parameters post (parameter-binding metadata that turns a cmdlet from "type the right string and hope" into something that helps the user) and the parallelism post (when the right answer is ForEach-Object -Parallel from script, not async from C#).