TB

MoppleIT Tech Blog

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

Predictable Native Command Errors in PowerShell: Stop on Non‑Zero Exit Codes

When you automate with PowerShell, you inevitably call native tools: git, docker, kubectl, openssl, npm, clang, and countless others. By default, PowerShell does not throw terminating errors when a native command exits with a non-zero code. That means your script can keep running after a failure, producing confusing logs or corrupting state. The fix is simple and robust: make non-zero exit codes behave like errors so your scripts stop where they should, every time, on every OS.

Why native commands can surprise you

Cmdlets participate in PowerShell's error pipeline and honor $ErrorActionPreference. Native commands (EXEs on Windows, ELF/Mach-O binaries on Linux/macOS) do not. Historically, PowerShell would only set $LASTEXITCODE and continue unless you explicitly checked it after each call. This creates several pitfalls:

  • Silent failures: A failing native command is easy to miss and the script continues with bad assumptions.
  • Inconsistent behavior: Cmdlets stop on errors with 'Stop', but native tools don't, mixing two failure models.
  • Flaky CI/CD: Pipelines may pass sporadically or fail late, far from the root cause.
  • Harder debugging: You need to sprinkle checks for $LASTEXITCODE everywhere, and you still risk missing one.

The modern PowerShell way is to tell the engine to treat non-zero exits from native commands as actual errors, and then opt to stop on errors globally. You get predictable control flow, cleaner logs, and safer pipelines.

Make non-zero exits act like errors

Two lines establish predictable behavior:

  1. $PSNativeCommandUseErrorActionPreference = $true makes native commands honor $ErrorActionPreference for non-zero exit codes.
  2. $ErrorActionPreference = 'Stop' makes non-terminating errors turn into terminating errors (exceptions), causing try/catch to work the way you expect.

Use both at the top of your script or module. Then add try/catch around native calls and log $LASTEXITCODE with useful context so you can fix failures quickly. Here is a cross-platform test to verify behavior consistently across Windows, Linux, and macOS:

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

if ($IsWindows) {
  $bin = 'cmd.exe'; $args = @('/c','exit','2')
} else {
  $bin = '/bin/sh'; $args = @('-c','exit 2')
}

try {
  & $bin @args
  Write-Host 'Native OK'
} catch {
  Write-Warning ("Caught native failure. Exit={0}" -f $LASTEXITCODE)
}

Expected result: it catches the failure and prints the warning with Exit=2. If you see "Native OK", your environment is not configured as expected—ensure both variables are set before the call, and confirm your PowerShell version supports $PSNativeCommandUseErrorActionPreference (PowerShell 7+).

Alternative universal test

If you want a single-line cross-platform test using PowerShell itself:

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
try {
  & pwsh -NoProfile -Command 'exit 2'
  Write-Host 'Unexpected: succeeded'
} catch {
  Write-Warning "Caught native failure from pwsh. Exit=$LASTEXITCODE"
}

This uses the PowerShell binary as the native process we expect to fail with exit code 2. It works the same way on all runners and OSes that have pwsh in PATH.

Patterns for robust, cross-platform native integrations

1) Set preferences once, early, and locally

Set the preferences at the top of your script/module so child calls inherit the behavior. If you are writing a function that wraps native tools, set these inside the function to avoid relying on the caller’s global state:

function Invoke-NativeChecked {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string]$FilePath,
    [string[]]$ArgumentList
  )

  $ErrorActionPreference = 'Stop'
  $PSNativeCommandUseErrorActionPreference = $true

  try {
    $output = & $FilePath @ArgumentList 2>&1
    [pscustomobject]@{
      Output   = $output
      ExitCode = $LASTEXITCODE
      Command  = "$FilePath $($ArgumentList -join ' ')"
    }
  } catch {
    $context = [pscustomobject]@{
      FilePath  = $FilePath
      Arguments = $ArgumentList -join ' '
      ExitCode  = $LASTEXITCODE
      Pwd       = (Get-Location).Path
      PSVersion = $PSVersionTable.PSVersion.ToString()
      OS        = ($PSVersionTable.OS ?? $env:OS)
      Error     = $_.Exception.Message
    }
    Write-Error ("Native command failed: {0} (exit {1})`nContext: {2}" -f $FilePath, $LASTEXITCODE, ($context | ConvertTo-Json -Compress))
    throw
  }
}

# Example usage
$result = Invoke-NativeChecked -FilePath 'git' -ArgumentList @('status','--porcelain')
$result.Output | ForEach-Object { Write-Host $_ }

Key points:

  • Stop on the first failure. You get fast, clear failures instead of cascading errors.
  • Capture output for logging or decision-making while still failing on non-zero exits.
  • Include context (args, PWD, PS/OS version) to make root-cause analysis quick.

2) Fail fast in CI/CD and scripts

Use these preferences in GitHub Actions, Azure Pipelines, GitLab, or Jenkins to stop a job immediately when a tool fails:

- name: Enforce native failures stop the job
  shell: pwsh
  run: |
    $ErrorActionPreference = 'Stop'
    $PSNativeCommandUseErrorActionPreference = $true
    & git --version
    & pwsh -NoProfile -Command 'exit 2'
    Write-Host 'This line is never reached'

Because non-zero exits are treated as errors with 'Stop', your job fails deterministically and points to the exact failing step.

3) Wrap critical native calls with try/catch and log $LASTEXITCODE

Even when PowerShell throws, it’s still useful to surface the native exit code and relevant context in logs. Add a small helper for consistent logging:

function Invoke-NativeOrDie {
  param([Parameter(Mandatory)][string]$Exe, [string[]]$Args)
  $ErrorActionPreference = 'Stop'
  $PSNativeCommandUseErrorActionPreference = $true
  try {
    & $Exe @Args
  } catch {
    $msg = "{0} {1} failed with exit {2}" -f $Exe, ($Args -join ' '), $LASTEXITCODE
    Write-Error $msg
    throw
  }
}

Invoke-NativeOrDie 'docker' @('ps')

This keeps logs readable without duplicating error-handling boilerplate everywhere.

4) Cross-platform testing for predictable behavior

Automate a smoke test across OSes so you never regress behavior when refactoring:

param([switch]$Fail)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true

if ($Fail) {
  if ($IsWindows) {
    & cmd.exe /c exit 2
  } else {
    & /bin/sh -c 'exit 2'
  }
} else {
  if ($IsWindows) {
    & cmd.exe /c exit 0
  } else {
    & /bin/sh -c 'exit 0'
  }
}

Write-Host 'Reached end successfully'

Run with pwsh ./script.ps1 -Fail and verify it stops with a caught error and logs the exit code. Run without -Fail and ensure the success message prints.

5) Practical tips and gotchas

  • Set preferences inside functions that invoke native tools to protect against callers overriding global state.
  • Only catch what you can handle. If you rethrow, include the native exit code and command line for forensic clarity.
  • Don’t rely on $? for native commands; use exceptions plus $LASTEXITCODE for details.
  • If a tool writes failures only to stderr but returns 0, you must parse output or use tool flags that enforce non-zero exits (e.g., --fail with curl).
  • For long-lived processes, prefer Start-Process -PassThru -Wait and inspect ExitCode, then throw if non-zero.
  • Make sure your team’s shell is consistent. Use pwsh in CI with the preferences set at the start of every step.

What you get: fewer surprises, clearer failures, safer pipelines, consistent behavior.

Build safer native integrations in PowerShell and level up your scripts. For deeper patterns, advanced scenarios, and production-ready recipes, check out the PowerShell Advanced CookBook.

← All Posts Home →