Crash logging в C++

Почему обычный логгер не всегда можно вызвать из signal handler

Когда C++ приложение падает, первое желание разработчика — записать последнее сообщение в лог. Например: “received SIGABRT”, “segmentation fault”, “fatal check failed”, “terminating process”. На первый взгляд это выглядит просто: раз в проекте уже есть логгер, надо вызвать его из signal handler или crash handler и затем завершить процесс.

Но именно здесь начинается проблема. Обычный логгер рассчитан на нормальный execution flow. Он может форматировать сообщение, выделять память, брать lock-и, проходить по списку backend-ов, писать в файл, делать flush, работать с async queue, rotation и retention. В обычной работе это нормально. В аварийном обработчике это может быть опасно.

Crash logging — это отдельный сценарий. Он требует другого контракта.

В чем проблема

POSIX signal handler может быть вызван почти в любой момент. Поток мог быть прерван внутри malloc, printf, файловой операции, mutex lock-а или даже внутри самого логгера. Если в этот момент обработчик снова вызывает обычный logging API, он может попытаться повторно взять тот же lock, снова обратиться к heap или снова войти в незавершенное состояние backend-а.

В лучшем случае сообщение не будет записано. В худшем — процесс зависнет внутри crash handler-а, и вместо полезного аварийного следа получится deadlock.

Типичный небезопасный вариант выглядит так:

static void HandleSigabrt(int signum)
{
  ::signal(signum, SIG_DFL);
  LogmeE("SIGABRT received. Terminating the program...");
  Logme::Instance->FlushAll();
  ::abort();
}

Этот код понятен, но у него неправильный контракт. LogmeE — это обычный высокоуровневый logging path. FlushAll() тоже проходит по channels и backend-ам. В нормальном shutdown это допустимо. В настоящем signal handler — нет.

Такая проблема не уникальна для logme. Это общая проблема C++ logging libraries.

Как с этим обстоят дела у других логгеров

У spdlog сильная сторона — быстрый и удобный application logging. У него есть sinks, async logging, formatting, backtrace buffer и flush-механизмы. Но это не означает, что обычный spdlog API можно безопасно вызывать из signal handler.

Сама постановка проблемы хорошо известна: если async logger не успел сбросить очередь, при crash можно потерять последние сообщения. Кажется логичным вызвать spdlog::shutdown() в обработчике SIGSEGV или SIGABRT, но такой путь может быть небезопасен из-за mutex и других обычных механизмов синхронизации. То есть spdlog отлично подходит для обычного logging path, но signal handler требует осторожности и отдельной схемы.

Quill решает эту область иначе. Это low-latency asynchronous logger, и в нем есть optional built-in signal handler для сохранения логов в распространенных crash/termination сценариях. Это важная возможность. Но даже там документация отдельно говорит, что если приложение использует собственный POSIX signal handler, он должен быть минимальным, а произвольные вызовы general logging или flush API из arbitrary signal context — плохая идея без проверки конкретной платформы и состояния процесса.

Есть и другой класс библиотек, например g3log. Он прямо позиционируется как asynchronous crash-safe logger. То есть crash safety — одна из заявленных целей дизайна. Это хорошая иллюстрация того, что crash logging нельзя считать автоматическим свойством любого логгера. Если библиотека обещает такой контракт, он должен быть явно спроектирован.

Именно поэтому в logme эту тему нужно разделять честно: обычный logging path не должен притворяться crash-safe API. Для аварийных сценариев нужен отдельный путь.

Controlled fatal path — это не crash handler

В logme есть critical logging:

LogmeC("fatal error: %d", code);

Через compatibility layer можно писать и привычным glog-style способом:

CHECK(ptr != nullptr) << "ptr is null";
LOG(FATAL) << "unrecoverable error";

Это controlled fatal path. Программа сама дошла до точки, где понимает, что дальше продолжать нельзя. В этом сценарии обычный runtime еще работает. Поэтому logme может записать critical record, выполнить FlushAll() и затем вызвать fatal handler.

