TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Cooperative Cancellation in PowerShell with CancellationToken: Clean Exits and Predictable Timeouts

Hard-stopping a PowerShell runspace to terminate work can leave files open, network sockets dangling, and state half-written. Cooperative cancellation gives you clean exits: your code decides when to stop, runs finalizers, and releases resources deterministically. In this post you will learn how to use CancellationTokenSource and CancellationToken from PowerShell to cancel long-running .NET work gracefully, with both manual and timeout-driven scenarios. You will pass tokens to tasks, check for cancellation in loops, and dispose everything to prevent leaks and stale handles.

Why cooperative cancellation beats hard stops

  • Cleanup always runs: finally blocks, disposals, and custom teardown logic execute reliably.
  • Fewer hangs: no orphaned threads or background work that lingers after you think it stopped.
  • Predictable behavior: timeouts and user-initiated cancels behave the same way across CPU and I/O tasks.
  • Better diagnostics: you can record why a task stopped (timeout vs. manual cancel) and surface clear messages.

The core pattern: CancellationTokenSource + ThrowIfCancellationRequested

At the center is CancellationTokenSource (CTS). You create a CTS, get its Token, pass that token to your work, and check for cancellation inside the work. When you decide to cancel (manually or via a timeout), the token is signaled, and your code cooperatively exits.

# Create a CancellationTokenSource (CTS)
$cts = [System.Threading.CancellationTokenSource]::new()

# Option A: automatic timeout (cancel after 5 seconds)
$cts.CancelAfter([TimeSpan]::FromSeconds(5))

# Option B: manual cancel later
# $cts.Cancel()

$token = $cts.Token

# Start long-running work on a background .NET Task
$task = [System.Threading.Tasks.Task]::Run({
  # Work runs on a pool thread; cooperatively check for cancellation
  $total = 0
  foreach ($i in 1..50) {
    $token.ThrowIfCancellationRequested()
    Start-Sleep -Milliseconds 200  # Simulate compute/IO
    $total += $i
  }
  $total
})

try {
  # Block until the task completes (or is canceled)
  $task.Wait()
  Write-Host ("Total={0}" -f $task.Result)
}
catch [AggregateException] {
  # Task exceptions are wrapped in AggregateException
  if ($task.IsCanceled) {
    Write-Warning 'Canceled cooperatively.'
  }
  else {
    Write-Warning ("Failed: {0}" -f $_.Exception.InnerException.Message)
  }
}
finally {
  # Always dispose to release handles and timers
  $task.Dispose()
  $cts.Dispose()
}

Key points:

  • CancelAfter arms a timer inside the CTS; when it fires, the token transitions to canceled.
  • ThrowIfCancellationRequested stops the loop as soon as cancellation is signaled by throwing OperationCanceledException. This exception is expected and indicates a graceful stop.
  • Dispose both the Task and CancellationTokenSource to avoid resource leaks (e.g., timer handles).

Manual cancel from user input

Sometimes you want to stop based on an operator action. You can read a key and call $cts.Cancel() to signal the same token your work is using.

$cts = [System.Threading.CancellationTokenSource]::new()
$token = $cts.Token

$task = [System.Threading.Tasks.Task]::Run({
  1..100 | ForEach-Object {
    $token.ThrowIfCancellationRequested()
    Start-Sleep -Milliseconds 100
  }
})

# Listen in parallel for user cancel (press C to cancel)
$cancelListener = [System.Threading.Tasks.Task]::Run({
  Write-Host 'Press C to cancel...'
  while ($true) {
    if ([Console]::KeyAvailable) {
      $key = [Console]::ReadKey($true)
      if ($key.Key -eq 'C') { $cts.Cancel(); break }
    }
    Start-Sleep -Milliseconds 50
  }
})

try {
  [System.Threading.Tasks.Task]::WaitAll(@($task, $cancelListener))
}
catch [AggregateException] {
  if ($task.IsCanceled) { Write-Warning 'Canceled by user.' }
}
finally {
  $cancelListener.Dispose()
  $task.Dispose()
  $cts.Dispose()
}

Timeouts with CancelAfter

Timeouts are safer with cooperative cancellation than with thread aborts. You can set different time budgets per operation:

$overall = [System.Threading.CancellationTokenSource]::new()
$overall.CancelAfter([TimeSpan]::FromMinutes(2))

# A sub-operation gets a stricter 10s budget, linked to the overall budget
$sub = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource($overall.Token)
$sub.CancelAfter([TimeSpan]::FromSeconds(10))

$task = [System.Threading.Tasks.Task]::Run({
  param($token)
  for ($i = 0; $i -lt 100; $i++) {
    $token.ThrowIfCancellationRequested()
    Start-Sleep -Milliseconds 150
  }
}, $sub.Token)

try {
  $task.Wait()
}
catch [AggregateException] {
  if ($task.IsCanceled) {
    Write-Warning 'Sub-operation timed out; overall budget preserved.'
  }
}
finally {
  $task.Dispose()
  $sub.Dispose()
  $overall.Dispose()
}

Pass the token everywhere: loops, Tasks, and async I/O

Cooperative cancellation only works if you propagate the token into every layer that might block or take time. That means checking in tight loops and handing the token to .NET APIs that accept it.

In CPU-bound loops

function Invoke-Compute {
  param(
    [int]$Count = 1_000_000,
    [System.Threading.CancellationToken]$Token
  )

  $acc = 0
  for ($i = 0; $i -lt $Count; $i++) {
    if (($i % 10_000) -eq 0) { $Token.ThrowIfCancellationRequested() }
    $acc += ($i -band 0xFF)
  }
  return $acc
}

$cts = [System.Threading.CancellationTokenSource]::new()
$cts.CancelAfter([TimeSpan]::FromSeconds(3))

try {
  $result = Invoke-Compute -Count 50000000 -Token $cts.Token
  Write-Host "Result: $result"
}
catch [System.OperationCanceledException] {
  Write-Warning 'Compute canceled cooperatively.'
}
finally {
  $cts.Dispose()
}

With async I/O

Many .NET APIs accept tokens. Honor them and cancellation will be fast and safe. In PowerShell 7+, you can also use WaitAsync to avoid indefinite Wait() calls.

$cts = [System.Threading.CancellationTokenSource]::new()
$cts.CancelAfter([TimeSpan]::FromSeconds(5))
$token = $cts.Token

$handler = [System.Net.Http.HttpClientHandler]::new()
$client  = [System.Net.Http.HttpClient]::new($handler)

try {
  $uri = 'https://example.com/large-file'
  $task = $client.GetAsync($uri, $token)  # Pass token to the HTTP call

  # Prefer WaitAsync in PS7+/.NET 6+ to avoid deadlocks
  $awaited = $task.WaitAsync($token)  # Throws OperationCanceledException if canceled
  $awaited.GetAwaiter().GetResult() | Out-Null

  $response = $task.Result
  $response.EnsureSuccessStatusCode()
  Write-Host "Downloaded headers: $($response.Content.Headers.ContentType)"
}
catch [System.OperationCanceledException] {
  Write-Warning 'HTTP request canceled (timeout or manual).'
}
finally {
  $client.Dispose()
  $handler.Dispose()
  $cts.Dispose()
}

Cleanup, disposal, and error handling

To keep your runspace healthy, always clean up:

  • Dispose CTS and Tasks: timers and native wait handles otherwise linger.
  • Use finally to ensure disposals run whether your task completes, fails, or is canceled.
  • Don’t swallow OperationCanceledException: treat it as a successful cooperative stop; log it distinctly from real errors.
  • Prefer WaitAsync over Wait on .NET 6+ to avoid blocking indefinitely and to naturally honor cancellation.
  • Link tokens using CreateLinkedTokenSource to aggregate multiple cancellation paths (e.g., overall job budget + per-step budget).

Register a cancellation callback for local cleanup

Cts registration lets you run lightweight callbacks at the moment of cancellation (avoid long/ blocking work in the callback):

$cts = [System.Threading.CancellationTokenSource]::new()
$reg = $cts.Token.Register({
  # Quick, non-blocking hints; do not do heavy work here
  [Console]::Error.WriteLine('Cancellation signaled; preparing to exit...')
})

try {
  # ... start work and possibly cancel ...
}
finally {
  $reg.Dispose()
  $cts.Dispose()
}

End-to-end example: predictable timeout, cooperative loop, full cleanup

$cts = [System.Threading.CancellationTokenSource]::new()
$cts.CancelAfter([TimeSpan]::FromSeconds(5))
$token = $cts.Token

$task = [System.Threading.Tasks.Task]::Run({
  $total = 0
  foreach ($i in 1..50) {
    $token.ThrowIfCancellationRequested()
    Start-Sleep -Milliseconds 200
    $total += $i
  }
  $total
})

try {
  $task.Wait()  # or: $task.WaitAsync($token).GetAwaiter().GetResult()
  Write-Host ("Total={0}" -f $task.Result)
}
catch [AggregateException] {
  if ($task.IsCanceled) {
    Write-Warning 'Canceled cooperatively.'
  }
  else {
    Write-Warning ("Failed: {0}" -f $_.Exception.InnerException.Message)
  }
}
finally {
  $task.Dispose()
  $cts.Dispose()
}

Practical tips and pitfalls

  • Check early and often: in CPU loops, check cancellation every few thousand iterations; in I/O, pass the token to the API.
  • Don’t force-kill threads: avoid Stop-Process, Thread.Abort, or unceremoniously disposing runspaces; you’ll leak resources and risk corruption.
  • Classify outcomes: return a status object with fields like Status = Completed | Canceled | Failed for downstream automation.
  • Surface reasons: differentiate between user-cancel and timeout by checking whether you called Cancel() or CancelAfter fired (track this via flags or logging).
  • Throttle concurrency: when running many tasks, a shared token makes it easy to cancel all at once. Pair with SemaphoreSlim for bounded parallelism.

With these patterns, you get clean exits, fewer hangs, safer timeouts, and predictable behavior in your PowerShell automation. Cooperative cancellation is simple to wire in, pays off immediately in reliability, and keeps your runspaces healthy during long-running, concurrent operations.

← All Posts Home →