14

We have a bunch of .bat build scripts which are invoked by a PowerShell based GitLab runner that were recently refactored from:

program args
if !errorlevel! neq 0 exit /b !errorlevel!

to the more succinct:

program args || exit /b

Today I investigated a build job which obviously failed if you looked at the error log but which was reported as a success. After much experimentation I discovered that this pattern didn't always work as expected:

program args || exit /b

but this did appear to work when the former didn't:

program args || exit /b !errorlevel!

I've read the SO question Windows batch exit option b with or without errorlevel and the statement below from https://www.robvanderwoude.com/exit.php but still can't quite explain what I'm observing.

The DOS online help (HELP EXIT) doesn't make it clear that the /B parameter exits the current instance of script which is not necessarily the same as exiting the current script. I.e. if the script is in a CALLed piece of code, the EXIT /B exits the CALL, not the script.


This is the minimal batch file I used to explore this:

@echo off
setlocal EnableDelayedExpansion
cmd /c "exit 99" || exit /b
:: cmd /c "exit 99" || exit /b !errorlevel!

And this is how I invoked the batch file (to simulate how it was invoked by the GitLab PowerShell based runner):

& .\test.bat; $LastExitCode

Here is the output depending on which of the two lines in the batch file is executed:

PS> & .\test.bat; $LastExitCode
0
PS> & .\test.bat; $LastExitCode
99

There is another way to get the correct behaviour which is to invoke the batch file longhand from within PowerShell using CALL as well:

PS> & cmd.exe /c "call .\test.bat"; $LastExitCode
99

While I appreciate that this may be the correct way to invoke a batch file from PowerShell, that does not appear to be common knowledge based on the many examples I've seen. Also I wonder why PowerShell doesn't invoke a batch file this way if it's "the right way". Lastly I still don't understand why, when leaving off the call, the behaviour changes depending on whether we add the !errorlevel! to the exit /b statement.


UPDATE: Thanks for all the discussion so far but I feel it's getting lost in the weeds which is probably my fault for being too vague in my original question. What I think I'm really after (if possible) is a definitive statement about when the errorlevel is (supposedly) evaluated in the following statement:

program || exit /b

Is it really evaluated early (i.e. before program is run) like this:

program || exit /b %errorlevel%

Or is it evaluated lazily (i.e. when exit is being executed after program has run and internally errorlevel has been updated), more analogous to this:

program || exit /b !errorlevel!

Hence I'm not really after speculation unless, sadly, that is the best that we can do in which case knowing there is no definitive answer or that it's a bug is an acceptable answer to me :o).

