איך משאירים לוגים ו-crash dump כשאפליקציית Windows קורסת

· עודכן בתאריך: · · פיתוח Windows, exception handling, לוגים, WER, crash dump, חקירת תקלות

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 19 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173643)

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

Go Komura (2026). איך משאירים לוגים ו-crash dump כשאפליקציית Windows קורסת. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173643 https://comcomponent.com/he/blog/windows-app-crash-logging-best-practices/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173643
DOI (הגרסה הזו)
10.5281/zenodo.22173644

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

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

  • קורס רק בסביבת הלקוח
  • קורס רק אחרי הרצה ארוכה
  • WPF / WinForms / Windows Service / אפליקציה שרצה ברקע, עם repro נמוך
  • מעורבים COM, P/Invoke, native DLL, vendor SDK
  • יש “רק את הודעת ה-exception”, בלי הקשר של מה קרה רגע לפני

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

הלוג האחרון in-process הוא best effortתרשים שמראה שברגע שמכניסים לחשבון stack corruption, memory corruption, fast fail, kill כפוי וניתוק חשמל, אי אפשר להבטיח שנשמר לוג רק מתוך התהליך שקורס, והלוג האחרון in-process הוא במהותו best effort.stack corruption / memory corruptionהלוג האחרון in-processfast fail / kill כפויניתוק חשמלבמהותו best effortאי אפשר להבטיח בוודאות

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

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

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

המאמר הזה, בהנחה של אפליקציות שולחן עבודה ל-Windows, אפליקציות שרצות ברקע, Windows Service וכלי חיבור לציוד, מסדר best practices לשמירה על יכולת חקירה גם כשהאפליקציה קורסת מ-exception שמקורו ב-bug.

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

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

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

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

חלוקת תפקידים לפני, ברגע ואחרי הקריסהתרשים שמראה שה-best practice הוא לא לעשות הכול ברגע הקריסה, אלא לחלק תפקידים: לפני הקריסה לוג רגיל, ברגע הקריסה כתיבה מקומית קצרה, ואחרי הקריסה דחיסה, upload והתראה.לפני: שומרים לוג רגילברגע הקריסה: כתיבה מקומית קצרה בלבדאחרי: דחיסה / upload / התראהב-startup הבא או בתהליך נפרד

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

1.1 מונחים שחוזרים במאמר

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

מונח פירוש משמעות
WER Windows Error Reporting מנגנון Windows שתופס ומתעד סיום חריג של אפליקציה בצד ה-OS. ההגדרה ששומרת dump מקומי נקראת LocalDumps
dump / minidump crash dump קובץ ששומר את תוכן הזיכרון של התהליך ברגע הקריסה. אפשר לראות אחר כך threads, stacks ומודולים
PDB Program Database קובץ symbols שנוצר ב-build. בלעדיו, גם כשפותחים dump לא מתקבלים שמות פונקציות או מספרי שורות
in-process בתוך התהליך עיבוד בתוך התהליך שקורס עצמו. ההפך: תהליך נפרד
best effort מאמץ מיטבי “נשמר אם מסתדר, אין הבטחה”. ככה מתנהג הלוג in-process ברגע הקריסה
fast fail / __fastfail סיום כשל מהיר כשמחליטים שהמצב שבור, מסיימים מיד במינימום פעולות ובלי cleanup. ב-native זה __fastfail, ב-.NET המקביל הוא Environment.FailFast
watchdog תהליך ניטור תהליך נפרד שמשגיח מבחוץ על startup, סיום וחיוּת של התהליך הראשי. אפשר לבנות גם כ-launcher או כ-parent service
heartbeat אות חיים אות שמודיע ל-watchdog בקביעות “עדיין רץ”
UNC path Universal Naming Convention נתיב share ברשת בפורמט \\server\share\.... שימוש בו בזמן קריסה גורם להמתנה בגלל ניתוק רגעי או credentials
ACL Access Control List מי רשאי לקרוא ולכתוב לתיקייה. סיבה קבועה לכך ש-dump או לוג לא נכתבים בכלל
SEH Structured Exception Handling מנגנון ה-exceptions הנייטיבי של Windows. SetUnhandledExceptionFilter שייך לכאן
CRT C Runtime מימוש הספרייה הסטנדרטית של C / C++. יש לו termination paths משלו, בנפרד מ-SEH
session session ID ערך שמזהה “על איזה מופע הרצה מדובר”. המפתח להצלבת לוג, dump ורישומי watchdog

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 24, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. למה לוג in-process לבדו לא יכול להיות ודאי

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

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

