תכנון שמירת יומנים ו-dump בקריסת יישום Windows

· עודכן בתאריך: · · פיתוח Windows, טיפול בחריגות, יומנים, WER, dump של קריסה, חקירת תקלות

בחקירת תקלות ביישומי Windows, הכי קשה זה המצב שבו רק יודעים שהיישום קרס, אבל אין שום עדות למה.

הבעיה הזו מחמירה במיוחד בפרויקטים כמו:

  • קורס רק בסביבת הלקוח
  • קורס רק אחרי הפעלה ארוכת טווח
  • שיעור שחזור נמוך ב-WPF‏/‏WinForms‏/‏שירות Windows‏/‏יישום שרץ ברקע
  • מעורבים COM,‏ P/Invoke,‏ DLL נייטיבי, SDK של ספק
  • יש “רק את הודעת החריגה” אבל אין הקשר לפני כן

עם זאת, בכנות מראש: אי אפשר להבטיח “בוודאות” שמירת יומן רק על ידי התהליך שקורס. כשלוקחים בחשבון קריסת stack, השחתת זיכרון, fast fail, סיום כפוי וניתוק חשמל, היומן האחרון בתוך התהליך (in-process) הוא במהותו best effort.

היומן האחרון בתוך התהליך הוא best effortתרשים המראה שכשלוקחים בחשבון קריסת stack, השחתת זיכרון, fast fail, סיום כפוי וניתוק חשמל, לא ניתן להבטיח בוודאות שמירת יומן רק על ידי התהליך הקורס, והיומן האחרון בתוך התהליך הוא במהותו best effort.קריסת stack · השחתת זיכרוןהיומן האחרון בתהליךfast fail · סיום כפויניתוק חשמלבמהותו best effortאי אפשר להבטיח 'בוודאות'

איור 1: התהליך הקורס בלבד לא יכול להבטיח “בוודאות” את שמירת היומן האחרון.

בעבודה בפועל, המטרה היא תצורה שלא מסתמכת רק על התהליך הקורס. כלומר, חושבים בשלוש שכבות:

  1. יומן כרונולוגי בזמן רגיל
  2. סמן הקריסה הסופי ברגע הנפילה
  3. ראיית קריסה שנשמרת בצד מערכת ההפעלה או בתהליך נפרד

במאמר הזה, על בסיס הנחת יסוד של יישומי שולחן עבודה ל-Windows, יישומים שרצים ברקע, שירותי Windows וכלי חיבור לציוד, נסדר שיטות עבודה מומלצות לשמירה על יכולת החקירה גם כשהיישום קורס מחריגה שמקורה בבאג.

1. המסקנה קודם

קודם רק המסקנות, בשורה.

  • הכי חשוב: לא להמר על “היומן האחרון” רק ב-handler אחד בתוך התהליך.
  • הכי בטוח בעבודה בפועל: שילוב יומן רגיל + סמן קריסה סופי + WER LocalDumps.
  • בהפעלה ארוכת טווח, חיבור לציוד, plugins, או ערבוב SDK נייטיבי, הוספת תהליך ניטור (watchdog/launcher/service) מחזקת מאוד.
  • ב-handler של קריסה, לא לעשות עיבוד כבד הוא כלל ברזל. מוציאים דחיסה, שליחת HTTP, פתרון DI, דו-שיח UI ובניית JSON מורכב.
  • בקריסה, שומרים רק בקצרה מקומית, ודחיסה/העלאה/התראה עוברים להפעלה הבאה או לתהליך נפרד.
  • שימוש ב-ThreadException של WinForms או DispatcherUnhandledException של WPF כדי להאריך חיים למראית עין מסוכן מול חריגה שמקורה בבאג.
  • גם ב-.NET וגם ב-native, בטוח יותר לבסס חריגה שחשודה במצב שבור על “רישום וסיום” ולא “התאוששות”.
  • אם לוקחים dump, שמירת PDB והבינארי המופץ חייבת להיעשות באותו זמן, אחרת אי אפשר לקרוא בדיעבד.

בקיצור, שיטת העבודה המומלצת היא “לא לנסות לעשות הכול ברגע הקריסה. לחלק תפקידים לפני הקריסה, ברגע הקריסה ואחריה”.

חלוקת תפקידים לפני, ברגע ואחרי הקריסהתרשים המראה ששיטת העבודה המומלצת היא לא לעשות הכול ברגע הקריסה, אלא לחלק תפקידים - לפני הקריסה יומן רגיל, ברגע הקריסה כתיבה מקומית קצרה, ואחרי הקריסה דחיסה והעלאה והתראה.לפני: שומרים יומן רגילברגע הקריסה: כתיבה מקומית קצרה בלבדאחרי: דחיסה · העלאה · התראהבהפעלה הבאה או בתהליך נפרד

איור 2: לא הכול ברגע הקריסה - מחלקים תפקידים בין לפני, ברגע, ואחרי.

1.1 מונחים שהמאמר משתמש בהם

המונחים שישמשו בהמשך ללא הסבר נוסף, מרוכזים כאן מראש.

מונח פירוש/הגייה משמעות
WER Windows Error Reporting, דיווח שגיאות של Windows מנגנון Windows שתופס ורושם סיום חריג של אפליקציה בצד מערכת ההפעלה. ההגדרה ששומרת dump מקומי נקראת LocalDumps
dump‏ / minidump crash dump קובץ ששומר את תוכן הזיכרון של התהליך ברגע הקריסה. אפשר לראות בדיעבד threads, stack ומודולים
PDB Program Database קובץ סמלים שנוצר בזמן ה-build. בלעדיו, גם כשפותחים dump לא מתקבלים שמות פונקציות או מספרי שורות
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, רשימת בקרת גישה ההגדרה מי רשאי לקרוא ולכתוב לתיקייה. גורם קבוע ל”ירי בלק” של dump ויומן
SEH Structured Exception Handling, טיפול חריגות מובנה מנגנון החריגות הנייטיבי של Windows. SetUnhandledExceptionFilter שייך לכאן
CRT C Runtime מימוש הספרייה הסטנדרטית של C/‏C++. יש לו מסלולי סיום עצמאיים, בנפרד מ-SEH
session ‏session ID ערך לזיהוי “על איזה מופע הפעלה מדובר”. המפתח להצלבת יומן, dump ורישומי watchdog

מפת הידע של המאמר

כדי שיישום Windows יאפשר לאתר את הסיבה גם כשהוא קורס מחריגה שמקורה בבאג, יעיל תכנון שלא מסתמך רק על היומן של התהליך הקורס עצמו, אלא מחלק את הראיות לשלוש שכבות - יומן כרונולוגי רגיל, סמן קריסה סופי ברגע הנפילה, ו-dump באמצעות WER LocalDumps. שימוש ב-AppDomain.UnhandledException, וב-ThreadException של WinForms או DispatcherUnhandledException של WPF כדי להאריך חיים למראית עין מסוכן; בטוח יותר להשתמש בהם כשער לרישום ואז לסיים באמצעות API של סיום מיידי כמו Environment.FailFast - אך קריאה ל-FailFast בתוך UnhandledException מחליפה את סיבת ה-dump. ב-C++ נייטיבי צריך לקלוט לא רק SEH אלא גם את מסלולי הסיום של ה-CRT, וכאשר מדובר בהפעלה 24 שעות או בבקרת ציוד, הוספת תהליך ניטור מאפשרת לזהות מבחוץ גם exit code וגם מספר הפעלות מחדש.