Например:

Logme::Instance->SetFatalHandler([]()
{
  std::abort();
});

Это правильный механизм для CHECK, LOG(FATAL), PCHECK и других контролируемых fatal-сценариев. Он дает нормальный лог, проходит через channels и backend-и, сохраняет форматирование и позволяет приложению самому определить политику завершения.

Но это не настоящий crash handler.

Если процесс уже находится в SIGSEGV, stack overflow, heap corruption или обработчике сигнала, обычный path может быть опасен. Там не нужно пытаться красиво пройти через всю logging subsystem. Там нужно минимально и грубо записать аварийный marker.

Почему FlushAll не решает crash logging

FlushAll() полезен для normal shutdown и controlled fatal path. Если приложение само решило завершиться после LogmeC, логгер может пройти по backend-ам и сбросить буферы.

Но FlushAll() не является signal-handler-safe API. Он работает с обычными структурами logme: channels, backend-и, locks, file backend state, async output. Это нормально в обычном коде, но не в аварийном обработчике.

Поэтому правило простое: если мы еще в нормальном execution flow — можно использовать LogmeC и FlushAll(). Если мы уже внутри crash/signal handler — нужен другой путь.

Отдельный crash logging path в logme

Для таких ситуаций в logme добавлен отдельный crash logging path. Он не проходит через обычные channels, backend-и, rotation, retention, output flags, stream formatting, JSON/XML formatting или async queue.

Его задача намного проще: попытаться записать короткое аварийное сообщение напрямую в заранее подготовленный output.

Подготовка делается заранее:

Logme::Instance->OpenCrashLog("crash.log");
Logme::Instance->SetCrashOutputMask(Logme::CRASH_OUTPUT_FILE | Logme::CRASH_OUTPUT_STDERR);

После этого можно писать в crash output:

LogmeCrash("fatal error: code=%d", code);
LogmeCrashToFile("fatal error: code=%d", code);
LogmeCrashToStderr("fatal error: code=%d", code);

Для максимально жестких аварийных сценариев есть raw-вариант:

LogmeCrashRaw("SIGABRT received\n");
LogmeCrashRawToFile("SIGABRT received\n");
LogmeCrashRawToStderr("SIGABRT received\n");

Это не еще один красивый логгер. Это emergency path.

Два уровня: LogmeCrash и LogmeCrashRaw

В logme crash logging разделен на два уровня.

LogmeCrashRaw — самый строгий вариант. Он предназначен для случаев, когда нельзя доверять обычному C++ runtime. Сообщение должно быть готовой literal-строкой. Никакого std::string, std::format, fmt, stream operators, dynamic allocation или сложного форматирования.

LogmeCrashRawToStderr("SIGABRT received\n");

Этот путь нужен для самых плохих ситуаций. Он дает минимум удобства, зато не тащит за собой обычную logging subsystem.

LogmeCrash — более мягкий вариант. Он допускает C-style formatting в фиксированный buffer:

LogmeCrashToFile("signal received: %d\n", signum);

Такой вариант удобнее, потому что можно записать номер сигнала, код ошибки или другой простой параметр. Но он менее строгий, чем raw path. Его стоит использовать там, где состояние процесса еще не полностью разрушено и C formatting допустим.

То есть выбор такой:

LogmeC        // normal controlled fatal path
LogmeCrash    // emergency path with simple C formatting
LogmeCrashRaw // minimal raw emergency marker

Чем ниже уровень, тем меньше возможностей, но тем жестче контракт.

Почему файл нужно открыть заранее

В crash handler нельзя начинать сложную работу. Нельзя строить путь, создавать директорию, открывать новый backend, запускать rotation, искать channel или выделять память для имени файла.

Поэтому crash output готовится заранее:

Logme::Instance->OpenCrashLog("crash.log");

