Как проектировать логи и дампы при сбое Windows-приложения

· Обновлено: · · Разработка Windows, Обработка исключений, Логи, WER, Дамп сбоя, Расследование сбоев

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619748)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Как проектировать логи и дампы при сбое Windows-приложения. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619748 https://comcomponent.com/ru/blog/2026/03/19/000-windows-app-crash-logging-best-practices/

DOI (последняя версия)
10.5281/zenodo.21619748
DOI (эта версия)
10.5281/zenodo.21619749

Самое тяжёлое в расследовании сбоев Windows-приложения — ситуация, когда известно только, что процесс упал, а почему — не осталось ничего.

Особенно остро это проявляется в таких проектах:

  • падает только в среде заказчика;
  • падает только после долгой непрерывной работы;
  • WPF / WinForms / служба Windows / постоянно работающее приложение, и воспроизводится редко;
  • в деле COM, P/Invoke, native DLL, vendor SDK;
  • «только текст исключения» есть, а контекста того, что было непосредственно перед этим, нет.

Но сразу честно: гарантировать, что лог «обязательно» сохранится, силами одного только падающего процесса нельзя. Если учитывать порчу стека, порчу памяти, fast fail, принудительное завершение и отключение питания, последняя in-process запись по природе — best effort.

Последняя in-process запись — это best effortРисунок показывает, что если учитывать порчу стека, порчу памяти, fast fail, принудительное завершение и отключение питания, падающий процесс сам по себе не может гарантированно сохранить лог, и последняя in-process запись по природе является best effort.Порча стека и памятиПоследняя in-process записьfast fail и принудительное завершениеОтключение питанияПо природе best effort«Обязательно сохранится» — нельзя

Рис. 1: Одним падающим процессом последнюю запись «обязательно» не сохранить.

На практике цель — схема, которая не полагается только на падающий процесс. То есть думают тремя слоями:

  1. хронологический лог штатной работы;
  2. маркер сбоя в момент падения;
  3. следы сбоя, которые оставляет ОС или отдельный процесс.

Статья рассчитана на настольные Windows-приложения, постоянно работающие приложения, службы Windows и инструменты интеграции с оборудованием. В ней собраны практические приёмы, которые не дают потерять возможность расследования даже когда приложение падает из-за исключения, вызванного ошибкой в программе.

1. Сначала выводы

Сначала только выводы.

  • Главное — не рассчитывать, что «последнюю запись» даст один in-process обработчик.
  • На практике самое надёжное сочетание — штатный лог + маркер сбоя + WER LocalDumps.
  • При долгой непрерывной работе, интеграции с оборудованием, плагинах и смеси native SDK добавление процесса-наблюдателя (watchdog / launcher / service) заметно усиливает схему.
  • В обработчике сбоя железное правило — не делать ничего тяжёлого. Сжатие, отправку по HTTP, резолв через DI, диалоги UI, сборку сложного JSON убирают.
  • В момент сбоя пишут коротко и только локально, а сжатие, загрузку и уведомления переносят на следующий запуск или на отдельный процесс.
  • Использовать ThreadException в WinForms или DispatcherUnhandledException в WPF, чтобы формально продлить жизнь процессу, опасно, если причина — ошибка в программе.
  • И в .NET, и в native для исключений, которые заставляют подозревать повреждённое состояние, безопаснее опираться на «записать и завершить», а не на восстановление.
  • Если снимаете дампы, PDB и распространяемые двоичные файлы нужно хранить одновременно — иначе дамп потом не прочитать.

Иными словами, лучшая практика такая: не пытаться сделать всё в момент падения. Роли делят между временем до падения, моментом падения и временем после.

Разделение ролей до, в момент и после паденияРисунок показывает лучшую практику: не делать всё в момент падения, а разделить роли — до падения штатный лог, в момент падения короткая локальная запись, после падения сжатие, загрузка и уведомление.До падения: штатный логВ момент падения: коротко и только локальноПосле: сжатие, загрузка, уведомлениеНа следующем запуске или в отдельном процессе

Рис. 2: В момент падения всё не делают — роли делят между «до», «в момент» и «после».

1.1 Термины, которые используются дальше

Слова, которые дальше идут без пояснения, собраны здесь заранее.

Термин Расшифровка Смысл
WER Windows Error Reporting, отчёт об ошибках Windows Механизм Windows, который на стороне ОС ловит аварийное завершение приложения и записывает его. Настройка, которая оставляет дамп локально, — это LocalDumps
дамп / minidump crash dump Файл с содержимым памяти процесса в момент падения. Потом можно увидеть потоки, стеки и модули
PDB Program Database Файл символов, который появляется при сборке. Без него даже открытый дамп не даст имён функций и номеров строк
in-process внутри процесса Работа внутри того самого процесса, который падает. Противоположность — отдельный процесс
best effort наилучшая попытка «Сохранится, если получится; гарантии нет». Так ведёт себя in-process лог в момент падения
fast fail / __fastfail быстрое аварийное завершение Когда состояние уже считают сломанным, процесс завершают сразу, минимальным числом шагов и без уборки. В native это __fastfail, в .NET — Environment.FailFast
watchdog процесс-наблюдатель Отдельный процесс, который снаружи следит за запуском, завершением и живучестью основного. Иногда его делают как launcher или как родительскую службу
heartbeat сигнал жизни Регулярный сигнал watchdog: «ещё работаю»
UNC-путь Universal Naming Convention Сетевой путь вида \\server\share\.... Если писать туда в момент сбоя, ждут из-за краткого обрыва сети или учётных данных
ACL Access Control List, список управления доступом Кто может читать и писать в папку. Частая причина того, что дамп или лог «не появляются»
SEH Structured Exception Handling, структурная обработка исключений Нативный механизм исключений Windows. SetUnhandledExceptionFilter относится сюда
CRT C Runtime, среда выполнения C Реализация стандартной библиотеки C / C++. Помимо SEH у неё свои пути завершения
session идентификатор сеанса Значение, которое отвечает на вопрос «о каком именно запуске речь». Ключ, которым сопоставляют лог, дамп и записи watchdog

Карта знаний этой статьи

Чтобы у Windows-приложения можно было найти причину и когда оно падает из-за исключения по ошибке в программе, эффективен проект, который не опирается только на журнал самого падающего процесса, а делит следы на три слоя: обычный хронологический журнал, финальный маркер сбоя в момент падения и дамп через WER LocalDumps. AppDomain.UnhandledException, ThreadException в WinForms и DispatcherUnhandledException в WPF опасны, если ими пользуются для видимого продления жизни процесса: безопаснее взять их как точку записи и завершить через API немедленного выхода вроде Environment.FailFast — но вызов FailFast внутри UnhandledException подменяет причину дампа. В native C++ помимо SEH нужно закрыть и пути завершения CRT; при круглосуточной работе или управлении оборудованием сторожевой процесс позволяет снаружи видеть и exit code, и число перезапусков.