מפת הידע של תכנון היומן וה-dump בקריסת יישום Windowsתרשים המראה שתכנון שלא מסתמך על ראיה רק בתוך התהליך הקורס מתבסס על חלוקת תפקידים בין שלוש שכבות - יומן רגיל, סמן קריסה סופי, ו-WER LocalDumps - על שילוב handler-ים שונים כמו AppDomain.UnhandledException ו-FailFast, ועל זיהוי חיצוני על ידי תהליך ניטור.מחייבמחייבמחייבמחייבמחייבמענה מומלץ לצריך לקדום למחייבמענה מומלץ לשימוש לא מומלץ לשימוש לא מומלץ לשימוש לא מומלץ למשתמש במענה מומלץ למענה מומלץ למחייבמחייבנבדק באמצעותמממש אתמשתמש במשתמש בצריך לקדום למממש אתמענה מומלץ לתכנון היומנים והראיות בעת קריסהWER LocalDumpsיומן רגיל (יומן כרונולוגי)סמן הקריסה הסופי‏SEH (טיפול חריגות מובנה)מסלולי הסיום של ה-CRT ושל זמן הריצה של C++תהליך ניטור (watchdog)דרישות מחמירות כמו הפעלה 24/7 ובקרת ציודעיבוד המשך אחרי ההפעלה הבאההצלבת ראיות לפי session IDAppDomain.UnhandledExceptionApplication.ThreadException(WinForms)המשך העיבוד אחרי חריגה בלתי צפויה שמקורה בבאגApplication.DispatcherUnhandledException(WPF)TaskScheduler.UnobservedTaskExceptionEnvironment.FailFastיומן אירועי היישומים של Windows‏ACL של תיקיית שמירת ה-dump‏crash dump (תמונת קריסה)‏PDB (מסד נתוני התוכנית)WinDbgMiniDumpWriteDumpרישום קובץ ליומן באמצעות WerRegisterFile

בתרשים, קו מלא מציין קשר שמתקיים תמיד וקו מקווקו מציין קשר מותנה (תנאי ההתקיימות מפורטים בהסבר של כל קשר בעמוד המפורט). רשימת כל הקשרים (סך הכול 24, עם אסמכתה ורמת ודאות) והגדרות המושגים המרכזיים מרוכזות בעמוד המפורט של מפת הידע (ביפנית). נתונים: JSON-LD / Turtle

2. למה אי אפשר להבטיח “וודאות” רק בתוך התהליך

אם זה נשאר מעורפל, התכנון מתנדנד.

2.1 לפעמים ההקשר של ה-thread שקרס עצמו שבור

ה-hook של חריגה בלתי מטופלת או exception filter ברמה העליונה עלולים לפעול בתוך ההקשר של ה-thread השבור. בשלב הזה, זה קורה בדרך כלל:

  • ה-stack כבר מסוכן
  • הקצאה נוספת מסוכנת בגלל השחתת heap
  • המתנה נעצרת בגלל נעילה שהוחזקה בזמן החריגה
  • אובייקטים שה-logger עצמו תלוי בהם כבר שבורים

לכן בטוח יותר להתייחס ל-handler האחרון לא כ”מקום שאפשר לעשות בו הכול”, אלא כ”מקום שאפשר לעשות בו מעט מאוד”.

ב-handler האחרון אפשר לעשות מעטתרשים המראה שה-hook של חריגה בלתי מטופלת עלול לפעול בהקשר של thread שבור, כשה-stack וה-heap מסוכנים, המתנה לנעילה עלולה לעצור, וגם התלויות של ה-logger שבורות, ולכן זה מקום שאפשר לעשות בו מעט מאוד.פועל בהקשר thread שבורstack ו-heap מסוכניםהמתנה לנעילה עלולה לעצורתלויות ה-logger שבורותמקום שאפשר לעשות בו מעט מאוד

איור 3: ה-handler האחרון הוא לא “מקום שאפשר לעשות בו הכול” אלא מקום מוגבל מאוד.

2.2 ‏fast fail וחריגות של מצב שבור מניחות “פעולה מזערית בתוך התהליך”

במצב של השחתת זיכרון או מצב קטלני, עדיף שלא לצפות מטיפול חריגות רגיל. בפרט, __fastfail בצד native וחריגות שחשודות במצב שבור מתוכננים לכיוון “סיום מיידי עם overhead מינימלי ככל האפשר”.

כלומר, הגישה הטבעית היא: אם היומן האחרון ב-in-process נכתב - מזל, והראיה העיקרית נמצאת בצד מערכת ההפעלה / תהליך נפרד.

2.3 גם אירוע החריגה הבלתי מטופלת ב-.NET אינו מקום ל”עיבוד התאוששות כבד”

AppDomain.UnhandledException של .NET נוח, אבל עדיף לחשוב שמה שמותר לעשות כאן הוא רק רישום קצר.

  • עלול להיות מושפע מנעילה שהוחזקה בזמן החריגה
  • לא כל חריגה, כולל חריגת מצב שבור, ניתנת לתפיסה בטוחה
  • אם כופים כאן מדיניות המשך, נוטים להאריך חיים במצב חצי-שבור

ריאלי לתפוס את זה כ“אירוע חריגה בלתי מטופלת = ההתראה האחרונה”, ולא כ“נקודת התאוששות בטוחה”.

אירוע החריגה הבלתי מטופלת הוא התראה אחרונהתרשים המראה שב-AppDomain.UnhandledException מותר רק רישום קצר, לא כל חריגה ניתנת לתפיסה בטוחה, ושהאירוע הוא ההתראה האחרונה ולא נקודת התאוששות בטוחה.אירוע חריגה בלתי מטופלתמותר רק רישום קצרמשמש כהתראה אחרונהאינו נקודת התאוששות בטוחהמדיניות המשך כפויה - הארכת חיים חצי-שבורה

איור 4: אירוע החריגה הבלתי מטופלת הוא שער לרישום, לא מקום להתאוששות.

3. ארכיטקטורה מומלצת - הפרדה בין רגע הקריסה להפעלה הבאה

הכי קל לסדר בשיטה שמפרידה בין מה עושים ברגע הקריסה למה עושים אחרי ההפעלה מחדש.

קודם, תרשים אחד שמראה עבור כל אחת משלוש השכבות באחריות איזה תהליך, ולאן היא נופלת.

