ikappaki
ikappaki

Reputation: 33

How to best indicate failure of a PowerShell module to the calling shell/process?

If I have a PowerShell module that acts as a wrapper over an executable program, and I would like to communicate failure to the parent process so that it can be checked programmatically, how would I best do this?

Possible ways that the module could be invoked from (non-exhaustive):

  1. From within the PowerShell shell (powershell or pwsh),
  2. From the command prompt (.e.g as powershell -Command \<module fn\>),
  3. From an external program creating a PowerShell process (e.g. by calling powershell -Command \<module fn\>)).

If I throw an exception from the module when the executable fails, say, with

if ($LastExitCode -gt 0) { throw $LastExitCode; }

it appears to cover all of the requirements. If an exception is thrown and the module was called

  1. from within the PowerShell shell, $? variable is set to False.
  2. from the command prompt, the %errorlevel% variable is set to 1.
  3. from an external process, the exit code is set to 1.

Thus the parent process can check for failure depending on how the module was called.

A small drawback with this approach is that the full range of exit codes cannot be communicated to the parent process (it either returns True/False in $? or 0/1 as the exit code), but more annoyingly the exception message displayed on the output is too verbose for some tastes (can it be suppressed?):

+     if ($LastExitCode -gt 0) { throw $LastExitCode; }
+                                ~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (9:Int32) [], RuntimeException
    + FullyQualifiedErrorId : 9

Are there any better ways to communicate failure of an executable invoked from a PowerShell module to the parent process?

Thanks

Upvotes: 3

Views: 739

Answers (2)

mklement0
mklement0

Reputation: 438198

Your best bet is to provide a wrapper .ps1 script file for your module's function, which you can then call via the PowerShell CLI's -File parameter:

# yourWrapperScript.ps1

# Default to 'yourFunction' as the function to invoke,
# but allow passing a different command name, optionally with 
# arguments, too.
# From inside PowerShell, you could even pass a script block.
param(
  $funcName = 'yourFunction'
)

# Make all commands executed in this script *abort* on emitting *any
# error*.
# Note that this aborts on the *first* PowerShell error reported.
# (A function could be emitting multiple, non-terminating errors.)
# The assumption is that your function itself checks $LASTEXITCODE 
# after calling the wrapped external program and issues a PowerShell error in response.
$ErrorActionPreference = 'Stop'

$global:LASTEXITCODE = 0 # Reset the $LASTEXITCODE value.

try {

  # Call the function, passing any additional arguments through.
  & $funcName @args

  # If no PowerShell error was reported, assume that the
  # function succeeded.
  exit 0

}
catch {
  # Emit the message associated with the PowerShell error that occurred.
  # Note: 
  #  * In *PowerShell (Core) 7+*, emitting the error message
  #    via Write-Error *is* a one-liner, but (a) 
  #    invariably prefixed with the function name and (b)
  #    printed in *red. If that's acceptable, you can use
  #      $_ | Write-Error
  #  * In *Windows PowerShell*, the error is "noisy", and the only
  #    way to avoid that is to write directly to stderr, as shown
  #    below. 
  # Print the error message directly to stderr.
  [Console]::Error.WriteLine($_)
  if ($LASTEXITCODE) {
    # The error is assumed to have been reported in response
    # to the external-program call reporting a nonzero exit code.
    # Use that exit code.
    # Note: if that assumption is too broad, you'll ned to examine
    #       the details of the [System.Management.Automation.ErrorRecord] instance reflected in $_.
    exit $LASTEXITCODE
  } else {
    # An error unrelated to the external-program call.
    # Report a nonzero exit code of your choice. 
    exit 1
  }
}

Then pass your wrapper script, say yourWrapperScript.ps1, to the CLI's -File parameter:

powershell -File yourWrapperScript.ps1

Unlike the -Command CLI parameter, -File does pass a .ps1 script file's specific exit code through, if set via exit.

The downsides of this approach are:

  • yourWrapperScript.ps1 must either be in the current directory, in a directory listed in the $env:PATH environment variable, or you must refer it by its full path.

    • If you bundle the script with your module (simply by placing it inside the module directory), you can only anticipate its full path if you know that it is in one of the standard module-installation directories (as listed in $env:PSModulePath, though on Unix that environment variable only exists inside a PowerShell session)

    • An alternative would be to distribute your script as a separate, installable script that can be installed with Install-Script.

  • Unless you're prepared to pass the function name as an argument (e.g., powershell -File yourWrapperScript.ps1 yourFunction), you'll need a separate .ps1 wrapper for each of your functions.


The - cumbersome - alternative is to use -Command and pass the code above as a one-liner, wrapped in "..." (from outside PowerShell):

# Code truncated for brevity.
powershell -Command "param(..."

For a comprehensive overview of PowerShell's CLI, in both editions, see this post.


If, in order to make do with just a function call via -Command, you're willing to live with:

  • the loss of the specific exit code reported by your external program and have any nonzero code mapped to 1

  • a "noisy", multi-line Windows PowerShell error message (less problematic in PowerShell (Core) 7+, where the message prints as a single line, albeit invariably in red, and prefixed with the function name)

you have two options:

  • Stick with your original approach and use throw in your function in response to $LASTEXITCODE being nonzero after the external-program call. This causes a script-terminating (fatal) error.

    • This means that the PowerShell CLI process is instantly aborted, with exit code 1. Similarly, if your function is also called from PowerShell scripts, the entire script (and its callers) are instantly aborted - which may or may not be desired. See the next point if you'd rather avoid such errors.

    • Also note that cmdlets implemented via binary modules (as opposed to cmdlet-like advanced functions implemented in PowerShell code) do not and, in fact, cannot emit such script-terminating errors, only statement-terminating errors.

  • Make your function set $? to $false - without aborting execution overall - which the -Command CLI parameter also translates to exit code 1, assuming your function call is the only or last statement.

    • This can only be done implicitly, by emitting either one or more non-terminating errors or a statement-terminating error from your function.

    • In order for these to set $? properly from a function written in PowerShell, your function (a) must be an advanced function and (b) must use either $PSCmdlet.WriteError() (non-terminating error) or $PSCmdlet.ThrowTerminatingError() (statement-terminating error); notably, using Write-Error does not work.

    • Calling these methods is nontrivial, unfortunately; zett42 showed the technique in an (unfortunately) since-deleted answer; you can also find an example in this comment from GitHub issue # (the issue also contains additional background information about PowerShell's error types).


For an overview of PowerShell's bewilderingly complex error handling, see GitHub docs issue #1583.

Upvotes: 1

efotinis
efotinis

Reputation: 14961

The PowerShell exit keyword has an optional parameter. If that parameter is an integer, it's used as the process exit code. This way you can propagate the error code of the wrapped executable.

An example of Python capturing PowerShell's exit code:

:~> py
Python 3.7.9 [...]
>>> from subprocess import run
>>> res = run('powershell -command "exit 123"')
>>> res.returncode
123

Upvotes: 0

Related Questions