Карта знаний проектирования журналов и дампов при сбое Windows-приложенияСхема, которая показывает, что проект, который не рассчитывает на следы только внутри падающего процесса, держится на разделении ролей трёх слоёв — обычный журнал, финальный маркер сбоя и WER LocalDumps — на выборе обработчиков исключений вроде AppDomain.UnhandledException и FailFast и на внешнем обнаружении сторожевым процессомтребуеттребуеттребуеттребуеттребуетрекомендуется длядолжен предшествоватьтребуетрекомендуется дляне рекомендуетсяне рекомендуетсяне рекомендуетсяиспользуетрекомендуется длярекомендуется длятребуеттребуетпроверяетсяреализуетиспользуетиспользуетдолжен предшествоватьреализуетрекомендуется дляпроектирование логов и следов при сбоеWER LocalDumpsобычный журнал (хронологический лог)маркер фатального сбояSEH (структурированная обработка исключений)пути завершения CRT и C++ runtimeпроцесс-watchdogповышенные требования 24/7 и управления оборудованиемпостобработка после следующего запускасопоставление следов по session IDAppDomain.UnhandledExceptionApplication.ThreadException (WinForms)продолжение работы после непредвиденного исключенияApplication.DispatcherUnhandledException (WPF)TaskScheduler.UnobservedTaskExceptionEnvironment.FailFastжурнал приложений WindowsACL папки дампааварийный дампPDB (Program Database)WinDbgMiniDumpWriteDumpрегистрация вложения лога через WerRegisterFile

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

2. Почему одного in-process недостаточно, чтобы было «наверняка»

Если этот пункт оставить размытым, проектирование начинает шататься.

2.1 Контекст упавшего потока сам по себе уже может быть сломан

Хуки необработанных исключений и фильтры исключений верхнего уровня иногда работают в контексте уже сломанного потока. На этом этапе обычны такие ситуации:

  • стек уже небезопасен;
  • из-за порчи кучи дополнительное выделение памяти опасно;
  • ожидание на блокировке, которую держали в момент исключения, останавливает выполнение;
  • объекты, от которых зависит сам logger, уже сломаны.

Поэтому безопаснее смотреть на финальный обработчик не как на «место, где можно всё», а как на «место, где можно очень мало».

В финальном обработчике можно очень малоРисунок показывает, что хук необработанного исключения может работать в контексте сломанного потока, стек и куча уже опасны, ожидание блокировки может остановить выполнение, зависимости logger тоже могут быть сломаны, поэтому это место, где можно очень мало.Работает в контексте сломанного потокаСтек и куча уже опасныОжидание блокировки может остановитьЗависимости logger тоже могут быть сломаныМесто, где можно очень мало

Рис. 3: Финальный обработчик — не «место, где можно всё», а место сплошных ограничений.

2.2 Fast fail и исключения повреждённого состояния рассчитаны на «минимум in-process работы»

При порче памяти или фатальном состоянии на обычную обработку исключений лучше не рассчитывать. В особенности семейство native __fastfail и аномалии, из-за которых подозревают повреждённое состояние, спроектированы так, чтобы «завершаться сразу, с как можно меньшими накладными расходами».

То есть естественная позиция такая: последняя in-process запись — удача, если она вообще сохранилась; основные следы должны жить на стороне ОС / отдельного процесса.

2.3 Событие необработанного исключения .NET тоже не место для «тяжёлого восстановления»

AppDomain.UnhandledException в .NET удобен, но здесь допустима только короткая запись.

  • Может сказаться блокировка, которую держали в момент исключения
  • Не всё, включая исключения повреждённого состояния, можно безопасно поймать
  • Если здесь насильно строить политику продолжения, легко продлить жизнь наполовину сломанному процессу

Реалистично считать, что «событие необработанного исключения = последнее уведомление», а не «безопасная точка восстановления».

Событие необработанного исключения — последнее уведомлениеРисунок показывает, что в AppDomain.UnhandledException допустима только короткая запись, исключения повреждённого состояния безопасно поймать нельзя, и событие необработанного исключения — последнее уведомление, а не безопасная точка восстановления.Событие необработанного исключенияДопустима только короткая записьИспользуют как последнее уведомлениеЭто не безопасная точка восстановленияНасильная политика продолжения продлевает жизнь полусломанному процессу

Рис. 4: Событие необработанного исключения — точка входа для записи, а не место восстановления.

3. Рекомендуемая архитектура — разделять crash-time и after-restart

Проще всего разделить то, что делают в момент сбоя, и то, что делают после перезапуска.

Сначала на одном рисунке: три слоя, кто за какой процесс отвечает и куда это падает.

здоровый процесс следующего запускапроцесс watchdogсторона Windowsпроцесс приложенияисключениезавершение процессазавершение процессаСжатие / загрузка / уведомлениеобнаружение прошлого аварийного завершенияКод выхода и время завершениярешение о перезапускеWER LocalDumpsдамп вне процессаШтатный логхронология append-onlyМаркер сбояодна строка и выходфиксированная локальная папка

Рис. 5: Общая картина трёх слоёв следов. Падающий процесс сам пишет только два из них, основные следы — снаружи.

Три главных пункта.

  • Сам падающий процесс пишет только то, что внутри рамки «процесс приложения» — два пункта. И маркер сбоя на этом заканчивается: «записать одну строку и выйти».
  • Основные следы живут вне процесса. Дамп WER и записи watchdog остаются, даже когда приложение уже сломано.
  • Ключ сопоставления — общий session ID и PID. Если они не совпадают, три вида следов выглядят как три разных происшествия.
Фаза Цель Где выполняется Что делать
Штатная работа Сохранить хронологию Внутри приложения Структурированный лог, heartbeat, граничные события
Момент сбоя Оставить минимальные следы Внутри приложения + ОС Маркер сбоя, дамп WER
Сразу после завершения Обнаружить unexpected exit Отдельный процесс Запись кода выхода, решение о перезапуске, уведомление
После следующего запуска Сделать тяжёлую последующую работу Новый здоровый процесс Сжатие, загрузка, уведомление пользователя, разбор старых логов

При таком разделении проектирование заметно стабилизируется.

3.1 Минимальная схема

Для небольших бизнес-инструментов и внутренних приложений на WPF / WinForms часто хватает такого набора.

  • Штатный лог: локальный файл, который только дописывают в конец
  • Маркер сбоя: короткий выделенный файл
  • Дамп: WER LocalDumps
  • При следующем запуске: показать «В прошлый раз приложение завершилось аварийно. Есть диагностическая информация»

3.2 Усиленная схема

На уровень сильнее стоит подниматься при таких требованиях:

  • работа 24/7;
  • управление оборудованием, мониторинг, постоянная работа в фоне;
  • много COM / P/Invoke / native SDK;
  • есть дочерние процессы, плагины, выполнение скриптов;
  • в среде заказчика нельзя «зависнуть и так и остаться».

