Search code examples
powershellvalidateset

Powershell - Why does [ValidateSet("varA", "varB")] not set $LASTEXITCODE?


I wondered that scripts whose parameter are validated by the ValidateSet attribute do not return any invalid $LASTEXITCODE when invalid parameters are given.

Let's say I have the following PowerShell script, called try.ps1:

#INPUT Variables for the Script
Param(
    [Parameter(Mandatory)]
    [ValidateSet("a", "b")]
    [string] $first_param,

    [Parameter(Mandatory)]
    [ValidateSet("c", "d")]
    [string] $second_param
)

exit 0

If I call it now in PS via PS C:\Users\MyUser> .\try.ps1 -first_param n -second_param c (invalid $first_param) I get a ParameterBindingValidationException with an Error called ParameterArgumentValidationError but this seems not not set my $LASTEXITCODE variable, but why not?

So how is it possible for me (without bloating my code and validating each of my variables in some if-else statements to trigger an error) to get $LASTEXITCODE=1 back when calling my script with invalid params?


Solution

  • tl;dr

    • Don't rely on exit codes for error handling in PowerShell; use them to only to communicate success vs. failure to the outside world.

      • For scripts you know to be explicitly designed to report an exit code to the outside world, you can query $LASTEXITCODE, but that alone isn't enough to handle all error conditions, as your example shows.

      • Even then, as discussed in detail in the next section, $LASTEXITCODE may contain an unrelated value inside a PowerShell session.

    • In your invocation attempt, your script doesn't even get to execute, because the parameter-validation failure generates a statement-terminating error before the body of your script is entered.

      • When a statement-terminating error occurs, execution continues by default.

      • You can query the automatic $? variable (a Boolean success indicator) to see if (at least one) error occurred in the previous statement:

         .\try.ps1 -first_param n -second_param 
         if (-not $?) { # An error occurred
           # Handle the error here.
           # E.g., for a script designed to be called from outside PowerShell:
           exit 2  # Signal failure to an outside caller.
         }
        
      • However, by the time you check $?, the error message has already printed (at least by default); if you want to intercept the error, so as to print either no message, a different message, or a modified message, you need a conceptual try / catch statement:

         try {
           .\try.ps1 -first_param n -second_param 
         } catch {
           # Handle the error
           # $_ contains the error at hand, as an [System.Management.Automation.ErrorRecord] instance.
           # E.g., for a script designed to be called from outside PowerShell:
           Write-Error "Invocation of try.ps1 failed unexpectedly: $_"
           exit 2 # Signal failure to an outside caller.
         }
        

    See also:

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

    • For a comprehensive discussion of exit codes in PowerShell, see this post.

    Read on for some additional conceptual information.


    Background information:

    Unfortunately, the excerpt from the documentation in Josef Z's answer is only partly correct (in short: $LASTEXITODE is set in response to exit $number calls in scripts in-session, and $number is reported as the specific exit code in -File CLI calls - GitHub docs issue #10046 aims to get the documentation corrected).
    Let me present what I believe to be the correct (bigger) picture:

    • PowerShell itself does not use exit codes for its error handling.

    • PowerShell uses exit codes only in order to interact with the outside world:

      • It stores the exit code of the most recently executed external program (child process) in the automatic $LASTEXITCODE variable, so you can check whether it signaled success (exit code 0) or failure (any non-zero exit code, by convention; sometimes, non-zero exit codes are used to communicate specific success conditions too; robocopy.exe is a notable example).

        • Integration of non-zero exit codes into PowerShell's error handling via a $PSNativeCommandErrorActionPreference preference is being considered as of PowerShell 7.3.x; it is currently an experimental feature, available in preview versions of PowerShell 7.4 - which means that it may or may not become an actual feature - see this answer for more information.
      • For use in script files(*.ps1), PowerShell offers the exit keyword so that PowerShell scripts can communicate an exit code to the outside world when invoked via PowerShell's CLI (powershell.exe for Windows PowerShell, pwsh for PowerShell (Core) 7+)), which is important to signal failure vs. success, particularly in CI/CD environments.

      • The PowerShell CLI's exit-code reporting works differently depending on whether the -File or the -Command parameter is used (the default parameter is -File in PowerShell (Core) 7+ vs. -Command in Windows PowerShell):

        • Note: If a fatal (script-terminating) error occurs - such as when throw is used - both -File and -Command invocations report 1 as the exit code.

        • With -File - for invoking a single *.ps1 file, optionally with arguments - the exit code is 0 by default, except if explicitly specified via an exit $number call in the script, where $number represents the desired exit code.

        • With -Command - for executing arbitrary PowerShell commands - it is by default the implied success status (as it would be reflected in $?) of the last command in the command string(s) passed to -Command that determines the exit code: success is reported as exit code 0, and failure as 1

          • Therefore, to explicitly control what exit code is reported in this case, place an ; exit $number at the end of the -Command string(s); if the last (or only) command is a call to an external program or to a script that uses exit $number itself, and you want to relay that specific exit code, use ; exit $LASTEXITCODE, but note the caveat below.
    • While an intra-session call to a script (*.ps1) that uses exit - either without an argument or with a numeric argument[1] - e.g. exit 1 - does cause PowerShell to set $LASTEXITCODE to that value (just exit sets 0), you shouldn't generally rely on that, because:

      • Scripts designed only for intra-session use (for calls from inside a PowerShell session) typically do not use exit, so $LASTEXITCODE won't have a meaningful value, because it then doesn't get set - and may therefore reflect an unrelated exit code from an earlier call to an external program / script.

        • That is, intra-session only explicit exit calls set $LASTEXITCODE, not the execution of a script file per se.

        • Caveat: If you do not use exit and your script happens to call an external program (or another script that does use exit) its exit code will be reflected in $LASTEXITCODE - (a) this may not be your intent and (b) such an incidental exit code is not communicated to outside callers via the CLI (see below).
          Therefore, if your script must reliably report an exit code, make sure that all code paths end with an exit or exit $number statement.

      • By contrast, when the CLI is used, with -File in the case of a *.ps1 file, PowerShell defaults to 0 as the exit code (reported to the outside caller) in the absence of an exit $number call in the script ($number representing the desired exit code).

        • This asymmetry between intra-session and external behavior is the subject of GitHub issue #11712.
      • Either way, it is important to note that for outside callers it is only ever an explicit exit $number call in a *.ps1 file that sets a non-zero exit code.

        • This contrasts with POSIX-compatible shells such as bash, where, in the absence of exit, it is the exit code of the script's last command that determines the script's exit code as a whole.

    [1] Curiously, exit quietly accepts and effectively ignores an argument that either isn't numeric or cannot be coerced to a number, so a call such as exit (Get-Date) is effectively the same as exit and therefore sets $LASTEXITCODE / the exit code to 0