מפת שלוש השכבות של ראיות הקריסהתרשים המראה שהתהליך של האפליקציה כותב יומן רגיל וסמן קריסה סופי, WER LocalDumps שומר dump מחוץ לתהליך, תהליך watchdog רושם exit code והחלטת הפעלה מחדש, וכולם נופלים לתיקייה מקומית קבועה שממנה מתבצע עיבוד אחרי ההפעלה הבאה.התהליך הבריא שהופעל הבאתהליך watchdogצד Windowsתהליך האפליקציהחריגהסיום תהליךסיום תהליךדחיסה / העלאה / התראה - מזהה סיום חריג קודםרושם exit code וזמן סיום - מחליט אם להפעיל מחדשWER LocalDumps - שומר dump מחוץ לתהליךיומן רגיל - כרונולוגיה append-onlyסמן קריסה סופי - כותב שורה אחת ומסייםתיקייה מקומית קבועה

איור 5: התמונה המלאה של שלוש שכבות הראיות. התהליך הקורס כותב רק שתיים, והראיה העיקרית נמצאת מחוצה לו.

שלוש נקודות מרכזיות:

  • התהליך הקורס כותב בעצמו רק שני דברים - שנמצאים בתוך המסגרת “תהליך האפליקציה”. וגם סמן הקריסה הסופי מסתכם ב”כתיבת שורה אחת וסיום”.
  • הראיה העיקרית נמצאת מחוץ לתהליך. ה-dump של WER ורישום ה-watchdog נשארים גם כשהאפליקציה שבורה.
  • המפתח להצלבה הוא session ID ו-PID משותפים. בלעדיהם, שלוש הראיות נראות כשלושה מקרים נפרדים.
שלב מטרה היכן פועל מה עושים
זמן רגיל לשמור כרונולוגיה בתוך האפליקציה יומן מובנה, heartbeat, אירועי גבול
ברגע הקריסה להפיל ראיה מינימלית בתוך האפליקציה + OS סמן קריסה סופי, dump של WER
מיד אחרי הסיום לזהות exit בלתי צפוי תהליך נפרד רישום exit code, החלטת הפעלה מחדש, התראה
אחרי ההפעלה הבאה עיבוד כבד תהליך בריא חדש דחיסה, העלאה, התראת משתמש, ניקוי יומנים ישנים

החלוקה הזו מייצבת מאוד את התכנון.

3.1 תצורה מינימלית

בכלי עסקי קטן או WPF‏/‏WinForms פנים-ארגוני, לרוב מספיק בערך זה:

  • יומן רגיל: קובץ append-only מקומי
  • סמן קריסה סופי: קובץ קצר ייעודי
  • dump: WER LocalDumps
  • בהפעלה הבאה: הצגת “הסתיים בפעם הקודמת בצורה חריגה. יש מידע לאבחון”

3.2 תצורה חזקה יותר

כדאי לחזק שלב אחד כשיש דרישות כמו:

  • הפעלה 24/7
  • בקרת ציוד, ניטור, פעולה ברקע
  • הרבה COM‏/‏P/Invoke‏/‏SDK נייטיבי
  • יש תהליכי בת, plugins, הרצת סקריפט
  • בסביבת הלקוח לא מקובל “להישאר תקוע”

במקרה כזה, החלוקה

  • תהליך worker: העיבוד המרכזי
  • launcher‏/‏watchdog‏/‏service: ניטור הפעלה, רישום exit, הפעלה מחדש
  • WER LocalDumps: בצד ה-worker
  • ההפעלה הבאה או ה-watchdog: איסוף מידע לאבחון

הופכת מעשית מאוד.

חלוקת התפקידים בתצורה חזקהתרשים המראה שבדרישות חזקות כמו 24/7 ובקרת ציוד, מחלקים לתהליך worker שמבצע את העיבוד המרכזי ולתהליך watchdog שמנטר הפעלה ורושם exit ומפעיל מחדש, כשWER LocalDumps מוגדר בצד ה-worker ומידע האבחון נאסף בהפעלה הבאה או על ידי watchdog.דרישות חזקות(24/7, בקרת ציוד)worker: העיבוד המרכזיwatchdog: ניטור, רישום exit, הפעלה מחדשWER LocalDumps בצד ה-workerאיסוף אבחון בהפעלה הבאה או ב-watchdog

איור 6: בתצורה חזקה, מפרידים את הגוף הראשי מהניטור לתהליכים נפרדים עם תפקידים קבועים.

4. שיטות עבודה מומלצות ליומן הרגיל

אם מנסים להילחם רק עם השורה האחרונה ברגע הקריסה, לרוב מפסידים. מה שבאמת עוזר הוא היומן הרגיל עד לרגע האחרון.

4.1 יומן צריך “מידע שאפשר להצליב בדיעבד” ולא “טקסט לבני אדם”

רשימת הפריטים שכדאי לכלול ביומן הרגיל, לכל הפחות.

  • ‏timestamp ב-UTC
  • זמן שחלף מתחילת התהליך
  • PID‏/‏TID
  • שם האפליקציה, גרסה, מספר build, מזהה commit
  • session ID
  • מזהה פעולה / מזהה job / מזהה הצלבה
  • שם מודול / שם מסך / שם worker
  • הפעולה החיצונית האחרונה
    • כתיבה לקובץ
    • עדכון DB
    • שליחת פקודה לציוד
    • בקשת תקשורת
  • סוג החריגה, 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}

זה ארוך, אבל מספיק לראות שורה אחת אחת כדי לדעת “מתי, איזה מופע הפעלה, איזו גרסה, באמצע איזו פעולה, מה עשה”. מתחבר לצד ה-dump דרך pid ו-session, ולצד ה-build דרך 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

יותר חשוב “שאפשר יהיה להצליב שלושה קבצים בדיעבד”, מאשר להשאיר טקסט ארוך לבני אדם.

שורה אחת מחברת שלוש ראיותתרשים המראה ששורה אחת ביומן הרגיל מתחברת ל-dump דרך pid ו-session, ולצד ה-build דרך ver ו-commit, ושהיכולת להצליב שלושה קבצים בדיעבד חשובה יותר מטקסט ארוך לבני אדם.שורה אחת ביומן הרגילpid/session אל צד ה-dumpver/commit אל צד ה-buildניתן להצליב שלושה קבצים

איור 7: שורה אחת ביומן מתחברת לראיות אחרות דרך pid,‏ session,‏ ver ו-commit.

4.2 שומרים אירועים קריטיים באופן סינכרוני

אם כל היומן הרגיל נכתב סינכרונית, זה כבד. אבל אם הכול מסתמך על buffer אסינכרוני, הכול נעלם יחד ברגע הקריסה.

לכן, ריאלי לשנות את הטיפול לפי הרמה בעבודה בפועל.

  • אירועי Information דקים: מותר לאגור ב-buffer
  • Warning ומעלה: לעשות flush מוקדם
  • אירועי גבול חשובים: לשמור באופן סינכרוני
    • ‏ProcessStart
    • ‏ConfigLoaded
    • ‏WorkerStarted
    • ‏ExternalCommandSent
    • ‏TransactionCommitted
    • ‏RecoveryStarted
    • ‏FatalPathEntered