Тогда разделяют так:

  • worker-процесс: основная работа;
  • launcher / watchdog / service: контроль запуска, запись выхода, перезапуск;
  • WER LocalDumps: на стороне worker;
  • следующий запуск или watchdog: сбор диагностической информации.

Так схема становится гораздо ближе к реальной эксплуатации.

Разделение ролей в усиленной схемеРисунок показывает, что при круглосуточной работе или управлении оборудованием основную работу отдаёт worker-процесс, запуск, запись выхода и перезапуск — launcher или watchdog, WER LocalDumps настраивают на стороне worker, а диагностику собирает следующий запуск или watchdog.Более жёсткие требования (24/7, управление оборудованием)worker: основная работаwatchdog: запуск, запись выхода, перезапускWER LocalDumps настраивают на стороне workerСледующий запуск или watchdog собирает диагностику

Рис. 6: В усиленной схеме основную работу и наблюдение разносят по разным процессам и фиксируют роли.

4. Практические приёмы штатного лога

Если пытаться обойтись одной последней строкой в момент сбоя, обычно этого недостаточно. По-настоящему помогает штатный лог вплоть до момента, предшествующего сбою.

4.1 Лог — это не «текст для человека», а «данные, которые потом можно сопоставить»

Минимум того, что стоит класть в штатный лог.

  • Метка времени UTC
  • Время с момента старта процесса
  • PID / TID
  • Имя приложения, версия, номер сборки, идентификатор коммита
  • Идентификатор сеанса
  • Идентификатор операции / задания / correlation ID
  • Имя модуля / экрана / worker
  • Последние внешние воздействия:
    • запись в файл;
    • обновление БД;
    • отправка команды оборудованию;
    • сетевые запросы
  • Тип исключения, HRESULT / ошибка Win32 / код исключения
  • Краткое описание основных входных параметров
  • Идентификаторы объектов в той мере, в какой в них нет секретов

Удобный формат — одно событие на строку, JSON Lines или key=value.

В JSON Lines одно событие получается примерно такой зернистости.

{"ts":"2026-03-18T01:15:33.412Z","up_ms":184213,"pid":1234,"tid":9,"app":"MyApp","ver":"3.2.1.884","commit":"9f1c2ab","session":"4f1c","level":"Warning","module":"DeviceWorker","op":"JOB-20260318-0042","event":"ExternalCommandSent","target":"COM3","cmd":"SEQ_START","timeout_ms":3000}

Строка длинная, но по одной строке уже видно, когда, какой экземпляр запуска, какой версии, в середине какой операции и что именно сделал. С дампом это связывается через pid и session, со сборкой — через ver и commit.

В формате key=value то же содержание выглядит так.

ts=2026-03-18T01:15:33.412Z up_ms=184213 pid=1234 tid=9 app=MyApp ver=3.2.1.884 commit=9f1c2ab session=4f1c level=Warning module=DeviceWorker op=JOB-20260318-0042 event=ExternalCommandSent target=COM3 cmd=SEQ_START timeout_ms=3000

Длинный текст для человека менее важен, чем то, что потом можно сопоставить три файла.

Одна строка связывает три вида следовРисунок показывает, что одна строка штатного лога связывается с дампом через pid и session, со сборкой — через ver и commit, и возможность потом сопоставить три файла важнее длинного текста для человека.Одна строка штатного логаЧерез pid / session к дампуЧерез ver / commit к сборкеТри файла можно сопоставить

Рис. 7: Строка лога связывается с другими следами через pid, session, ver и commit.

4.2 Критические события пишут синхронно

Если все записи штатного лога делать синхронными, это утяжеляет работу. Но если всё отдать асинхронному буферу, в момент падения пропадает сразу всё.

Поэтому на практике обращение меняют в зависимости от уровня.

  • Мелкие события уровня Information: буферизовать можно
  • Warning и выше: flush делать раньше
  • Важные граничные события: писать синхронно
    • ProcessStart
    • ConfigLoaded
    • WorkerStarted
    • ExternalCommandSent
    • TransactionCommitted
    • RecoveryStarted
    • FatalPathEntered

Смысл в том, что хотя бы границы, значимые для бизнеса, должны надёжно доходить до диска.

Запись в зависимости от уровняРисунок показывает разделение: мелкие события Information можно буферизовать, Warning и выше flush делают раньше, граничные события бизнес-логики пишут синхронно.Запись логаInformation: буфер допустимWarning и выше: flush раньшеГраничные события: писать синхронноГраницы бизнес-логики доводят до диска

Рис. 8: Не всё синхронно и не всё в буфер — запись выбирают по уровню.

4.3 «Штатный лог, который пишут сейчас» и «маркер сбоя» разделяют

Это очень важно.

Если пытаться уместить всё в один rolling log, случается следующее:

  • шла ротация;
  • запись оставалась в асинхронной очереди;
  • сразу после исключения умер сам logger;
  • строка лога оборвалась на середине.

Поэтому лучше разделить хотя бы на два файла.

  • app-<session>.jsonl хронологический лог штатной работы
  • fatal-last.log или fatal-<session>.log только для маркера сбоя

Уже одна ясность, куда кладут последнюю строку, сильно помогает в полевых условиях.

Штатный лог и fatal-маркер разделяютРисунок показывает, что если всё класть в один rolling log, последняя строка пропадает из-за ротации, остатка в асинхронной очереди или смерти logger, поэтому хронологический лог штатной работы и файл только для маркера сбоя разделяют.вместо этогоВсё в один rolling logПропадает из-за ротации, очереди, смерти loggerДва файла: штатный лог и fatal-маркерМесто последней строки становится ясным

Рис. 9: Место последней строки фиксируют в отдельном от штатного лога файле.

4.4 Место хранения лога — фиксированный локальный путь, не сеть

В момент сбоя опираться на UNC-путь, NAS, HTTP или облачный API опасно.

  • краткий обрыв сети;
  • задержка DNS;
  • истекшие учётные данные;
  • ожидание в потоке UI;
  • нехватка прав у учётной записи службы.

В момент сбоя сначала пишут в фиксированный локальный путь. Отправляют после следующего запуска или из отдельного процесса.

4.5 В имя файла кладут session

Одной даты недостаточно. За один день приложение может перезапуститься много раз.

Например, так:

Logs\
  MyApp_20260318_101530_pid1234_session-4f1c.jsonl
  MyApp_fatal_20260318_101533_pid1234_session-4f1c.log
  MyApp_watchdog_20260318.jsonl

Уже ясность в вопросе «о каком именно запуске речь» сильно меняет скорость разбора.

5. Практические приёмы маркера сбоя

Это не место, где строят полнофункциональный logger. Это место, где записывают один раз, коротко, как можно надёжнее.

5.1 Цель — не «подробности причины», а «зафиксировать точку входа»

