This guide assumes you've read the first compiled-module post, have a project targeting
net10.0, and are comfortable withasync/awaitin 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:
ConfigureAwait(false)everywhere in the async path. Powershell hosts may install a sync context; capturing it is the classic deadlock.StopProcessing()is your Ctrl-C handler. It runs on a different thread thanProcessRecord. Cancel theCancellationTokenSourceand let your async code unwind cleanly.IDisposableon the cmdlet is honored by Powershell. Dispose yourHttpClientandCancellationTokenSourcehere.
Ctrl-C, in Detail
Powershell signals cancellation by calling StopProcessing on a separate thread. Your ProcessRecord is still running. The contract is:
- Your
StopProcessingshould return quickly (cancel a token, signal an event). - Your
ProcessRecordis 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 safeThread.Abortsubstitute 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 callsProcess.Killon 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 toGetAsyncEnumerator()into yourawait foreachbody. 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
WriteObjectfrom inside the parallel body.WriteObjectisn'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:
- Wrap the async work in a base class once. A single
AsyncCmdletbase that owns theCancellationTokenSource, theStopProcessingplumbing, and the queue-from-worker-thread-back-to-pipeline pattern means every cmdlet you write afterwards is justawait DoTheThingAsync(token). - 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.
- 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 letsSelect-Object -First Nactually 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#).