hook של unhandled exception או top-level exception filter עלולים לרוץ ב-context של ה-thread השבור. בשלב הזה, זה קורה באופן שגרתי:

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

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

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

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

2.2 fast fail ו-corrupted-state exceptions מניחים פעולה מזערית in-process

במצב של memory corruption או מצב קטלני, עדיף לא לצפות מ-exception handling רגיל. בפרט, __fastfail בצד native וחריגות שחשודות ב-corrupted state מתוכננים לכיוון “סיום מיידי עם overhead מינימלי”.

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

2.3 גם unhandled exception event ב-.NET אינו מקום ל-recovery כבד

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

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

ריאלי לתפוס את זה כ“unhandled exception event = ההתראה האחרונה”, לא כ“נקודת recovery בטוחה”.

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

איור 4: unhandled exception event הוא entry point לתיעוד, לא מקום ל-recovery.

3. ארכיטקטורה מומלצת: מפרידים crash-time מ-after-restart

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

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

מפת שלוש השכבות של תיעוד הקריסהתרשים שמראה שתהליך האפליקציה כותב לוג רגיל ו-fatal crash marker, WER LocalDumps שומר dump מחוץ לתהליך, תהליך watchdog רושם exit code והחלטת restart, וכולם נכתבים לתיקייה מקומית קבועה שממנה מתבצע עיבוד אחרי ה-startup הבא.תהליך בריא אחרי restartתהליך watchdogצד Windowsתהליך האפליקציהexceptionסיום תהליךסיום תהליךדחיסה / upload / התראה: מזהה סיום חריג קודםרושם exit code וזמן סיום: מחליט על restartWER LocalDumps: שומר dump מחוץ לתהליךלוג רגיל: timeline append-onlyfatal crash marker: שורה אחת ואז סיוםתיקייה מקומית קבועה

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

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

  • התהליך שקורס כותב בעצמו רק שני דברים — שנמצאים בתוך המסגרת “תהליך האפליקציה”. וגם ה-fatal crash marker מסתכם ב”כותבים שורה אחת ומסיימים”.
  • התיעוד העיקרי נמצא מחוץ לתהליך. ה-dump של WER ורישום ה-watchdog נשארים גם כשהאפליקציה שבורה.
  • מפתח ההצלבה הוא session ID ו-PID משותפים. בלעדיהם, שלושת קבצי התיעוד נראים כמו שלושה מקרים נפרדים.
שלב מטרה היכן רץ מה עושים
זמן ריצה רגיל לשמור timeline בתוך האפליקציה לוג מובנה, heartbeat, boundary events
ברגע הקריסה להשאיר תיעוד מינימלי בתוך האפליקציה + OS fatal crash marker, dump של WER
מיד אחרי הסיום לזהות unexpected exit תהליך נפרד רישום exit code, החלטת restart, התראה
אחרי ה-startup הבא עיבוד כבד תהליך בריא חדש דחיסה, upload, התראת משתמש, ניקוי לוגים ישנים

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

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

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

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

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

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

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

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

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

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

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

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

4. Best practices ללוג הרגיל

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

4.1 לוג צריך מידע שאפשר להצליב אחר כך, לא טקסט לבני אדם

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

  • timestamp ב-UTC
  • זמן שחלף מתחילת התהליך
  • PID / TID
  • שם האפליקציה, גרסה, מספר build, מזהה commit
  • session ID
  • operation ID / job ID / correlation ID
  • שם מודול / שם מסך / שם worker
  • הפעולה החיצונית האחרונה
    • כתיבה לקובץ
    • עדכון DB
    • שליחת פקודה לציוד
    • בקשת תקשורת
  • סוג ה-exception, HRESULT / Win32 error / exception code
  • תקציר פרמטרי הקלט העיקריים
  • מזהה יעד, בטווח שלא כולל מידע סודי

מומלץ פורמט 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 מוקדם
  • boundary events חשובים: לשמור באופן סינכרוני
    • ProcessStart
    • ConfigLoaded
    • WorkerStarted
    • ExternalCommandSent
    • TransactionCommitted
    • RecoveryStarted
    • FatalPathEntered

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

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

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