Информацию в маркере сбоя лучше сузить.

  • UTC момента возникновения
  • PID / TID
  • Идентификатор сеанса
  • Версия / номер сборки
  • Из какого хука это пришло
    • AppDomain.UnhandledException
    • Application.ThreadException
    • DispatcherUnhandledException
    • SetUnhandledExceptionFilter
    • _set_invalid_parameter_handler
    • set_terminate
  • Тип исключения или код исключения
  • По возможности — короткое сообщение
  • Идентификатор последней операции
  • Имя файла штатного лога
  • Предполагаемая папка дампа

Этого достаточно.

Маркер фиксирует точку входаРисунок показывает, что маркер сбоя хранит не подробности причины, а то, из какого хука и с каким исключением упали, имя штатного лога и предполагаемую папку дампа — чтобы зафиксировать точку входа в расследование.Маркер сбояХук, тип исключения, sessionИмя штатного лога и папка дампаТочка входа в расследование фиксируетсяПодробности причины оставляют дампу и штатному логу

Рис. 10: Роль маркера — не подробности причины, а фиксация точки входа в расследование.

5.2 Чего нельзя делать в обработчике сбоя

Любой пункт из списка с высокой вероятностью становится миной.

  • Резолвить logger через DI-контейнер
  • Использовать async / await
  • Запускать Task
  • Ждать блокировку
  • Собирать сложный JSON
  • Трогать COM-объекты
  • Показывать диалоги UI
  • Сжимать
  • Отправлять по HTTP / SMTP / Slack / Teams
  • Разбирать дамп и делать сводку
  • Глотать исключение и продолжать

Обработчик сбоя — не продолжение обычного потока обработки. Его сводят к «сделать минимальную локальную запись и закончить».

5.3 Что делать в обработчике сбоя

Наоборот, действия здесь довольно простые.

  1. Предотвратить повторный вход
  2. Записать только одну строку
  3. Сделать flush
  4. Завершиться

Именно в таком порядке.

По возможности используют:

  • заранее созданную выделенную папку;
  • путь, существование которого уже проверили;
  • место хранения с уже проверенным ACL.

В штатном логе чрезмерный flush утяжеляет работу, но у fatal-маркера записей крайне мало, поэтому здесь можно flush усилить. В .NET это FileStream.Flush(true), в native — FlushFileBuffers; проектировать проще, если к этой единственной строке относиться как к правилу «именно эту строку нужно прямо сейчас довести до диска».

Четыре шага обработчика сбояРисунок показывает, что в обработчике сбоя делают только четыре вещи: предотвращают повторный вход, пишут одну строку, делают flush и завершаются, а поскольку записей fatal-маркера крайне мало, усиленный flush до диска здесь допустим.1. Предотвратить повторный вход2. Записать одну строку3. Сделать flush4. ЗавершитьсяИменно эту строку доводят до диска сразу

Рис. 11: В обработчике четыре шага, и этот порядок не ломают.

5.4 Не пытаться продолжать

Если неожиданное исключение выросло из ошибки в программе, финальный обработчик безопаснее считать не средством восстановления, а средством записи.

Особенно «не продолжать» стоит взять за основу в таких случаях:

  • даже NullReferenceException или InvalidOperationException, но посреди обновления общего состояния;
  • неожиданное исключение в потоке UI;
  • неожиданное исключение, которое вытекло из цикла мониторинга или родительского цикла;
  • AccessViolationException;
  • StackOverflowException;
  • аномалия на native-границе;
  • invalid parameter / purecall / terminate в CRT.

Желание не ронять приложение понятно, но выживание в наполовину сломанном состоянии чаще тяжелее и для диагностики, и для эксплуатации.

При завершении стоит рассмотреть API немедленного завершения — в .NET Environment.FailFast, в native RaiseFailFastException или __fastfail — и проектировать так, чтобы не рассчитывать на finally и обычную уборку.

Не средство восстановления, а средство записиРисунок показывает, что при неожиданном исключении из ошибки в программе финальный обработчик лучше считать средством записи, а не восстановления, и безопаснее записать и завершить API немедленного завершения, чем выживать в наполовину сломанном состоянии.Исключение из ошибки в программеВыжить наполовину сломаннымЗаписать и завершитьИ диагностика, и эксплуатация тяжелееРассмотреть FailFast и другие API немедленного завершения

Рис. 12: Финальный обработчик — не средство восстановления, а средство записать и завершить.

5.5 Минимальная реализация на C#

Если свести сказанное выше к C# для .NET 6 и новее, получается примерно такой объём. Ни DI, ни logger здесь нет.

using System;
using System.IO;
using System.Text;
using System.Threading;

internal static class FatalMarker
{
    // Заранее созданный локальный фиксированный путь, на котором уже проверили запись
    private static readonly string LogDirectory = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "MyApp", "Logs");

    // Ключ, которым сопоставляют штатный лог, дамп и записи watchdog
    private static readonly string SessionId = Guid.NewGuid().ToString("N").Substring(0, 8);

    // Флаг против повторного входа. 0 — ещё не писали
    private static int _written;

    /// <summary>Вызывать один раз сразу после старта приложения.</summary>
    public static void Install()
    {
        // В момент сбоя CreateDirectory вызывать не хочется, поэтому папку создаём здесь
        Directory.CreateDirectory(LogDirectory);

        AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
    }

    /// <summary>Вызывать из места, где решено, что продолжать больше нельзя.</summary>
    public static void FailNow(string reason)
    {
        Write("FailNow", null, reason);
        Environment.FailFast(reason);
    }

    private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
    {
        Write("AppDomain.UnhandledException", e.ExceptionObject as Exception, null);

        // Здесь не завершаем. Исключение необработанное, дальше CLR пойдёт по своему завершению.
        // Если вызвать FailFast здесь, причиной дампа в WER
        // станет FailFast, а не исходное исключение.
    }

    private static void Write(string hook, Exception ex, string note)
    {
        // Со второго раза ничего не делаем
        if (Interlocked.Exchange(ref _written, 1) != 0)
        {
            return;
        }

        try
        {
            string fileName = string.Format(
                "MyApp_fatal_{0:yyyyMMdd_HHmmss}_pid{1}_session-{2}.log",
                DateTime.UtcNow, Environment.ProcessId, SessionId);
            string path = Path.Combine(LogDirectory, fileName);

            // Сериализатор не вызываем: одну строку собираем конкатенацией
            string line = string.Join("\t",
                "ts=" + DateTime.UtcNow.ToString("O"),
                "pid=" + Environment.ProcessId,
                "tid=" + Environment.CurrentManagedThreadId,
                "session=" + SessionId,
                "hook=" + hook,
                "type=" + (ex != null ? ex.GetType().FullName : "none"),
                "message=" + Flatten(ex != null ? ex.Message : note),
                "log=" + LogDirectory);

            using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
            {
                byte[] bytes = new UTF8Encoding(false).GetBytes(line + Environment.NewLine);
                stream.Write(bytes, 0, bytes.Length);

                // true — сбросить не в кэш ОС, а на диск
                stream.Flush(true);
            }
        }
        catch
        {
            // Если здесь не получилось, больше сделать нечего. Проглатываем и выходим
        }
    }

    private static string Flatten(string s)
    {
        return string.IsNullOrEmpty(s) ? string.Empty : s.Replace("\r", " ").Replace("\n", " ");
    }
}