בקיצור, רק את גבולות העסק מפילים כמו שצריך לקרקע.

הטיפול משתנה לפי רמת האירועתרשים המראה שאירועי Information דקים מותר לאגור ב-buffer, Warning ומעלה עושים flush מוקדם, ואירועי גבול עסקיים נשמרים באופן סינכרוני, וכך רק גבולות העסק מופלים לקרקע.כתיבת יומןInformation: מותר bufferWarning ומעלה: flush מוקדםאירוע גבול: שמירה סינכרוניתרק גבולות עסק מופלים לקרקע

איור 8: לא הכול סינכרוני ולא הכול ב-buffer - הטיפול משתנה לפי רמת האירוע.

4.3 מפרידים בין “היומן הרגיל שכותבים כרגע” ל”סמן הקריסה האחרון”

זה די חשוב.

אם מנסים לשים הכול ב-rolling log אחד, זה קורה:

  • היה באמצע rotation
  • נשאר בתור אסינכרוני
  • ה-logger עצמו מת מיד אחרי החריגה
  • שורת היומן נחתכה באמצע

לכן מומלץ לפצל לפחות לשני קבצים:

  • app-<session>.jsonl יומן כרונולוגי רגיל
  • fatal-last.log או fatal-<session>.log ייעודי לסמן הקריסה הסופי

עצם הבהירות “איפה נשמרת השורה האחרונה” עוזרת מאוד בשטח.

מפרידים יומן רגיל מסמן fatalתרשים המראה ששימוש ברישום אחד בלבד מסתכן באובדן השורה האחרונה עקב rotation, תור אסינכרוני או מות ה-logger, ולכן מפצלים לשני קבצים - יומן כרונולוגי רגיל וקובץ ייעודי לסמן הקריסה הסופי.במקום זאתהכול ב-rolling log אחדנעלם ב-rotation / תור / מות loggerמפצלים ליומן רגיל וסמן fatalברור איפה נשמרת השורה האחרונה

איור 9: מקום השורה האחרונה מקובע בקובץ נפרד מהיומן הרגיל.

4.4 יעד שמירת היומן קבוע ומקומי, לא ברשת

בזמן קריסה, מסוכן להסתמך על נתיב UNC,‏ NAS,‏ HTTP או API בענן.

כי מעורבים:

  • ניתוק רגעי ברשת
  • עיכוב DNS
  • פקיעת אישורי גישה
  • המתנה ב-thread של ה-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
  • session ID
  • גרסה / מספר build
  • מאיזה hook זה הגיע
    • ‏AppDomain.UnhandledException
    • ‏Application.ThreadException
    • ‏DispatcherUnhandledException
    • ‏SetUnhandledExceptionFilter
    • ‏_set_invalid_parameter_handler
    • ‏set_terminate
  • סוג החריגה או קוד החריגה
  • הודעה קצרה, אם אפשר
  • מזהה הפעולה האחרונה
  • שם קובץ היומן הרגיל
  • תיקיית ה-dump המשוערת

זה מספיק.

מטרת הסמן היא לקבע שער כניסהתרשים המראה שסמן הקריסה הסופי לא מכיל פרטי סיבה אלא מקבע שער כניסה לחקירה - איזה hook, סוג חריגה, session, שם יומן ותיקיית dump - כשפרטי הסיבה עצמם נמצאים ב-dump וביומן הרגיל.סמן קריסה סופיhook, סוג חריגה, sessionשם יומן רגיל ותיקיית dumpשער כניסה לחקירה מקובעפרטי הסיבה - ב-dump וביומן הרגיל

איור 10: תפקיד הסמן הוא לא פרטי הסיבה, אלא לקבע את שער הכניסה לחקירה.

5.2 מה אסור לעשות ב-handler של קריסה

כל אחד מאלה נוטה מאוד להתפוצץ.

  • לפתור logger מ-DI container
  • להשתמש ב-async/await
  • לזרוק Task
  • להמתין לנעילה
  • לבנות JSON מורכב
  • לגעת באובייקט COM
  • להציג דו-שיח UI
  • לדחוס
  • לשלוח HTTP‏/‏SMTP‏/‏Slack‏/‏Teams
  • לנתח ולסכם dump
  • לתפוס חריגה ולהמשיך

‏handler של קריסה אינו המשך של זרימת עיבוד רגילה. מטים אותו ל”כתיבה מקומית מזערית בלבד, ואז סיום”.

5.3 מה כן עושים ב-handler של קריסה

ולהפך, מה שכן עושים די פשוט.

  1. מונעים כניסה כפולה
  2. כותבים שורה אחת בלבד
  3. עושים flush
  4. מסיימים

בסדר הזה.

רצוי להשתמש ב-

  • תיקייה ייעודית שנוצרה מראש
  • נתיב שקיומו אומת מראש
  • יעד שה-ACL שלו אומת

ביומן הרגיל, יותר מדי flush כבד, אבל סמן ה-fatal הוא מספר זעיר של פעמים, אז מותר כאן flush חזק. ב-.NET זה FileStream.Flush(true), ב-native זה FlushFileBuffers - קל יותר לתכנן אם מתייחסים לזה כ“רק את השורה הזו מפילים לקרקע מיד”.

ארבעת שלבי handler הקריסהתרשים המראה שב-handler הקריסה עושים רק ארבעה שלבים - מונעים כניסה כפולה, כותבים שורה אחת, עושים flush, ומסיימים - ומכיוון שסמן ה-fatal נדיר, מותר flush חזק שמפיל לדיסק.1. מונעים כניסה כפולה2. כותבים שורה אחת3. עושים flush4. מסיימיםהשורה הזו מופלת לקרקע מיד

איור 11: ב-handler עושים רק ארבעה שלבים, בסדר הזה בלבד.

5.4 לא מנסים להמשיך

עבור חריגה בלתי צפויה שמקורה בבאג, בטוח יותר לחשוב על ה-handler האחרון כמכשיר רישום, לא מכשיר התאוששות.

בפרט, כדאי לבסס “לא ממשיכים” במקרים כמו:

  • גם NullReferenceException או InvalidOperationException שקרו באמצע עדכון מצב משותף
  • חריגה בלתי צפויה ב-thread של ה-UI
  • חריגה בלתי צפויה שדלפה מלולאת ניטור או לולאת אב
  • ‏AccessViolationException
  • ‏StackOverflowException
  • חריגה בגבול native
  • ‏invalid parameter‏/‏purecall‏/‏terminate של ה-CRT

מובן הרצון “לא רוצים להפיל”, אבל לשרוד במצב חצי-שבור לרוב קשה יותר גם לאבחון וגם לתפעול.

בזמן הסיום, כדאי לשקול API של סיום מיידי - ב-.NET זה Environment.FailFast, ב-native זה RaiseFailFastException או __fastfail - ולא לצפות מ-finally או מניקיון רגיל.

