3

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

ikappaki
  • 33
  • 3
  • 1
    Make sure to check `$LastExitCode -ne 0`, instead of `$LastExitCode -gt 0` because the variable is of type `Int32` and thus can be negative! – zett42 Oct 29 '22 at 18:28

2 Answers2

1

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.

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Hi @mkelement0, this will put another element of indirection on top of the wrapper module (also how do you bundle a `.ps1` script with a module?), and might not even work for all cases; if there is a ps error in the module's code (not related to the executable failing), the `.ps1` wrapper or the one liner would exit with an error code of 0, hiding the module's failure from the calling process? – ikappaki Oct 30 '22 at 11:05
  • @ikappaki, the focus of your question and therefore of my answer is how to report the exit code of the wrapped external program. How you report _PowerShell_ errors depends on how you handle them in your module, and what nonzero exit code(s) you want to translate them to. To write "non-noisy" stderr output, your only option is to use `[Console]::Error.WriteLine()` - see [this answer](https://stackoverflow.com/a/38064769/45375). – mklement0 Oct 30 '22 at 12:46
  • @ikappaki, similarly, your question also looks for a _single command_ that exhibits the desired behavior, and for that a `.ps1` wrapper is the only option (I can think of).You can bundle a `.ps1` file with your module simply placing it inside the module folder. – mklement0 Oct 30 '22 at 12:54
  • Hi @mklement0, sorry if this wasn't clear, but the focus of my question is as the title of the question and the opening paragraph says, "how to indicate failure to the parent proccess", i.e. that the wrapper module has failed; I'm not so much interested about the actual exit code, but that the module has failed and the parent process/shell can pick this up programmatically. If I bundle the `.ps1` script with the module, how do I call it? is it going to be in the path and thus I can just invoke it by name? – ikappaki Oct 30 '22 at 13:48
  • Hi @mklement0, thanks for the update; it looks good. With this method I can confirm by running a basic test that the `$?` is set in PS shell correctly on exit/exception, and the exit code/exception is correctly communicated to the parent cmd prompt or process, while there is no annoying PS exception message. – ikappaki Oct 30 '22 at 17:57
  • Though, in my case, requiring to install yet another script alongside the module could be considered too taxing for users. Just to double check with you for the purpose of evaluating my options, do you see an issue, other than those which were already mentioned (i.e. exit code defaults to 1 on error, annoying exception message), with throwing an exception from the module to indicate failure? I believe @zett42 alluded in a previous answer that throwing exceptions from PS fns is considered bad practice and should be avoided, thanks – ikappaki Oct 30 '22 at 18:05
  • Thanks @mklement0, it looks great. Just to add another minor drawback with the script approach is that it seem to require a script file per module fn (or alias in my case). It should be possible to use one script file in the expense of the first argument being a switch I presume, though it adds yet another level of indirection. For completion, an enhancement to the script wrapper could be to accept arguments with `& $yourModuleFunction @args` while adding `$ErrorActionPreference = 'Stop'` at the start of the module fn. – ikappaki Oct 31 '22 at 08:02
  • @ikappaki, I've updated the script wrapper to optionally accept a function name and pass-through arguments. The original wrapper _in effect_ already behaved like `$ErrorActionPreference = 'Stop'`, through combining `try` / `catch` with `-ErrorAction Stop` (assuming the invoked function is an _advanced_ one), but I agree that it's simpler and conceptually clearer to place `$ErrorActionPreference = 'Stop'` at the start of the script, which I have now done (plus, it also works with simple functions and scripts). I hope we've covered all angles now. – mklement0 Oct 31 '22 at 14:05
  • HI @mklement0, it looks much better know, I can confirm the first argument defaults to what `funcName` is and can be overridden with `-funcName otherFn`, but if i pass a double dash argument to the script from within powershell, it fails e.g. ` --version` I get `The term '--version' is not recognized as a cmdlet, function ...`. Any idea what is going wrong here? – ikappaki Nov 01 '22 at 08:04
  • @ikappaki, `--version` binds to `-FuncName` _positionally_, so an attempt is made to invoke `--version` as a command, which predictably fails. The only way to disable positional binding to `-FuncName` would be via `[CmdletBinding(PositionalBinding=$false)]`, but that makes the script an _advanced_ one, in which case you can no longer use `$args` / `@args`. While you could declare another, catch-all parameter for the pass-through arguments, e.g., `[Parameter(ValueFromRemainingArguments)] $passThruArgs`, that would only support _positional_ arguments ass pass-through arguments. – mklement0 Nov 01 '22 at 08:14
  • @ikappaki: If all pass-through arguments are ultimately passed through to an _external program_, that wouldn't be a problem. By contrast, it would be a problem if you want to pass _named_ arguments through to a _PowerShell_ command (e.g., `-Path *.txt`) – mklement0 Nov 01 '22 at 08:17
  • @ikappaki: In short: If you must support named pass-through arguments to PowerShell commands, you can either live with the limitation that you must name the function _explicitly_ if you want to also pass arguments to it, or - suboptimally - perform your own parsing of the positional arguments to infer which ones are actually parameter names..See [this answer](https://stackoverflow.com/a/62622638/45375) for an example of the latter. – mklement0 Nov 01 '22 at 08:23
  • Hi @mklement0, I think this solution is great as a general answer to my question. My particular use case falls under the section **making a function call via -Command** thus I'm also well covered. For completion, if I were to evaluate the extra .ps1 file solution (as I did for the solution suggested in my question), would it be fair to say that some minor cons are (1) it requires an additional step to install the `.ps1` file and (2) the non-expert PowerShell user has to be conscious of the first argument PS idiosyncrasies when calling the script from the PS shell? Thanks – ikappaki Nov 02 '22 at 07:47
  • @ikappaki, yes, that's a good summary, but re (2): the workaround in the [aforementioned answer](https://stackoverflow.com/a/62622638/45375), while cumbersome, probably works well enough in practice, if you don't want to force users to always pass the function name whenever arguments must be passed too, and it is acceptable that when a different function name must be passed, that it is passed with a _named_ argument, e.g, `-FuncName foo`. – mklement0 Nov 02 '22 at 16:06
0

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
efotinis
  • 14,565
  • 6
  • 31
  • 36
  • Hi @efotinis, this unfortunately won't work for case #1 from the original post (e.g. when calling the module from within PowerShell itself), because it will not only exit the module, but also the user's PowerShell session altogether (i.e. the parent process): (Example is from Windows Terminal trying to put it all in one line) `Windows PowerShell Copyright (C) Microsoft Corporation. All rights reserved. PS C:\Users\ikappaki> exit 25 [process exited with code 25 (0x00000019)]` – ikappaki Oct 29 '22 at 18:03
  • 1
    @ikappaki, my bad, I didn't consider that case. I've also been struggling recently with the error handling idiosyncrasies in PS5. After reading [this thorough answer](https://stackoverflow.com/a/57468523), I believe it's simply impossible to get the desired result. Exit codes apparently cannot serve both PowerShell's and external programs' needs. – efotinis Oct 29 '22 at 22:26
  • @efotinis, please see my update. Yes, in PowerShell exit codes are meant for communicating with the _outside world_. – mklement0 Oct 30 '22 at 15:09