На стороне вызова сразу после старта вызывают Install(). Если это забыть, код выше не сделает ни байта.

internal static class Program
{
    private static void Main(string[] args)
    {
        FatalMarker.Install();

        // Дальше обычный запуск приложения
        RunApplication(args);
    }

    private static void RunApplication(string[] args)
    {
        // Пример: если общее состояние уже считают сломанным, не продолжают, а роняют
        // FatalMarker.FailNow("device state inconsistent");
    }
}

Эти примерно 60 строк соблюдают только четыре пункта из 5.3.

  1. Interlocked.Exchange предотвращает повторный вход
  2. Конкатенация строк пишет одну строку
  3. Flush(true) доводит запись до диска
  4. Из UnhandledException не продолжают работу

Environment.FailFast пишет сообщение в журнал событий приложений Windows, сразу завершает процесс и включает это содержимое в отчёт об ошибке. То есть сам FailFast добавляет ещё один след. Но, как сказано выше, если вызвать его на пути необработанного исключения, вид дампа меняется — место вызова нужно выбирать.

Место вызова FailFast выбираютРисунок показывает, что Environment.FailFast пишет в журнал событий и сразу завершает процесс, добавляя ещё один след, но вызов на пути необработанного исключения подменяет причину дампа в WER с исходного исключения на FailFast, поэтому место вызова выбирают.Environment.FailFastПишет в журнал событий и сразу завершаетПоявляется ещё один следЕсли вызвать на пути необработанного исключенияПричиной дампа становится FailFast

Рис. 13: FailFast добавляет след, но вызов из необработанного исключения подменяет причину.

6. Особенности по фреймворкам

6.1 Общее для .NET: AppDomain.CurrentDomain.UnhandledException

Это полезно как последнее уведомление. Но тяжёлого восстановления здесь избегают.

Базовое использование простое.

  • Записать маркер сбоя
  • При необходимости оставить минимальное сообщение в журнале событий Windows
  • Не продолжать
  • Не ждать и не повторять попытку здесь

UnhandledException удобен, но безопаснее не исходить из того, что отсюда приложение можно вернуть в здоровое состояние.

6.2 WinForms: Application.ThreadException

Сложность в том, что он ловит необработанные исключения потока UI и позволяет формально продолжить.

Для превращения ожидаемых ошибок бизнес-ввода в диалоги это ещё можно понять, но для продолжения после неожиданного исключения из ошибки в программе он не подходит.

Если приоритет — действительно докопаться до причины, безопаснее:

  • в ThreadException делать только минимальную запись;
  • либо склоняться к UnhandledExceptionMode.ThrowException;
  • и затем завершать процесс, оставляя дамп и логи.

6.3 WPF: Application.DispatcherUnhandledException

В WPF похожая картина.

  • В основном затрагивает только исключения в потоке UI
  • Handled = true позволяет формально продолжить
  • Но если сделать это против ошибки в программе, состояние экрана и внутреннее состояние легко расходятся

Поэтому и в WPF безопаснее не использовать это как средство продлить жизнь ради продолжения, а как точку входа для записи.

События UI не используют, чтобы продлить жизньРисунок показывает, что ThreadException в WinForms и DispatcherUnhandledException в WPF позволяют формально продолжить, но при ошибке в программе состояние экрана и внутреннее состояние расходятся, поэтому их используют как точку входа для записи, затем завершают процесс и оставляют дамп и логи.Необработанное исключение потока UIФормально можно продолжитьЭкран и внутреннее состояние расходятсяИспользуют как точку входа для записиЗавершают и оставляют дамп и логи

Рис. 14: События UI в WinForms / WPF делают точкой входа для записи, а не средством продлить жизнь.

6.4 TaskScheduler.UnobservedTaskException не делают основным путём

Это не «последний рубеж перед падением».

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

Поэтому его можно применять, чтобы:

  • рано находить пропуски наблюдения за исключениями;
  • во время разработки выявлять пробелы в проектировании Task,

но главной ролью финального обработчика сбоя его не назначают.

6.5 Native Win32 / C++: не переоценивать SetUnhandledExceptionFilter

На native-стороне хочется опереться на SetUnhandledExceptionFilter.

Но он работает в контексте faulting thread, поэтому на него влияют:

  • недействительный стек;
  • глубокая рекурсия;
  • уже сломанная куча;
  • блокировки, которые держали в момент исключения.

Поэтому SetUnhandledExceptionFilter разумнее считать точкой входа best effort, чтобы получить последнее уведомление.

6.6 В native C++ ловят и пути завершения CRT

В native C++ одного необработанного SEH недостаточно: остаются дыры.

Конкретно смотрят сюда:

  • _set_invalid_parameter_handler;
  • _set_purecall_handler;
  • set_terminate.

Это семейство существует, чтобы ловить пути завершения, которые начинаются в C runtime и C++ runtime.

На практике спокойный подход такой:

  • писать маркер сбоя и в этих обработчиках;
  • но тяжёлого восстановления не делать;
  • завершать надёжно;
  • основные следы оставлять WER / dump.
Одного SEH недостаточноРисунок показывает, что в native C++ одного SetUnhandledExceptionFilter мало: теряются пути завершения CRT и C++ runtime, поэтому invalid parameter, purecall и terminate тоже ловят, оставляют минимальную запись и надёжно завершаются, а основные следы оставляют WER и dump.Только SetUnhandledExceptionFilterПути завершения CRT теряютсяЛовят и invalid parameter / purecall / terminateМинимальная запись и надёжное завершениеОсновные следы оставляют WER / dump

Рис. 15: В native C++ дыры закрываются, только когда к SEH добавляют пути завершения CRT.

7. В основу кладут WER LocalDumps

На практике этот слой очень силён.

7.1 Первая рекомендация — WER LocalDumps

В смысле «после падения оставить минимальные следы как можно надёжнее» проще всего начать с WER LocalDumps.

Причины простые.

  • Дамп может сохранить сама ОС
  • Легко включить без дополнительных инструментов
  • Настраивается для каждого приложения отдельно
  • Основные следы сбоя можно вынести из in-process

То, чего не покажет один лог:

  • какой поток упал;
  • на каком стеке произошло падение;
  • на какой границе модуля это случилось;
  • где искать — в managed, native, COM или SDK.

Возможность увидеть это потом — сильная сторона подхода.

Почему силён WER LocalDumpsРисунок показывает, что WER LocalDumps может сохранить дамп на стороне ОС, настраивается на приложение, выносит основные следы из in-process и потом позволяет увидеть, какой поток на каком стеке упал и на какой границе модуля.WER LocalDumpsДамп сохраняет ОСНастройка на приложениеОсновные следы выносят из in-processВидны поток, стек, граница модуля

Рис. 16: WER LocalDumps — основа, которая выносит основные следы из падающего процесса.