4.3 מפרידים בין הלוג הרגיל שנכתב עכשיו לבין ה-crash marker האחרון

זה די חשוב.

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

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

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

  • app-<session>.jsonl לוג כרונולוגי רגיל
  • fatal-last.log או fatal-<session>.log ייעודי ל-fatal crash marker

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

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

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

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

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

כי מעורבים:

  • ניתוק רגעי ברשת
  • עיכוב DNS
  • פקיעת credentials
  • המתנה ב-UI thread
  • חוסר הרשאות ב-service account

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

4.5 שמים session בשם הקובץ

תאריך בלבד לא מספיק. כי עושים restart כמה פעמים באותו יום.

לדוגמה, בצורה כזו.

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

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

5. Best practices ל-fatal crash marker

זה לא המקום לבנות logger מלא. זה מקום לכתיבה חד-פעמית, קצרה, שנוטה להיות ודאית.

5.1 המטרה היא לקבע entry point, לא לפרט את הסיבה

עדיף לצמצם את המידע שנכנס ל-fatal crash marker.

  • UTC של האירוע
  • PID / TID
  • session ID
  • גרסה / מספר build
  • מאיזה hook זה הגיע
    • AppDomain.UnhandledException
    • Application.ThreadException
    • DispatcherUnhandledException
    • SetUnhandledExceptionFilter
    • _set_invalid_parameter_handler
    • set_terminate
  • סוג ה-exception או exception code
  • הודעה קצרה, אם אפשר
  • מזהה הפעולה האחרונה
  • שם קובץ הלוג הרגיל
  • תיקיית ה-dump המשוערת

זה מספיק.

מטרת ה-marker היא לקבע entry pointתרשים שמראה ש-fatal crash marker לא מכיל את פרטי הסיבה אלא מקבע entry point לחקירה: איזה hook, סוג exception, session, שם לוג ותיקיית dump, כשפרטי הסיבה עצמם נמצאים ב-dump ובלוג הרגיל.fatal crash markerhook, סוג exception, sessionשם לוג רגיל ותיקיית dumpentry point לחקירה מקובעפרטי הסיבה: ב-dump ובלוג הרגיל

איור 10: תפקיד ה-marker הוא לא פרטי הסיבה, אלא לקבע את ה-entry point לחקירה.

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

כל אחד מאלה נוטה מאוד להיכשל.

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

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

5.3 מה כן עושים ב-crash handler

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

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

בסדר הזה.

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

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

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

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

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

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

עבור unexpected exception שמקורו ב-bug, בטוח יותר לחשוב על ה-handler האחרון כמנגנון תיעוד, לא מנגנון recovery.

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

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

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

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

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

איור 12: ה-handler האחרון הוא מנגנון תיעוד וסיום, לא מנגנון recovery.

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

    // דגל שמונע reentrancy. 0 פירושו טרם נכתב
    private static int _written;

    /// <summary>קוראים לזה פעם אחת בלבד, מיד אחרי startup של האפליקציה.</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);

        // לא מסיימים כאן. זה unhandled exception, אז ה-CLR ימשיך אחר כך ל-termination המוגדר כברירת מחדל.
        // אם קוראים כאן ל-FailFast, הסיבה שנשארת ב-dump של WER
        // תוחלף מה-exception המקורי ל-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 של ה-OS
                stream.Flush(true);
            }
        }
        catch
        {
            // אם נכשל כאן, אין יותר מה לעשות. בולעים ומסיימים
        }
    }

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

בצד הקורא, רק קוראים ל-Install() מיד אחרי ה-startup. אם שוכחים את זה, הקוד למעלה לא פועל אפילו בייט אחד.

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

        // מכאן ואילך, תהליך ה-startup הרגיל של האפליקציה
        RunApplication(args);
    }

    private static void RunApplication(string[] args)
    {
        // לדוגמה: אם הוחלט ש-shared state שבור, מפילים בלי להמשיך
        // FatalMarker.FailNow("device state inconsistent");
    }
}

60 השורות האלה שומרות רק על ארבעת הדברים שהוזכרו בסעיף 5.3.

  1. Interlocked.Exchange מונע reentrancy
  2. שרשור מחרוזות כותב שורה אחת
  3. Flush(true) כותב לדיסק
  4. מ-UnhandledException לא ממשיכים