Chris Oldwood
  • 1,060
  • 10
  • 17
  • 1
    You need the delayed expansion of `ErrorLevel`, otherwise, you get the value from a previous line(s), which would be zero in your case. – jwdonahue Feb 17 '21 at 19:25
  • Thanks, this is not about `%errorlevel%` vs `!errorlevel!` it's about the use or absence of it with `exit /b`. – Chris Oldwood Feb 17 '21 at 21:52
  • That's exactly what I said. Let put it another way: `exit /b` is equivalent to `exit /b %ErrorLevel%`, but that default behavior does not include delayed expansion. – jwdonahue Feb 17 '21 at 22:49
  • The question though is if the implicit `%errorlevel%` when it's missing is actually evaluated at the same point (i.e. when the entire line is parsed) or if internally it behaves more like `!errorlevel!` as it's lazily evaluated when the `exit /b` is executed. What I'm asking is if _that_ behaviour is formally documented anywhere or are we just guessing? – Chris Oldwood Feb 18 '21 at 10:05
  • Put another way I see many comments that suggest `exit /b` is equivalent to `exit /b %errorlevel%` but _do they actually mean that_, that the `errorlevel` is evaluated _early_ for a multi-part statement or are they being simplistic in their analogy? Given that `help exit` doesn't say another source would be helpful. – Chris Oldwood Feb 18 '21 at 10:12
  • No, `exit /B` is not internally running `exit /B % ErrorLevel%`, rather it does not touch the `ErrorLevel` value; the problem is whose `ErrorLevel` or exit code is returned; I guess it is the one from `cmd.exe` rather than from your batch script… – aschipfl Feb 18 '21 at 10:15
  • > … but that default behavior does not include delayed expansion -- @jwdonahue do you have a definitive source for that behaviour because that would be really useful, thanks. – Chris Oldwood Feb 18 '21 at 10:15
  • 1
    > rather it does not touch the ErrorLevel value -- Right, it doesn't _change_ `errorlevel` but it must evaluate it and it's the point of evaluation in a `prog || exit` statement that is the crux of my question. – Chris Oldwood Feb 18 '21 at 10:19
  • Only the empirical evidence from the script I wrote to test it. I am considering how to make that answer more clear. I think `exit` behaves a bit differently when exit the script itself vs exiting a call. I am convinced that in your case, the script line must first be interpreted before the exit can happen, so either you get zero or you must use delayed expansion. – jwdonahue Feb 18 '21 at 10:20
  • 1
    I will add that there have been many unintended behaviors in `command.com` and `cmd.exe`, and while Microsoft is loath to fix most of them, they also don't seem to want to document them either. The `::` bad label bug being used for comments comes to mind. So I have gradually collected scripts that serve no purpose other than elucidating the actual behavior. – jwdonahue Feb 18 '21 at 10:36
  • Also, [this answer](https://stackoverflow.com/a/148991/3150445) might be important. Afraid I missed the fact that you were using `.bat` extension. I banned those wherever I've had sufficient control. – jwdonahue Feb 18 '21 at 10:43
  • Thanks, I personally use `.cmd` by default but this codebase is a web of `.bat`s if I can mix my metaphors :o). I had forgotten about the differences but as `exit` is not in that list I presume it's not (intentionally) relevant. – Chris Oldwood Feb 18 '21 at 10:54
  • 1
    Regarding your update: The definitive statement is: `program || exit /b` works like `program || exit /b !errorlevel!` (assuming `setlocal enabledelayedexpansion`), but - with respect to setting the _`cmd.exe` process exit code_ - _only if your batch file is invoked with `call`_. Without `call`, `exit /b`'s error level unexpectedly does _not_ determine `cmd.exe`'s process exit code (it ends up `0`, even though _intra_-session the error level _is_ set, but that is irrelevant in your scenario). However, `exit /b ` - i.e. an _explicit_ exit code - _always_ sets `cmd.exe`'s process exit code. – mklement0 Feb 18 '21 at 15:29
  • 1
    So "Without `call`, `exit /b`'s error level unexpectedly does not determine `cmd.exe`'s process exit code" is what I'm taking away from all this. Many thanks for your patience! – Chris Oldwood Feb 18 '21 at 18:05
  • I've updated my answer to recommend `cmd /c " ... & exit"` as the most robust workaround, because `cmd /c call ...` has the unavoidable side effect of doubling `^` characters in arguments - see [this post](https://stackoverflow.com/q/66975883/45375) for details. – mklement0 Apr 08 '21 at 12:48

5 Answers5

8