7.2 Типичная настройка

Например, чтобы сохранять дампы MyApp.exe в C:\CrashDumps\MyApp, можно сделать так:

reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpFolder /t REG_EXPAND_SZ /d "C:\CrashDumps\MyApp" /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpCount /t REG_DWORD /d 10 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\MyApp.exe" /v DumpType /t REG_DWORD /d 2 /f

Для начала такого упрощения достаточно.

Значение С чего начать
DumpFolder Выделенная папка
DumpCount 5–10
DumpType На машине разработки — 2; в полевых условиях — 1 или 2 в зависимости от объёма диска и требований к конфиденциальности

7.3 ACL места хранения дампа обязательно проверяют

И для лога, и для дампа одно и то же: настройка папки, в которую нельзя писать, бессмысленна.

Особенно для:

  • служб Windows;
  • дочерних процессов с разделением привилегий;
  • ограниченных учётных записей на полевых машинах;
  • случаев, связанных с UAC,

ACL места хранения — главная причина того, что дамп не появляется.

Место хранения проверяют целиком:

  • создано заранее;
  • запись протестирована;
  • число хранимых копий ограничено;
  • эксплуатационный персонал может туда зайти.
Как не получить пустой дамп из-за ACLРисунок показывает типичную ошибку служб Windows и ограниченных учётных записей — папка дампа, в которую нельзя писать, из-за чего дамп не появляется, поэтому папку создают заранее, проверяют запись, ограничивают число копий и убеждаются, что туда может зайти эксплуатация.как избежатьСлужба или ограниченная учётная записьУказывают папку, в которую нельзя писатьДамп не появляетсяСоздать заранее и проверить записьПроверить число копий и доступ для эксплуатации

Рис. 17: Главная причина пустого дампа — ACL места хранения; её ловят предварительной проверкой записи.

7.4 Если текущий лог нужно приложить к отчёту WER

Если используются отчёты WER в адрес Microsoft или собственный конвейер WER, есть и способ через WerRegisterFile зарегистрировать текущий файл лога, чтобы включить его в отчёт об ошибке.

Но это лучше считать дополнительным каналом, а не заменой локального хранения. В момент сбоя в первую очередь нужно, чтобы на самом устройстве запись осталась как можно надёжнее.

Практичнее такой порядок:

  1. локальный штатный лог;
  2. локальный fatal-маркер;
  3. локальный dump;
  4. при необходимости — ещё и регистрация связанных файлов на пути отправки WER.

7.5 Хранят не только дамп, но и сведения о версии

Даже имея дамп, позже оказывается, что:

  • EXE / DLL той сборки уже нет;
  • PDB нет;
  • неизвестно, из какого коммита собрали,

и позиция становится слабой.

Как минимум оставляют:

  • распространяемые двоичные файлы;
  • соответствующие PDB;
  • версию;
  • дату и время сборки;
  • идентификатор коммита;
  • версию установщика.

Сбор дампов и хранение PDB — парная задача.

Сбор дампов и хранение PDB — параРисунок показывает, что одного дампа мало: без EXE или DLL той сборки, PDB и сведений о коммите дамп потом не прочитать, поэтому вместе с дампами хранят распространяемые двоичные файлы, PDB, версию и идентификатор коммита.поэтомуОставляют только дампНет PDB и двоичных файлов той сборкиПотом не прочитатьХранят распространяемые двоичные файлы и PDBОставляют также версию и идентификатор коммита

Рис. 18: Дамп читается, только если остались PDB и двоичные файлы той же сборки.

7.6 До того момента, когда снятый дамп впервые открывают

«Дампы копятся, но их никто не открывал» встречается очень часто. Глубокий разбор оставляем профильной статье, а здесь — только первые десять минут.

Подготовка из двух шагов.

  1. Собрать в одну папку этот dump и PDB с распространяемыми двоичными файлами той же сборки
  2. Подготовить WinDbg из Debugging Tools for Windows в составе Windows SDK

Дальше в WinDbg открывают .dmp и по очереди вводят.

.sympath srv*C:\symbols*https://msdl.microsoft.com/download/symbols;C:\myapp\pdb
.reload /f
.ecxr
!analyze -v
~*k
lm v

Роль каждой команды такая.

Команда Что делает
.sympath Задаёт, где искать символы. Указывают и сервер символов Microsoft, и своё место хранения PDB
.reload /f Принудительно перечитывает символы
.ecxr Переходит в контекст регистров на момент исключения. Если это забыть, смотрят не место падения, а стек ожидания на стороне WER
!analyze -v Автоматически разбирает текущее исключение и показывает подробности. Сначала читают это
~*k Выводит стеки вызовов всех потоков. Видно, чем занимались потоки кроме упавшего
lm v Показывает загруженные модули и версии. Нужно, чтобы сопоставить с распространяемыми файлами и номером сборки

Если на этом шаге «имён функций нет» и «номеров строк нет», причина почти всегда в том, что PDB от другой сборки. Возвращайтесь к учёту версий в 7.5.

Первые десять минут с дампомРисунок показывает первый порядок работы: собрать PDB и двоичные файлы той же сборки, открыть дамп в WinDbg, перейти в контекст исключения и читать разбор; если имён функций нет, PDB от другой сборки и нужно вернуться к учёту версий.нетСобрать PDB и двоичные файлы той же сборкиОткрыть дамп в WinDbgПерейти в контекст исключения и разобратьБез .ecxr смотрят стек ожидания WERПоявляются имена функций и номера строк?PDB от другой сборки, вернуться к учёту версий

Рис. 19: Разбор дампа начинают с подготовки, открытия и перехода в контекст исключения.

Более глубокий сбор и разбор собраны в связанных статьях в конце.

8. Как относиться к MiniDumpWriteDump и собственным crash-репортёрам

Собственная реализация иногда нужна.

  • Хочется кнопку «Сохранить диагностическую информацию» в UI
  • Хочется собрать вместе ещё логи и файлы конфигурации
  • Хочется обрабатывать группу дочерних процессов вместе
  • Хочется добавить собственное маскирование перед автоматической загрузкой

Но самое важное здесь — не взваливать снятие dump ещё и на падающую сторону.

8.1 Отдельный процесс лучше self-dump

MiniDumpWriteDump — сильный инструмент, но вызывать его из отдельного процесса безопаснее, чем из самого упавшего.

Типичная схема выглядит так.

  • worker обнаруживает аномалию;
  • по возможности уведомляет helper через событие или именованный канал (named pipe);
  • helper снимает dump worker;
  • helper собирает tail логов и файлы конфигурации;
  • helper после завершения кладёт всё в очередь загрузки.

Тогда даже если worker сломан, сторона helper ещё здорова.

Снятие дампа отдельным процессом helperРисунок показывает поток: worker обнаруживает аномалию и уведомляет helper событием или именованным каналом, здоровый helper снимает дамп worker, собирает логи и файлы конфигурации и после завершения кладёт всё в очередь загрузки.helperworkerhelperworkerДаже если worker сломан, helper здоровУведомление об аномалии событием или каналомСнять dump workerСобрать tail логов и файлы конфигурацииПосле завершения положить в очередь загрузки