Environment.FailFast כותב הודעה ל-Windows Application Event Log, מסיים מיד את התהליך, וכולל את התוכן גם ב-error report. כלומר, גם FailFast עצמו מוסיף תיעוד אחד. אבל כפי שצוין קודם, קריאה לו במסלול unhandled exception משנה את אופן הופעת ה-dump, אז בוחרים בזהירות איפה לקרוא לו.

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

איור 13: FailFast מוסיף תיעוד, אבל קריאה לו מתוך unhandled exception מחליפה את הסיבה.

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

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

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

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

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

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

6.2 WinForms: Application.ThreadException

הקושי כאן הוא שהוא תופס unhandled exception ב-UI thread, כך שאפשר להמשיך לרוץ למראית עין.

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

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

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

6.3 WPF: Application.DispatcherUnhandledException

דומה גם ב-WPF.

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

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

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

איור 14: אירועי UI ב-WinForms / WPF משמשים entry point לתיעוד, לא מנגנון להשאיר את התהליך בחיים.

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

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

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

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

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

אבל עדיף לא להפוך אותו ל-handler הראשי של הקריסה.

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

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

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

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

לכן, נכון להתייחס ל-SetUnhandledExceptionFilter כentry point מסוג best effort לקבלת ההתראה האחרונה.

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

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

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

  • _set_invalid_parameter_handler
  • _set_purecall_handler
  • set_terminate

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

בפועל, סביר:

  • לכתוב fatal crash marker גם ב-handlers האלה
  • אבל לא לעשות recovery כבד
  • לוודא סיום
  • להשאיר את התיעוד העיקרי ל-WER / dump
SEH בלבד יוצר פספוסיםתרשים שמראה שב-C++ נייטיבי, הסתכלות רק על SetUnhandledExceptionFilter מפספסת את ה-termination paths של ה-CRT, ולכן קולטים גם invalid parameter, purecall ו-terminate, כותבים תיעוד מזערי ומוודאים סיום, כשהתיעוד העיקרי ב-WER/dump.SetUnhandledExceptionFilter בלבדפספוס termination paths של CRTקולטים גם invalid parameter / purecall / terminateתיעוד מזערי וסיום ודאיהתיעוד העיקרי: WER / dump

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

7. WER LocalDumps כתשתית

כאן, בפועל, זה די חזק.

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

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

הסיבה פשוטה:

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

מה שהלוג לבדו לא מגלה —

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

אפשר לראות אחר כך, וזה חזק.

למה WER LocalDumps חזקתרשים שמראה ש-WER LocalDumps שומר dump בצד ה-OS, ניתן להגדרה ברמת אפליקציה, מוציא את התיעוד העיקרי אל מחוץ ל-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 Service
  • child process עם הפרדת הרשאות
  • restricted account במחשב שטח
  • מעורבות UAC

ה-ACL של יעד השמירה הוא הגורם המרכזי לכך שה-dump לא נכתב בכלל.

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

  • יצירה מראש
  • בדיקת כתיבה
  • הגבלת מספר שמורים
  • האם זה מקום שהצוות התפעולי יכול לגשת אליו
מונעים dump שלא נכתב בגלל ACLתרשים שמראה ש-Windows Service ו-restricted accounts מגדירים בטעות תיקייה שאי אפשר לכתוב אליה, ה-dump לא נוצר, ומונעים זאת ביצירה מראש, בדיקת כתיבה, הגבלת כמות ובדיקה שהמקום נגיש לצוות התפעולי.למניעהservice / restricted accountהגדרת תיקייה שאי אפשר לכתוב אליהה-dump לא נכתביצירה מראש ובדיקת כתיבהגם הגבלת כמות ונגישות לצוות

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

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

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

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

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

  1. לוג רגיל מקומי
  2. fatal marker מקומי
  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 והבינארי מאותו build נשמרו יחד איתו.

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

“יש הרבה dumps באוסף, אבל אף אחד לא פתח אותם” — זה קורה ממש הרבה. את ההעמקה נשאיר למאמר ייעודי; כאן רק עשר הדקות הראשונות.

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

  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 קובעת יעד חיפוש ל-symbols. מציינים גם את ה-symbol server של Microsoft וגם את מיקום ה-PDB שלכם
