Как проектировать логи и дампы при сбое 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.
flowchart TB
accTitle: Последняя in-process запись — это best effort
accDescr: Рисунок показывает, что если учитывать порчу стека, порчу памяти, fast fail, принудительное завершение и отключение питания, падающий процесс сам по себе не может гарантированно сохранить лог, и последняя in-process запись по природе является best effort.
a1["Порча стека и памяти"] --> a4["Последняя in-process запись"]
a2["fast fail и принудительное завершение"] --> a4
a3["Отключение питания"] --> a4
a4 --> a5["По природе best effort"]
a5 -.-> a6["«Обязательно сохранится» — нельзя"]
Рис. 1: Одним падающим процессом последнюю запись «обязательно» не сохранить.
На практике цель — схема, которая не полагается только на падающий процесс. То есть думают тремя слоями:
- хронологический лог штатной работы;
- маркер сбоя в момент падения;
- следы сбоя, которые оставляет ОС или отдельный процесс.
Статья рассчитана на настольные Windows-приложения, постоянно работающие приложения, службы Windows и инструменты интеграции с оборудованием. В ней собраны практические приёмы, которые не дают потерять возможность расследования даже когда приложение падает из-за исключения, вызванного ошибкой в программе.
1. Сначала выводы
Сначала только выводы.
- Главное — не рассчитывать, что «последнюю запись» даст один in-process обработчик.
- На практике самое надёжное сочетание — штатный лог + маркер сбоя + WER LocalDumps.
- При долгой непрерывной работе, интеграции с оборудованием, плагинах и смеси native SDK добавление процесса-наблюдателя (watchdog / launcher / service) заметно усиливает схему.
- В обработчике сбоя железное правило — не делать ничего тяжёлого. Сжатие, отправку по HTTP, резолв через DI, диалоги UI, сборку сложного JSON убирают.
- В момент сбоя пишут коротко и только локально, а сжатие, загрузку и уведомления переносят на следующий запуск или на отдельный процесс.
- Использовать
ThreadExceptionв WinForms илиDispatcherUnhandledExceptionв WPF, чтобы формально продлить жизнь процессу, опасно, если причина — ошибка в программе. - И в .NET, и в native для исключений, которые заставляют подозревать повреждённое состояние, безопаснее опираться на «записать и завершить», а не на восстановление.
- Если снимаете дампы, PDB и распространяемые двоичные файлы нужно хранить одновременно — иначе дамп потом не прочитать.
Иными словами, лучшая практика такая: не пытаться сделать всё в момент падения. Роли делят между временем до падения, моментом падения и временем после.
flowchart TB
accTitle: Разделение ролей до, в момент и после падения
accDescr: Рисунок показывает лучшую практику: не делать всё в момент падения, а разделить роли — до падения штатный лог, в момент падения короткая локальная запись, после падения сжатие, загрузка и уведомление.
b1["До падения: штатный лог"] --> b2["В момент падения: коротко и только локально"]
b2 --> b3["После: сжатие, загрузка, уведомление"]
b3 -.-> b4["На следующем запуске или в отдельном процессе"]
Рис. 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, и число перезапусков.
flowchart LR
accTitle: Карта знаний проектирования журналов и дампов при сбое Windows-приложения
accDescr: Схема, которая показывает, что проект, который не рассчитывает на следы только внутри падающего процесса, держится на разделении ролей трёх слоёв — обычный журнал, финальный маркер сбоя и WER LocalDumps — на выборе обработчиков исключений вроде AppDomain.UnhandledException и FailFast и на внешнем обнаружении сторожевым процессом
crash_time_logging_design["проектирование логов и следов при сбое"]
wer_localdumps["WER LocalDumps"]
application_log["обычный журнал (хронологический лог)"]
fatal_crash_marker["маркер фатального сбоя"]
seh["SEH (структурированная обработка исключений)"]
crt_termination_handler["пути завершения CRT и C++ runtime"]
watchdog_process["процесс-watchdog"]
high_reliability_operation_requirement["повышенные требования 24/7 и управления оборудованием"]
post_restart_processing["постобработка после следующего запуска"]
session_id_correlation["сопоставление следов по session ID"]
dotnet_unhandledexception["AppDomain.UnhandledException"]
winforms_threadexception["Application.ThreadException (WinForms)"]
unexpected_exception_continuation["продолжение работы после непредвиденного исключения"]
wpf_dispatcherunhandledexception["Application.DispatcherUnhandledException (WPF)"]
unobserved_task_exception["TaskScheduler.UnobservedTaskException"]
environment_failfast["Environment.FailFast"]
windows_application_event_log["журнал приложений Windows"]
dump_folder_acl["ACL папки дампа"]
crash_dump["аварийный дамп"]
pdb["PDB (Program Database)"]
windbg["WinDbg"]
minidumpwritedump["MiniDumpWriteDump"]
wer_file_registration["регистрация вложения лога через WerRegisterFile"]
crash_time_logging_design -->|"требует"| application_log
crash_time_logging_design -->|"требует"| fatal_crash_marker
crash_time_logging_design -->|"требует"| wer_localdumps
crash_time_logging_design -.->|"требует"| seh
crash_time_logging_design -.->|"требует"| crt_termination_handler
watchdog_process -->|"рекомендуется для"| high_reliability_operation_requirement
fatal_crash_marker -->|"должен предшествовать"| post_restart_processing
fatal_crash_marker -.->|"требует"| session_id_correlation
fatal_crash_marker -->|"рекомендуется для"| dotnet_unhandledexception
winforms_threadexception -->|"не рекомендуется"| unexpected_exception_continuation
wpf_dispatcherunhandledexception -->|"не рекомендуется"| unexpected_exception_continuation
unobserved_task_exception -->|"не рекомендуется"| fatal_crash_marker
environment_failfast -->|"использует"| windows_application_event_log
fatal_crash_marker -->|"рекомендуется для"| seh
fatal_crash_marker -->|"рекомендуется для"| crt_termination_handler
wer_localdumps -.->|"требует"| dump_folder_acl
crash_dump -.->|"требует"| pdb
crash_dump -->|"проверяется"| windbg
minidumpwritedump -->|"реализует"| crash_dump
watchdog_process -.->|"использует"| minidumpwritedump
wer_file_registration -->|"использует"| application_log
wer_localdumps -->|"должен предшествовать"| wer_file_registration
wer_localdumps -->|"реализует"| crash_dump
fatal_crash_marker -->|"рекомендуется для"| winforms_threadexception
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 24, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle
2. Почему одного in-process недостаточно, чтобы было «наверняка»
Если этот пункт оставить размытым, проектирование начинает шататься.
2.1 Контекст упавшего потока сам по себе уже может быть сломан
Хуки необработанных исключений и фильтры исключений верхнего уровня иногда работают в контексте уже сломанного потока. На этом этапе обычны такие ситуации:
- стек уже небезопасен;
- из-за порчи кучи дополнительное выделение памяти опасно;
- ожидание на блокировке, которую держали в момент исключения, останавливает выполнение;
- объекты, от которых зависит сам logger, уже сломаны.
Поэтому безопаснее смотреть на финальный обработчик не как на «место, где можно всё», а как на «место, где можно очень мало».
flowchart TB
accTitle: В финальном обработчике можно очень мало
accDescr: Рисунок показывает, что хук необработанного исключения может работать в контексте сломанного потока, стек и куча уже опасны, ожидание блокировки может остановить выполнение, зависимости logger тоже могут быть сломаны, поэтому это место, где можно очень мало.
c1["Работает в контексте сломанного потока"] --> c2["Стек и куча уже опасны"]
c1 --> c3["Ожидание блокировки может остановить"]
c1 --> c4["Зависимости logger тоже могут быть сломаны"]
c2 --> c5["Место, где можно очень мало"]
c3 --> c5
c4 --> c5
Рис. 3: Финальный обработчик — не «место, где можно всё», а место сплошных ограничений.
2.2 Fast fail и исключения повреждённого состояния рассчитаны на «минимум in-process работы»
При порче памяти или фатальном состоянии на обычную обработку исключений лучше не рассчитывать.
В особенности семейство native __fastfail и аномалии, из-за которых подозревают повреждённое состояние, спроектированы так, чтобы «завершаться сразу, с как можно меньшими накладными расходами».
То есть естественная позиция такая: последняя in-process запись — удача, если она вообще сохранилась; основные следы должны жить на стороне ОС / отдельного процесса.
2.3 Событие необработанного исключения .NET тоже не место для «тяжёлого восстановления»
AppDomain.UnhandledException в .NET удобен, но
здесь допустима только короткая запись.
- Может сказаться блокировка, которую держали в момент исключения
- Не всё, включая исключения повреждённого состояния, можно безопасно поймать
- Если здесь насильно строить политику продолжения, легко продлить жизнь наполовину сломанному процессу
Реалистично считать, что «событие необработанного исключения = последнее уведомление», а не «безопасная точка восстановления».
flowchart TB
accTitle: Событие необработанного исключения — последнее уведомление
accDescr: Рисунок показывает, что в AppDomain.UnhandledException допустима только короткая запись, исключения повреждённого состояния безопасно поймать нельзя, и событие необработанного исключения — последнее уведомление, а не безопасная точка восстановления.
d1["Событие необработанного исключения"] --> d2["Допустима только короткая запись"]
d2 --> d3["Используют как последнее уведомление"]
d1 -.-> d4["Это не безопасная точка восстановления"]
d4 -.-> d5["Насильная политика продолжения продлевает жизнь полусломанному процессу"]
Рис. 4: Событие необработанного исключения — точка входа для записи, а не место восстановления.
3. Рекомендуемая архитектура — разделять crash-time и after-restart
Проще всего разделить то, что делают в момент сбоя, и то, что делают после перезапуска.
Сначала на одном рисунке: три слоя, кто за какой процесс отвечает и куда это падает.
flowchart TD
subgraph APP["процесс приложения"]
L1["Штатный лог<br/>хронология append-only"]
L2["Маркер сбоя<br/>одна строка и выход"]
end
subgraph WIN["сторона Windows"]
WER["WER LocalDumps<br/>дамп вне процесса"]
end
subgraph WD["процесс watchdog"]
EX["Код выхода и время завершения<br/>решение о перезапуске"]
end
subgraph NEXT["здоровый процесс следующего запуска"]
POST["Сжатие / загрузка / уведомление<br/>обнаружение прошлого аварийного завершения"]
end
DISK[("фиксированная локальная папка")]
L1 --> DISK
L2 --> DISK
WER --> DISK
EX --> DISK
DISK --> POST
L1 -. исключение .-> L2
L2 -. завершение процесса .-> WER
L2 -. завершение процесса .-> EX
Рис. 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: сбор диагностической информации.
Так схема становится гораздо ближе к реальной эксплуатации.
flowchart TB
accTitle: Разделение ролей в усиленной схеме
accDescr: Рисунок показывает, что при круглосуточной работе или управлении оборудованием основную работу отдаёт worker-процесс, запуск, запись выхода и перезапуск — launcher или watchdog, WER LocalDumps настраивают на стороне worker, а диагностику собирает следующий запуск или watchdog.
e0["Более жёсткие требования (24/7, управление оборудованием)"] --> e1["worker: основная работа"]
e0 --> e2["watchdog: запуск, запись выхода, перезапуск"]
e1 -.-> e3["WER LocalDumps настраивают на стороне worker"]
e2 -.-> e4["Следующий запуск или 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
Длинный текст для человека менее важен, чем то, что потом можно сопоставить три файла.
flowchart TB
accTitle: Одна строка связывает три вида следов
accDescr: Рисунок показывает, что одна строка штатного лога связывается с дампом через pid и session, со сборкой — через ver и commit, и возможность потом сопоставить три файла важнее длинного текста для человека.
f1["Одна строка штатного лога"] --> f2["Через pid / session к дампу"]
f1 --> f3["Через ver / commit к сборке"]
f2 --> f4["Три файла можно сопоставить"]
f3 --> f4
Рис. 7: Строка лога связывается с другими следами через pid, session, ver и commit.
4.2 Критические события пишут синхронно
Если все записи штатного лога делать синхронными, это утяжеляет работу. Но если всё отдать асинхронному буферу, в момент падения пропадает сразу всё.
Поэтому на практике обращение меняют в зависимости от уровня.
- Мелкие события уровня
Information: буферизовать можно Warningи выше: flush делать раньше- Важные граничные события: писать синхронно
- ProcessStart
- ConfigLoaded
- WorkerStarted
- ExternalCommandSent
- TransactionCommitted
- RecoveryStarted
- FatalPathEntered
Смысл в том, что хотя бы границы, значимые для бизнеса, должны надёжно доходить до диска.
flowchart TB
accTitle: Запись в зависимости от уровня
accDescr: Рисунок показывает разделение: мелкие события Information можно буферизовать, Warning и выше flush делают раньше, граничные события бизнес-логики пишут синхронно.
g0["Запись лога"] --> g1["Information: буфер допустим"]
g0 --> g2["Warning и выше: flush раньше"]
g0 --> g3["Граничные события: писать синхронно"]
g3 -.-> g4["Границы бизнес-логики доводят до диска"]
Рис. 8: Не всё синхронно и не всё в буфер — запись выбирают по уровню.
4.3 «Штатный лог, который пишут сейчас» и «маркер сбоя» разделяют
Это очень важно.
Если пытаться уместить всё в один rolling log, случается следующее:
- шла ротация;
- запись оставалась в асинхронной очереди;
- сразу после исключения умер сам logger;
- строка лога оборвалась на середине.
Поэтому лучше разделить хотя бы на два файла.
app-<session>.jsonlхронологический лог штатной работыfatal-last.logилиfatal-<session>.logтолько для маркера сбоя
Уже одна ясность, куда кладут последнюю строку, сильно помогает в полевых условиях.
flowchart TB
accTitle: Штатный лог и fatal-маркер разделяют
accDescr: Рисунок показывает, что если всё класть в один rolling log, последняя строка пропадает из-за ротации, остатка в асинхронной очереди или смерти logger, поэтому хронологический лог штатной работы и файл только для маркера сбоя разделяют.
h1["Всё в один rolling log"] --> h2["Пропадает из-за ротации, очереди, смерти logger"]
h2 -.->|"вместо этого"| h3["Два файла: штатный лог и fatal-маркер"]
h3 --> h4["Место последней строки становится ясным"]
Рис. 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.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledExceptionSetUnhandledExceptionFilter_set_invalid_parameter_handlerset_terminate
- Тип исключения или код исключения
- По возможности — короткое сообщение
- Идентификатор последней операции
- Имя файла штатного лога
- Предполагаемая папка дампа
Этого достаточно.
flowchart TB
accTitle: Маркер фиксирует точку входа
accDescr: Рисунок показывает, что маркер сбоя хранит не подробности причины, а то, из какого хука и с каким исключением упали, имя штатного лога и предполагаемую папку дампа — чтобы зафиксировать точку входа в расследование.
i1["Маркер сбоя"] --> i2["Хук, тип исключения, session"]
i1 --> i3["Имя штатного лога и папка дампа"]
i2 --> i4["Точка входа в расследование фиксируется"]
i3 --> i4
i4 -.-> i5["Подробности причины оставляют дампу и штатному логу"]
Рис. 10: Роль маркера — не подробности причины, а фиксация точки входа в расследование.
5.2 Чего нельзя делать в обработчике сбоя
Любой пункт из списка с высокой вероятностью становится миной.
- Резолвить logger через DI-контейнер
- Использовать async / await
- Запускать Task
- Ждать блокировку
- Собирать сложный JSON
- Трогать COM-объекты
- Показывать диалоги UI
- Сжимать
- Отправлять по HTTP / SMTP / Slack / Teams
- Разбирать дамп и делать сводку
- Глотать исключение и продолжать
Обработчик сбоя — не продолжение обычного потока обработки. Его сводят к «сделать минимальную локальную запись и закончить».
5.3 Что делать в обработчике сбоя
Наоборот, действия здесь довольно простые.
- Предотвратить повторный вход
- Записать только одну строку
- Сделать flush
- Завершиться
Именно в таком порядке.
По возможности используют:
- заранее созданную выделенную папку;
- путь, существование которого уже проверили;
- место хранения с уже проверенным ACL.
В штатном логе чрезмерный flush утяжеляет работу, но у fatal-маркера записей крайне мало, поэтому здесь можно flush усилить.
В .NET это FileStream.Flush(true), в native — FlushFileBuffers; проектировать проще, если к этой единственной строке относиться как к правилу «именно эту строку нужно прямо сейчас довести до диска».
flowchart TB
accTitle: Четыре шага обработчика сбоя
accDescr: Рисунок показывает, что в обработчике сбоя делают только четыре вещи: предотвращают повторный вход, пишут одну строку, делают flush и завершаются, а поскольку записей fatal-маркера крайне мало, усиленный flush до диска здесь допустим.
j1["1. Предотвратить повторный вход"] --> j2["2. Записать одну строку"]
j2 --> j3["3. Сделать flush"]
j3 --> j4["4. Завершиться"]
j3 -.-> j5["Именно эту строку доводят до диска сразу"]
Рис. 11: В обработчике четыре шага, и этот порядок не ломают.
5.4 Не пытаться продолжать
Если неожиданное исключение выросло из ошибки в программе, финальный обработчик безопаснее считать не средством восстановления, а средством записи.
Особенно «не продолжать» стоит взять за основу в таких случаях:
- даже
NullReferenceExceptionилиInvalidOperationException, но посреди обновления общего состояния; - неожиданное исключение в потоке UI;
- неожиданное исключение, которое вытекло из цикла мониторинга или родительского цикла;
AccessViolationException;StackOverflowException;- аномалия на native-границе;
- invalid parameter / purecall / terminate в CRT.
Желание не ронять приложение понятно, но выживание в наполовину сломанном состоянии чаще тяжелее и для диагностики, и для эксплуатации.
При завершении стоит рассмотреть API немедленного завершения — в .NET Environment.FailFast, в native RaiseFailFastException или __fastfail — и проектировать так, чтобы не рассчитывать на finally и обычную уборку.
flowchart TB
accTitle: Не средство восстановления, а средство записи
accDescr: Рисунок показывает, что при неожиданном исключении из ошибки в программе финальный обработчик лучше считать средством записи, а не восстановления, и безопаснее записать и завершить API немедленного завершения, чем выживать в наполовину сломанном состоянии.
k1["Исключение из ошибки в программе"] --> k2["Выжить наполовину сломанным"]
k1 --> k3["Записать и завершить"]
k2 -.-> k4["И диагностика, и эксплуатация тяжелее"]
k3 --> k5["Рассмотреть 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.
Interlocked.Exchangeпредотвращает повторный вход- Конкатенация строк пишет одну строку
Flush(true)доводит запись до диска- Из
UnhandledExceptionне продолжают работу
Environment.FailFast пишет сообщение в журнал событий приложений Windows, сразу завершает процесс и включает это содержимое в отчёт об ошибке. То есть сам FailFast добавляет ещё один след. Но, как сказано выше, если вызвать его на пути необработанного исключения, вид дампа меняется — место вызова нужно выбирать.
flowchart TB
accTitle: Место вызова FailFast выбирают
accDescr: Рисунок показывает, что Environment.FailFast пишет в журнал событий и сразу завершает процесс, добавляя ещё один след, но вызов на пути необработанного исключения подменяет причину дампа в WER с исходного исключения на FailFast, поэтому место вызова выбирают.
l1["Environment.FailFast"] --> l2["Пишет в журнал событий и сразу завершает"]
l2 --> l3["Появляется ещё один след"]
l1 -.-> l4["Если вызвать на пути необработанного исключения"]
l4 -.-> l5["Причиной дампа становится 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 безопаснее не использовать это как средство продлить жизнь ради продолжения, а как точку входа для записи.
flowchart TB
accTitle: События UI не используют, чтобы продлить жизнь
accDescr: Рисунок показывает, что ThreadException в WinForms и DispatcherUnhandledException в WPF позволяют формально продолжить, но при ошибке в программе состояние экрана и внутреннее состояние расходятся, поэтому их используют как точку входа для записи, затем завершают процесс и оставляют дамп и логи.
m1["Необработанное исключение потока UI"] --> m2["Формально можно продолжить"]
m2 -.-> m3["Экран и внутреннее состояние расходятся"]
m2 --> m4["Используют как точку входа для записи"]
m4 --> m5["Завершают и оставляют дамп и логи"]
Рис. 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.
flowchart TB
accTitle: Одного SEH недостаточно
accDescr: Рисунок показывает, что в native C++ одного SetUnhandledExceptionFilter мало: теряются пути завершения CRT и C++ runtime, поэтому invalid parameter, purecall и terminate тоже ловят, оставляют минимальную запись и надёжно завершаются, а основные следы оставляют WER и dump.
n1["Только SetUnhandledExceptionFilter"] --> n2["Пути завершения CRT теряются"]
n2 --> n3["Ловят и invalid parameter / purecall / terminate"]
n3 --> n4["Минимальная запись и надёжное завершение"]
n4 -.-> n5["Основные следы оставляют WER / dump"]
Рис. 15: В native C++ дыры закрываются, только когда к SEH добавляют пути завершения CRT.
7. В основу кладут WER LocalDumps
На практике этот слой очень силён.
7.1 Первая рекомендация — WER LocalDumps
В смысле «после падения оставить минимальные следы как можно надёжнее» проще всего начать с WER LocalDumps.
Причины простые.
- Дамп может сохранить сама ОС
- Легко включить без дополнительных инструментов
- Настраивается для каждого приложения отдельно
- Основные следы сбоя можно вынести из in-process
То, чего не покажет один лог:
- какой поток упал;
- на каком стеке произошло падение;
- на какой границе модуля это случилось;
- где искать — в managed, native, COM или SDK.
Возможность увидеть это потом — сильная сторона подхода.
flowchart TB
accTitle: Почему силён WER LocalDumps
accDescr: Рисунок показывает, что WER LocalDumps может сохранить дамп на стороне ОС, настраивается на приложение, выносит основные следы из in-process и потом позволяет увидеть, какой поток на каком стеке упал и на какой границе модуля.
p1["WER LocalDumps"] --> p2["Дамп сохраняет ОС"]
p1 --> p3["Настройка на приложение"]
p2 --> p4["Основные следы выносят из in-process"]
p3 --> p4
p4 -.-> p5["Видны поток, стек, граница модуля"]
Рис. 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 места хранения — главная причина того, что дамп не появляется.
Место хранения проверяют целиком:
- создано заранее;
- запись протестирована;
- число хранимых копий ограничено;
- эксплуатационный персонал может туда зайти.
flowchart TB
accTitle: Как не получить пустой дамп из-за ACL
accDescr: Рисунок показывает типичную ошибку служб Windows и ограниченных учётных записей — папка дампа, в которую нельзя писать, из-за чего дамп не появляется, поэтому папку создают заранее, проверяют запись, ограничивают число копий и убеждаются, что туда может зайти эксплуатация.
q1["Служба или ограниченная учётная запись"] --> q2["Указывают папку, в которую нельзя писать"]
q2 --> q3["Дамп не появляется"]
q3 -.->|"как избежать"| q4["Создать заранее и проверить запись"]
q4 --> q5["Проверить число копий и доступ для эксплуатации"]
Рис. 17: Главная причина пустого дампа — ACL места хранения; её ловят предварительной проверкой записи.
7.4 Если текущий лог нужно приложить к отчёту WER
Если используются отчёты WER в адрес Microsoft или собственный конвейер WER, есть и способ через WerRegisterFile зарегистрировать текущий файл лога, чтобы включить его в отчёт об ошибке.
Но это лучше считать дополнительным каналом, а не заменой локального хранения. В момент сбоя в первую очередь нужно, чтобы на самом устройстве запись осталась как можно надёжнее.
Практичнее такой порядок:
- локальный штатный лог;
- локальный fatal-маркер;
- локальный dump;
- при необходимости — ещё и регистрация связанных файлов на пути отправки WER.
7.5 Хранят не только дамп, но и сведения о версии
Даже имея дамп, позже оказывается, что:
- EXE / DLL той сборки уже нет;
- PDB нет;
- неизвестно, из какого коммита собрали,
и позиция становится слабой.
Как минимум оставляют:
- распространяемые двоичные файлы;
- соответствующие PDB;
- версию;
- дату и время сборки;
- идентификатор коммита;
- версию установщика.
Сбор дампов и хранение PDB — парная задача.
flowchart TB
accTitle: Сбор дампов и хранение PDB — пара
accDescr: Рисунок показывает, что одного дампа мало: без EXE или DLL той сборки, PDB и сведений о коммите дамп потом не прочитать, поэтому вместе с дампами хранят распространяемые двоичные файлы, PDB, версию и идентификатор коммита.
r1["Оставляют только дамп"] --> r2["Нет PDB и двоичных файлов той сборки"]
r2 --> r3["Потом не прочитать"]
r3 -.->|"поэтому"| r4["Хранят распространяемые двоичные файлы и PDB"]
r4 --> r5["Оставляют также версию и идентификатор коммита"]
Рис. 18: Дамп читается, только если остались PDB и двоичные файлы той же сборки.
7.6 До того момента, когда снятый дамп впервые открывают
«Дампы копятся, но их никто не открывал» встречается очень часто. Глубокий разбор оставляем профильной статье, а здесь — только первые десять минут.
Подготовка из двух шагов.
- Собрать в одну папку этот dump и PDB с распространяемыми двоичными файлами той же сборки
- Подготовить 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.
flowchart TB
accTitle: Первые десять минут с дампом
accDescr: Рисунок показывает первый порядок работы: собрать PDB и двоичные файлы той же сборки, открыть дамп в WinDbg, перейти в контекст исключения и читать разбор; если имён функций нет, PDB от другой сборки и нужно вернуться к учёту версий.
s1["Собрать PDB и двоичные файлы той же сборки"] --> s2["Открыть дамп в WinDbg"]
s2 --> s3["Перейти в контекст исключения и разобрать"]
s3 -.-> s4["Без .ecxr смотрят стек ожидания WER"]
s3 --> s5{"Появляются имена функций и номера строк?"}
s5 -->|"нет"| s6["PDB от другой сборки, вернуться к учёту версий"]
Рис. 19: Разбор дампа начинают с подготовки, открытия и перехода в контекст исключения.
Более глубокий сбор и разбор собраны в связанных статьях в конце.
8. Как относиться к MiniDumpWriteDump и собственным crash-репортёрам
Собственная реализация иногда нужна.
- Хочется кнопку «Сохранить диагностическую информацию» в UI
- Хочется собрать вместе ещё логи и файлы конфигурации
- Хочется обрабатывать группу дочерних процессов вместе
- Хочется добавить собственное маскирование перед автоматической загрузкой
Но самое важное здесь — не взваливать снятие dump ещё и на падающую сторону.
8.1 Отдельный процесс лучше self-dump
MiniDumpWriteDump — сильный инструмент, но
вызывать его из отдельного процесса безопаснее, чем из самого упавшего.
Типичная схема выглядит так.
- worker обнаруживает аномалию;
- по возможности уведомляет helper через событие или именованный канал (named pipe);
- helper снимает dump worker;
- helper собирает
tailлогов и файлы конфигурации; - helper после завершения кладёт всё в очередь загрузки.
Тогда даже если worker сломан, сторона helper ещё здорова.
sequenceDiagram
accTitle: Снятие дампа отдельным процессом helper
accDescr: Рисунок показывает поток: worker обнаруживает аномалию и уведомляет helper событием или именованным каналом, здоровый helper снимает дамп worker, собирает логи и файлы конфигурации и после завершения кладёт всё в очередь загрузки.
participant W as worker
participant H as helper
W->>H: Уведомление об аномалии событием или каналом
H->>W: Снять dump worker
H->>H: Собрать tail логов и файлы конфигурации
H->>H: После завершения положить в очередь загрузки
Note over H: Даже если worker сломан, helper здоров
Рис. 20: Снятие dump отдают не падающей стороне, а здоровому процессу helper.
8.2 Если без in-process никак, это выносят на отдельный поток
Даже когда отдельный процесс невозможен, лучше, если под снятие dump выделяют отдельный поток.
Но и тогда суть остаётся best effort. Из «сделали собственную реализацию dump» не следует «теперь на 100% безопасно».
8.3 Тяжёлое переносят на следующий запуск
В собственном репортёре часто хочется сделать:
- сжатие в zip;
- сопоставление с символьной информацией;
- загрузку на сервер;
- снимок экрана;
- получение дополнительной информации из БД.
Всё это переносят не на момент сбоя, а на время после перезапуска или на сторону helper.
9. Что меняется, если добавить процесс-наблюдатель
Для систем с долгой непрерывной работой процесс-наблюдатель окупается очень сильно.
9.1 Что записывает процесс-наблюдатель
Со стороны watchdog / launcher / родительской службы можно оставить такие сведения:
- время запуска дочернего процесса;
- аргументы запуска;
- PID;
- версию наблюдаемого объекта;
- время последнего полученного heartbeat;
- время завершения;
- код выхода;
- число перезапусков;
- наличие dump;
- был ли выполнен перезапуск.
Уже этого достаточно, чтобы довольно ясно увидеть:
- действительно ли произошёл сбой;
- было ли это выключением ОС;
- закрыл ли приложение сам пользователь;
- было ли зависание с последующим принудительным завершением;
- сколько раз цикл ушёл в перезапуск.
flowchart TB
accTitle: Что позволяют различить записи watchdog
accDescr: Рисунок показывает, что если процесс-наблюдатель оставляет код выхода, время завершения, последний heartbeat и число перезапусков, снаружи можно отличить настоящий сбой, выключение ОС, закрытие пользователем, kill после зависания и цикл перезапусков.
t1["Код выхода и время завершения"] --> t4["Можно различить снаружи"]
t2["Последний heartbeat"] --> t4
t3["Число перезапусков"] --> t4
t4 -.-> t5["Сбой, выключение или закрытие пользователем"]
Рис. 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;
- номера сборки не совпадают.
В результате три вида следов выглядят как три отдельные истории.
flowchart TB
accTitle: Следы не связываются
accDescr: Рисунок показывает, что если в имени дампа нет session, в логе нет PID или session, а номера сборки не совпадают, дамп, лог и записи watchdog выглядят как три разных происшествия.
u1["В имени дампа нет session"] --> u4["Три вида следов выглядят как разные истории"]
u2["В логе нет PID / session"] --> u4
u3["Номера сборки не совпадают"] --> u4
Рис. 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
- По умолчанию выбирать запись и завершение, а не продолжение
В конечном счёте схема, по которой причину можно восстановить даже без последней строки, сильнее попытки любой ценой вытянуть эту последнюю строку.
flowchart TB
accTitle: Схема, которая не держится на последней строке
accDescr: Рисунок показывает, что схема, по которой причину можно восстановить даже без последней строки, сильнее попытки вытянуть эту строку, при этом маркер сбоя всё равно коротко кладут в отдельный файл, а основные следы оставляют дампу WER и штатному логу вплоть до момента сбоя.
v0["Целевая форма"] --> v2["Схема, по которой можно идти даже без последней строки"]
v2 --> v3["Маркер коротко кладут в отдельный файл"]
v2 --> v4["Основные следы — дамп и штатный лог"]
Рис. 23: Сильнее не вытягивать последнюю строку, а сделать схему, которая работает и без неё.
И всё же последняя строка нужна, поэтому маркер сбоя коротко кладут в отдельный файл. А настоящие основные следы доверяют дампу WER и штатному логу вплоть до момента, предшествующего сбою. В практике Windows-приложений это довольно устойчивый способ работы.
Похожие статьи
- Сбор дампов сбоев Windows — введение: WER, ProcDump, WinDbg
- Таблица решений: завершать приложение или продолжать после неожиданного исключения
Справочные материалы
- 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
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Сбор дампов сбоев Windows — введение: WER, ProcDump, WinDbg
Чтобы расследовать трудно воспроизводимые сбои Windows-приложений, разбираем, как выбирать между WER LocalDumps, ProcDump, MiniDumpWriteD...
Как читать «использование памяти» в Windows: Working Set, Private Bytes, Commit и файл подкачки
Столбец «Память» в диспетчере задач, Working Set, Private Bytes и Commit — это разные величины. Разбираем связь виртуальной и физической ...
Инцидент не закрывается восстановлением — постмортем для небольшой команды
Если закрыть инцидент после исправления и извинений, тот же сбой повторится. Адаптируем blameless-постмортем для небольшой команды: шабло...
Спящий режим, гибернация и Modern Standby: как не дать долгоживущему приложению остановиться ночью
Разбираем, почему долгоживущее Windows-приложение к утру оказывается остановленным: чем отличаются спящий режим S3, гибернация и Modern S...
Сетевые диски и UNC-пути: типичные ловушки ── как бизнес-приложению работать с файловым сервером (общей папкой)
Разбираем типичные сбои, когда бизнес-приложение пишет в общую папку или следит за ней. Почему службе не видна буква диска (Z:), какие пр...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Расследование ошибок и причин
Сбои, которые проявляются только в среде заказчика, аварийные завершения с низкой воспроизводимостью и разбор причины по дампу вместе с логами хорошо стыкуются с расследованием дефектов и анализом первопричины.
Разработка приложений для Windows
Как в WPF, WinForms, постоянно работающих приложениях и службах Windows спроектировать штатный лог, WER и watchdog, напрямую связано с разработкой Windows-приложений.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли гарантированно сохранить лог силами самого падающего приложения?
- Нет. Если учитывать порчу стека, порчу памяти, 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.