А в аварийном месте выполняется только запись:

LogmeCrashRawToFile("SIGABRT received\n");

Если crash file не был открыт заранее, LogmeCrashToFile не должен пытаться открыть его во время аварии. Это важная часть дизайна. Иначе raw crash path снова превратится в обычный logging path, только под другим именем.

Кроме файла можно писать в stderr или stdout:

LogmeCrashRawToStderr("SIGABRT received\n");
LogmeCrashRawToStdout("SIGABRT received\n");

Это полезно для сервисов, контейнеров, systemd, CI и консольных приложений. Но и здесь важно не обещать невозможного: crash logging — это попытка сохранить последнее сообщение, а не гарантия доставки при любом разрушении процесса.

Что crash path намеренно не делает

Crash path в logme специально ограничен.

Он не выбирает channel. Не проходит по backend-ам. Также он не делает rotation. Конечно не применяет retention. Не пишет JSON или XML. Также не использует обычные output flags. Не отправляет запись в async queue. И не пытается красиво собрать полный timestamp, thread id, file location и structured context через обычный механизм логгера.

Это не недостаток, а смысл дизайна. Чем больше возможностей попадает в crash path, тем меньше оснований считать его пригодным для аварийного контекста.

Красивые production-логи должны идти через обычный logme API. Последний аварийный marker должен идти через crash path.

Пример с signal handler

Минимальный вариант:

static void HandleSigabrt(int)
{
  LogmeCrashRawToStderr("SIGABRT received\n");
  LogmeCrashRawToFile("SIGABRT received\n");

  ::signal(SIGABRT, SIG_DFL);
  ::abort();
}

Файл должен быть открыт заранее:

Logme::Instance->OpenCrashLog("crash.log");

Если нужен номер сигнала, можно использовать formatted crash path:

static void HandleSignal(int signum)
{
  LogmeCrashToStderr("signal received: %d\n", signum);
  LogmeCrashToFile("signal received: %d\n", signum);

  ::signal(signum, SIG_DFL);
  ::abort();
}

Но для самых жестких случаев лучше оставить только raw literal write.

В чем отличие подхода logme

В logme обычное логирование и crash logging не смешиваются.

Обычный path остается богатым: channels, backend-и, runtime-control, уровни, output formats, rotation, retention, collapse, once/every, форматирование и прочие возможности production logging.

Controlled fatal path использует обычный логгер: LogmeC, CHECK, LOG(FATAL), FlushAll() и FatalHandler.

Crash path — отдельный: LogmeCrash и LogmeCrashRaw. Он пишет напрямую в заранее подготовленный crash output и не пытается быть полноценным логгером.

Именно такое разделение важно. Нельзя одновременно требовать от одного и того же API полного набора возможностей и пригодности для аварийного signal context. Эти требования конфликтуют.

Итог

Crash logging в C++ — это не просто “вызвать логгер перед abort”. Обычный logging API может быть слишком сложным для signal handler: locks, heap, formatting, backend-и, очереди и flush могут превратить падение в deadlock.

Разные библиотеки подходят к проблеме по-разному. spdlog хорош как быстрый general-purpose logger, но обычный shutdown/flush path нельзя автоматически считать безопасным для signal handler. Quill предлагает optional built-in signal handler для crash-сценариев, но тоже проводит границу между встроенным механизмом и произвольным вызовом general logging API из пользовательского handler-а. g3log изначально позиционируется как crash-safe logger.

В logme решение построено на явном разделении контрактов.

LogmeC, CHECK и LOG(FATAL) — для контролируемых fatal-сценариев.

LogmeCrash — для аварийной записи с простым C formatting.

LogmeCrashRaw — для минимального raw marker-а, когда обычный логгер вызывать уже нельзя.

Это позволяет сохранить сильную production-модель logme и одновременно иметь отдельный аварийный путь для ситуаций, где обычная logging subsystem уже слишком опасна.

Добавить комментарий