Рис. 20: Снятие dump отдают не падающей стороне, а здоровому процессу helper.

8.2 Если без in-process никак, это выносят на отдельный поток

Даже когда отдельный процесс невозможен, лучше, если под снятие dump выделяют отдельный поток.

Но и тогда суть остаётся best effort. Из «сделали собственную реализацию dump» не следует «теперь на 100% безопасно».

8.3 Тяжёлое переносят на следующий запуск

В собственном репортёре часто хочется сделать:

  • сжатие в zip;
  • сопоставление с символьной информацией;
  • загрузку на сервер;
  • снимок экрана;
  • получение дополнительной информации из БД.

Всё это переносят не на момент сбоя, а на время после перезапуска или на сторону helper.

9. Что меняется, если добавить процесс-наблюдатель

Для систем с долгой непрерывной работой процесс-наблюдатель окупается очень сильно.

9.1 Что записывает процесс-наблюдатель

Со стороны watchdog / launcher / родительской службы можно оставить такие сведения:

  • время запуска дочернего процесса;
  • аргументы запуска;
  • PID;
  • версию наблюдаемого объекта;
  • время последнего полученного heartbeat;
  • время завершения;
  • код выхода;
  • число перезапусков;
  • наличие dump;
  • был ли выполнен перезапуск.

Уже этого достаточно, чтобы довольно ясно увидеть:

  • действительно ли произошёл сбой;
  • было ли это выключением ОС;
  • закрыл ли приложение сам пользователь;
  • было ли зависание с последующим принудительным завершением;
  • сколько раз цикл ушёл в перезапуск.
Что позволяют различить записи watchdogРисунок показывает, что если процесс-наблюдатель оставляет код выхода, время завершения, последний heartbeat и число перезапусков, снаружи можно отличить настоящий сбой, выключение ОС, закрытие пользователем, kill после зависания и цикл перезапусков.Код выхода и время завершенияМожно различить снаружиПоследний heartbeatЧисло перезапусковСбой, выключение или закрытие пользователем

Рис. 21: Записи watchdog позволяют снаружи отличить тип завершения.

9.2 Особенно подходящие случаи

Разделение стоит рассматривать в таких случаях:

  • worker, который несёт vendor SDK;
  • обработка изображений / видео / устройств ввода-вывода;
  • родительские циклы мониторинга или опроса;
  • выполнение скриптов или плагинов;
  • хостинг существующих активов COM / ActiveX;
  • мосты и взаимодействие между 64-bit и 32-bit.

Замкнуть опасную работу в одном worker проще и для проектирования логов, и для проектирования восстановления.

10. Частые антипаттерны

Здесь собраны ловушки уровня проектирования. Что нельзя делать внутри обработчика сбоя на уровне процедуры, разобрано в 5.2 — если вы как раз пишете обработчик, смотрите туда. Пункты, которые выглядят пересечением (например, отправка HTTP в 10.3), в 5.2 отвечают на «почему внутри обработчика это плохо», а здесь — на «что из этого получается в эксплуатации».

10.1 catch (Exception), который только пишет в лог и продолжает

Самый частый и самый опасный вариант.

  • Остаются частичные изменения
  • Ломается общее состояние
  • Множатся последующие сбои
  • Размывается настоящая точка причины

Взамен одной дополнительной строки лога инцидент часто растягивается.

10.2 Доверие только очереди асинхронного logger

Само по себе асинхронное логирование не плохо. Проблема в том, что даже на fatal path данные кладут в ту же очередь — и на этом всё.

Если worker останавливается в момент сбоя, очередь пропадает вместе с ним.

Безопаснее иметь запасной путь, при котором только fatal path пишет напрямую.

10.3 Отправка по HTTP из обработчика сбоя

Реализовать хочется, но это довольно опасно.

  • DNS;
  • TLS;
  • прокси;
  • аутентификация;
  • тайм-ауты;
  • ожидание повторной отправки —

всё это ложится на уже упавший контекст.

Отправку делают после перезапуска.

10.4 Дамп есть, но со штатным логом он не связан

Это встречается часто.

  • в имени файла дампа нет session;
  • в логе нет PID / session;
  • у watchdog нет PID;
  • номера сборки не совпадают.

В результате три вида следов выглядят как три отдельные истории.

Следы не связываютсяРисунок показывает, что если в имени дампа нет session, в логе нет PID или session, а номера сборки не совпадают, дамп, лог и записи watchdog выглядят как три разных происшествия.В имени дампа нет sessionТри вида следов выглядят как разные историиВ логе нет PID / sessionНомера сборки не совпадают

Рис. 22: Если нет ключевых идентификаторов, даже хорошие следы между собой не связываются.

10.5 Продление жизни через события необработанного исключения в WinForms / WPF

Внешне приложение «перестаёт падать», и поначалу это радует. Но на деле часто получается состояние-зомби:

  • остаётся только экран;
  • worker уже мёртв;
  • остаётся только активная кнопка;
  • неизвестно, сохранились ли данные.

10.6 Игнорирование native-путей завершения

Если успокоиться, полагаясь только на SetUnhandledExceptionFilter, можно упустить:

  • invalid parameter;
  • purecall;
  • terminate;
  • fast fail.

В native C++ лучше держать в поле зрения не только SEH, но и пути завершения CRT / C++ runtime.

11. Минимальный чек-лист внедрения

Если выполнены следующие пункты, схема вполне боеспособна.

  • Штатный лог пишет одно событие на строку
  • В каждой строке лога есть UTC, PID, TID, version и session
  • Записываются ProcessStart и ProcessExit
  • Важные граничные события синхронно проходят flush
  • Есть выделенный файл для маркера сбоя
  • На fatal path асинхронный logger не используется
  • WER LocalDumps настроен для каждого приложения
  • ACL места хранения дампа проверен
  • PDB и распространяемые двоичные файлы хранятся
  • При следующем запуске можно обнаружить предыдущее аварийное завершение
  • Сжатие / загрузка / уведомление выполняются после перезапуска или в отдельном процессе
  • В native C++ учтены invalid parameter / purecall / terminate
  • На тестовой машине выполнено намеренное падение и подтверждено, что следы действительно сохраняются

Особенно важна последняя строка. Одного проектирования недостаточно — обязательно нужна проверка, что следы действительно снимаются.

12. Насколько глубоко тестировать

Пункты, которые стоит проверить, собраны в таблицу.

Тест Что проверять
Необработанное managed-исключение Появляются ли одновременно штатный лог, fatal-маркер и dump
Исключение в потоке UI Ведёт ли себя путь событий WinForms / WPF как задумано
Исключение в потоке worker Доходит ли до AppDomain.UnhandledException, может ли watchdog это обнаружить
Native-исключение Действительно ли снимается дамп WER
invalid parameter / terminate Остаётся ли минимальная запись даже на пути CRT / C++ runtime
Принудительное завершение (kill) Может ли watchdog зафиксировать unexpected exit, даже если in-process ничего не может
Перезапуск Работают ли уведомление, сбор и загрузка после следующего запуска

Важно не «предположение, что при исключении лог должен появиться», а подтверждение «при этом условии остаётся именно этот файл».

13. Итог

Если Windows-приложение должно при падении из-за исключения, вызванного ошибкой в программе, оставлять сведения, нужные для расследования, оси мышления довольно простые.

  • Не полагаться только на падающий процесс
  • Разделить следы на штатный лог, маркер сбоя и следы на стороне ОС / отдельного процесса
  • В момент сбоя писать коротко и только локально
  • Тяжёлую работу переносить на время после перезапуска или на отдельный процесс
  • В основу класть WER LocalDumps
  • По умолчанию выбирать запись и завершение, а не продолжение

В конечном счёте схема, по которой причину можно восстановить даже без последней строки, сильнее попытки любой ценой вытянуть эту последнюю строку.

Схема, которая не держится на последней строкеРисунок показывает, что схема, по которой причину можно восстановить даже без последней строки, сильнее попытки вытянуть эту строку, при этом маркер сбоя всё равно коротко кладут в отдельный файл, а основные следы оставляют дампу WER и штатному логу вплоть до момента сбоя.Целевая формаСхема, по которой можно идти даже без последней строкиМаркер коротко кладут в отдельный файлОсновные следы — дамп и штатный лог

Рис. 23: Сильнее не вытягивать последнюю строку, а сделать схему, которая работает и без неё.

И всё же последняя строка нужна, поэтому маркер сбоя коротко кладут в отдельный файл. А настоящие основные следы доверяют дампу WER и штатному логу вплоть до момента, предшествующего сбою. В практике Windows-приложений это довольно устойчивый способ работы.

Похожие статьи

Справочные материалы

  • Microsoft Learn: Collecting User-Mode Dumps https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps
  • Microsoft Learn: Using WER https://learn.microsoft.com/en-us/windows/win32/wer/using-wer
  • Microsoft Learn: MiniDumpWriteDump function https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/nf-minidumpapiset-minidumpwritedump
  • Microsoft Learn: SetUnhandledExceptionFilter function https://learn.microsoft.com/en-us/windows/win32/api/errhandlingapi/nf-errhandlingapi-setunhandledexceptionfilter
  • Microsoft Learn: System.AppDomain.UnhandledException event https://learn.microsoft.com/en-us/dotnet/fundamentals/runtime-libraries/system-appdomain-unhandledexception
  • Microsoft Learn: Application.ThreadException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.application.threadexception
  • Microsoft Learn: Application.DispatcherUnhandledException Event https://learn.microsoft.com/en-us/dotnet/api/system.windows.application.dispatcherunhandledexception
  • Microsoft Learn: TaskScheduler.UnobservedTaskException Event https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler.unobservedtaskexception
  • Microsoft Learn: Environment.FailFast https://learn.microsoft.com/en-us/dotnet/api/system.environment.failfast
  • Microsoft Learn: Registering for Application Recovery https://learn.microsoft.com/en-us/windows/win32/recovery/registering-for-application-recovery
  • Microsoft Learn: RegisterApplicationRecoveryCallback https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-registerapplicationrecoverycallback
  • Microsoft Learn: WerRegisterFile https://learn.microsoft.com/en-us/windows/win32/api/werapi/nf-werapi-werregisterfile
  • Microsoft Learn: _set_invalid_parameter_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-invalid-parameter-handler-set-thread-local-invalid-parameter-handler
  • Microsoft Learn: _set_purecall_handler https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-purecall-handler-set-purecall-handler
  • Microsoft Learn: set_terminate https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/set-terminate-crt
  • Microsoft Learn: __fastfail https://learn.microsoft.com/en-us/cpp/intrinsics/fastfail
  • Microsoft Learn: FileStream.Flush(Boolean) https://learn.microsoft.com/en-us/dotnet/api/system.io.filestream.flush
  • Microsoft Learn: !analyze (WinDbg) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-analyze
  • Microsoft Learn: .ecxr (Display Exception Context Record) https://learn.microsoft.com/en-us/windows-hardware/drivers/debuggercmds/-ecxr–display-exception-context-record-
  • Microsoft Learn: Symbol path for Windows debuggers https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/symbol-path

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Расследование ошибок и причин

Сбои, которые проявляются только в среде заказчика, аварийные завершения с низкой воспроизводимостью и разбор причины по дампу вместе с логами хорошо стыкуются с расследованием дефектов и анализом первопричины.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Можно ли гарантированно сохранить лог силами самого падающего приложения?
Нет. Если учитывать порчу стека, порчу памяти, fast fail, принудительное завершение и отключение питания, последняя in-process запись по природе — best effort. На практике работу делят на три слоя: хронологический лог штатной работы, маркер сбоя в момент падения и следы, которые оставляет ОС или отдельный процесс, — и не полагаются только на код внутри падающего процесса. Самое надёжное на практике сочетание — штатный лог + маркер сбоя + WER LocalDumps.
Чего нельзя делать внутри обработчика сбоя?
Тяжёлой работы в целом. Резолв logger через DI-контейнер, async/await, ожидание блокировок, сборка сложного JSON, работа с COM-объектами, диалоги UI, сжатие, отправка по HTTP/SMTP/Slack — всё это с высокой вероятностью ломает обработчик. Делать нужно только четыре вещи: предотвратить повторный вход, записать одну строку, сделать flush и завершиться. Сжатие, загрузку и уведомления переносят на следующий запуск или на отдельный процесс.
Как настроить WER LocalDumps?
В реестре, в разделе HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\имя_приложения.exe, задают DumpFolder (выделенная папка), DumpCount (примерно 5–10) и DumpType (на машине разработки — 2, в полевых условиях — 1 или 2 в зависимости от объёма диска и требований к конфиденциальности). Главное — проверить ACL каталога: типичная ошибка — указать папку, в которую не может писать служба Windows или ограниченная учётная запись, и дамп просто не появляется. Сбор дампов и хранение PDB вместе с распространяемыми двоичными файлами — парная задача: без любого из них дамп потом не прочитать.
Можно ли перехватить исключение в DispatcherUnhandledException в WPF и продолжить работу?
Для неожиданных исключений, вызванных ошибкой в программе, это опасно. Handled=true позволяет формально продолжить, но легко получается состояние-зомби: экран ещё есть, а worker уже мёртв; кнопка остаётся активной, но неизвестно, сохранились ли данные. Событие необработанного исключения стоит использовать не как средство восстановления, а как точку входа для записи: записав маркер сбоя, процесс завершают, а не продолжают, а основные следы оставляют в дампе WER и в штатном логе вплоть до момента сбоя. Для завершения стоит рассмотреть API немедленного завершения вроде Environment.FailFast.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог