One more Heisenbug мимо крокодил

One more Heisenbug мимо крокодил

$> set -o pipefail

$> fortune | head -1 > /dev/null && echo "Повезло!" || echo "Вы проиграли"
Повезло!

$> fortune | head -1 > /dev/null && echo "Повезло!" || echo "Вы проиграли"
Вы проиграли

Здесь fortune условная программа без exit(rand()).

Cможете объяснить что здесь глючит?

Лирично-историческое отступление

Первый раз с этим Гейзенбагом я познакомился четверть века назад. Тогда для шлюза в FaxNET требовалось сделать несколько утилит через pipes «играющих в шашки» под FreeBSD. Как положено, я считал себя продвинутым и достаточно опытным программистом. Поэтому намеревался сделать всё максимально аккуратно и тщательно, уделив особое внимание обработке ошибок…

Усердия в «тщательной обработке ошибок» мне тогда добавил предыдущий опыт борьбы с багами в sendmail и uucp/uupc. Погружаться в детали той истории смысла нет, но боролся с этим Гейзенбагом я недели две по 10-14 часов. Поэтому запомнилось, и вот вчера этот старый знакомый снова заглянул в гости.

TL;DR Ответ

Утилита head может закрыть канал от fortune сразу как только прочитает первую строку. Если fortune выводит более одной строки, то соответствующий вызов write() вернет ошибку, либо сообщит о выводе меньшего количества байт чем запрошено. В свою очередь, написанная с тщательной обработкой ошибок fortune вправе отразить эту ситуацию в своем статусе выхода. Тогда из-за установки set -o pipefail отработает || echo "Вы проиграли".

Однако, head может не успеть закрыть до того, как fortune закончит вывод данных. Тогда отработает && echo "Повезло!".

В одном из моих сегодняшних GNUMakefile есть такой фрагмент:

echo '#define MDBX_BUILD_COMPILER "$(shell set -o pipefail; $(CC) --version | head -1 || echo 'Please use GCC or CLANG compatible compiler')"'

В переводе на человеческий

Здесь обычным для GNU Make и bash способом у компилятора при помощи опции --version спрашивается кто он такой, а если опция не поддерживается, то подставляется заглушка "Please use GCC or CLANG compatible compiler".

Подобный boilerplate можно встретить где угодно. В этом месте он появился давно и прекрасно везде работал (Linux, Solaris, OSX, FreeBSD, WSL и т.д.). Но вчера в AltLinux на платформе Эльбру́с 2000 (E2K) я заметил:

#define MDBX_BUILD_COMPILER "lcc:1.23.20:Sep--4-2019:e2k-v3-linux Please use GCC or CLANG compatible compiler"

Признаться я не сразу разглядел старого "знакомого". Более того, проект уже многократно проверялся на "Эльбрусах" и под массой различных дистрибутивов, в том числе с "Альтом". С различными компиляторами, версиями GNU Make и bash. Поэтому своей оплошности я тут видеть никак не хотел.

При попытке воспроизвести проблему и/или понять в чем дело странностей стало больше.
Заклинание в командной строке:

echo "#define MDBX_BUILD_COMPILER '$(set -o pipefail; LC_ALL=C cc --version | head -1 || echo "Please use GCC or CLANG compatible compiler")'"

Через-раз то выдавало лишний текст, то нет… Нередко один из вариантов залипал достаточно долго, но если потыкать подольше то всегда получались оба!

Конечно, strace наше всё! И вот набрав strace-тираду, но не успев нажать Enter я узнал своего старого знакомого мистера Гейзенбага, а в разработчиках компилятора себя самого 25 лет назад, Nostalgie… И решил взгрустнуть написать эту заметку 😉

Кстати, как любой уважающий себя Гейзенбаг, под strace предпочитает не воспроизводиться.

Так что же происходит?

  • Утилита head вправе (скорее даже вынуждена) закрыть читаемый канал, сразу как только прочитает запрошенное количество строк.
  • Генерирующая данные программа-писатель (в данном случае cc) может выводить несколько строк и вольна делать это посредством нескольких вызовов write().
  • Если читатель успеет закрыть канал со своей стороны до окончания записи на стороне писателя, то писателю прилетит ошибка.
  • Программа-писатель вправе как проигнорировать ошибку записи в канал, так и отразить её в своем коде завершения.
  • Вследствие установки set -o pipefail код завершения конвейера будет ненулевым (ошибочным) при ненулевом результате хотя бы от одного элемента, и тогда отработает || echo "Please use GCC or CLANG compatible compiler".

Могут быть вариации, в зависимости от того как программа-писатель работает с сигналами. Например, программа может быть аварийно завершена (с автоматическим формированием ненулевого/ошибочного статуса завершения), либо write() вернет результат о записи меньшего количества байтов чем запрошено и установит errno = EPIPE.

Кто виноват?

В описываемом случае все понемногу. Обработка ошибок в cc (lcc:1.23.20:Sep—4-2019:e2k-v3-linux) не является избыточной. Во многих случаях лучше перебдеть, хотя это и выявляет внезапные недочеты в boilerplate рассчитанным на традиционное поведение.

Что делать?

Неправильно:

fortune | head -1 && echo "Повезло, но вы рискуете!" || echo "WTF?"

Правильно:

  1. (fortune && echo "Успешно" || echo "Ошибка") | head -1

    Здесь раннее закрытие канала будет обработано интерпретатором команд при обслуживании вложенного конвейера ("внутри" скобок). Соответственно, если fortune сообщит в статусе об ошибки записи в закрытый канал, то вывод || echo "Ошибка" уже никуда не попадет, так как канал уже закрыт.

  2. fortune | cat - | head -1 && echo "Успешно" || echo "Ошибка"

    Здесь утилита cat выступает демпфером, так как игнорирует ошибку EPIPE при выводе. Этого достаточно пока вывод fortune небольшой (несколько строк) и помещается в канальном буфере (от 512 байт до ≈64К, в большинстве ОС ≥4К). В противном случае проблема может вернуться.

Как правильно обрабатывать EPIPE и другие ошибки при записи?

Единственно верного решения нет, но есть простые рекомендации:

  • EPIPE требуется обязательно обрабатывать (и отражать в статусе выхода) при выводе данных требующих целостности. Например, в ходе работы архиваторов или утилит резервного копирования.
  • EPIPE лучше игнорировать при выводе информационных и вспомогательных сообщений. Например, при выводе информации по опциям --help или --version.
  • Если разрабатываемый код допустимо использовать в конвейере перед | head, то EPIPE лучше игнорировать, иначе лучше обрабатывать и отражать в статусе выхода.

Пользуясь случаем хочу выразить благодарность коллективам МЦСТ и AltLinux за огромную результативную работу. Ваша целеустремлённость восхищает!
Так держать Camarades, до встречи осенью!

Спасибо berez за исправление очепяток и ошибок.
КДПВ от Георгий А.

Источник: habr.com