Привет, Хабр!

Сегодня у нас на повестке — интересная тема: как адекватно обрабатывать ошибки в cmd-файлах (он же — Windows Batch).

В общем, если вам приходилось писать batch-скрипт, который делает чуть больше, чем echo Hello World — добро пожаловать. Рассмотрим как работает %ERRORLEVEL%, когда и как юзать exit /b, как раскладывать try/catch на лейблы и почему всё это ломается, когда в бой идут call, for и if.

%ERRORLEVEL%

Вообще, в cmd-скриптах нет try/catch как такового. Вместо этого — договорённость: если программа/команда завершилась неудачно, она устанавливает переменную %ERRORLEVEL% в ненулевое значение.

Пример:

@echo off
mkdir my-folder
if %ERRORLEVEL% NEQ 0 echo "mkdir failed"

Вроде просто., но фокус в том, что %ERRORLEVEL% — это не системная переменная, а синтаксический сахар. То есть она работает, только если вы её используете правильно. А именно: проверка должна происходить сразу после вызова команды.

@echo off
mkdir my-folder
REM какие-то действия
if %ERRORLEVEL% NEQ 0 echo "mkdir failed"  REM это уже может не сработать

Никогда не откладывайте проверку — одна строка между вызовом и проверкой может похоронить контроль ошибок.

exit /b vs exit vs goto :eof

exit в cmd ведёт себя по-разному в зависимости от того, где он вызывается:

  • exit — завершает cmd.exe, т.е. полностью закрывает текущую сессию (в т.ч окно, если скрипт шел через двойной клик).

  • exit /b — завершает текущую подпрограмму или скрипт, не трогая верхнюю оболочку.

  • goto :eof — как exit /b, но менее явный. Служебный ярлык, который просто передаёт управление за пределы текущей метки.

Пример:

call :doSomething
if %ERRORLEVEL% NEQ 0 echo "doSomething failed"
goto :eof

:doSomething
mkdir test-dir
if %ERRORLEVEL% NEQ 0 exit /b 1
REM что-то ещё
exit /b 0

call тут важен. Без него вы не можете вызывать метки и сохранять поток выполнения.

«Эмуляция try/catch» через метки и errorlevel

Нам не хватает нормального механизма перехвата ошибок, и мы начинаем импровизировать. Самый частый приём — структура из call'ов и if errorlevel:

@echo off
call :step1 || goto :fail
call :step2 || goto :fail
call :step3 || goto :fail
echo All steps complete!
goto :eof

:fail
echo Step failed. Exiting...
exit /b 1

:step1
REM команда, которая может упасть
mkdir build
exit /b %ERRORLEVEL%

:step2
REM ещё одна
xcopy /E /I src\ dist\
exit /b %ERRORLEVEL%

:step3
echo "Finalizing"
exit /b 0

Почти как try-цепочка: шаги вызваны, ошибки отлавливаются, выход по fail. Но || goto :fail не сработает, если внутри метки ошибка не прокинута наружу через exit /b с ненулевым кодом.

Проблемы в call/for/if

Пример 1 — for обнуляет errorlevel:

@echo off
mkdir test-dir-1
for %%f in (*.txt) do echo %%f
if %ERRORLEVEL% NEQ 0 echo "FAIL"  REM тут уже 0

Пример 2 — call сбрасывает errorlevel, если не аккуратничать:

call :failFn
if %ERRORLEVEL% NEQ 0 echo "Detected error"
goto :eof

:failFn
exit /b 1

errorlevel станет 1 — и правильно, он так и сделает. Но если вы между call и if вставите какой-то benign echo, всё — уехали.

Чтобы избежать: всегда прокидывайте exit /b %ERRORLEVEL% в конце каждой метки, если она — потенциальный источник ошибки.

Каркас батника с шагами и откатом

Рассмотрим шаблон, который можно использовать в полях. Он покрывает:

  • пошаговое выполнение

  • откат в случае фейла

  • осмысленные логи

  • сохранение промежуточного состояния

@echo off
setlocal EnableDelayedExpansion
set LOG=build.log
set RETCODE=0

call :step1 || set RETCODE=1 & goto :rollback
call :step2 || set RETCODE=2 & goto :rollback
call :step3 || set RETCODE=3 & goto :rollback

echo Success! >> %LOG%
goto :cleanup

:rollback
echo Error in step !RETCODE!, rolling back... >> %LOG%
call :undoStep3
call :undoStep2
call :undoStep1

:cleanup
REM Очистка
endlocal
exit /b %RETCODE%

:step1
mkdir temp
exit /b %ERRORLEVEL%

:step2
xcopy src\ temp\ /E /I
exit /b %ERRORLEVEL%

:step3
call :riskySubstep
exit /b %ERRORLEVEL%

:undoStep3
REM что-то, что откатывает step3
exit /b 0

:undoStep2
REM удаление temp
rmdir /S /Q temp
exit /b 0

:undoStep1
REM если что-то ещё
exit /b 0

:riskySubstep
REM допустим, может упасть
some_nonexistent_command
exit /b %ERRORLEVEL%

Когда ваш .bat скрипт делает больше трёх шагов можно уже логгировать. Не полагайтесь на echo в консоль: он улетит в пустоту при запуске через GUI, планировщик или интерактивный шелл. Можно сохранять лог в файл:

echo [INFO] Копируем файлы... >> %LOG%
xcopy /E /I src\\ dist\\ >> %LOG% 2>&1

>> добавляет в файл, 2>&1 — перенаправляет stderr туда же. Всё просто.

Возврат кодов ошибок

Можно использовать разные коды возврата для разных типов ошибок. Это позволяет вызывающей стороне понять, что конкретно сломалось. Вместо тупого exit /b 1 можно:

exit /b 101  REM ошибка создания каталога
exit /b 202  REM ошибка копирования
exit /b 303  REM сбой в подпроцессе

А потом в основном скрипте интерпретировать их:

call :buildStuff
if %ERRORLEVEL% EQU 101 echo \"Каталог не создался\"
if %ERRORLEVEL% EQU 202 echo \"Не удалось скопировать файлы\"
if %ERRORLEVEL% GEQ 300 echo \"Что-то сломалось глубоко внутри\"

Итоги

Основные правила:

  • всегда проверяйте %ERRORLEVEL% немедленно после вызова

  • используйте exit /b вместо exit, если не хотите выйти из всего cmd

  • call необходим для вызова подпрограмм (меток), без него цепочка ломается

  • всегда exit /b %ERRORLEVEL% в конце метки — иначе ловите баг

  • стройте rollback-подход, если шаги могут ломать состояние


Если вы когда-нибудь ловили баг из-за неудачного обновления Windows или сидели ночью у сервера, потому что упал единственный контроллер домена — вы не одиноки. Инфраструктура должна держать удар, но чтобы это стало реальностью, нужно немного больше, чем просто «всё работает». Чтобы разобраться, рекомендуем два практических вебинара, которые пройдут в рамках курса «Администратор Windows»:

Комментарии (1)


  1. SlFed
    03.06.2025 20:56

    А еще чтобы не городить:

    if%ERRORLEVEL% EQU 1 Goto metka1
    if%ERRORLEVEL% EQU 2 Goto metka2
    .......

    Проще сделать в одну строку:

    Goto metka%ERRORLEVEL%