.reload /f טוענת מחדש את ה-symbols בכפייה
.ecxr עוברת ל-register context של רגע ה-exception. אם שוכחים את זה, רואים את ה-wait stack בצד WER, לא את מקום הקריסה
!analyze -v מנתחת אוטומטית את ה-exception הנוכחי ומציגה פירוט. קוראים את זה קודם
~*k מציגה call stack של כל ה-threads. רואים מה עשו ה-threads שלא קרסו
lm v מציגה את המודולים הטעונים והגרסאות שלהם. משמש להצלבה עם ההפצה ומספר ה-build

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

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

איור 19: פתיחת dump מתחילה בהכנה, פתיחה ומעבר ל-exception context, בסדר הזה.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • דחיסת zip
  • הצלבה עם מידע symbols
  • upload לשרת
  • screen capture
  • שליפת מידע נוסף מ-DB

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

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

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

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

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

  • זמן הפעלת ה-child process
  • command-line arguments
  • PID
  • גרסת היעד המנוטר
  • זמן קבלת ה-heartbeat האחרון
  • זמן הסיום
  • exit code
  • מספר restart-ים
  • קיום dump או לא
  • האם בוצע restart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

10.3 שולחים HTTP מתוך crash handler

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

  • DNS
  • TLS
  • proxy
  • authentication
  • timeout
  • המתנה ל-retry

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

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

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 משאירים את התהליך בחיים דרך unhandled exception events ב-WinForms / WPF

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

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

10.6 לא בודקים את ה-termination paths בצד native

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

  • invalid parameter
  • purecall
  • terminate
  • fast fail

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

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

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

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

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

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

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

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

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

13. סיכום

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

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

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

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

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

ובכל זאת רוצים את השורה האחרונה, אז שומרים את ה-fatal crash marker בקצרה בקובץ נפרד. והתיעוד העיקרי האמיתי נשען על ה-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 corruption, memory corruption, fast fail, kill כפוי וניתוק חשמל, הלוג האחרון in-process הוא במהותו best effort. בפועל מחלקים לשלוש שכבות — לוג כרונולוגי בזמן ריצה רגילה, fatal crash marker ברגע הקריסה, ותיעוד קריסה שנשמר בצד ה-OS או בתהליך נפרד — ובונים תצורה שלא סומכת רק על התהליך שקורס. השילוב הכי שמרני הוא לוג רגיל + fatal crash marker + WER LocalDumps.
מה אסור לעשות בתוך crash handler?
כל עיבוד כבד. resolve של logger מ-DI container, async/await, המתנה על lock, בניית JSON מורכב, עבודה עם COM, דיאלוג UI, דחיסה, ושליחת HTTP/SMTP/Slack — כל אלה נוטים מאוד להיכשל. מה שכן עושים מצטמצם לארבעה צעדים: מונעים reentrancy, כותבים שורה אחת, עושים flush, ומסיימים. דחיסה, upload והתראה עוברים ל-startup הבא או לתהליך נפרד.
איך מגדירים WER LocalDumps?
תחת HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\שם-האפליקציה.exe ב-Registry מגדירים DumpFolder (תיקייה ייעודית), DumpCount (בערך 5–10), ו-DumpType (2 במחשב פיתוח; בשטח 1 או 2 לפי נפח ודרישות סודיות). הקריטי הוא לבדוק את ה-ACL של תיקיית היעד — הכשל האופייני הוא תיקייה ש-Windows Service או restricted account לא יכולים לכתוב אליה, ואז ה-dump פשוט לא נוצר. בנוסף, איסוף dump ושמירת PDB והבינארי שהופץ הם חבילה אחת: אם אחד מהם חסר, אי אפשר לקרוא את ה-dump אחר כך.
מותר לתפוס exception ב-DispatcherUnhandledException של WPF ולהמשיך?
מסוכן כשמדובר ב-unexpected exception שמקורו ב-bug. עם Handled=true אפשר להמשיך למראית עין, אבל זה נוטה לייצר מצב זומבי — המסך נשאר וה-worker כבר מת, הכפתור נשאר enabled אבל לא ברור אם השמירה הצליחה. בטוח יותר להשתמש ב-unhandled exception event כ-entry point לתיעוד, לא כמנגנון recovery: לכתוב את ה-fatal crash marker ואז לסיים בלי להמשיך, ולהשאיר את התיעוד העיקרי ל-dump של WER וללוג הרגיל עד לרגע הקריסה. לסיום כדאי לשקול API של סיום מיידי כמו Environment.FailFast.

פרופיל הכותב

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

Go Komura

מנהל KomuraSoft LLC

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

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

חזרה לבלוג