Windows batch scripting certainly does not have any formal exception handling - hardly surprising considering how primitive the language is. Never in my wildest dreams did I ever think effective exception handling could be hacked up.
But then some amazing discoveries were made on a Russian site concerning the behavior of an erroneous GOTO statement (I have no idea what is said, I can't read Russian). An English summary was posted at DosTips, and the behavior was further investigated.
It turns out that (GOTO) 2>NUL
behaves almost identically to EXIT /B, except concatenated commands within an already parsed block of code are still executed after the effective return, within the context of the CALLer!
Here is a short example that demonstrates most of the salient points.
@echo off
setlocal enableDelayedExpansion
set "var=Parent Value"
(
call :test
echo This and the following line are not executed
exit /b
)
:break
echo How did I get here^^!^^!^^!^^!
exit /b
:test
setlocal disableDelayedExpansion
set "var=Child Value"
(goto) 2>nul & echo var=!var! & goto :break
echo This line is not executed
:break
echo This line is not executed
-- OUTPUT --
var=Parent Value
How did I get here!!!!
This feature is totally unexpected, and incredibly powerful and useful. It has been used to:
- Create PrintHere.bat - an emulation of the 'nix here document feature
- Create a RETURN.BAT utility that any batch "function" can conveniently CALL to return any value across the ENDLOCAL barrier, with virtually no limitations. The code is a fleshed out version of jeb's original idea.
Now I can also add exception handling to the list :-)
The technique relies on a batch utility called EXCEPTION.BAT to define environment variable "macros" that are used to specify TRY/CATCH blocks, as well as to throw exceptions.
Before a TRY/CATCH block can be implemented, the macros must be defined using:
call exception init
Then TRY/CATCH blocks are defined with the following syntax:
:calledRoutine
setlocal
%@Try%
REM normal code goes here
%@EndTry%
:@Catch
REM Exception handling code goes here
:@EndCatch
Exceptions can be thrown at any time via:
call exception throw errorNumber "messageString" "locationString"
When an exception is thrown, it pops the CALL stack iteratively using (GOTO) 2>NUL
until it finds an active TRY/CATCH, whereupon it branches to the CATCH block and executes that code. A series of exception attribute variables are available to the CATCH block:
- exception.Code - The numeric exception code
- exception.Msg - The exception message string
- exception.Loc - The string describing the location where the exception was thrown
- exception.Stack - A string that traces the call stack from the CATCH block (or command line if not caught), all the way to the exception origin.
If the exception is fully handled, then the exception should be cleared via call exception clear
, and the script carries on normally. If the exception is not fully handled, then a new exception can be thrown with a brand new exception.Stack, or the old stack can be preserved with
call exception rethrow errorNumber "messageString" "locationString"
If an exception is not handled, then an "unhandled exception" message is printed, including the four exception attributes, all batch processing is terminated, and control is returned to the command line context.
Here is the code that makes all this possible - full documentation is embedded within the script and available from the command line via exception help
or exception /?
.
EXCEPTION.BAT
::EXCEPTION.BAT Version 1.4
::
:: Provides exception handling for Windows batch scripts.
::
:: Designed and written by Dave Benham, with important contributions from
:: DosTips users jeb and siberia-man
::
:: Full documentation is at the bottom of this script
::
:: History:
:: v1.4 2016-08-16 Improved detection of command line delayed expansion
:: using an original idea by jeb
:: v1.3 2015-12-12 Added paged help option via MORE
:: v1.2 2015-07-16 Use COMSPEC instead of OS to detect delayed expansion
:: v1.1 2015-07-03 Preserve ! in exception attributes when delayed expansion enabled
:: v1.0 2015-06-26 Initial versioned release with embedded documentation
::
@echo off
if "%~1" equ "/??" goto pagedHelp
if "%~1" equ "/?" goto help
if "%~1" equ "" goto help
shift /1 & goto %1
:throw errCode errMsg errLoc
set "exception.Stack="
:: Fall through to :rethrow
:rethrow errCode errMsg errLoc
setlocal disableDelayedExpansion
if not defined exception.Restart set "exception.Stack=[%~1:%~2] %exception.Stack%"
for /f "delims=" %%1 in ("%~1") do for /f "delims=" %%2 in ("%~2") do for /f "delims=" %%3 in ("%~3") do (
setlocal enableDelayedExpansion
for /l %%# in (1 1 10) do for /f "delims=" %%S in (" !exception.Stack!") do (
(goto) 2>NUL
setlocal enableDelayedExpansion
if "!!" equ "" (
endlocal
setlocal disableDelayedExpansion
call set "funcName=%%~0"
call set "batName=%%~f0"
if defined exception.Restart (set "exception.Restart=") else call set "exception.Stack=%%funcName%%%%S"
setlocal EnableDelayedExpansion
if !exception.Try! == !batName!:!funcName! (
endlocal
endlocal
set "exception.Code=%%1"
if "!!" equ "" (
call "%~f0" setDelayed
) else (
set "exception.Msg=%%2"
set "exception.Loc=%%3"
set "exception.Stack=%%S"
)
set "exception.Try="
(CALL )
goto :@Catch
)
) else (
for %%V in (Code Msg Loc Stack Try Restart) do set "exception.%%V="
if "^!^" equ "^!" (
call "%~f0" showDelayed
) else (
echo(
echo Unhandled batch exception:
echo Code = %%1
echo Msg = %%2
echo Loc = %%3
echo Stack=%%S
)
echo on
call "%~f0" Kill
)>&2
)
set exception.Restart=1
setlocal disableDelayedExpansion
call "%~f0" rethrow %1 %2 %3
)
:: Never reaches here
:init
set "@Try=call set exception.Try=%%~f0:%%~0"
set "@EndTry=set "exception.Try=" & goto :@endCatch"
:: Fall through to :clear
:clear
for %%V in (Code Msg Loc Stack Restart Try) do set "exception.%%V="
exit /b
:Kill - Cease all processing, ignoring any remaining cached commands
setlocal disableDelayedExpansion
if not exist "%temp%Kill.Yes" call :buildYes
call :CtrlC <"%temp%Kill.Yes" 1>nul 2>&1
:CtrlC
@cmd /c exit -1073741510
:buildYes - Establish a Yes file for the language used by the OS
pushd "%temp%"
set "yes="
copy nul Kill.Yes >nul
for /f "delims=(/ tokens=2" %%Y in (
'"copy /-y nul Kill.Yes <nul"'
) do if not defined yes set "yes=%%Y"
echo %yes%>Kill.Yes
popd
exit /b
:setDelayed
setLocal disableDelayedExpansion
for %%. in (.) do (
set "v2=%%2"
set "v3=%%3"
set "vS=%%S"
)
(
endlocal
set "exception.Msg=%v2:!=^!%"
set "exception.Loc=%v3:!=^!%"
set "exception.Stack=%vS:!=^!%"
)
exit /b
:showDelayed -
setLocal disableDelayedExpansion
for %%. in (.) do (
set "v2=%%2"
set "v3=%%3"
set "vS=%%S"
)
for /f "delims=" %%2 in ("%v2:!=^!%") do for /f "delims=" %%3 in ("%v3:!=^!%") do for /f "delims=" %%S in ("%vS:!=^!%") do (
endlocal
echo(
echo Unhandled batch exception:
echo Code = %%1
echo Msg = %%2
echo Loc = %%3
echo Stack=%%S
)
exit /b
:-?
:help
setlocal disableDelayedExpansion
for /f "delims=:" %%N in ('findstr /rbn ":::DOCUMENTATION:::" "%~f0"') do set "skip=%%N"
for /f "skip=%skip% tokens=1* delims=:" %%A in ('findstr /n "^" "%~f0"') do echo(%%B
exit /b
:-??
:pagedHelp
setlocal disableDelayedExpansion
for /f "delims=:" %%N in ('findstr /rbn ":::DOCUMENTATION:::" "%~f0"') do set "skip=%%N"
((for /f "skip=%skip% tokens=1* delims=:" %%A in ('findstr /n "^" "%~f0"') do @echo(%%B)|more /e) 2>nul
exit /b
:-v
:/v
:version
echo(
for /f "delims=:" %%A in ('findstr "^::EXCEPTION.BAT" "%~f0"') do echo %%A
exit /b
:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
:::DOCUMENTATION:::
EXCEPTION.BAT is a pure batch script utility that provides robust exception
handling within batch scripts. It enables code to be placed in TRY/CATCH blocks.
If no exception is thrown, then only code within the TRY block is executed.
If an exception is thrown, the batch CALL stack is popped repeatedly until it
reaches an active TRY block, at which point control is passed to the associated
CATCH block and normal processing resumes from that point. Code within a CATCH
block is ignored unless an exception is thrown.
An exception may be caught in a different script from where it was thrown.
If no active TRY is found after throwing an exception, then an unhandled
exception message is printed to stderr, all processing is terminated within the
current CMD shell, and control is returned to the shell command line.
TRY blocks are specified using macros. Obviously the macros must be defined
before they can be used. The TRY macros are defined using the following CALL
call exception init
Besides defining @Try and @EndTry, the init routine also explicitly clears any
residual exception that may have been left by prior processing.
A TRY/CATCH block is structured as follows:
%@Try%
REM any normal code goes here
%@EndTry%
:@Catch
REM exception handling code goes here
:@EndCatch
- Every TRY must have an associated CATCH.
- TRY/CATCH blocks cannot be nested.
- Any script or :labeled routine that uses TRY/CATCH must have at least one
SETLOCAL prior to the appearance of the first TRY.
- TRY/CATCH blocks use labels, so they should not be placed within parentheses.
It can be done, but the parentheses block is broken when control is passed to
the :@Catch or :@EndCatch label, and the code becomes difficult to interpret
and maintain.
- Any valid code can be used within a TRY or CATCH block, including CALL, GOTO,
:labels, and balanced parentheses. However, GOTO cannot be used to leave a
TRY block. GOTO can only be used within a TRY block if the label appears
within the same TRY block.
- GOTO must never transfer control from outside TRY/CATCH to within a TRY or
CATCH block.
- CALL should not be used to call a label within a TRY or CATCH block.
- CALLed routines containing TRY/CATCH must have labels that are unique within
the script. This is generally good batch programming practice anyway.
It is OK for different scripts to share :label names.
- If a script or routine recursively CALLs itself and contains TRY/CATCH, then
it must not throw an exception until after execution of the first %@Try%
Exceptions are thrown by using
call exception throw Code Message Locati