Exception Handling with batch using erroneous GOTO

Discussion forum for all Windows batch related topics.

Moderator: DosItHelp

Message
Author
dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Exception Handling with batch using erroneous GOTO

#1 Post by dbenham » 09 Jun 2015 17:04

Important Update - The current version of EXCEPTION.BAT (version 1.4) is available at viewtopic.php?f=3&t=6497&p=48480#p48480.
But go ahead and read the entire thread for explanations and to get a sense of the development history.


Up until now, I've been aware of two good methods for terminating batch processing, regardless how deep the CALL stack. Both methods are described at StackOverflow: Exit batch script from inside a function:

1) Jeb's fatal syntax error method - This works, but it has a nasty side effect. Environment changes after SETLOCAL are preserved, meaning the implicit ENDLOCAL does not fire at the end of batch processing.

2) My Ctrl-C method - This works cleanly, all SETLOCAL are properly released at end.

But both methods are limited in that there is no mechanism for CALLs to catch the closure, so there is no cleanup. (no exception handling) :(


But the topic Tricky way to detect calls from another batch script led me to a new way of cleanly exiting all batch processing, and this method provides primitive exception handling. Each CALL has the option of specifying cleanup code to execute in the event that batch processing has been terminated from within any arbitrary deeper CALL :!:

Note that these exception cleanup commands are executed within a command line context, after all SETLOCAL have been released. Also, there is no way to "handle" the exception and resume normal batch processing. Despite these limitations, the method is still potentially very useful.

One of the problems I had to solve was how to tell whether code is executing in a batch context, or a command line context. I have found the following simple test works great:

Code: Select all

(
  setlocal enableDelayedExpansion
  if "!!" equ "" (
    endlocal
    echo BATCH CONTEXT
  ) else echo COMMAND LINE CONTEXT
)

The above works, regardless whether the command line has delayed expansion enabled or disabled :)

The other problem was figuring a way for the exception handler to detect when the exception has been raised (batch processing terminated). I first attempted to return an unusual return code (similar to the Ctrl-C method), but I couldn't make that work.

I could have checked if in a command line context, but I opted not to go that route.

Instead, I simply define a _KillBatchException variable at the end of the code that raises the exception. Upon CALL return, if _KillBatchException is defined, then there was an exception, otherwise not.

Putting it all together, I define a KillBatch.bat script that is intended to be somewhere within PATH:

KillBatch.bat

Code: Select all

@echo off
(
  for /l %%. in (1 1 10) do (goto)2>nul
  setlocal enableDelayedExpansion
  if "!!" equ "" (
    endlocal
    call "%~f0"
  ) else set "_KillBatchException=1"
)

Here is a test script that demonstrates how KilBatch.bat can be used. It is a recursive script that expects two arugments:

1) The total number of recursive levels
2) The level at which the exception should be raised.

The script raises the exception on the way out of the recursive call.

test.bat

Code: Select all

@echo off

:: Critical to undefine this variable at start of batch
set "_KillBatchException="

:: Explicitly clear these variables so we can prove all ENDLOCAL are honored
set "CommandLineContext="
set "#depth="
set "#maxDepth="
set "#killDepth="

setlocal
set "CommandLineContext=BatchContext"
set /a "#depth=0, #maxDepth=%~1+0, #killDepth=%~2+0"

:test
setlocal
set /a #depth+=1
echo Enter :test level %#depth%
if %#depth% lss %#maxDepth% (
  call :test
  if defined _KillBatchException call echo level %#depth% exception cleanup  %%CommandLineContext%%
)
if %#depth% equ %#killDepth% (
  call echo Level %#depth% exception exit  %%CommandLineContext%%
  call killBatch
)
call echo Level %#depth% normal exit  %%CommandLineContext%%
exit /b

Here are the results of a test run:

Code: Select all

D:\test>test 20 12
Enter :test level 1
Enter :test level 2
Enter :test level 3
Enter :test level 4
Enter :test level 5
Enter :test level 6
Enter :test level 7
Enter :test level 8
Enter :test level 9
Enter :test level 10
Enter :test level 11
Enter :test level 12
Enter :test level 13
Enter :test level 14
Enter :test level 15
Enter :test level 16
Enter :test level 17
Enter :test level 18
Enter :test level 19
Enter :test level 20
Level 20 normal exit  BatchContext
Level 19 normal exit  BatchContext
Level 18 normal exit  BatchContext
Level 17 normal exit  BatchContext
Level 16 normal exit  BatchContext
Level 15 normal exit  BatchContext
Level 14 normal exit  BatchContext
Level 13 normal exit  BatchContext
Level 12 exception exit  BatchContext
level 11 exception cleanup  %CommandLineContext%
level 10 exception cleanup  %CommandLineContext%
level 9 exception cleanup  %CommandLineContext%
level 8 exception cleanup  %CommandLineContext%
level 7 exception cleanup  %CommandLineContext%
level 6 exception cleanup  %CommandLineContext%
level 5 exception cleanup  %CommandLineContext%
level 4 exception cleanup  %CommandLineContext%
level 3 exception cleanup  %CommandLineContext%
level 2 exception cleanup  %CommandLineContext%
level 1 exception cleanup  %CommandLineContext%

D:\test>set #
Environment variable # not defined

Sweet :D 8)

Dave Benham

EDIT - I simplified detection of the command line context
Last edited by dbenham on 16 Aug 2016 06:01, edited 5 times in total.

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#2 Post by dbenham » 09 Jun 2015 22:16

My previous post is good, but some might object to defining a _KillBatchException variable upon fatal exception that persists after the script terminates.

I've modified the code to detect a fatal exception by looking for a command line context.

First, I simplified KillBatch.bat

KillBatch.bat

Code: Select all

@echo off
(
  for /l %%. in (1 1 10) do (goto)2>nul
  setlocal enableDelayedExpansion
  if "!!" equ "" (
    endlocal
    call "%~f0"
  )
)


And here is all it takes to detect the fatal exception:

test1.bat

Code: Select all

@echo off

:: Explicitly clear these variables so we can prove all ENDLOCAL are honored
set "CommandLineContext="
set "#depth="
set "#maxDepth="
set "#killDepth="

setlocal
set "CommandLineContext=BatchContext"
set /a "#depth=0, #maxDepth=%~1+0, #killDepth=%~2+0"

:test
setlocal
set /a #depth+=1
echo Enter :test level %#depth%

if %#depth% lss %#maxDepth% (
  call :test
  setlocal enableDelayedExpansion & if "!!"=="" (endlocal) else (
    %= Fatal exception handling goes here =%
    call echo level %#depth% exception cleanup  %%CommandLineContext%%
  )
)

if %#depth% equ %#killDepth% (
  call echo Level %#depth% exception exit  %%CommandLineContext%%
  call killBatch
)

call echo Level %#depth% normal exit  %%CommandLineContext%%
2>nul set /a 1/(#depth%%3) %= Raise an error every 3rd CALL =%
exit /b %errorlevel%

-- Test Output --

Code: Select all

C:\test>test1 20 12
Enter :test level 1
Enter :test level 2
Enter :test level 3
Enter :test level 4
Enter :test level 5
Enter :test level 6
Enter :test level 7
Enter :test level 8
Enter :test level 9
Enter :test level 10
Enter :test level 11
Enter :test level 12
Enter :test level 13
Enter :test level 14
Enter :test level 15
Enter :test level 16
Enter :test level 17
Enter :test level 18
Enter :test level 19
Enter :test level 20
Level 20 normal exit  BatchContext
Level 19 normal exit  BatchContext
Level 18 normal exit  BatchContext
Level 17 normal exit  BatchContext
Level 16 normal exit  BatchContext
Level 15 normal exit  BatchContext
Level 14 normal exit  BatchContext
Level 13 normal exit  BatchContext
Level 12 exception exit  BatchContext
level 11 exception cleanup  %CommandLineContext%
level 10 exception cleanup  %CommandLineContext%
level 9 exception cleanup  %CommandLineContext%
level 8 exception cleanup  %CommandLineContext%
level 7 exception cleanup  %CommandLineContext%
level 6 exception cleanup  %CommandLineContext%
level 5 exception cleanup  %CommandLineContext%
level 4 exception cleanup  %CommandLineContext%
level 3 exception cleanup  %CommandLineContext%
level 2 exception cleanup  %CommandLineContext%
level 1 exception cleanup  %CommandLineContext%

C:\test>set #
Environment variable # not defined


But some of my CALLs return a non-fatal error, while others return success. I might want to differentiate between success vs. non-fatal error vs. fatal exception.

The solution is easy if delayed expansion is enabled when I make each CALL:

test2.bat

Code: Select all

@echo off

:: Explicitly clear these variables so we can prove all ENDLOCAL are honored
set "CommandLineContext="
set "#depth="
set "#maxDepth="
set "#killDepth="

setlocal enableDelayedExpansion
set "CommandLineContext=BatchContext"
set /a "#depth=0, #maxDepth=%~1+0, #killDepth=%~2+0"

:test
setlocal
set /a #depth+=1
echo Enter :test level %#depth%

if %#depth% lss %#maxDepth% (
  call :test
  if "!!" equ "" (
    if !errorlevel! equ 0 (
      %= Success handling goes here =%
      echo Level %#depth% CALL returned Success
    ) else (
      %= Normal Error handling goes here =%
      echo Level %#depth% CALL returned +++ERROR+++
    )
  ) else (
    %= Fatal exception handling goes here =%
    call echo level %#depth% exception cleanup       %%CommandLineContext%%
  )
)

if %#depth% equ %#killDepth% (
  call echo Level %#depth% exception exit         %%CommandLineContext%%
  call killBatch
)

call echo Level %#depth% normal exit            %%CommandLineContext%%
2>nul set /a 1/(#depth%%3) %= Raise an error every 3rd CALL =%
exit /b

-- Sample Output --

Code: Select all

C:\test>test3 20 12
Enter :test level 1
Enter :test level 2
Enter :test level 3
Enter :test level 4
Enter :test level 5
Enter :test level 6
Enter :test level 7
Enter :test level 8
Enter :test level 9
Enter :test level 10
Enter :test level 11
Enter :test level 12
Enter :test level 13
Enter :test level 14
Enter :test level 15
Enter :test level 16
Enter :test level 17
Enter :test level 18
Enter :test level 19
Enter :test level 20
Level 20 normal exit            BatchContext
Level 19 CALL returned Success
Level 19 normal exit            BatchContext
Level 18 CALL returned Success
Level 18 normal exit            BatchContext
Level 17 CALL returned +++ERROR+++
Level 17 normal exit            BatchContext
Level 16 CALL returned Success
Level 16 normal exit            BatchContext
Level 15 CALL returned Success
Level 15 normal exit            BatchContext
Level 14 CALL returned +++ERROR+++
Level 14 normal exit            BatchContext
Level 13 CALL returned Success
Level 13 normal exit            BatchContext
Level 12 CALL returned Success
Level 12 exception exit         BatchContext
level 11 exception cleanup       %CommandLineContext%
level 10 exception cleanup       %CommandLineContext%
level 9 exception cleanup       %CommandLineContext%
level 8 exception cleanup       %CommandLineContext%
level 7 exception cleanup       %CommandLineContext%
level 6 exception cleanup       %CommandLineContext%
level 5 exception cleanup       %CommandLineContext%
level 4 exception cleanup       %CommandLineContext%
level 3 exception cleanup       %CommandLineContext%
level 2 exception cleanup       %CommandLineContext%
level 1 exception cleanup       %CommandLineContext%

C:\test>set #
Environment variable # not defined


The problem is more difficult if delayed expansion is disabled when the CALL is made. But it still isn't too bad:

test3.bat

Code: Select all

@echo off

:: Explicitly clear these variables so we can prove all ENDLOCAL are honored
set "CommandLineContext="
set "#depth="
set "#maxDepth="
set "#killDepth="

setlocal disableDelayedExpansion
set "CommandLineContext=BatchContext"
set /a "#depth=0, #maxDepth=%~1+0, #killDepth=%~2+0"

:test
setlocal
set /a #depth+=1
echo Enter :test level %#depth%

if %#depth% lss %#maxDepth% (
  call :test && ( setlocal enableDelayedExpansion & if "!!" equ "" ( endlocal
    %= Success handling goes here =%
    echo Level %#depth% CALL returned Success
    (call ) %= Explicitly clear ERRORLEVEL so can't fall into error handler =%
  )) || ( setlocal enableDelayedExpansion & if "!!" equ "" ( endlocal
    %= Normal Error handling goes here =%
    echo Level %#depth% CALL returned +++ERROR+++
  ))
  setlocal enableDelayedExpansion & if "!!"=="" (endlocal) else (
    %= Fatal exception handling goes here =%
    call echo level %#depth% exception cleanup       %%CommandLineContext%%
  )
)

if %#depth% equ %#killDepth% (
  call echo Level %#depth% exception exit         %%CommandLineContext%%
  call killBatch
)

call echo Level %#depth% normal exit            %%CommandLineContext%%
2>nul set /a 1/(#depth%%3) %= Raise an error every 3rd CALL =%
exit /b

Output is identical to test2.bat


Dave Benham

EDIT - I fixed the logic of test2.bat and updated the output. Now it functions as it should, and test3.bat truly gives the same output.

jeb
Expert
Posts: 1041
Joined: 30 Aug 2007 08:05
Location: Germany, Bochum

Re: Exception Handling with batch using erroneous GOTO

#3 Post by jeb » 10 Jun 2015 15:39

I'm impressed :o of your idea of exception handling with batch, this idea didn't become to me before.

But I wanted to build a more powerful exception handling and with the new two points of information I discoverd today,
so I build another solution.

A code snippet

Code: Select all

:myFunc1
setlocal
%TRY%
(
  FOR %%a in (*.lst) DO (
    CALL :checkTheFile "%%a"
  )
)
<:%CATCH% (
  echo EXCEPTION in %0: Message '!exceptionMessage!'
)
echo Now the function ends
exit /b

:checkTheFile
if "%~1"==".lst" CALL :throw "WrongFilenameException"
echo The normal way
exit /b


- This means I can build a "normal" try-catch block, like in other languages.
- It really cancels even the FOR-block in the case of throwing an exception.
- It's also allowed to rethrow the exception in the catch-block
- An exception can be catched anywhere in the call-stack, if no one catch the exception at all, the batch will be exited with an unhandled exception warning.

The full code (but not final)

Code: Select all

@echo off
cls
setlocal
set "try=call set tryCatch=%%0"
set "catch=<nul set "tryCatch=" & if 1==0 "
set "tryCatch="

set "seperator=-"
echo START Main
call :func%seperator%1
echo END Main
exit /b


:func-1
:func-2
:func-3
:func-4
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

:func-5
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
%TRY%
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
)
<:%CATCH% (
    echo CATCH THE EXCEPTION: '!exceptionMsg!' in %0
    echo Rethrow it
    call :throw "!exceptionMsg!"
)
echo END %0
echo(
exit /b

:func-6
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
:next
echo NEXT ********************* NEXT 6-8
exit /b
)
echo END %0
echo(
exit /b

:func-7
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
FOR /L %%i in (1 1 3) DO (
    echo %0 %%i hello

    if %%i==1 (
        call :func%seperator%%cnt%
    )
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

:func-8
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
%TRY%
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
)
<:%CATCH% (
    echo CATCH THE EXCEPTION: '!exceptionMsg!' in %0
    echo Rethrow it
    call :throw "!exceptionMsg! from the first handler"
)
echo END %0
echo(
exit /b

:func-9
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    call :throw "MyException"
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

:throw
echo(
rem echo *************** CANCEL STARTS  %count%
set CANCEL=11
for /F "delims=" %%M in ("%~1") DO (
    for /L %%a in (1 1 %CANCEL%) DO (
        (goto) 2>NUL       
        call set "funcName=%%0"
        setlocal EnableDelayedExpansion
        REM call echo   - !funcName! %%1 fn=!fn! !! %%~f0
        if defined tryCatch (
            if !tryCatch! == !funcName! (
                set "exceptionMsg=%%~M"
                REM echo CATCHER found !tryCatch!
                set "tryCatch="
                goto :%%CATCH%%
            )
        )
        if "!funcName:~0,1!" NEQ ":" (
            REM echo *************** CANCEL ENDS
            echo(
            echo Unhandled exception in !funcName!, message '%%~M'
            exit /b
        )
    )
)
echo *************** NEVER COMES HERE - CANCEL ENDS
exit /b

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#4 Post by dbenham » 11 Jun 2015 09:25

:shock: 8) That is really exciting and inventive jeb :!: :D

I realize you are still working on this, but here are some thoughts on what you have posted so far:

1) I'm concerned that your exception handler relies on ERRORLEVEL to determine whether an exception has been raised or not. I know I have had problems in the past making sure the ERRORLEVEL is as I want it. I'm thinking that a developer could inadvertantly set a positive ERRORLEVEL in the TRY block, thus messing up the logic.Oops - I somehow misread jeb's code, so point 1) is invalid

2) I had really hoped that an unhandled exception could percolate all the way to the command line context, even across multiple script calls. I can see where it shouldn't always do this, but I thought it would have been a nice option. However, I don't see a method to break out of any cached code once you have reached the command line context - There is no :ExistantLabel, and EXIT /B doesn't work any more :(

3) I believe the following limitations are all true:

- A TRY/CATCH construct can only catch exceptions that are thrown within a CALLed :label that was CALLed within the TRY block.Ignore this point - it is simply wrong
- Any routine that contains TRY/CATCH must have SETLOCAL before the first TRY, otherwise the exception handler stack breaks.
- All TRY/CATCH sould be at the outermost block level within a routine. Of course this means you can't have nested TRY/CATCH within the same block.

I couldn't resist taking a stab at this myself. I've come up with some variations:

A) I came up with a new mechanism and syntax for establishing TRY/CATCH blocks that does not rely on ERRORLEVEL. I believe the logic is easier to understand, and just a bit easier on the eyes (no weird looking <).

B) I put the exception routines in a separate script (exception.bat) that is intended to be within the PATH. This makes it easy to include the functionality in other scripts, and it makes it easier and more efficient for the :throw routine to percolate up an arbitrary number of levels until it reaches a handler or the root, which ever comes first.

C) I've added a call stack

D) The :throw routine clears any existing call stack. The :rethrow routine preserves any existing stack. This is important when rethrowing an exception within an exception handler. Use :throw to limit the stack to the point of that throw. Use :rethrow to preserve the historical stack so can see the original exception.

E) The :init routine defines the macros needed for exception handling, and clears any existing exceptions.

F) Persisting unhandled exception definitions in variables for the caller is problematic because we don't know how many ENDLOCAL are required. I haven't implemented either, but the only options I've come up with is to use a file, or DOSKEY macros.


exception.bat

Code: Select all

@echo off
shift /1 & goto %1

:throw  errMsg  errLoc
set "exceptionStack="
:: Fall through to :rethrow


:rethrow  errMsg  errLoc
setlocal disableDelayedExpansion
call set "batName=%%~f0"
setlocal enableDelayedExpansion
set "exceptionStack=[%~1] !exceptionStack!"
for %%B in ("!batName!") do (
  for /l %%# in (1 1 10) do for /f "delims=" %%S in (" !exceptionStack!") do (
    (goto) 2>NUL
    setlocal disableDelayedExpansion
    call set "funcName=%%0"
    setlocal EnableDelayedExpansion
    set "exceptionStack=!funcName!%%S"
    if !exceptionTry! == !funcName! (
      endlocal
      endlocal
      set "exceptionMsg=%~1"
      set "exceptionLoc=%~2"
      set "exceptionStack=%%S"
      set "exceptionTry="
      (CALL )
      goto :@Catch
    )
    if "!funcName:~0,1!" neq ":" (
      echo(
      echo Unhandled batch exception:
      if "%~1" neq "" for /f "delims=" %%M in ("%~1") do echo   Msg = %%M
      echo   Bat = %%~B
      if "%~2" neq "" for /f "delims=" %%M in ("%~2") do echo   Loc = %%M
      echo   Stack: !funcName!%%S
      exit /b 1
    ) >&2
  )
  call "%~f0" rethrow %1 %2 %3
)
exit /b


:init
set "@Try=(call set exceptionTry=%%0"
set "@EndTry=set "exceptionTry=" & goto :@endCatch)"
set "@ClearException=for %%A in (Msg Loc Stack) do set "exception%%A=""
set "exceptionTry="
%@ClearException%
exit /b


test.bat (Derived from jeb's original exception handler demonstation)

Code: Select all

@echo off
cls
setlocal
call exception init

set "seperator=-"
echo START Main
call :func%seperator%1
echo END Main
exit /b


:func-1
:func-2
:func-3
:func-4
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

:func-5
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
%@Try%
  call :func%seperator%%cnt%
  echo INSIDE BLOCK %0 fn=!fn!
%@EndTry%
:@Catch
  echo(
  echo CATCH THE EXCEPTION: '!exceptionMsg!' in %0
  echo Rethrow it
  call exception rethrow "Func-5 exception" "%0"
:@EndCatch
echo END %0
echo(
exit /b

:func-6
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    call :func%seperator%%cnt%
    echo INSIDE BLOCK %0 fn=!fn!
:next
echo NEXT ********************* NEXT 6-8
exit /b
)
echo END %0
echo(
exit /b

:func-7
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
FOR /L %%i in (1 1 3) DO (
    echo %0 %%i hello

    if %%i==1 (
        call :func%seperator%%cnt%
    )
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

:func-8
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
%@Try%
  call :func%seperator%%cnt%
  echo INSIDE BLOCK %0 fn=!fn!
%@EndTry%
:@Catch
  echo(
  echo CATCH THE EXCEPTION: '!exceptionMsg!' in "%0"
  echo Throw new exception
  call exception throw "!exceptionMsg! in 8" "%0"
:@EndCatch
echo END %0
echo(
exit /b

:func-9
setlocal EnableDelayedExpansion
set "fn=%0"
set "fn=!fn:*%seperator%=!"
set /a cnt=fn + 1
echo START %0
(
    echo Throwing 'MyException9' in "%0"
    call exception throw "MyException9"  "%0"
    echo INSIDE BLOCK %0 fn=!fn!
)
echo END %0
echo(
exit /b

--OUTPUT--

Code: Select all

START Main
START :func-1
START :func-2
START :func-3
START :func-4
START :func-5
START :func-6
START :func-7
:func-7 1 hello
START :func-8
START :func-9
Throwing 'MyException9' in ":func-9"

CATCH THE EXCEPTION: 'MyException9' in ":func-8"
Throw new exception

CATCH THE EXCEPTION: 'MyException9 in 8' in :func-5
Rethrow it

Unhandled batch exception:
  Msg = Func-5 exception
  Bat = D:\test\exception.bat
  Loc = :func-5
  Stack: test :func-1 :func-2 :func-3 :func-4 :func-5 [Func-5 exception]  :func-6 :func-7 :func-8 [MyException9 in 8]


Dave Benham
Last edited by dbenham on 11 Jun 2015 18:55, edited 3 times in total.

Ed Dyreen
Expert
Posts: 1569
Joined: 16 May 2011 08:21
Location: Flanders(Belgium)
Contact:

Re: Exception Handling with batch using erroneous GOTO

#5 Post by Ed Dyreen » 11 Jun 2015 09:49

Code: Select all

<:%CATCH% (
  echo EXCEPTION in %0: Message '!exceptionMessage!'
)
a redirection, a label, so it won't work for macro's, aaah :(

I'd hoped to do something like this

Code: Select all

macro = (
   %try% ()
   %catch% ()
   %finally% ()
)

jeb
Expert
Posts: 1041
Joined: 30 Aug 2007 08:05
Location: Germany, Bochum

Re: Exception Handling with batch using erroneous GOTO

#6 Post by jeb » 11 Jun 2015 11:21

Ed Dyreen wrote:a redirection, a label, so it won't work for macro's, aaah :(

Currently, I assume that the try/catch technic can't work with macros :!:
But I can be wrong, again :)

But this technic needs the (goto)-bug and then it pops call-stack levels and with EXIT /B it even removes cached blocks.
And at the end it needs a label for the last GOTO.

So, from this point of view these points are all against the macro technic.

@dbenham
dbenham wrote:1) I'm concerned that your exception handler relies on ERRORLEVEL to ...

No I don't use errorlevels at all, first I thought about it, but it seems very unstable and some problems I can't solve with ERRORLEVEL.

dbenham wrote:2) I had really hoped that an unhandled exception could percolate all the way to the command line context, even across multiple script calls. I can see where it shouldn't always do this, but I thought it would have been a nice option. However, I don't see a method to break out of any cached code once you have reached the command line context - There is no :ExistantLabel, and EXIT /B doesn't work any more :(

It can be done, I've already have a solution but it still produces some major problems.
dbenham wrote:3) I believe the following limitations are all true:
- A TRY/CATCH construct can only catch exceptions that are thrown within a CALLed :label that was CALLed within the TRY block.

It is also possible that it catches an exception created just in the own TRY block itself, but I suppose I have to rearrange the code a bit for this.

dbenham wrote:- Any routine that contains TRY/CATCH must have SETLOCAL before the first TRY, otherwise the exception handler stack breaks

Currently it's necessary when you use more than one TRY/CATCH block in nested functions, but this can be solved with a little trick:
The TRY macro can be changed to something like this set "try=call set tryCatch_%%0=CatcherExists"

dbenham wrote:- All TRY/CATCH sould be at the outermost block level within a routine. Of course this means you can't have nested TRY/CATCH within the same block

Yes, and currently I don't need nested TRY-Catch blocks in one function :)

Btw. I like the idea of the variable with the exceptionStack, before I outputed it only for debug reasons :D
But your exceptionStack should be deleted at each new TRY block, the output with your test should be only
Stack: test :func-1 :func-2 :func-3 :func-4 :func-5


Currently, I don't understand why you need this line :?:

Code: Select all

call "%~f0" rethrow %1 %2 %3

jeb
Expert
Posts: 1041
Joined: 30 Aug 2007 08:05
Location: Germany, Bochum

Re: Exception Handling with batch using erroneous GOTO

#7 Post by jeb » 11 Jun 2015 11:36

I'm thinking about the future and possibilities for the exception handler concept.

My final goal is an output like
Exception 'myException' was thrown in :func4

StackDump:
:func4 line 432
:func3 line 321
:func2 line 110
:func1 line 30
test.bat line 10

Variables Dump:
:func4 (2 setlocal level)
setlocal level 2
var1=modified
setlocal level 1
var1 undefined
setlocal level 0
var1=origin

:func3 (1 setlocal level)
....

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#8 Post by dbenham » 11 Jun 2015 12:24

jeb wrote:
dbenham wrote:1) I'm concerned that your exception handler relies on ERRORLEVEL to ...

No I don't use errorlevels at all, first I thought about it, but it seems very unstable and some problems I can't solve with ERRORLEVEL.

Doh :!: :oops:
Don't ask me how, but somehow I saw set "catch=<nul set "tryCatch=" & if 1==0 " and read ERRORLEVEL :roll:

jeb wrote:
dbenham wrote:2) I had really hoped that an unhandled exception could percolate all the way to the command line context, even across multiple script calls. I can see where it shouldn't always do this, but I thought it would have been a nice option. However, I don't see a method to break out of any cached code once you have reached the command line context - There is no :ExistantLabel, and EXIT /B doesn't work any more :(

It can be done, I've already have a solution but it still produces some major problems.

I'm intrigued...

jeb wrote:
dbenham wrote:3) I believe the following limitations are all true:
- A TRY/CATCH construct can only catch exceptions that are thrown within a CALLed :label that was CALLed within the TRY block.

It is also possible that it catches an exception created just in the own TRY block itself, but I suppose I have to rearrange the code a bit for this.

I was flat out wrong, and I'm glad :!:
I haven't tried your code, but with mine, it simply works. I had forgotten that the first (GOTO) within :THROW returns to the original routine that called :throw, so all is well :)

jeb wrote:
dbenham wrote:- Any routine that contains TRY/CATCH must have SETLOCAL before the first TRY, otherwise the exception handler stack breaks

Currently it's necessary when you use more than one TRY/CATCH block in nested functions, but this can be solved with a little trick:
The TRY macro can be changed to something like this set "try=call set tryCatch_%%0=CatcherExists"

I don't see how that can work if the routine that calls :throw also calls itself recursively. Even if you increment a counter, you can't know when to decrement the counter because the THROW/CATCH may not be executed on every call. Also, two or more THROW/CATCH within a recursive routine would further complicate things.

jeb wrote:Btw. I like the idea of the variable with the exceptionStack, before I outputed it only for debug reasons :D
But your exceptionStack should be deleted at each new TRY block, the output with your test should be only
Stack: test :func-1 :func-2 :func-3 :func-4 :func-5


Currently, I don't understand why you need this line :?:

Code: Select all

call "%~f0" rethrow %1 %2 %3


Actually it is working exactly as I want. I want to be able to see the stack from the point the exception is first thrown.

:rethrow should only be used within a CATCH block. It allows me to preserve the stack up until the most recently executed :throw.
Alternatively, :throw can be used within the CATCH block, and then you get what you propose. I like to have control :)

Try changing func-5 to use :throw and see the result.
Then try changing both func-5 and func-8 to both use :rethrow.
I think you will see the benefit of having both options.

But perhaps I should call it a Trace: instead of a Stack:. It traces the path of exception processing, it does not necessarily show the stack at the time the active exception was thrown.


Dave Benham

Aacini
Expert
Posts: 1885
Joined: 06 Dec 2011 22:15
Location: México City, México
Contact:

Re: Exception Handling with batch using erroneous GOTO

#9 Post by Aacini » 12 Jun 2015 21:02

It seems that this technique can be used to get different types of information and/or different managements, depending on the requirements or preferences of the programmer. Here it is my own version:

Code: Select all

@echo off
setlocal

call :SetLineNumbers

set "level="
echo START %0
echo -^> calling Func-1
set /A "line#=0+1"
call :Func-1 "   "
echo END   %0
goto :EOF


:Func-1
:Func-4
setlocal EnableDelayedExpansion
set /A level+=1
set > VarsInLevel%level%.txt
echo %~1%level%-START %0
set /A "line#=0+5"
:loop1-4
set /A number=%random% %% 10
if %number% leq 6 (
   if %number% neq 0 echo %~1-^> calling Func-%number%
   call :Func-%number% "%~1   "
   goto loop1-4
)
echo %~1%level%-END   %0
del VarsInLevel%level%.txt
set "spc=%~1"
echo %spc:~3%^<- returning to caller
exit /B


:Func-2
:Func-5
setlocal EnableDelayedExpansion
set /A level+=1
set > VarsInLevel%level%.txt
echo %~1%level%-START %0
set /A "line#=0+5"
:loop2-5
set /A number=%random% %% 10
if %number% leq 6 (
   if %number% neq 0 echo %~1-^> calling Func-%number%
   call :Func-%number% "%~1   "
   goto loop2-5
)
echo %~1%level%-END   %0
del VarsInLevel%level%.txt
set "spc=%~1"
echo %spc:~3%^<- returning to caller
exit /B


:Func-3
:Func-6
setlocal EnableDelayedExpansion
set /A level+=1
set > VarsInLevel%level%.txt
echo %~1%level%-START %0
set /A "line#=0+5"
:loop3-6
set /A number=%random% %% 10
if %number% leq 6 (
   if %number% neq 0 echo %~1-^> calling Func-%number%
   call :Func-%number% "%~1   "
   goto loop3-6
)
echo %~1%level%-END   %0
del VarsInLevel%level%.txt
set "spc=%~1"
echo %spc:~3%^<- returning to caller
exit /B


:// The exception handler

:Func-0
set "spc=%~1"
set /P "answer=-^>%spc:~5%Exception raised here^! Cancel execution? "
if /I "%answer:~0,1%" equ "y" (
   (goto) 2> NUL
   set "spc="
   set "answer="
   echo/
   call echo Execution cancelled in %%level%%-%%~0 at line %%line#%%
   for /L %%i in (1,1,20) do if defined level (
      rem Patch to fix a bug in FINDSTR.EXE:
      set "LOGONSERVER="
      set "Path="
      set > VarsCurrent.txt
      set "Path=%Path%"
      call findstr /I /L /V /G:VarsInLevel%%level%%.txt VarsCurrent.txt
      call del VarsInLevel%%level%%.txt
      echo ==================================
      (goto) 2> NUL
      call echo Called from %%level%%-%%~0 at line %%line#%%
   )
   del VarsCurrent.txt
   goto :endCancel
)
:endCancel
exit /B


:SetLineNumbers
setlocal EnableDelayedExpansion

set "anyChange="
set "equal=="
set lastLine=0
< "%~F0" (
   for /F "delims=:" %%a in ('findstr /N /I /C:"line#%equal%" "%~F0"') do (
      set /A lines=%%a-lastLine-1, lastLine=%%a
      for /L %%i in (1,1,!lines!) do (
         set "line="
         set /P "line="
         echo(!line!
      )
      set /P "line="
      for /F "tokens=2 delims==+-" %%b in ("!line!") do (
         if "%%b" neq "%%a" (
            set "line=!line:%%b=%%a!"
            set "anyChange=true"
         )
      )
      echo !line!
   )
   findstr "^"
) > temp.tmp
if defined anyChange (
   move /Y temp.tmp "%~F0" > NUL
   exit /B
) else (
   del temp.tmp
)
exit /B

In this program I used my method to get line numbers of the source code. This is an output example:

Code: Select all

START TestTrace.bat
-> calling Func-1
   1-START :Func-1
   -> calling Func-5
      2-START :Func-5
      -> calling Func-2
         3-START :Func-2
         3-END   :Func-2
      <- returning to caller
      -> calling Func-5
         3-START :Func-5
         -> calling Func-3
            4-START :Func-3
            4-END   :Func-3
         <- returning to caller
         3-END   :Func-5
      <- returning to caller
      -> calling Func-2
         3-START :Func-2
         -> calling Func-3
            4-START :Func-3
->          Exception raised here! Cancel execution? n
            -> calling Func-1
               5-START :Func-1
               5-END   :Func-1
            <- returning to caller
            -> calling Func-1
               5-START :Func-1
               -> calling Func-4
                  6-START :Func-4
                  -> calling Func-6
                     7-START :Func-6
                     -> calling Func-6
                        8-START :Func-6
                        -> calling Func-2
                           9-START :Func-2
                           -> calling Func-5
                              10-START :Func-5
->                            Exception raised here! Cancel execution? y

Execution cancelled in 10-:Func-5 at line 47
number=0
==================================
Called from 9-:Func-2 at line 47
line#=47
number=5
==================================
Called from 8-:Func-6 at line 68
number=2
==================================
Called from 7-:Func-6 at line 68
line#=68
==================================
Called from 6-:Func-4 at line 26
number=6
==================================
Called from 5-:Func-1 at line 26
line#=26
number=4
==================================
Called from 4-:Func-3 at line 68
answer=n
line#=68
number=1
spc=
==================================
Called from 3-:Func-2 at line 47
number=3
==================================
Called from 2-:Func-5 at line 47
line#=47
number=2
==================================
Called from 1-:Func-1 at line 26
line#=26
number=5
==================================
Called from -TestTrace.bat at line 10

Antonio

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#10 Post by dbenham » 21 Jun 2015 14:36

OK - I think I have completed the exception handling to my satisfiaction.

I discovered a bug in my previous version - The unhandled exception message was supposed to report what batch script failed to handle the exception, but instead it was always reporting exception.bat. I've fixed that silly bug in my new code.

Note that the reported exception.loc property is the location where the exception was thrown, not where the exception is caught.

One other point of note - the code between %@Try% and %@EndTry% is within a block, so delayed expansion or CALL will have to be used to access variables defined within the block. The parentheses are part of the macro definitions for convenience. But the block is not obvious, so you may want to add additional parentheses to make the block visible:

Code: Select all

(%@Try%
  REM code that may raise an exception goes here
%@EndTry%)
:@Catch
  REM code that handles any exception goes here
:@EndCatch


One of the things I wanted was to be able to propagate an exception across script CALLs (when a script returns with an unhandled exception). I'm not able to do it directly, but I solved the problem by storing the exception properties using DOSKEY macros. I then added an exception load routine to retrieve the exception from DOSKEY.

The DOSKEY commands take a significant amount of time, so the EXCEPTION THROW command only saves the unhandled exception definition with DOSKEY if variable EXCEPTION.PRESERVE is defined.

Also in the interest of performance, I have adopted a convention that exceptions should return negative ERRORLEVEL. This enables my code to avoid unnecessary checks for an unhandled exception upon CALL return. But that convention is not a requirement of exception.bat.

Unfortunately, the CALL must have stderr redirected to NUL if you don't want to see the unhandled exception message from the CALLed script. Of course you would probably not redirect stderr to NUL if you choose not to propagate the exception.

Here is an example of how to call a script and propagate any returned exception, assuming the exception ERRORLEVEL is negative:

Code: Select all

call SomeScriptThatMayReturnAnException 2>nul || if not errorlevel 0 call load exception
if defined exception.code call exception rethrow %%exception.code%% "%%exception.message%%" "%%exception.loc%%"

The CALL with exception detection certainly can be within a TRY block, but it need not be. I have assumed the CALL is within some type of block, hence the percent doubling in the rethrow.

If you opt not to use negative ERRORLVEL for exceptions, then simply remove the IF NOT ERRORLEVEL 0 from the CALL code.

So here is my "final" exception.bat utility script:

Code: Select all

@echo off
shift /1 & goto %1


:throw  errCode  errMsg  errLoc  [/M]
set "exception.Stack="
:: Fall through to :rethrow


:rethrow  errCode  errMsg  errLoc  [/M]
setlocal disableDelayedExpansion
setlocal enableDelayedExpansion
set "exception.Stack=[%~1:%~2] !exception.Stack!"
(
  for /l %%# in (1 1 10) do for /f "delims=" %%S in (" !exception.Stack!") do (
    (goto) 2>NUL
    setlocal disableDelayedExpansion
    call set "funcName=%%0"
    call set "batName=%%~f0"
    setlocal EnableDelayedExpansion
    set "exception.Stack=!funcName!%%S"
    if !exception.Try! == !funcName! (
      endlocal
      endlocal
      set "exception.Code=%~1"
      set "exception.Msg=%~2"
      set "exception.Loc=%~3"
      set "exception.Stack=%%S"
      set "exception.Try="
      (CALL )
      goto :@Catch
    )
    if "!funcName:~0,1!" neq ":" (
      echo(
      echo Unhandled batch exception:
      if "%~1" neq "" for /f "delims=" %%M in ("%~1") do (
        echo   Code = %%M
        if defined exception.Preserve doskey /exename=exception exception.Code=%%M
      )
      if "%~2" neq "" for /f "delims=" %%M in ("%~2") do (
        echo   Msg  = %%M
        if defined exception.Preserve doskey /exename=exception exception.Msg=%%M
      )
      echo   Bat  = !batName!
      if defined exception.Preserve doskey /exename=exception exception.Bat=!batName!
      if "%~3" neq "" for /f "delims=" %%M in ("%~3") do (
        echo   Loc  = %%M
        if defined exception.Preserve doskey /exename=exception exception.Loc=%%M
      )
      echo   Stack: !funcName!%%S
      if defined exception.Preserve doskey /exename=exception exception.Stack=!funcName!%%S
      exit /b %~1
    ) >&2
  )
  call "%~f0" rethrow %1 %2 %3
)
:: Never reaches here


:init
set "@Try=(call set exception.Try=%%0"
set "@EndTry=set "exception.Try=" & goto :@endCatch)"
set "exception.Try="
call :clear
exit /b


:load
for /f "delims=" %%A in ('doskey /m:exception') do set "%%A"
exit /b


:clear
for %%A in (Code Msg Loc Stack Bat) do set "exception.%%A="
for /f "delims==" %%A in ('doskey /m:exception') do doskey /exename=exception %%A=
exit /b

And below is script to test the capabilities. The script recursively calls itself 7 times. Each iteration has two CALLs, one to a :label that demonstrates normal exception propagation, and the other to a script that demonstrates exception propagation across script CALLs.

While returning from a recursive call, it throws an exception if the iteration count is a multiple of 3 (iterations 3 and 6).

Each CALL has its own exception handler that normally reports the exception and then rethrows a modified exception. But if the iteration count is 5, then the exception is handled. Note that the exception should be cleared if it is handled (not thrown or rethrown).

testException.bat

Code: Select all

@echo off

:: Main
setlocal disableDelayedExpansion
if not defined @Try (
  call exception init
  set "exception.preserve=1"   This is needed to propagate exceptions accross script calls
)
set /a cnt+=1
echo Main Iteration %cnt% - Calling :Sub
%@Try%
  call :Sub
  call echo Main Iteration %cnt% - :Sub returned %%errorlevel%%
%@EndTry%
:@Catch
  echo(
  echo Main Iteration %cnt% - Exception detected:
  echo   Code     = %exception.code%
  echo   Message  = %exception.msg%
  echo   Location = %exception.loc%
  echo Rethrowing modified exception
  echo(
  call exception rethrow -%cnt% "Main Exception" "%~f0(%0)"
:@EndCatch
echo Main Iteration %cnt% - Exit
exit /b %cnt%


:Sub
setlocal
echo :Sub Iteration %cnt% - Start
(%@Try%

  if %cnt% lss 7 (
    echo :Sub Iteration %cnt% - Calling testException.bat

    %= The next two lines show how to call a script and propagate any returned exception.         =%
    %= I redirect stderr to nul to hide the "unhandled exception" message from the CALLed script. =%
    %= I use a convention that negative ERRORLEVEL implies an exception so I don't waste time     =%
    %= trying to load an exception that isn't there.                                              =%
    call testException 2>nul || if not errorlevel 0 call exception load
    if defined exception.code call exception rethrow %%exception.code%% "%%exception.msg%%" "%%exception.loc%%"

    %= Show any non-exception return code (demonstrate ERRORLEVEL is preserved if no exception)   =%
    call echo :Sub Iteration %cnt% - testException returned %%errorlevel%%
  )

  %= Throw an exception if the iteration count is a multiple of 3 =%
  set /a "1/(cnt%%3)" 2>nul || (
    echo Throwing exception
    call exception throw -%cnt% "Divide by 0 exception" "%~f0(%0)"
  )

%@EndTry%)
:@Catch
  echo(
  echo :Sub Iteration %cnt% - Exception detected:
  echo   Code     = %exception.code%
  echo   Message  = %exception.msg%
  echo   Location = %exception.loc%

  %= Handle the exception if iteration count is a multiple of 5, else rethrow it with new properties =%
  set /a "1/(cnt%%5)" 2>nul && (
    echo Rethrowing modified exception
    echo(
    call exception rethrow -%cnt% ":Sub Exception" "%~f0(%0)"
  ) || (
    call exception clear
    echo Exception handled
    echo(
  )
:@EndCatch
echo :Sub Iteration %cnt% - Exit
exit /b %cnt%

Here is output of a test run

Code: Select all

C:\test>testException
Main Iteration 1 - Calling :Sub
:Sub Iteration 1 - Start
:Sub Iteration 1 - Calling testException.bat
Main Iteration 2 - Calling :Sub
:Sub Iteration 2 - Start
:Sub Iteration 2 - Calling testException.bat
Main Iteration 3 - Calling :Sub
:Sub Iteration 3 - Start
:Sub Iteration 3 - Calling testException.bat
Main Iteration 4 - Calling :Sub
:Sub Iteration 4 - Start
:Sub Iteration 4 - Calling testException.bat
Main Iteration 5 - Calling :Sub
:Sub Iteration 5 - Start
:Sub Iteration 5 - Calling testException.bat
Main Iteration 6 - Calling :Sub
:Sub Iteration 6 - Start
:Sub Iteration 6 - Calling testException.bat
Main Iteration 7 - Calling :Sub
:Sub Iteration 7 - Start
:Sub Iteration 7 - Exit
Main Iteration 7 - :Sub returned 7
Main Iteration 7 - Exit
:Sub Iteration 6 - testException returned 7
Throwing exception

:Sub Iteration 6 - Exception detected:
  Code     = -6
  Message  = Divide by 0 exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


Main Iteration 6 - Exception detected:
  Code     = -6
  Message  = :Sub Exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


:Sub Iteration 5 - Exception detected:
  Code     = -6
  Message  = Main Exception
  Location = C:\test\testException.bat(testException)
Exception handled

:Sub Iteration 5 - Exit
Main Iteration 5 - :Sub returned 5
Main Iteration 5 - Exit
:Sub Iteration 4 - testException returned 5
:Sub Iteration 4 - Exit
Main Iteration 4 - :Sub returned 4
Main Iteration 4 - Exit
:Sub Iteration 3 - testException returned 4
Throwing exception

:Sub Iteration 3 - Exception detected:
  Code     = -3
  Message  = Divide by 0 exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


Main Iteration 3 - Exception detected:
  Code     = -3
  Message  = :Sub Exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


:Sub Iteration 2 - Exception detected:
  Code     = -3
  Message  = Main Exception
  Location = C:\test\testException.bat(testException)
Rethrowing modified exception


Main Iteration 2 - Exception detected:
  Code     = -2
  Message  = :Sub Exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


:Sub Iteration 1 - Exception detected:
  Code     = -2
  Message  = Main Exception
  Location = C:\test\testException.bat(testException)
Rethrowing modified exception


Main Iteration 1 - Exception detected:
  Code     = -1
  Message  = :Sub Exception
  Location = C:\test\testException.bat(:Sub)
Rethrowing modified exception


Unhandled batch exception:
  Code = -1
  Msg  = Main Exception
  Bat  = C:\test\testException.bat
  Loc  = C:\test\testException.bat(testException)
  Stack: testException [-1:Main Exception]  :Sub [-1::Sub Exception]  [-2:Main Exception] testException [-2:Main Exception]  :Sub [-2::Sub Exception]  [-3:Main Exception] testException [-3:Main Exception]  :Sub [-3::Sub Exception]  [-3:Divide by 0 exception]

Upon return, the exception is not defined in any variable, but the exception properties are stored within DOSKEY macros:

Code: Select all

C:\test>set exception
Environment variable exception not defined

C:\test>doskey /m:exception
exception.Stack=testException [-1:Main Exception]  :Sub [-1::Sub Exception]  [-2:Main Exception] testException [-2:Main Exception]  :Sub [-2::Sub Exception]  [-3:Main Exception] testException [-3:Main Exception]  :Sub [-3::Sub Exception]  [-3:Divide by 0 exception]
exception.Loc=C:\test\testException.bat(testException)
exception.Bat=C:\test\testException.bat
exception.Msg=Main Exception
exception.Code=-1

You can retrieve the exception via the EXCEPTION LOAD command:

Code: Select all

C:\test>exception load

C:\test>set exception
exception.Bat=C:\test\testException.bat
exception.Code=-1
exception.Loc=C:\test\testException.bat(testException)
exception.Msg=Main Exception
exception.Stack=testException [-1:Main Exception]  :Sub [-1::Sub Exception]  [-2:Main Exception] testException [-2:Main Exception]  :Sub [-2::Sub Exception]  [-3:Main Exception] testException [-3:Main Exception]  :Sub [-3::Sub Exception]  [-3:Divide by 0 exception]

You can clear the exception via EXCEPTION CLEAR

Code: Select all

C:\test>exception clear

C:\test>set exception
Environment variable exception not defined

C:\test>doskey /m:exception

C:\test>


If you want to include line numbers within your messages, then you can use any of the methods described at viewtopic.php?f=3&t=6455.
My favorite is my latest JREPL.BAT line numbering method at viewtopic.php?p=41762#p41762.


Dave Benham

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#11 Post by dbenham » 22 Jun 2015 08:10

One more example that demonstrates that TRY/CATCH is not needed to throw an exception that propagates back to the command line context. In this case, the init routine is not needed.

I took my prior example, removed all TRY/CATCH, and set the max iteration to 5 instead of 7. The exception is thrown when returning from the 3rd iteration.

Code: Select all

@echo off

:: Main
setlocal disableDelayedExpansion
set exception.preserve=1
set /a cnt+=1
echo Main Iteration %cnt% - Calling :Sub
call :Sub
call echo Main Iteration %cnt% - :Sub returned %%errorlevel%%
echo Main Iteration %cnt% - Exit
exit /b %cnt%


:Sub
setlocal
echo :Sub Iteration %cnt% - Start
if %cnt% lss 5 (
  echo :Sub Iteration %cnt% - Calling testException.bat

  %= The next two lines show how to call a script and propagate any returned exception.         =%
  %= I redirect stderr to nul to hide the "unhandled exception" message from the CALLed script. =%
  %= I use a convention that negative ERRORLEVEL implies an exception so I don't waste time     =%
  %= trying to load an exception that isn't there.                                              =%
  call testException 2>nul || if not errorlevel 0 call exception load
  if defined exception.code call exception rethrow %%exception.code%% "%%exception.msg%%" "%%exception.loc%%"

  %= Show any non-exception return code (demonstrate ERRORLEVEL is preserved if no exception)   =%
  call echo :Sub Iteration %cnt% - testException returned %%errorlevel%%
)

%= Throw an exception if the iteration count is a multiple of 3 =%
set /a "1/(cnt%%3)" 2>nul || (
  echo Throwing exception
  call exception throw -%cnt% "Divide by 0 exception" "%~f0(%0)"
)
echo :Sub Iteration %cnt% - Exit
exit /b %cnt%

-- OUTPUT --

Code: Select all

D:\test>testException
Main Iteration 1 - Calling :Sub
:Sub Iteration 1 - Start
:Sub Iteration 1 - Calling testException.bat
Main Iteration 2 - Calling :Sub
:Sub Iteration 2 - Start
:Sub Iteration 2 - Calling testException.bat
Main Iteration 3 - Calling :Sub
:Sub Iteration 3 - Start
:Sub Iteration 3 - Calling testException.bat
Main Iteration 4 - Calling :Sub
:Sub Iteration 4 - Start
:Sub Iteration 4 - Calling testException.bat
Main Iteration 5 - Calling :Sub
:Sub Iteration 5 - Start
:Sub Iteration 5 - Exit
Main Iteration 5 - :Sub returned 5
Main Iteration 5 - Exit
:Sub Iteration 4 - testException returned 5
:Sub Iteration 4 - Exit
Main Iteration 4 - :Sub returned 4
Main Iteration 4 - Exit
:Sub Iteration 3 - testException returned 4
Throwing exception

Unhandled batch exception:
  Code = -3
  Msg  = Divide by 0 exception
  Bat  = D:\test\testException.bat
  Loc  = D:\test\testException.bat(:Sub)
  Stack: testException :Sub [-3:Divide by 0 exception] testException :Sub [-3:Divide by 0 exception] testException :Sub [-3:Divide by 0 exception]


Dave Benham

Sponge Belly
Posts: 216
Joined: 01 Oct 2012 13:32
Location: Ireland
Contact:

Re: Exception Handling with batch using erroneous GOTO

#12 Post by Sponge Belly » 23 Jun 2015 11:43

Hello All! :)

Fascinating topic. But could someone show me how to use this new technique to stop a for /l loop in its tracks?

TIA!

- SB

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#13 Post by dbenham » 24 Jun 2015 22:35

Sponge Belly wrote:Fascinating topic. But could someone show me how to use this new technique to stop a for /l loop in its tracks?

Not quite :wink:

The only way to kill a FOR /L loop is to issue the EXIT command and exit the command shell, or press Ctrl-C to kill all processing (including the loop) but remain within the command shell. I have posted a way to programmaticaly press Ctrl-C using batch, which works great to exit a FOR /L loop, but does not provide exception handling.

But wait :!: This solves a major problem I was having... :idea:

Earlier in this thread, I made the following statement:
dbenham wrote:2) I had really hoped that an unhandled exception could percolate all the way to the command line context, even across multiple script calls. I can see where it shouldn't always do this, but I thought it would have been a nice option. However, I don't see a method to break out of any cached code once you have reached the command line context - There is no :ExistantLabel, and EXIT /B doesn't work any more :(

The Ctrl-C trick solves the problem perfectly 8) :D

Note that it is impossible to use an exception to exit a FOR /L loop if the exception is caught. It will only exit FOR /L if the exception is unhandled all the way to the command line, which is when the CTRL-C trick is used.

Before I show the solution, I have a few additional discoveries to document.

Previously posted batch exception handlers have a limitation that gets exposed if a routine is called recursively from within a TRY block. If the recursive CALL then throws an exception prior to the first TRY/CATCH, then the THROW routine will see that the name of the current routine matches the Try routine, and mistakenly GOTO the Catch routine within that call, when in reality it should pop the call stack until it reaches the TRY block that issued the CALL. The solution is to make sure that all THROW occur within or after the first TRY. If the THROW must occur before the first TRY within a recursive routine, then the exception.Try variable must be cleared before the THROW.

This issue also points to a related problem, labels are not unique across scripts, so a THROW before a TRY/CATCH within the same routine could branch to the wrong CATCH if an early script with the same routine name CALLed this one within a TRY block. However, this problem can be solved by incorporating both %~f0 and %~0 into the exception.Try variable. (Technically, the same label could appear multiple times within the same script, but that would be a bad programming practice. The solution for such a problem is the same as for the recursive call)

I've also managed to eliminate the need for DOSKEY to transport exception properties across script barriers, all the way to the command line if necessary. The only limitation is exception properties with ! in the value will be corrupted if delayed expansion is enabled at the start of the CATCH block. The solution for this is to either make sure none of the exception values contain !, or else make sure all CALLs within TRY are made with delayed expansion disabled.

I'm not going to make any more proclamations about a "final" batch exception utility. But this one pretty much has the full functionality I was looking for. :D

Before long I hope to have a version with built in Help and Versioning. Until then, I thought I should share what I have so far:

exception.bat

Code: Select all

@echo off
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"
        set "exception.Msg=%%2"
        set "exception.Loc=%%3"
        set "exception.Stack=%%S"
        set "exception.Try="
        (CALL )
        goto :@Catch
      )
    ) else (
      set "exception.restart="
      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)"
set "exception.Try="
call :clear
exit /b


:clear
for %%V in (Code Msg Loc Stack) 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

testException.bat - test exception TRY/CATCH and propagation accross both script and :label calls

Code: Select all

@echo off

:: Main
setlocal disableDelayedExpansion
if not defined @Try call exception init

set /a cnt+=1
echo Main Iteration %cnt% - Calling :Sub
%@Try%
  call :Sub
  call echo Main Iteration %cnt% - :Sub returned %%errorlevel%%
%@EndTry%
:@Catch
  setlocal enableDelayedExpansion
  echo(
  echo Main Iteration %cnt% - Exception detected:
  echo   Code     = !exception.code!
  echo   Message  = !exception.msg!
  echo   Location = !exception.loc!
  echo Rethrowing modified exception
  echo(
  endlocal
  call exception rethrow -%cnt% "Main Exception" "%~f0<%~0>"
:@EndCatch
echo Main Iteration %cnt% - Exit
exit /b %cnt%


:Sub
setlocal
echo :Sub Iteration %cnt% - Start
%@Try%
  if %cnt% lss 7 (
    echo :Sub Iteration %cnt% - Calling "%~f0"
    call "%~f0"
    %= Show any non-exception return code (demonstrate ERRORLEVEL is preserved if no exception) =%
    call echo :Sub Iteration %cnt% - testException returned %%errorlevel%%
  )
  %= Throw an exception if the iteration count is a multiple of 3 =%
  set /a "1/(cnt%%3)" 2>nul || (
    echo Throwing exception
    call exception throw -%cnt% "Divide by 0 exception" "%~f0<%~0>"
  )
%@EndTry%
:@Catch
  setlocal enableDelayedExpansion
  echo(
  echo :Sub Iteration %cnt% - Exception detected:
  echo   Code     = !exception.code!
  echo   Message  = !exception.msg!
  echo   Location = !exception.loc!
  endlocal
  %= Handle the exception if iteration count is a multiple of 5, else rethrow it with new properties =%
  set /a "1/(cnt%%5)" 2>nul && (
    echo Rethrowing modified exception
    echo(
    call exception rethrow -%cnt% ":Sub Exception" "%~f0<%~0>"
  ) || (
    call exception clear
    echo Exception handled
    echo(
  )
:@EndCatch
echo :Sub Iteration %cnt% - Exit
exit /b %cnt%

--OUTPUT--

Code: Select all

C:\test>testException
Main Iteration 1 - Calling :Sub
:Sub Iteration 1 - Start
:Sub Iteration 1 - Calling "C:\test\testException.bat"
Main Iteration 2 - Calling :Sub
:Sub Iteration 2 - Start
:Sub Iteration 2 - Calling "C:\test\testException.bat"
Main Iteration 3 - Calling :Sub
:Sub Iteration 3 - Start
:Sub Iteration 3 - Calling "C:\test\testException.bat"
Main Iteration 4 - Calling :Sub
:Sub Iteration 4 - Start
:Sub Iteration 4 - Calling "C:\test\testException.bat"
Main Iteration 5 - Calling :Sub
:Sub Iteration 5 - Start
:Sub Iteration 5 - Calling "C:\test\testException.bat"
Main Iteration 6 - Calling :Sub
:Sub Iteration 6 - Start
:Sub Iteration 6 - Calling "C:\test\testException.bat"
Main Iteration 7 - Calling :Sub
:Sub Iteration 7 - Start
:Sub Iteration 7 - Exit
Main Iteration 7 - :Sub returned 7
Main Iteration 7 - Exit
:Sub Iteration 6 - testException returned 7
Throwing exception

:Sub Iteration 6 - Exception detected:
  Code     = -6
  Message  = Divide by 0 exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


Main Iteration 6 - Exception detected:
  Code     = -6
  Message  = :Sub Exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


:Sub Iteration 5 - Exception detected:
  Code     = -6
  Message  = Main Exception
  Location = C:\test\testException.bat<C:\test\testException.bat>
Exception handled

:Sub Iteration 5 - Exit
Main Iteration 5 - :Sub returned 5
Main Iteration 5 - Exit
:Sub Iteration 4 - testException returned 5
:Sub Iteration 4 - Exit
Main Iteration 4 - :Sub returned 4
Main Iteration 4 - Exit
:Sub Iteration 3 - testException returned 4
Throwing exception

:Sub Iteration 3 - Exception detected:
  Code     = -3
  Message  = Divide by 0 exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


Main Iteration 3 - Exception detected:
  Code     = -3
  Message  = :Sub Exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


:Sub Iteration 2 - Exception detected:
  Code     = -3
  Message  = Main Exception
  Location = C:\test\testException.bat<C:\test\testException.bat>
Rethrowing modified exception


Main Iteration 2 - Exception detected:
  Code     = -2
  Message  = :Sub Exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


:Sub Iteration 1 - Exception detected:
  Code     = -2
  Message  = Main Exception
  Location = C:\test\testException.bat<C:\test\testException.bat>
Rethrowing modified exception


Main Iteration 1 - Exception detected:
  Code     = -1
  Message  = :Sub Exception
  Location = C:\test\testException.bat<:Sub>
Rethrowing modified exception


Unhandled batch exception:
  Code = -1
  Msg  = Main Exception
  Loc  = C:\test\testException.bat<testException>
  Stack= testException [-1:Main Exception]  :Sub [-1::Sub Exception]  C:\test\testException.bat [-2:Main Exception]  :Sub [-2::Sub Exception]  C:\test\testException.bat [-3:Main Exception]  :Sub [-3::Sub Exception]  [-3:Divide by 0 exception]

testException2.bat - test exception propagation across both script and :label calls, without any TRY/CATCH, and now initialization.

Code: Select all

@echo off

:: Main
setlocal disableDelayedExpansion
if not defined @Try call exception init
set /a cnt+=1
echo Main Iteration %cnt% - Calling :Sub
call :Sub
call echo Main Iteration %cnt% - :Sub returned %%errorlevel%%
echo Main Iteration %cnt% - Exit
exit /b %cnt%


:Sub
setlocal
echo :Sub Iteration %cnt% - Start
if %cnt% lss 10 (
  echo :Sub Iteration %cnt% - Calling testException2.bat
  call "%~f0"
  %= Show any non-exception return code (demonstrate ERRORLEVEL is preserved if no exception)   =%
  call echo :Sub Iteration %cnt% - testException2 returned %%errorlevel%%
)

%= Throw an exception if the iteration count is 6 =%
set /a "1/(cnt-6)" 2>nul || (
  echo Throwing exception
  call exception throw -%cnt% "Divide by 0 exception!" "%~f0<%~0>"
  echo Should NOT see this
)
echo :Sub Iteration %cnt% - Exit
exit /b %cnt%

--OUTPUT--

Code: Select all

C:\test>testException2
Main Iteration 1 - Calling :Sub
:Sub Iteration 1 - Start
:Sub Iteration 1 - Calling testException2.bat
Main Iteration 2 - Calling :Sub
:Sub Iteration 2 - Start
:Sub Iteration 2 - Calling testException2.bat
Main Iteration 3 - Calling :Sub
:Sub Iteration 3 - Start
:Sub Iteration 3 - Calling testException2.bat
Main Iteration 4 - Calling :Sub
:Sub Iteration 4 - Start
:Sub Iteration 4 - Calling testException2.bat
Main Iteration 5 - Calling :Sub
:Sub Iteration 5 - Start
:Sub Iteration 5 - Calling testException2.bat
Main Iteration 6 - Calling :Sub
:Sub Iteration 6 - Start
:Sub Iteration 6 - Calling testException2.bat
Main Iteration 7 - Calling :Sub
:Sub Iteration 7 - Start
:Sub Iteration 7 - Calling testException2.bat
Main Iteration 8 - Calling :Sub
:Sub Iteration 8 - Start
:Sub Iteration 8 - Calling testException2.bat
Main Iteration 9 - Calling :Sub
:Sub Iteration 9 - Start
:Sub Iteration 9 - Calling testException2.bat
Main Iteration 10 - Calling :Sub
:Sub Iteration 10 - Start
:Sub Iteration 10 - Exit
Main Iteration 10 - :Sub returned 10
Main Iteration 10 - Exit
:Sub Iteration 9 - testException2 returned 10
:Sub Iteration 9 - Exit
Main Iteration 9 - :Sub returned 9
Main Iteration 9 - Exit
:Sub Iteration 8 - testException2 returned 9
:Sub Iteration 8 - Exit
Main Iteration 8 - :Sub returned 8
Main Iteration 8 - Exit
:Sub Iteration 7 - testException2 returned 8
:Sub Iteration 7 - Exit
Main Iteration 7 - :Sub returned 7
Main Iteration 7 - Exit
:Sub Iteration 6 - testException2 returned 7
Throwing exception

Unhandled batch exception:
  Code = -6
  Msg  = Divide by 0 exception!
  Loc  = C:\test\testException2.bat<:Sub>
  Stack= testException2 :Sub C:\test\testException2.bat :Sub C:\test\testException2.bat :Sub C:\test\testException2.bat :Sub C:\test\testException2.bat :Sub C:\test\testException2.bat :Sub [-6:Divide by 0 exception!]


Dave Benham

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#14 Post by dbenham » 26 Jun 2015 12:21

I now realize that the TRY block need not have outer parentheses, so I have removed them from the @Try and @EndTry macro definitions.

I have added extensive documentation to EXCEPTION.BAT. I tried to list all the design considerations and limitations of the exception system.

The testException.bat and testException2.bat examples from my previous post continue to work fine with this new "official" version 1.0 of EXCEPTION.BAT.

EXCEPTION.BAT Version 1.0

Code: Select all

::EXCEPTION.BAT Version 1.0
::         
:: Designed and written by Dave Benham, with important contributions from
:: DosTips users jeb and siberia-man
::
:: Documentation is at the bottom of this script
::
@echo off
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"
        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="
      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


:-?
: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


:-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  Location

where

    Code = The numeric code value for the exception.

    Message = A description of the exception.
   
    Location = A string that helps identify where the exception occurred.
               Any value may be used. A good generic value is "%~f0[%~0]",
               which will expand to the full path of the currently executing
               script, followed by the currently executing routine name within
               square brackets.

The following variables will be defined for use by the CATCH block:

  exception.Code  = the Code value
  exception.Msg   = the Message value
  exception.Loc   = the Location value
  exception.Stack = traces the call stack from the CATCH block (or command line
                    if not caught), all the way to the exception.

If the exception is not caught, then all four values are printed as part of the
"unhandled exception" message, and the exception variables are not defined.

Note - It is possible, but unlikely, that one or more exception properties will
contain a ! literal. If the command line or CATCH block has delayed expansion
enabled at the time the exception is caught, then the value will be corrupted if
it contains !.

A CATCH block should always do ONE of the following at the end:

- If the exception has been handled and processing can continue, then clear the
  exception definition by using

    call exception clear
   
  Clear should never be used within a Try block.
   
- If the exception has not been fully handled, then a new exception should be
  thrown which can be caught by a higher level CATCH. You can throw a new
  exception using the normal THROW, which will clear exception.Stack and any
  higher CATCH will have no awareness of the original exception.

  Alternatively, you may rethrow an exception and preserve the exeption stack
  all the way to the original exception:

    call exception rethrow  Code  Message  Location
 
  It is your choice as to whether you want to pass the original Code and/or
  Message and/or Location. Either way, the stack will preserve all exceptions
  if rethrow is used.
 
  Rethrow should only be used within a CATCH block.


This documentation can be accessed via the following command

    exception help

The version of this utility can be accessed via

    call exception version


EXCEPTION.BAT was designed and written by Dave Benham, with important
contributions from DosTips users jeb and siberia-man.

Development history can be traced at:
  http://www.dostips.com/forum/viewtopic.php?f=3&t=6497


Dave Benham

dbenham
Expert
Posts: 2461
Joined: 12 Feb 2011 21:02
Location: United States (east coast)

Re: Exception Handling with batch using erroneous GOTO

#15 Post by dbenham » 03 Jul 2015 17:15

Here is version 1.1 that preserves ! within exception attributes even when the TRY block has delayed expansion enabled when the exception is thrown.

I also improved the documentation.

EXCEPTION.BAT Version 1.1

Code: Select all

::EXCEPTION.BAT Version 1.1
::
:: 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.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 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 "!os!" equ "%os%" (
        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


:-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  Location

where

    Code = The numeric code value for the exception.

    Message = A description of the exception.

    Location = A string that helps identify where the exception occurred.
               Any value may be used. A good generic value is "%~f0[%~0]",
               which expands to the full path of the currently executing
               script, followed by the currently executing routine name
               within square brackets.

The Message and Location values must be quoted if they contain spaces or poison
characters like & | < >. The values must not contain additional internal quotes,
and they must not contain a caret ^.

The following variables will be defined for use by the CATCH block:

  exception.Code  = the Code value
  exception.Msg   = the Message value
  exception.Loc   = the Location value
  exception.Stack = traces the call stack from the CATCH block (or command line
                    if not caught), all the way to the exception.

If the exception is not caught, then all four values are printed as part of the
"unhandled exception" message, and the exception variables are not defined.

A CATCH block should always do ONE of the following at the end:

- If the exception has been handled and processing can continue, then clear the
  exception definition by using

    call exception clear

  Clear should never be used within a Try block.

- If the exception has not been fully handled, then a new exception should be
  thrown which can be caught by a higher level CATCH. You can throw a new
  exception using the normal THROW, which will clear exception.Stack and any
  higher CATCH will have no awareness of the original exception.

  Alternatively, you may rethrow an exception and preserve the exeption stack
  all the way to the original exception:

    call exception rethrow  Code  Message  Location

  It is your choice as to whether you want to pass the original Code and/or
  Message and/or Location. Either way, the stack will preserve all exceptions
  if rethrow is used.

  Rethrow should only be used within a CATCH block.


One last restriction - the full path to EXCEPTION.BAT must not include ! or ^.


This documentation can be accessed via the following command

    exception help

The version of this utility can be accessed via

    call exception version


EXCEPTION.BAT was designed and written by Dave Benham, with important
contributions from DosTips users jeb and siberia-man.

Development history can be traced at:
  http://www.dostips.com/forum/viewtopic.php?f=3&t=6497


Dave Benham

Post Reply