Workarounds:

  • Call your batch file via cmd /c "<batch-file> ... & exit", in which case the || exit /b solution without an explicit exit code works as expected:

    • cmd /c ".\test.bat & exit"

      • If needed, escape any embedded " characters as `", such as around batch-file paths and pass-through arguments that contain spaces:
        cmd /c ".\test.bat `"quoted argument`" & exit"
      • Alternatively, if you don't need PowerShell's string interpolation to embed variable values in the call, you can use '...' quoting, in which case embedded " can be used as-is:
        cmd /c '.\test.bat "quoted argument" & exit'
    • Using cmd /c "<batch-file> ... & exit" routinely to call batch files from outside cmd.exe is advisable, as even batch files without explicit exit /b (or exit) calls can otherwise behave unexpectedly - see this answer.

  • Alternatively - but only if your batch file never needs to be called from another batch file to which control should be returned and if it never needs to be part of a cmd /c multi-command command line where it isn't the last command[1] - you can use || exit instead of || exit /b - this exits the executing cmd.exe process as a whole, instantly, but the exit code (error level) is then reliably reported (at least in the context of a <command> || exit statement) also with direct invocation from outside cmd.exe, such as & .\test.bat (or, in this simple case, just .\test.bat) from PowerShell.

While combining setlocal EnableDelayedExpansion with exit /b !ERRORLEVEL! works too (except inside (...) - see this post) - due to using an explicit exit code - it is obviously more cumbersome and can have side effects, notably quietly removing ! characters from commands such as echo hi! (while it's possible to minimize that problem by placing the setlocal EnableDelayedExpansion call on the line just before an exit /b call, that would require duplication if there are multiple exit points).


cmd.exe's behavior is unfortunate in this case, but can't be avoided.

When calling a batch file from outside cmd.exe:

  • exit /b - without an exit-code (error-level) argument - only sets the cmd.exe process exit code as expected - namely to the exit code of the most recently executed command in the batch file - if you follow the batch-file call with & exit, i.e. as cmd /c <batch-file> ... `& exit

    • Without the & exit workaround, an argument-less exit /b call from a batch file is reflected in the %ERRORLEVEL% variable intra-cmd.exe-session, but that doesn't translate to cmd.exe's process exit code, which then defaults to 0.[1]

    • With the & exit workaround, intra-batch-file argument-less exit /b does properly set cmd.exe's exit code, even in a <command> || exit /b statement, in which case <command>'s exit code is relayed, as intended.

  • exit /b <code>, i.e. passing an exit code <code> explicitly, always works[2], i.e. the & exit workaround is then not needed.

  • This distinction is an obscure inconsistency that could justifiably be called a bug; Jeb's helpful answer has sample code that demonstrates the behavior (using the less comprehensive cmd /c call ... workaround as of this writing, but it applies equally to cmd /c "... & exit").


[1] With cmd /c, you can pass multiple statements for execution, and it is the last statement that determines the cmd.exe process' exit code. E.g, cmd /c "ver & dir nosuch" reports exit code 1, because the non-existent file-system item nosuch caused dir to set the error level to 1, irrespective of whether or not the preceding command (ver) succeeded. The inconsistency is that, for a batch file named test.bat which exits with exit /b without an explicit exit-code argument, cmd /c test.bat always reports 0, whereas cmd /c test.bat `& exit properly reports the exit code of the last statement executed before the batch file exited.

[2] The exit code may be specified literally or via a variable, but the pitfall is that - due to cmd.exe's up-front variable expansion - <command> || exit /b %ERRORLEVEL% does not work as intended, because %ERRORLEVEL% at that point expands to the error level prior to this statement, not to the one set by <command>; this is why delayed expansion, via having run setlocal enabledelayedexpansion or having invoked the cmd.exe with the /V option, is necessary in this case: <command> || exit /b !ERRORLEVEL!

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • 1
    I built the sample code, because I didn"t believed your claims, but you are right! – jeb Feb 18 '21 at 16:37
8

There is a difference between exit /b and exit /b <code>.
As mklement0 states, the difference becomes visible when calling a batch file with or without CALL

In my tests, I used (call) to force the errorlevel to 1.

test1.bat

@echo off
(call)
exit /b

test2.bat

@echo off
(call)
exit /b %errorlevel%

Testing with test-all.bat:

cmd /c "test1.bat" & call echo      Test1 %%errorlevel%%
cmd /c "call test1.bat" & call echo call Test1 %%errorlevel%%
cmd /c "test2.bat" & call echo      Test2 %%errorlevel%%
cmd /c "call test2.bat" & call echo call Test2 %%errorlevel%%

Output:

     Test1 0  
call Test1 1  
     Test2 1  
call Test2 1

To get an always reliable errorlevel, you should use the explicit form of exit /b <code>.
In case of using the construct <command> || exit /b !errorlevel! the delayed expansion is necessary or the form

<command> || call exit /b %%errorlevel%%

Another solution

<command> || call :exit
...

:exit
(
   (goto) 2>nul
   exit /b
)    

This uses the batch exception handling
Does Windows batch support exception handling?

mklement0
  • 382,024
  • 64
  • 607
  • 775
jeb
  • 78,592
  • 17
  • 171
  • 225
  • 1
    I've updated my answer to recommend `cmd /c " ... & exit"` as the most robust workaround, because `cmd /c call ...` has the unavoidable side effect of doubling `^` characters in arguments (as you're well aware of) - see [this post](https://stackoverflow.com/q/66975883/45375) for details. – mklement0 Apr 08 '21 at 12:49
5

Let's look at the three possible scenarios:

cmd /c "exit 99" || exit /b

returns 0 because cmd /c "exit 99" executed correctly

cmd /c "exit 99" || exit /b !errorlevel!

returns 99 because cmd /c "exit 99" set errorlevel to 99 and we are returning the errorlevel which results from executing cmd /c "exit 99"

cmd /c "exit 99" || exit /b %errorlevel%

returns ? - errorlevel as it was when the cmd /c "exit 99" line was parsed as it was at that time that `%errorlevel% was evaluated.

If delayedexpansion was not set, then the only difference is that the !errorlevel! scenario attempts to assign a string to the error level, which most probably won't work very well.

As for Powershell - it's a corner case on a road less travelled. A scenario that was not tested thoroughly as the designers expected to execute .exes, etc. using this facility. No doubt even if it is reported, it would not be fixed as there's a workaround, even if it's not well-exposed.

This is, I believe, the fail-to-fail scenario - a facility that's assumed to work because the right conditions to cause it to fail are rarely met.

In the same way,

echo %%%%b

is ambiguous. Does it mean to echo the literal %%b or to echo % prefixed to the contents of metavariable b? (Answer : the latter). Not exactly encountered every day. What chance that the ambiguity will be resolved - ever? We can't even get /u implemented on the date command to have it deliver the date in a universal format which would solve the vast majority of date-oriented questions posted on the batch tag. As for the possibility of a switch to allow date to deliver days-since-some-epoch-date - I haven't even bothered to suggest it since, despite inviting suggestions for cmd modifications, absolutely nothing has been done about facilities offered, just user-interface changes. Off playing with all the shiny things while ol' faithful languishes in the bottom of a locked filing cabinet stuck in a disused lavatory with a sign on the door saying ‘Beware of the Leopard.”

Magoo
  • 77,302
  • 8
  • 62
  • 84
  • `cmd /c "exit 99"`, while executing correctly, still sets its process exit code to `99`, as explicitly requested, which you can see by running `echo %ERRORLEVEL%` afterwards. `exit /b` by itself _does_ pass this exit code through as expected, but the `cmd.exe` process as a whole inexplicably doesn't report it _unless_ the enclosing batch file was invoked with `call` (which, is _implicitly_ used when you invoke a batch file from cmd.exe itself interactively). PowerShell is not to blame here, and neither are the .NET APIs it uses behind the scenes. – mklement0 Feb 17 '21 at 22:06
  • @mklement0 If that were the case then I'd expect `exit /b` and `exit /b !errorlevel!` to behave the same, because in neither case was the `.bat` file invoked with `call`. – Chris Oldwood Feb 18 '21 at 11:01
  • @ChrisOldwood, no, the _explicit_ use of an exit code is what makes all the difference with respect to `exit /b`: _with_ an explicit exit code (whether specified literally or via a variable), the process exit code _is_ properly set; _without one_, the process exit code always ends up `0` instead of reporting the exit code of the most recently executed command (even on the LHS of `||`) - the latter happens _only with `call`_, and that is the inconsistency at hand, which could justifiably be called a bug. – mklement0 Feb 18 '21 at 13:30
1

Eliminating the irrelevant PowerShell and Cmd.exe invocation complexities so we can see how the cmd interpreter works:

@setlocal EnableExtensions EnableDelayedExpansion
@set prompt=$G

call :DefaultExit
@echo %ErrorLevel%

call :ExplicitExit
@echo %ErrorLevel%

@rem ErrorLevel is now 2

call :DefaultExit || echo One-liner ErrorLevel==%ErrorLevel%, not zero!
@echo %ErrorLevel%

@rem Reset the errorlevel to zero
call :DefaultExit
@echo %ErrorLevel%

call :ExplicitExit || echo One-liner ErrorLevel==!ErrorLevel!
@echo %ErrorLevel%

@exit /b

:DefaultExit
exit /b

:ExplicitExit
exit /b 2

Yields:

> test

>call :DefaultExit

>exit /b
0

>call :ExplicitExit

>exit /b 2
2

>call :DefaultExit   || echo One-liner ErrorLevel==2, not zero!

>exit /b
One-liner ErrorLevel==2, not zero
2

>call :DefaultExit

>exit /b
2

>call :ExplicitExit   || echo One-liner ErrorLevel==!ErrorLevel!

>exit /b 2
One-liner ErrorLevel==2
2

As you can see, exit /b is exactly equivalent to exit /b %ErrorLevel%, which is NOT what you want with your || failure execution path. You must invoke delayed expansion to get the results from the left side of the || operator.


I ran one more quick test of the above script and interactively checked the error level from the console window after the script exited:

> echo %errorlevel%
2

As you can see, exit /b alone, returned 2, which was the previously set error level. So my hunch seems correct. You definitely require delayed expansion of some form !ErrorLevel! or %^ErrorLevel% (as per @aschipfl).

jwdonahue
  • 6,199
  • 2
  • 21
  • 43
  • 1
    In batch files, `exit /b` is _not_ equivalent to `exit /b %ErrorLevel%`: It is _in effect_ equivalent to `exit /b 0` when the batch file is invoked _without_ `call` (`call` is used _implicitly_ in interactive invocations from `cmd.exe`). When invoked _with_ `call`, it correctly and dynamically picks up the exit code of the LHS operand of an `||` operation. – mklement0 Feb 18 '21 at 02:31
  • 1
    @mklement0, I'll let the empirical evidence speak for itself. – jwdonahue Feb 18 '21 at 10:30
  • @mklement0 Sorry but the opening part of my question explicitly states that this is not the case, that `program args || exit /b !errorlevel!` works even without the use of `call`. This is why I'm confused :o). – Chris Oldwood Feb 18 '21 at 11:11
  • @ChrisOldwood, the only reason why that works is that an _explicit_ exit code is used - that, without `call` - `exit /b` with _no_ explicit code does _not_ work, is the inconsistency at hand. If you want the convenience of argument-less `exit /b`, with the exit code being _implied_ by the most recently executed command (even if on the LHS of `||`), your only option is to use `call`. The other convenient option is to use just `exit`, but that prevents your batch files from being called by other batch files if control is to be returned to the latter. All that is explained in my answer. – mklement0 Feb 18 '21 at 13:24
  • @jwdonahue, your evidence is incidental to the problem at hand, which is _all about_ the invocation complexities from _outside_ `cmd.exe` (that it is PowerShell, specifically, calling, is incidental too). – mklement0 Feb 18 '21 at 13:32
  • P.S.: The empirical evidence that _does_ matter is in [Jeb's answer](https://stackoverflow.com/a/66258887/45375). – mklement0 Feb 18 '21 at 16:29
  • @mklement0, I disagree. The OP's example launches `cmd.exe` from a script. Converting from the `if...` format in the batch script, to the `||` operator style prompted the question due to the misunderstanding of how `cmd.exe` processes that line within the script. – jwdonahue Feb 18 '21 at 20:38
  • 1
    @mklement0, actually, I think you and Jeb covered it better than I did. – jwdonahue Feb 18 '21 at 20:47
1

The & operator of PowerShell internally invokes cmd.exe /C when running a batch file. I bet $LastExitCode refers to invocation of cmd.exe rather than to the batch file. In this context, call passes over the ErrorLevel from the batch file to cmd.exe.

The same issue arises in cmd.exe (with PowerShell not involved at all), running .\test.bat & call echo %^ErrorLevel% does not invoke cmd.exe /C to run the batch file, so this works (meaning it always returns 99). However, cmd /C .\test.bat & call echo %^ErrorLevel% just fails like your PowerShell attempt (returning 0 with your batch file as it stands), and cmd /C call .\test.bat & call echo %^ErrorLevel% succeeds and therefore returns 99.

By the way, call echo %^ErrorLevel% just enables us not having to use delayed expansion.

aschipfl
  • 33,626
  • 12
  • 54
  • 99
  • > The `&` operator of PowerShell internally invokes `cmd.exe /C` -- does it just use `cmd /c` or does it supply any other switches? I've not found a definitive source for what PowerShell does and just wanted to check that my "emulation" was correct. – Chris Oldwood Feb 18 '21 at 11:13
  • aschipfl, yes, it is the exit code of the `cmd.exe` process that is reported in `$LASTEXITCODE`, which also applies if you invoke a batch file _directly_, by assigning its path (perhaps surprisingly) _as-is_ as the `FileName` property of the [`System.Diagnostics.ProcessStartInfo`](https://learn.microsoft.com/en-US/dotnet/api/System.Diagnostics.ProcessStartInfo) instance PowerShell uses behind the scenes. That is, `cmd /C` is _not_ used, though if it were, the result would be the same. @ChrisOldwood, this also implies that no other switches are passed. – mklement0 Feb 18 '21 at 13:51
  • `call` is indeed what makes the difference here, and causes `cmd.exe`'s exit code and therefore `$LASTEXITCODE` to be set as expected even with an `exit /b` call _without an explicit exit code_, so `cmd /c call .\test.bat; $LASTEXITCODE` will do. As an aside re your attempt to have `cmd.exe` echo the exit code: your command doesn't actually work from PowerShell: first, you'd have to escape the `&` , but neither `%^ERRORLEVEL%` nor `%ERRORLEVEL%` would work as expected; you'd have to use `cmd /v /c call test.bat \`& echo !ERRORLEVEL!` – mklement0 Feb 18 '21 at 13:59
  • @mklement0, `cmd /C call .\test.bat & call echo %^ErrorLevel%` was not intended to work with PowerShell, I just used it to demonstrate that the issue has nothing to do with PowerShell. Your suggestion `cmd /V /C call test.bat \`& echo !ErrorLevel!` constitutes a different situation, because `echo` executes *in* the context of `cmd /V /C`, where the `ErrorLevel` of course still exits, but the issue is the transfer of the `ErrorLevel` *beyond* `cmd`… – aschipfl Feb 19 '21 at 10:12
  • True, the issue has nothing to do with PowerShell, just with how `cmd.exe` sets its _process_ exit code when its CLI (`/c`) is used to execute a batch file - and it misbehaves unless `call` is explicitly used. I now see what your command does: it just another way to show the same problem, only in the context of a `cmd.exe` session. Even though the problem isn't PowerShell-specific, what the OP is looking for is a solution in the context of PowerShell (which is `cmd /c call test.bat`, which causes `$LASTEXITCODE` to be set correctly), so I was confused by your sample command. – mklement0 Feb 19 '21 at 14:34
  • As an aside: I've since realized that the inconsistency around setting `cmd.exe`'s process exit code with CLI invocation is more fundamental than just `exit /b`, which leads me to conclude that using `cmd /c call` is generally the safest way to invoke batch files - see [this answer](https://stackoverflow.com/a/55290133/45375). – mklement0 Feb 19 '21 at 14:37