מכשיר רישום, לא מכשיר התאוששותתרשים המראה שבחריגה בלתי צפויה שמקורה בבאג, שרידה במצב חצי-שבור קשה גם לאבחון וגם לתפעול, ולכן עדיף לרשום ולסיים בעזרת API של סיום מיידי כמו FailFast.חריגה שמקורה בבאגשרידה במצב חצי-שבוררישום וסיוםקשה לאבחון ולתפעוללשקול FailFast וכדומה

איור 12: ה-handler האחרון הוא מכשיר רישום וסיום, לא מכשיר התאוששות.

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");

    // המפתח להצלבה עם יומן רגיל, dump ורישום 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, הסיבה שנשארת ב-dump של 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);

            // בונים שורה אחת רק בשרשור מחרוזות, בלי לקרוא ל-serializer
            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 מפילה עד לדיסק, לא רק ל-cache של המערכת
                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 עצמו מוסיף ראיה אחת. אבל כפי שצוין קודם, קריאה לו במסלול החריגה הבלתי מטופלת משנה את אופן הופעת ה-dump, אז בחרו בזהירות איפה לקרוא לו.

בוחרים איפה קוראים ל-FailFastתרשים המראה ש-Environment.FailFast כותב ליומן האירועים ומסיים מיד, מוסיף ראיה אחת, אך קריאה לו במסלול החריגה הבלתי מטופלת מחליפה את סיבת ה-dump מהחריגה המקורית ל-FailFast, ולכן בוחרים בזהירות היכן לקרוא לו.Environment.FailFastכותב ליומן האירועים ומסיים מידמוסיף ראיה אחתקריאה במסלול חריגה בלתי מטופלתסיבת ה-dump מוחלפת ל-FailFast

איור 13: FailFast מוסיף ראיה, אבל קריאה לו בתוך חריגה בלתי מטופלת מחליפה את הסיבה.

6. נקודות זהירות לפי framework

6.1 משותף ל-.NET: AppDomain.CurrentDomain.UnhandledException

זה שימושי כהתראה אחרונה. עם זאת, נמנעים כאן מעיבוד התאוששות כבד.

השימוש הבסיסי פשוט:

  • כותבים סמן קריסה סופי
  • במידת הצורך, משאירים הודעה מזערית ב-Windows Event Log
  • לא ממשיכים
  • לא ממתינים ולא מנסים שוב כאן

UnhandledException נוח, אבל בטוח יותר לא להניח שאפשר להחזיר את האפליקציה למצב בריא כאן.

6.2 WinForms: Application.ThreadException

הקושי כאן הוא שהוא תופס חריגה בלתי מטופלת ב-thread של ה-UI, כך שאפשר להמשיך למראית עין.

לצורך הצגת שגיאה עסקית צפויה בדו-שיח, זה עוד סביר, אבל לא מתאים לשימוש כהמשך אחרי חריגה בלתי צפויה שמקורה בבאג.

אם עדיפות אמיתית היא חקירת הסיבה, בטוח יותר:

  • לעשות רק רישום מזערי ב-ThreadException
  • או להטות ל-UnhandledExceptionMode.ThrowException
  • ואז לסיים את התהליך ולשמור dump ויומן

6.3 WPF: Application.DispatcherUnhandledException

דומה גם ב-WPF.

  • רק חריגה ב-thread של ה-UI היא היעד המרכזי
  • עם Handled = true אפשר להמשיך למראית עין
  • אבל מול חריגה שמקורה בבאג, זה נוטה ליצור פער בין מצב המסך למצב הפנימי

לכן, גם ב-WPF בטוח יותר לא להשתמש בזה כמכשיר הארכת חיים, אלא כשער לרישום.

לא מאריכים חיים דרך אירועי UIתרשים המראה ש-ThreadException של WinForms ו-DispatcherUnhandledException של WPF מאפשרים המשך למראית עין, אך מול חריגה שמקורה בבאג נוצר פער בין מצב המסך למצב הפנימי, ולכן משתמשים בהם כשער לרישום ומסיימים תוך שמירת dump ויומן.חריגה בלתי מטופלת ב-UIהמשך למראית עיןפער בין מסך למצב פנימימשמש כשער לרישוםמסיימים ושומרים dump ויומן

איור 14: אירועי UI ב-WinForms/‏WPF משמשים שער לרישום, לא מכשיר הארכת חיים.

6.4 לא הופכים את TaskScheduler.UnobservedTaskException למסלול הראשי

זה לא “המבצר האחרון לפני הקריסה”.

אפשר להשתמש בו כעזר לזיהוי חריגות שהוחמצו ב-Task, אבל הוא חלש כמסלול רישום ודאי ברגע הקריסה.

לכן, אפשר להשתמש בו למטרות כמו

  • לגלות מוקדם חריגה שלא נצפתה
  • לחשוף בזמן הפיתוח פערי תכנון ב-Task

אבל עדיף לא להפוך אותו לכוכב של ה-handler האחרון.

6.5 native Win32/‏C++: לא לתת אמון מלא ב-SetUnhandledExceptionFilter

בצד native, קל לצפות יותר מדי מ-SetUnhandledExceptionFilter.

אבל הוא פועל בהקשר של ה-faulting thread, ולכן מושפע מ-

  • stack לא תקין
  • רקורסיה עמוקה
  • heap שכבר שבור
  • נעילה שהוחזקה בזמן החריגה

לכן, נכון להתייחס ל-SetUnhandledExceptionFilter כשער best effort לקבלת ההתראה האחרונה.

6.6 ב-C++ נייטיבי, קולטים גם את מסלולי הסיום של ה-CRT

ב-C++ נייטיבי, אם מסתכלים רק על SEH בלתי מטופל, יש פספוסים.

באופן קונקרטי, כדאי לבדוק גם:

  • _set_invalid_parameter_handler
  • _set_purecall_handler
  • set_terminate

הקבוצה הזו קולטת “מסלולי סיום” שמקורם ב-C runtime או C++ runtime.

בעבודה בפועל, סביר:

  • לכתוב סמן קריסה סופי גם ב-handler-ים האלה
  • אבל לא לעשות עיבוד התאוששות כבד
  • לוודא סיום
  • להשאיר את הראיה העיקרית ל-WER‏/‏dump
SEH בלבד יוצר פספוסיםתרשים המראה שב-C++ נייטיבי, הסתכלות רק על SetUnhandledExceptionFilter מפספסת את מסלולי הסיום של ה-CRT, ולכן קולטים גם invalid parameter, purecall ו-terminate, כותבים רישום מזערי ומוודאים סיום, כשהראיה העיקרית ב-WER/dump.SetUnhandledExceptionFilter בלבדפספוס מסלולי סיום CRTקולטים גם invalid parameter/purecall/terminateרישום מזערי וסיום ודאיהראיה העיקרית - WER/dump

איור 15: רק SEH לא מספיק - קולטים גם את מסלולי הסיום של ה-CRT כדי לא לפספס.

7. ‏WER LocalDumps כתשתית

7.1 ההמלצה הראשונה: WER LocalDumps

במובן של “אחרי הקריסה, להשאיר ראיה מינימלית באופן ודאי יחסית”, ‏WER LocalDumps הכי נוח לטפל בו.

הסיבה פשוטה:

  • אפשר לשמור dump בצד מערכת ההפעלה
  • קל להכניס בלי כלים נוספים
  • אפשר להגדיר ברמת אפליקציה
  • אפשר להוציא את הראיה העיקרית מחוץ ל-in-process

מה שהיומן לבדו לא מגלה -

  • איזה thread קרס
  • באיזה stack
  • באיזה גבול מודול
  • איפה החשד - managed,‏ native,‏ COM,‏ SDK

אפשר לראות בדיעבד, וזה חזק.

למה WER LocalDumps חזקתרשים המראה ש-WER LocalDumps שומר dump בצד מערכת ההפעלה, ניתן להגדרה ברמת אפליקציה, מוציא את הראיה העיקרית אל מחוץ ל-in-process, ומאפשר לראות בדיעבד thread, stack וגבול מודול.WER LocalDumpsשומר dump בצד ה-OSניתן להגדרה ברמת אפליקציהמוציא ראיה אל מחוץ ל-in-processרואים thread, stack וגבול מודול

איור 16: WER LocalDumps הוא תשתית שמוציאה את הראיה העיקרית מחוץ לתהליך הקורס.

7.2 הגדרה טיפוסית

לדוגמה, כדי לשמור dump של 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 של יעד שמירת ה-dump

נכון גם ליומן וגם ל-dump - אין טעם להגדיר תיקייה שלא ניתן לכתוב אליה.

בפרט, ב-

  • שירותי Windows
  • תהליכי בת עם הפרדת הרשאות
  • חשבון מוגבל במחשב שטח
  • מעורבות UAC

ACL של יעד השמירה הוא הגורם המרכזי ל”ירי בלק”.

בודקים את יעד השמירה עד ל-

  • יצירה מראש
  • בדיקת כתיבה
  • הגבלת כמות שמורה
  • האם זה מקום שהצוות התפעולי יכול לגשת אליו
מונעים ירי בלק ב-ACL של יעד השמירהתרשים המראה ששירותי Windows וחשבונות מוגבלים מגדירים בטעות תיקייה שאי אפשר לכתוב אליה, ה-dump יורה בלק, ומונעים זאת ביצירה מראש, בדיקת כתיבה, הגבלת כמות ובדיקה שהמקום נגיש לצוות התפעולי.למניעהשירות / חשבון מוגבלהגדרת תיקייה שלא ניתן לכתובה-dump יורה בלקיצירה מראש ובדיקת כתיבהגם הגבלת כמות ונגישות לצוות

איור 17: הגורם המרכזי לירי בלק של dump הוא ACL, ובודקים כתיבה מראש כדי למנוע.

7.4 כשרוצים לצרף את היומן הנוכחי לדוח WER

אם משתמשים בדיווח WER ל-Microsoft או בתפעול WER עצמאי, יש גם אפשרות לרשום דרך WerRegisterFile כדי לכלול את קובץ היומן הנוכחי בדיווח השגיאה.

עם זאת, בטוח יותר להתייחס לזה כמסלול נוסף, לא כתחליף לשמירה מקומית. כי מה שבאמת רוצים ברגע הקריסה הוא קודם כול שמירה מקומית ודאית יחסית במכשיר עצמו.

מבחינת הסדר, זה מעשי יותר:

  1. יומן רגיל מקומי
  2. סמן fatal מקומי
  3. ‏dump מקומי
  4. במידת הצורך, רישום קבצים קשורים גם במסלול השליחה של WER

7.5 שומרים גם ניהול גרסאות, לא רק dump

גם אם לוקחים dump, אם בדיעבד -

  • אין את ה-EXE‏/‏DLL של אותו זמן
  • אין PDB
  • לא ברור באיזה commit נבנה

זה נחלש מאוד.

לפחות אלה חייבים להישמר:

  • הבינארי שהופץ
  • ה-PDB המתאים
  • גרסה
  • זמן ה-build
  • מזהה commit
  • גרסת המתקין

איסוף dump ושמירת PDB הם חבילה אחת.

איסוף dump ושמירת PDB - חבילה אחתתרשים המראה שאם שומרים dump בלבד בלי PDB והבינארי מאותו זמן, לא ניתן לקרוא אותו בדיעבד, ולכן שומרים גם את הבינארי המופץ, ה-PDB, הגרסה ומזהה ה-commit יחד עם איסוף ה-dump.לכןשומרים dump בלבדאין PDB או בינארי מאותו זמןאי אפשר לקרוא בדיעבדשומרים בינארי מופץ ו-PDBשומרים גם גרסה ומזהה commit

איור 18: אפשר לקרוא dump רק כשה-PDB והבינארי מאותה גרסה נשמרו יחד איתו.

7.6 עד לפתיחה הראשונה של dump שנתפס

‏”יש הרבה dump-ים באוסף, אבל אף אחד לא פתח אותם” - זה קורה ממש הרבה. את ההעמקה נשאיר למאמר ייעודי, כאן רק 10 הדקות הראשונות.

ההכנה כוללת שני דברים.

  1. לרכז לתוך תיקייה אחת PDB ובינארי מופץ מאותו build כמו ה-dump
  2. להכין את WinDbg מתוך Debugging Tools for Windows ב-Windows SDK

לאחר מכן, פותחים את ה-.dmp ב-WinDbg ומקלידים בזה אחר זה.

.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 עוברת להקשר ה-register של רגע החריגה. אם שוכחים את זה, רואים את ה-stack של ההמתנה בצד WER, לא את מקום הקריסה
!analyze -v מנתחת אוטומטית את החריגה הנוכחית ומציגה פירוט. קוראים את זה קודם
~*k מציגה call stack של כל ה-threads. רואים מה עשו ה-threads שלא קרסו
lm v מציגה את המודולים הטעונים והגרסאות שלהם. משמש להצלבה עם ההפצה ומספר ה-build

אם עד לשלב הזה “לא מתקבל שם פונקציה” או “לא מתקבל מספר שורה”, הסיבה לרוב היא ש-PDB ממתאים ל-build אחר. חזרו לניהול הגרסאות בסעיף 7.5.

עשר הדקות הראשונות בפתיחת dumpתרשים המראה שמרכזים PDB ובינארי מאותו build, פותחים את ה-dump ב-WinDbg, עוברים להקשר החריגה וקוראים ניתוח, ואם לא מתקבל שם פונקציה, זה סימן ל-PDB מגרסה אחרת וחוזרים לניהול גרסאות.לאמרכזים PDB ובינארי מאותו buildפותחים dump ב-WinDbgעוברים להקשר החריגה, קוראים ניתוחבלי .ecxr - רואים stack המתנה של WERמתקבל שם פונקציה ומספר שורהPDB מ-build אחר, חזרה לניהול גרסאות

איור 19: פתיחת dump מתחילה בהכנה, פתיחה ומעבר להקשר החריגה, בסדר הזה.

דיון מעמיק יותר באיסוף וניתוח מרוכז במאמר הקשור בסוף.

8. גישה לשימוש ב-MiniDumpWriteDump או ב-crash reporter עצמאי

יש מצבים שדורשים מימוש עצמאי.

  • רוצים כפתור “שמור מידע לאבחון” מה-UI
  • רוצים לצרף גם יומן וגם קבצי הגדרות
  • רוצים לטפל בקבוצת תהליכי בת יחד
  • רוצים להוסיף מיסוך עצמאי לפני העלאה אוטומטית

עם זאת, הכי חשוב כאן הוא לא להעמיס יותר מדי גם את פעולת לקיחת ה-dump על הצד הקורס.

8.1 תהליך נפרד עדיף על self-dump

MiniDumpWriteDump חזק, אבל בטוח יותר לקרוא לו מתהליך נפרד, לא מתוך התהליך שקרס עצמו.

תצורה טיפוסית נראית כך:

  • ה-worker הראשי מזהה חריגה
  • אם אפשר, מודיע ל-helper דרך event או named pipe
  • ה-helper לוקח dump של ה-worker
  • ה-helper מצרף יומן tail וקבצי הגדרות
  • ה-helper שם באחר הסיום בתור להעלאה

כך, גם אם ה-worker שבור, צד ה-helper עדיין בריא.

לקיחת dump על ידי helper בתהליך נפרדתרשים המראה שכשה-worker הראשי מזהה חריגה, הוא מודיע ל-helper דרך event או named pipe, וה-helper הבריא לוקח dump של ה-worker, מצרף יומן וקבצי הגדרות, ושם בתור להעלאה אחרי הסיום.helperworker ראשיhelperworker ראשיגם אם ה-worker שבור, ה-helper בריאמודיע על חריגה דרך event/pipeלוקח dump של ה-workerמצרף יומן tail וקבצי הגדרותשם בתור להעלאה אחרי הסיום

איור 20: את לקיחת ה-dump מבצע לא הצד הקורס, אלא תהליך helper בריא.

8.2 אם חייבים in-process, מטים לתוך thread ייעודי

גם כשאי אפשר להפריד לתהליך נפרד, עדיף לייעד thread נפרד ל-dump בלבד.

עם זאת, גם ככה זה נשאר במהותו best effort. לא הופך ל”100% בטוח כי הוספנו מימוש dump עצמאי”.

8.3 דברים כבדים עוברים להפעלה הבאה

יש דברים שקל להתפתות לעשות ב-reporter עצמאי.

  • דחיסת zip
  • הצלבה עם מידע symbol
  • העלאה לשרת
  • לכידת מסך
  • שליפת מידע נוסף מ-DB

את אלה מעבירים לא לרגע הקריסה, אלא להפעלה הבאה או לצד ה-helper.

9. מה משתנה כשמוסיפים תהליך ניטור

במערכות שפועלות זמן ארוך, תהליך ניטור מאוד יעיל.

9.1 מה תהליך הניטור שומר

בצד watchdog‏/‏launcher‏/‏שירות אב, אפשר לשמור מידע כמו:

  • זמן הפעלת תהליך הבת
  • ארגומנטים בהפעלה
  • PID
  • גרסת היעד המנוטר
  • זמן קבלת ה-heartbeat האחרון
  • זמן הסיום
  • exit code
  • מספר הפעלות מחדש
  • קיום dump או לא
  • האם הופעל מחדש

רק בזכות זה, אפשר לראות בבירור

  • האם באמת קרס
  • האם זה היה כיבוי מערכת ההפעלה
  • האם המשתמש סגר
  • האם נהרג אחרי hang
  • כמה פעמים היה לולאת הפעלה מחדש
מה אפשר להבחין מרישום ה-watchdogתרשים המראה שכשתהליך הניטור שומר exit code וזמן סיום, קבלת heartbeat אחרונה, ומספר הפעלות מחדש, אפשר להבחין מבחוץ בין קריסה אמיתית, כיבוי מערכת, סיום משתמש, או לולאת הפעלה מחדש.exit code וזמן סיוםניתן להבחין מבחוץheartbeat אחרוןמספר הפעלות מחדשקריסה, כיבוי מערכת, או סיום משתמש

איור 21: רישום ה-watchdog מאפשר להבחין מבחוץ בין סוגי הסיום.

9.2 מקרים שמתאימים במיוחד

כדאי לשקול ברצינות הפרדה במקרים כמו:

  • worker שנושא SDK של ספק
  • עיבוד תמונה/וידאו, device I/O
  • לולאת אב לניטור או polling
  • הרצת סקריפט או plugin
  • host לנכסי COM‏/‏ActiveX קיימים
  • גשר או שיתוף פעולה בין 64bit ל-32bit

כליאת עיבוד מסוכן ב-worker אחד מקלה גם על תכנון היומן וגם על תכנון ההתאוששות.

10. טעויות נפוצות

כאן מרוכזות מלכודות ברמת התכנון. הנושא ברמת ההליך - “מה אסור לעשות בתוך handler הקריסה” - מרוכז בסעיף 5.2, אז מי שבתהליך המימוש כדאי שיפנה לשם. פריטים שנראים חופפים (כמו שליחת HTTP בסעיף 10.3) - סעיף 5.2 כותב “למה זה בעייתי בתוך ה-handler”, וכאן כתוב “מה קורה בתפעול כתוצאה מכך”.

10.1 ‏catch (Exception) ורק מדפיסים ליומן וממשיכים

הכי נפוץ, והכי מסוכן.

  • שינוי חלקי נשאר
  • מצב משותף נשבר
  • תקלות המשך מתרבות
  • נקודת הסיבה האמיתית מיטשטשת

במקום להוסיף שורת יומן אחת, לרוב התקלה מתארכת.

10.2 מסתמכים רק על תור של async logger

‏async logging כשלעצמו לא רע. הבעיה היא שגם ב-fatal path נערם לאותו תור ונגמר שם.

אם ה-worker נעצר ברגע הקריסה, כל התור נעלם איתו.

בטוח יותר להחזיק דרך מילוט שבה רק ה-fatal path כותב ישירות.

10.3 שולחים HTTP מתוך handler הקריסה

מפתה לממש, אבל זה די מסוכן.

  • DNS
  • TLS
  • proxy
  • אימות
  • timeout
  • המתנה לשליחה חוזרת

כל אלה רוכבים על ההקשר הקורס.

השליחה מתבצעת אחרי ההפעלה מחדש.

10.4 יש dump, אבל הוא לא מתחבר ליומן הרגיל

זה נפוץ מאוד.

  • אין session בשם קובץ ה-dump
  • אין PID‏/‏session בצד היומן
  • אין PID בצד ה-watchdog
  • מספרי ה-build לא תואמים

התוצאה: שלוש הראיות נראות כמו שלושה מקרים נפרדים.

כשל של ראיות שלא מתחברותתרשים המראה שכשלשם קובץ ה-dump חסר session, ליומן חסרים PID/session, ומספרי ה-build לא תואמים, שלוש הראיות - dump, יומן ורישום watchdog - נראות כמקרים נפרדים.בשם ה-dump חסר sessionשלוש הראיות נראות נפרדותביומן חסר PID/sessionמספרי build לא תואמים

