TB

MoppleIT Tech Blog

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

Predictable Cleanup on Exit in PowerShell: Last-chance handlers with PowerShell.Exiting and safe finally blocks

Temporary workspaces are great until they hang around like glitter after a sprint demo. In build scripts, data migrations, test harnesses, or one-off automations, you often create a temp directory to hold intermediate artifacts. The challenge is making sure that directory disappears even if the session ends unexpectedly. In this post, you will wire up a last-chance cleanup with PowerShell.Exiting, pass the path you need to delete via -MessageData, and still clean in a finally block so Ctrl+C and errors are safe.

The minimal pattern: leave no trace

Here is a compact pattern you can paste into your scripts. It creates a temp workspace, registers a handler that cleans it when PowerShell exits, and also deletes it in a finally block.

$dir = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $dir -Force | Out-Null

$sub = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -MessageData $dir -Action {
  $d = $event.MessageData
  if (Test-Path $d) { Remove-Item -Path $d -Recurse -Force -ErrorAction SilentlyContinue }
}

try {
  $f = Join-Path $dir 'demo.txt'
  'ok' | Out-File -FilePath $f -Encoding utf8
  Start-Sleep -Seconds 1
  Write-Host ("Temp: {0}" -f $dir)
} finally {
  if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
  if ($sub) { Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue }
}
  • Register a last-chance handler: Register-EngineEvent -SourceIdentifier PowerShell.Exiting fires when the PowerShell engine is exiting. This is your safety net if the window is closed or the host is shutting down cleanly.
  • Pass state via -MessageData: Use -MessageData to carry the directory path into the action scriptblock. Read it inside with $event.MessageData. Avoid relying on outer scope in case it changes.
  • Still clean in finally: The finally block runs for normal completion, errors, and Ctrl+C (which raises a terminating exception). That covers the most common exit paths.
  • Unregister the event: Release the event subscription in finally to avoid memory leaks and duplicate handlers if you run the pattern multiple times.

What you get: fewer leaks, safer exits, cleaner temp files, and predictable runs.

How it works (and where it doesn't)

Understanding PowerShell.Exiting

PowerShell.Exiting is raised by the engine during a graceful shutdown (e.g., closing the terminal window, exiting the host, or running exit). Event actions run on the event manager queue, so keep your cleanup idempotent and quick. If your cleanup is heavy, do the minimum deletion (e.g., remove the directory) and leave long-running chores for another process.

Passing state safely with -MessageData

Event action blocks should not depend on outer scope, because that scope might not be intact when the event fires. Instead, pass the necessary data explicitly:

  • Use -MessageData: Send the path (or an object) to the handler.
  • Read it with $event.MessageData: Inside the action, prefer $event.MessageData over script/module variables.
  • Prefer -LiteralPath and guardrails: When deleting, use -LiteralPath and verify the path points under a temp root you control.
$sub = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -MessageData $dir -Action {
  try {
    $d = $event.MessageData
    if ($d -and (Test-Path -LiteralPath $d)) {
      Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue
    }
  } catch {}
}

Why you still need finally

Unlike the exit event, a finally block runs when exceptions occur and when the user presses Ctrl+C (PowerShell throws a terminating exception and unwinds the stack). That means your normal control flow cleanup reliably happens. The exit event is a safety net for host shutdown where your finally may not run (e.g., the user closes the window).

Limitations to know

  • Hard kills: If the process is force-killed (Stop-Process -Force, OS crash, power loss, or a container SIGKILL), neither finally nor PowerShell.Exiting will run. Design for best effort, not guarantees.
  • Time constraints: During OS shutdown or container stop with a short timeout, your handler might be interrupted. Keep actions quick.
  • Multiple handlers: If you register more than one handler and don't unregister them, you may delete twice or hit races. Always track and call Unregister-Event.

Production-ready hardening and reuse

Wrap the pattern in a reusable helper

Turn the pattern into a helper that returns a workspace object with a Cleanup method and remembers the subscription id. You can drop this in your script or profile.

function New-TempWorkspace {
  param(
    [string] $Prefix = 'psws_'
  )

  $dir = Join-Path ([IO.Path]::GetTempPath()) ($Prefix + [IO.Path]::GetRandomFileName())
  New-Item -ItemType Directory -Path $dir -Force | Out-Null

  $sub = Register-EngineEvent -SourceIdentifier PowerShell.Exiting -MessageData $dir -Action {
    try {
      $d = $event.MessageData
      if ($d -and (Test-Path -LiteralPath $d)) {
        Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue
      }
    } catch {}
  }

  [pscustomobject]@{
    Path = $dir
    SubscriptionId = $sub.Id
    Cleanup = {
      param([switch] $Unregister)
      try {
        if (Test-Path -LiteralPath $dir) {
          Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
        }
      } finally {
        if ($Unregister -and $sub) {
          Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue
        }
      }
    }
  }
}

# Usage
$ws = New-TempWorkspace -Prefix 'demo_'
try {
  $file = Join-Path $ws.Path 'example.txt'
  'data' | Set-Content -LiteralPath $file -Encoding utf8
  Write-Host ("Workspace: {0}" -f $ws.Path)
} finally {
  & $ws.Cleanup -Unregister
}

This approach provides a clear lifecycle: create, use, cleanup. If your script spawns child jobs or parallel tasks, pass $ws.Path to them explicitly, never rely on global variables.

Safety guardrails you should add

  • Validate the path: Before deletion, confirm the directory lives under a safe root you control (e.g., [IO.Path]::GetTempPath()). You can check with [IO.Path]::GetFullPath() and string comparison.
  • Use -LiteralPath: Prevent wildcard expansion and weird edge cases with special characters.
  • Idempotent cleanup: Write deletion so running twice is harmless. Use -ErrorAction SilentlyContinue.
  • Minimal privilege: Don't run as admin unless necessary. Least privilege limits damage if a path is wrong.
  • Debug logging: In CI, emit the path then delete it. When something goes wrong, logs help. For example, write Write-Verbose entries gated by $PSBoundParameters.ContainsKey('Verbose').

CI/CD and containers

  • Ephemeral runners: Runners often clean workspaces, but in multi-step workflows your step may fail early. Keep the finally cleanup anyway.
  • Container stops: A graceful stop (SIGTERM) can give your handler time to run; a hard kill (SIGKILL) will not. If cleanup is critical, add higher-level orchestration hooks (e.g., Kubernetes preStop).
  • Parallel jobs: Give each job its own temp workspace. Avoid sharing directories across jobs to prevent races.

Common use cases

  • Build pipelines: Store intermediate bundles, test coverage, or cache data in a temp directory that disappears after the step.
  • Data processing: Stage transformed files, then remove them even if the host is closed by accident.
  • Integration tests: Spin up throwaway workspaces per test run to avoid flaky interference between runs.

Checklist

  1. Create your temp directory under a safe root.
  2. Register PowerShell.Exiting and pass the path via -MessageData.
  3. Do the same deletion in finally.
  4. Unregister the event subscription.
  5. Keep your handler fast and idempotent.

Make cleanup a default, not an afterthought. With a simple exit handler plus a finally block, your scripts become predictable and solvent: no temp leaks, fewer surprises, and cleaner runs.

Further reading: PowerShell Advanced Cookbook — practical patterns for scripting and automation. Read on Amazon.

← All Posts Home →