7

I suspect there is no good solution, but perhaps I'm overlooking something:

What I'm after is a way to:

  • (a) call a batch file from PowerShell in a way that robustly reflects its - implicit or explicit - exit code in PowerShell's automatic $LASTEXITCODE variable.

    • Notably, calling a batch file that exits with, say, whoami -nosuch || exit /b, should result in $LASTEXITCODE reflecting whoami's exit code, i.e. 1. This is not the case when you invoke a batch file (by name or path) from PowerShell: the exit code is 0 (by contrast, inside a cmd.exe session %ERRORLEVEL% is set to 1).

    • Also note that the invocation should remain integrated with PowerShell's output streams, so I am not looking for solutions based on System.Diagnostics.Process.

    • Furthermore, I have no knowledge of or control over the batch files getting invoked - I'm looking for a generic solution.

  • (b) without double-quoted arguments passed to the batch file getting altered in any way, and without cmd.exe's behavior getting modified in any way; notably:

    • ^ characters should not be doubled (see below).
    • Enabling delayed expansion with /V:ON is not an option.

The only way I know how to solve (a) is to invoke the batch file via cmd /c call.

Unfortunately, this violates requirement (b), because the use of call seemingly invariably doubles ^ characters in arguments. (And, conversely, not using call then doesn't report the exit code reliably).

Is there a way to satisfy both requirements?

Note that PowerShell is only the messenger here: The problem lies with cmd.exe, and anyone calling a batch file from outside a cmd.exe session is faced with the same problem.


Example (PowerShell code):

# Create a (temporary) batch file that echoes its arguments,
# provokes an error, and exits with `exit /b` *without an explicit argument*.
'@echo off & echo [%*] & whoami -nosuch 2>NUL || exit /b' | Set-Content test.cmd

# Invoke the batch file and report the exit code.
.\test.cmd "a ^ 2"; $LASTEXITCODE

The output should be:

["a ^ 2"]
1

However, in reality the exit code is not reported:

["a ^ 2"]
0          # !! BROKEN

If I call with cmd /c call .\test.cmd instead, the exit code is correct, but the ^ characters are doubled:

PS> cmd /c call .\test.cmd "a ^ 2"; $LASTEXITCODE
["a ^^ 2"]  # !! BROKEN
1           # OK
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    This works for me: `cmd '/c .\test.cmd "a ^ 2" & exit /b'` . It returns correct output from your test batch file and if I put gibberish there I get `9009` in `$LASTEXITCODE` (`MSG_DIR_BAD_COMMAND_OR_FILE`). – beatcracker Apr 06 '21 at 21:45
  • That's great, @beatcracker - please post it as an answer, and I'll accept it. I had tried `|| exit /b`, which _should_ work; trying `& exit /b` hadn't even occurred to me - it suggests that `cmd.exe` itself isn't aware that the batch file failed, yet somehow does pass its nonzero exit code through with `exit /b`. ‍♂️ – mklement0 Apr 06 '21 at 21:51
  • 2
    You mentioning that the `%ERRORLEVEL% is set to 1` gave me a hint that something like this _might_ work ;). – beatcracker Apr 06 '21 at 22:02

2 Answers2

5

I've no idea why this works, but it does:

cmd /c '.\test.cmd "a ^ 2" & exit'
$LASTEXITCODE

Output:

["a ^ 2"] 
1
mklement0
  • 382,024
  • 64
  • 607
  • 775
beatcracker
  • 6,714
  • 1
  • 18
  • 41
3

Kudos to beatcracker for finding an effective workaround in his answer; let me add some background information and guidance:

  • First, to be clear, no workaround should be necessary; cmd.exe's behavior is clearly a bug.

  • cmd /c '.\test.cmd "a ^ 2" || exit' - i.e. || rather than & - is what one would expect to be an effective workaround too. The fact that only &, which unconditionally sequences commands, works, indicates that even cmd.exe-internally the failure status of the batch file isn't yet known as part of the same statement - only afterwards - which appears to be another manifestation of the bug.

    • Why an explicit exit call following the batch-file call as part of the same statement does relay the batch file's (zero or nonzero) exit code correctly is anyone's guess, but it seems to work.
  • Fortunately, the workaround is also effective for solving related exit-code problems in batch files that do not contain explicit exit /b / exit calls - see this answer.

Syntax considerations:

  • From PowerShell, the alternative to passing a single command-string is to pass individual arguments and escape the & character as `& (using `, the "backtick", PowerShell's escape character) so as to prevent PowerShell from interpreting it (quoting it as '&' would work too):

    cmd /c .\test.cmd "a ^ 2" `& exit
    
  • From an environment that doesn't involve a shell, such as when launching from Task Scheduler, the `-escaping of & is not needed (and mustn't be used).

Not having to enclose the entire for-cmd.exe command in quotes makes it easier to pass arguments that (a) individually require double quotes and (b) involve references to PowerShell variables and/or expressions, given that the latter requires use of "..." rather than '...':

# Passing *individual* arguments makes double-quoting easier.
PS> cmd /c .\test.cmd "Version = $($PSVersionTable.PSVersion)" `& exit; $LASTEXITCODE
["Version = 7.2.0-preview.4"]
1

Using quoting of the entire for-cmd.exe command would be awkward in this case, due to the need to escape the argument-specific " chars.:

# Embedded double quotes must now be `-escaped.
PS> cmd /c ".\test.cmd `"Version = $($PSVersionTable.PSVersion)`" & exit"
["Version = 7.2.0-preview.4"]
1

The Native module (authored by me; install it from the PowerShell Gallery with Install-Module Native) comes with function ie, which:

  • automatically applies the above workaround.

  • generally compensates for problems arising from PowerShell's broken argument-passing to external programs (see this answer).

# After having run Install-Module Native:
# Use of function `ie` applies the workaround behind the scenes.
PS> ie .\test.cmd "Version = $($PSVersionTable.PSVersion)"; $LASTEXITCODE
["Version = 7.2.0-preview.4"]
1

The hope is that what function ie does will become a part of PowerShell itself, as part of the upcoming (in PowerShell v7.2) PSNativeCommandArgumentPassing experimental feature that is intended as an opt-in fix to the broken argument-passing - see GitHub issue #15143

mklement0
  • 382,024
  • 64
  • 607
  • 775