איור 22: כשמפתח ההצלבה חסר, ראיות שהיו יכולות להתחבר נשארות נפרדות.

10.5 מאריכים חיים דרך אירועי חריגה בלתי מטופלת ב-WinForms/‏WPF

למראית עין “זה מפסיק לקרוס”, אז בהתחלה זה משמח. אבל בפועל, זה נוטה ליצור מצב זומבי שבו

  • רק המסך נשאר
  • ה-worker כבר מת
  • רק פעילות הכפתור נשארת
  • לא ברור אם השמירה הצליחה

10.6 לא בודקים את מסלולי הסיום בצד native

אם מסתפקים רק ב-SetUnhandledExceptionFilter, מפספסים את

  • ‏invalid parameter
  • ‏purecall
  • ‏terminate
  • ‏fast fail

ב-C++ נייטיבי, כדאי להיות מודעים לא רק ל-SEH, אלא גם למסלולי הסיום בצד ה-CRT/‏C++ runtime.

11. רשימת בדיקה מינימלית להטמעה

אם עומדים בכל אלה, זה כבר מוכן קרבית.

  • היומן הרגיל נשמר שורה אחת לאירוע
  • בכל שורות היומן יש UTC,‏ PID,‏ TID,‏ version,‏ session
  • ‏ProcessStart ו-ProcessExit נשמרים
  • אירועי גבול חשובים נעשה להם flush סינכרוני
  • קיים קובץ ייעודי לסמן הקריסה הסופי
  • ב-fatal path לא עוברים דרך async logger
  • ‏WER LocalDumps מוגדר ברמת אפליקציה
  • ה-ACL של יעד שמירת ה-dump אומת
  • ‏PDB והבינארי המופץ נשמרים
  • בהפעלה הבאה אפשר לזהות סיום חריג קודם
  • דחיסה/העלאה/התראה מתבצעים אחרי הפעלה מחדש או בתהליך נפרד
  • ב-C++ נייטיבי סודרו גם invalid parameter/‏purecall/‏terminate
  • במכשיר בדיקה, בוצעה קריסה מכוונת ואומת שהראיה באמת נשארת

השורה האחרונה חשובה במיוחד. אין טעם רק בתכנון - חייבים לבצע “בדיקה שבאמת תופסת עד הסוף”.

12. עד כמה בודקים

הפריטים שכדאי לוודא, מרוכזים בטבלה.

בדיקה מה בודקים
חריגה בלתי מטופלת ב-managed האם יומן רגיל, סמן fatal ו-dump נמצאים כולם
חריגה ב-thread של UI האם מסלול האירוע ב-WinForms/‏WPF תואם לציפייה
חריגה ב-thread של worker האם מגיע עד AppDomain.UnhandledException, והאם ה-watchdog מזהה
חריגה native האם ה-dump של WER באמת נלקח
invalid parameter / terminate האם נשאר רישום מזערי גם במסלול CRT/‏C++ runtime
הריגה כפויה גם אם אי אפשר ב-in-process, האם צד ה-watchdog רושם exit בלתי צפוי
הפעלה מחדש האם עובדים ההתראה, האיסוף וההעלאה בהפעלה הבאה

חשוב לא לומר “אמור להופיע יומן כשהחריגה נזרקת”, אלא לוודא “בתנאי הזה, הקובץ הזה נשאר”.

13. סיכום

אם רוצים להשאיר מידע לחקירה גם כשיישום Windows קורס מחריגה שמקורה בבאג, הציר המחשבתי די פשוט.

  • לא מסתמכים רק על התהליך הקורס
  • מחלקים ליומן רגיל, סמן קריסה סופי, וראיה בצד OS/‏תהליך נפרד
  • בקריסה, שומרים בקצרה ומקומית בלבד
  • עיבוד כבד עובר להפעלה הבאה או לתהליך נפרד
  • WER LocalDumps כתשתית
  • בסיס: רישום וסיום, ולא המשך

בסופו של דבר, “לבנות תצורה שאפשר לעקוב אחריה גם בלי השורה האחרונה” חזק יותר מ”להתאמץ על השורה האחרונה”.

תצורה שלא תלויה בשורה האחרונהתרשים המראה שהמטרה היא תצורה שאפשר לעקוב אחריה גם בלי השורה האחרונה, כשסמן הקריסה עדיין נשמר בקצרה בקובץ נפרד, והראיה העיקרית היא ה-dump והיומן הרגיל.המטרהתצורה שאפשר לעקוב אחריה גם בלי השורה האחרונההסמן נשמר בקצרה בקובץ נפרדהראיה העיקרית: dump ויומן רגיל

איור 23: עדיף לבנות תצורה שלא תלויה בשורה האחרונה, מאשר להתאמץ עליה בלבד.

ובכל זאת רוצים את השורה האחרונה, אז שומרים את סמן הקריסה הסופי בקצרה בקובץ נפרד. והראיה העיקרית האמיתית נשענת על ה-dump של 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

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

‏האם היישום שקורס יכול להבטיח בעצמו שמירת יומן?
לא. כשלוקחים בחשבון קריסת stack, השחתת זיכרון, fast fail, סיום כפוי וניתוק חשמל, היומן האחרון בתוך התהליך (in-process) הוא במהותו best effort. בעבודה בפועל, מחלקים לשלוש שכבות - יומן כרונולוגי בזמן רגיל, סמן קריסה סופי ברגע הנפילה, וראיות קריסה שנשמרות בצד מערכת ההפעלה או בתהליך נפרד - ובונים תצורה שלא מסתמכת רק על התהליך הקורס. הכי בטוח הוא שילוב של יומן רגיל + סמן קריסה סופי + WER LocalDumps.
‏מה אסור לעשות בתוך handler של קריסה?
כל עיבוד כבד. פתרון logger מ-DI container,‏ 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 או מחשבון מוגבל. בנוסף, איסוף dump ושמירת PDB והבינארי המופץ הם חבילה אחת - אם אחד מהם חסר, אי אפשר לקרוא בדיעבד.
‏האם מותר לתפוס חריגה ולהמשיך עם DispatcherUnhandledException ב-WPF?
מסוכן כשמדובר בחריגה בלתי צפויה שמקורה בבאג. עם Handled = true אפשר להמשיך למראית עין, אבל זה נוטה ליצור מצב זומבי - המסך נשאר אבל ה-worker כבר מת, כפתור נשאר פעיל אבל לא ברור אם השמירה הצליחה. בטוח יותר להשתמש באירוע החריגה הבלתי מטופלת כשער לרישום ולא כמנגנון התאוששות - לכתוב את סמן הקריסה הסופי ואז לסיים בלי להמשיך, ולהשאיר את הראיה העיקרית ל-dump של WER וליומן הרגיל עד לרגע הקריסה. לסיום כדאי לשקול API של סיום מיידי כמו Environment.FailFast.

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג