איך משאירים לוגים ו-crash dump כשאפליקציית Windows קורסת
· עודכן בתאריך: · Go Komura · פיתוח 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.
flowchart TB
accTitle: הלוג האחרון in-process הוא best effort
accDescr: תרשים שמראה שברגע שמכניסים לחשבון stack corruption, memory corruption, fast fail, kill כפוי וניתוק חשמל, אי אפשר להבטיח שנשמר לוג רק מתוך התהליך שקורס, והלוג האחרון in-process הוא במהותו best effort.
a1["stack corruption / memory corruption"] --> a4["הלוג האחרון in-process"]
a2["fast fail / kill כפוי"] --> a4
a3["ניתוק חשמל"] --> a4
a4 --> a5["במהותו best effort"]
a5 -.-> a6["אי אפשר להבטיח בוודאות"]
איור 1: התהליך שקורס לבדו לא יכול להבטיח שהלוג האחרון יישמר.
בפועל המטרה היא תצורה שלא סומכת רק על התהליך שקורס. כלומר, חושבים בשלוש שכבות:
- לוג כרונולוגי בזמן ריצה רגילה
- fatal crash marker ברגע הקריסה
- תיעוד קריסה שנשמר בצד ה-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 הוא לא לנסות לעשות הכול ברגע הקריסה. מחלקים תפקידים לפני הקריסה, ברגע הקריסה, ואחריה.
flowchart TB
accTitle: חלוקת תפקידים לפני, ברגע ואחרי הקריסה
accDescr: תרשים שמראה שה-best practice הוא לא לעשות הכול ברגע הקריסה, אלא לחלק תפקידים: לפני הקריסה לוג רגיל, ברגע הקריסה כתיבה מקומית קצרה, ואחרי הקריסה דחיסה, upload והתראה.
b1["לפני: שומרים לוג רגיל"] --> b2["ברגע הקריסה: כתיבה מקומית קצרה בלבד"]
b2 --> b3["אחרי: דחיסה / upload / התראה"]
b3 -.-> b4["ב-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 האחרון לא כ”מקום שאפשר לעשות בו הכול”, אלא כמקום שאפשר לעשות בו מעט מאוד.
flowchart TB
accTitle: ב-handler האחרון אפשר לעשות מעט
accDescr: תרשים שמראה ש-hook של unhandled exception עלול לרוץ ב-context של thread שבור, כשה-stack וה-heap לא בטוחים, המתנה על lock עלולה לתקוע, וגם התלויות של ה-logger שבורות, ולכן זה מקום שאפשר לעשות בו מעט מאוד.
c1["רץ ב-context של thread שבור"] --> c2["stack ו-heap לא בטוחים"]
c1 --> c3["המתנה על lock עלולה לתקוע"]
c1 --> c4["תלויות ה-logger עלולות להיות שבורות"]
c2 --> c5["מקום שאפשר לעשות בו מעט מאוד"]
c3 --> c5
c4 --> c5
איור 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 בטוחה”.
flowchart TB
accTitle: unhandled exception event הוא התראה אחרונה
accDescr: תרשים שמראה שב-AppDomain.UnhandledException מותר רק תיעוד קצר, לא כל exception ניתן לתפוס בבטחה, ושהאירוע הוא ההתראה האחרונה ולא נקודת recovery בטוחה.
d1["unhandled exception event"] --> d2["מותר רק תיעוד קצר"]
d2 --> d3["משמש כהתראה אחרונה"]
d1 -.-> d4["אינו נקודת recovery בטוחה"]
d4 -.-> d5["מדיניות המשך כפויה משאירה תהליך חצי-שבור"]
איור 4: unhandled exception event הוא entry point לתיעוד, לא מקום ל-recovery.
3. ארכיטקטורה מומלצת: מפרידים crash-time מ-after-restart
הכי קל לסדר בשיטה שמפרידה בין מה עושים ברגע הקריסה למה עושים אחרי restart.
קודם, תרשים אחד שמראה לכל אחת משלוש השכבות באחריות איזה תהליך, ולאן היא נכתבת.
flowchart TD
accTitle: מפת שלוש השכבות של תיעוד הקריסה
accDescr: תרשים שמראה שתהליך האפליקציה כותב לוג רגיל ו-fatal crash marker, WER LocalDumps שומר dump מחוץ לתהליך, תהליך watchdog רושם exit code והחלטת restart, וכולם נכתבים לתיקייה מקומית קבועה שממנה מתבצע עיבוד אחרי ה-startup הבא.
subgraph APP["תהליך האפליקציה"]
L1["לוג רגיל: timeline append-only"]
L2["fatal crash marker: שורה אחת ואז סיום"]
end
subgraph WIN["צד Windows"]
WER["WER LocalDumps: שומר dump מחוץ לתהליך"]
end
subgraph WD["תהליך watchdog"]
EX["רושם exit code וזמן סיום: מחליט על restart"]
end
subgraph NEXT["תהליך בריא אחרי restart"]
POST["דחיסה / upload / התראה: מזהה סיום חריג קודם"]
end
DISK[("תיקייה מקומית קבועה")]
L1 --> DISK
L2 --> DISK
WER --> DISK
EX --> DISK
DISK --> POST
L1 -. exception .-> L2
L2 -. סיום תהליך .-> WER
L2 -. סיום תהליך .-> EX
איור 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: איסוף מידע לאבחון
הופכת מעשית מאוד.
flowchart TB
accTitle: חלוקת התפקידים בתצורה חזקה
accDescr: תרשים שמראה שבדרישות חזקות כמו 24/7 ובקרת ציוד, מחלקים לתהליך worker שמבצע את העיבוד המרכזי ולתהליך watchdog שמנטר startup, רושם exit ומבצע restart, כש-WER LocalDumps מוגדר בצד ה-worker ומידע האבחון נאסף ב-startup הבא או על ידי watchdog.
e0["דרישות מחמירות (24/7, בקרת ציוד)"] --> e1["worker: העיבוד המרכזי"]
e0 --> e2["watchdog: ניטור, רישום exit, restart"]
e1 -.-> e3["WER LocalDumps בצד ה-worker"]
e2 -.-> e4["איסוף אבחון ב-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
חשוב יותר “שאפשר יהיה להצליב שלושה קבצים אחר כך”, מאשר להשאיר טקסט ארוך לבני אדם.
flowchart TB
accTitle: שורה אחת מחברת שלושה קבצי תיעוד
accDescr: תרשים שמראה ששורה אחת בלוג הרגיל מתחברת ל-dump דרך pid ו-session, ולצד ה-build דרך ver ו-commit, ושהיכולת להצליב שלושה קבצים אחר כך חשובה יותר מטקסט ארוך לבני אדם.
f1["שורה אחת בלוג הרגיל"] --> f2["pid / session אל צד ה-dump"]
f1 --> f3["ver / commit אל צד ה-build"]
f2 --> f4["ניתן להצליב שלושה קבצים"]
f3 --> f4
איור 7: שורה אחת בלוג מתחברת לתיעוד אחר דרך pid, session, ver ו-commit.
4.2 שומרים אירועים קריטיים באופן סינכרוני
אם כל הלוג הרגיל נכתב סינכרונית, זה כבד. אבל אם הכול מסתמך על buffer אסינכרוני, הכול נעלם יחד ברגע הקריסה.
לכן, בפועל משנים את הטיפול לפי הרמה.
- אירועי
Informationדקים: מותר לאגור ב-buffer Warningומעלה: לעשות flush מוקדם- boundary events חשובים: לשמור באופן סינכרוני
- ProcessStart
- ConfigLoaded
- WorkerStarted
- ExternalCommandSent
- TransactionCommitted
- RecoveryStarted
- FatalPathEntered
בקיצור, רק את גבולות העסק כותבים לדיסק כמו שצריך.
flowchart TB
accTitle: הטיפול משתנה לפי רמת האירוע
accDescr: תרשים שמראה שאירועי Information דקים מותר לאגור ב-buffer, Warning ומעלה עושים flush מוקדם, ו-boundary events עסקיים נשמרים באופן סינכרוני, וכך רק גבולות העסק נכתבים לדיסק.
g0["כתיבת לוג"] --> g1["Information: מותר buffer"]
g0 --> g2["Warning ומעלה: flush מוקדם"]
g0 --> g3["boundary event: שמירה סינכרונית"]
g3 -.-> g4["רק גבולות עסק נכתבים לדיסק"]
איור 8: לא הכול סינכרוני ולא הכול ב-buffer — הטיפול משתנה לפי רמת האירוע.
4.3 מפרידים בין הלוג הרגיל שנכתב עכשיו לבין ה-crash marker האחרון
זה די חשוב.
אם מנסים לשים הכול ב-rolling log אחד, זה קורה:
- היה באמצע rotation
- נשאר בתור אסינכרוני
- ה-logger עצמו מת מיד אחרי ה-exception
- שורת הלוג נחתכה באמצע
לכן מומלץ לפצל לפחות לשני קבצים:
app-<session>.jsonlלוג כרונולוגי רגילfatal-last.logאוfatal-<session>.logייעודי ל-fatal crash marker
עצם הבהירות “איפה נשמרת השורה האחרונה” עוזרת מאוד בשטח.
flowchart TB
accTitle: מפרידים לוג רגיל מ-fatal marker
accDescr: תרשים שמראה ששימוש ב-rolling log אחד מסתכן באובדן השורה האחרונה עקב rotation, תור אסינכרוני או מות ה-logger, ולכן מפצלים לשני קבצים: לוג כרונולוגי רגיל וקובץ ייעודי ל-fatal crash marker.
h1["הכול ב-rolling log אחד"] --> h2["נעלם ב-rotation / תור / מות logger"]
h2 -.->|"במקום זאת"| h3["מפצלים ללוג רגיל ו-fatal marker"]
h3 --> h4["ברור איפה נשמרת השורה האחרונה"]
איור 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.UnhandledExceptionApplication.ThreadExceptionDispatcherUnhandledExceptionSetUnhandledExceptionFilter_set_invalid_parameter_handlerset_terminate
- סוג ה-exception או exception code
- הודעה קצרה, אם אפשר
- מזהה הפעולה האחרונה
- שם קובץ הלוג הרגיל
- תיקיית ה-dump המשוערת
זה מספיק.
flowchart TB
accTitle: מטרת ה-marker היא לקבע entry point
accDescr: תרשים שמראה ש-fatal crash marker לא מכיל את פרטי הסיבה אלא מקבע entry point לחקירה: איזה hook, סוג exception, session, שם לוג ותיקיית dump, כשפרטי הסיבה עצמם נמצאים ב-dump ובלוג הרגיל.
i1["fatal crash marker"] --> i2["hook, סוג exception, session"]
i1 --> i3["שם לוג רגיל ותיקיית dump"]
i2 --> i4["entry point לחקירה מקובע"]
i3 --> i4
i4 -.-> i5["פרטי הסיבה: ב-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
ולהפך, מה שכן עושים די פשוט.
- מונעים reentrancy
- כותבים שורה אחת בלבד
- עושים flush
- מסיימים
בסדר הזה.
רצוי להשתמש ב-
- תיקייה ייעודית שנוצרה מראש
- נתיב שקיומו אומת מראש
- יעד שה-ACL שלו אומת
בלוג הרגיל, יותר מדי flush כבד, אבל סמן ה-fatal הוא מספר זעיר של פעמים, אז מותר כאן flush חזק.
ב-.NET זה FileStream.Flush(true), ב-native זה FlushFileBuffers — קל יותר לתכנן אם מתייחסים לזה כ“רק את השורה הזו כותבים לדיסק מיד”.
flowchart TB
accTitle: ארבעת שלבי ה-crash handler
accDescr: תרשים שמראה שב-crash handler עושים רק ארבעה שלבים: מונעים reentrancy, כותבים שורה אחת, עושים flush, ומסיימים, ומכיוון שסמן ה-fatal נדיר, מותר flush חזק שכותב לדיסק.
j1["1. מונעים reentrancy"] --> j2["2. כותבים שורה אחת"]
j2 --> j3["3. עושים flush"]
j3 --> j4["4. מסיימים"]
j3 -.-> j5["השורה הזו נכתבת לדיסק מיד"]
איור 11: ב-handler עושים רק ארבעה שלבים, בסדר הזה בלבד.
5.4 לא מנסים להמשיך
עבור unexpected exception שמקורו ב-bug, בטוח יותר לחשוב על ה-handler האחרון כמנגנון תיעוד, לא מנגנון recovery.
בפרט, כדאי לבסס “לא ממשיכים” במקרים כמו:
- גם
NullReferenceExceptionאוInvalidOperationExceptionשקרו באמצע עדכון shared state - unexpected exception ב-UI thread
- unexpected exception שדלף מלולאת ניטור או מלולאת האב
AccessViolationExceptionStackOverflowException- חריגה בגבול native
- invalid parameter / purecall / terminate של ה-CRT
מובן הרצון “לא רוצים להפיל”, אבל לשרוד במצב חצי-שבור לרוב קשה יותר גם לאבחון וגם לתפעול.
בזמן הסיום, כדאי לשקול API של סיום מיידי — ב-.NET זה Environment.FailFast, ב-native זה RaiseFailFastException או __fastfail — ולא לצפות מ-finally או מ-cleanup רגיל.
flowchart TB
accTitle: מנגנון תיעוד, לא מנגנון recovery
accDescr: תרשים שמראה שב-unexpected exception שמקורו ב-bug, הישרדות במצב חצי-שבור קשה גם לאבחון וגם לתפעול, ולכן עדיף לתעד ולסיים בעזרת API של סיום מיידי כמו FailFast.
k1["exception שמקורו ב-bug"] --> k2["הישרדות במצב חצי-שבור"]
k1 --> k3["תיעוד וסיום"]
k2 -.-> k4["קשה לאבחון ולתפעול"]
k3 --> k5["לשקול 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.
Interlocked.Exchangeמונע reentrancy- שרשור מחרוזות כותב שורה אחת
Flush(true)כותב לדיסק- מ-
UnhandledExceptionלא ממשיכים
Environment.FailFast כותב הודעה ל-Windows Application Event Log, מסיים מיד את התהליך, וכולל את התוכן גם ב-error report. כלומר, גם FailFast עצמו מוסיף תיעוד אחד. אבל כפי שצוין קודם, קריאה לו במסלול unhandled exception משנה את אופן הופעת ה-dump, אז בוחרים בזהירות איפה לקרוא לו.
flowchart TB
accTitle: בוחרים איפה קוראים ל-FailFast
accDescr: תרשים שמראה ש-Environment.FailFast כותב ל-Event Log ומסיים מיד, מוסיף תיעוד אחד, אך קריאה לו במסלול unhandled exception מחליפה את סיבת ה-dump מה-exception המקורי ל-FailFast, ולכן בוחרים בזהירות היכן לקרוא לו.
l1["Environment.FailFast"] --> l2["כותב ל-Event Log ומסיים מיד"]
l2 --> l3["מוסיף תיעוד אחד"]
l1 -.-> l4["קריאה במסלול unhandled exception"]
l4 -.-> l5["סיבת ה-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 לתיעוד.
flowchart TB
accTitle: לא משאירים את התהליך בחיים דרך אירועי UI
accDescr: תרשים שמראה ש-ThreadException של WinForms ו-DispatcherUnhandledException של WPF מאפשרים המשך למראית עין, אך מול exception שמקורו ב-bug נוצר פער בין מצב המסך למצב הפנימי, ולכן משתמשים בהם כ-entry point לתיעוד ומסיימים תוך שמירת dump ולוג.
m1["unhandled exception ב-UI"] --> m2["המשך למראית עין"]
m2 -.-> m3["פער בין מסך למצב פנימי"]
m2 --> m4["משמש כ-entry point לתיעוד"]
m4 --> m5["מסיימים ושומרים 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_handlerset_terminate
הקבוצה הזו קולטת “termination paths” שמקורם ב-C runtime או ב-C++ runtime.
בפועל, סביר:
- לכתוב fatal crash marker גם ב-handlers האלה
- אבל לא לעשות recovery כבד
- לוודא סיום
- להשאיר את התיעוד העיקרי ל-WER / dump
flowchart TB
accTitle: SEH בלבד יוצר פספוסים
accDescr: תרשים שמראה שב-C++ נייטיבי, הסתכלות רק על SetUnhandledExceptionFilter מפספסת את ה-termination paths של ה-CRT, ולכן קולטים גם invalid parameter, purecall ו-terminate, כותבים תיעוד מזערי ומוודאים סיום, כשהתיעוד העיקרי ב-WER/dump.
n1["SetUnhandledExceptionFilter בלבד"] --> n2["פספוס termination paths של CRT"]
n2 --> n3["קולטים גם invalid parameter / purecall / terminate"]
n3 --> n4["תיעוד מזערי וסיום ודאי"]
n4 -.-> n5["התיעוד העיקרי: 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
אפשר לראות אחר כך, וזה חזק.
flowchart TB
accTitle: למה WER LocalDumps חזק
accDescr: תרשים שמראה ש-WER LocalDumps שומר dump בצד ה-OS, ניתן להגדרה ברמת אפליקציה, מוציא את התיעוד העיקרי אל מחוץ ל-in-process, ומאפשר לראות אחר כך thread, stack וגבול מודול.
p1["WER LocalDumps"] --> p2["שומר dump בצד ה-OS"]
p1 --> p3["ניתן להגדרה ברמת אפליקציה"]
p2 --> p4["מוציא תיעוד אל מחוץ ל-in-process"]
p3 --> p4
p4 -.-> p5["רואים 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 לא נכתב בכלל.
בודקים את יעד השמירה עד ל-
- יצירה מראש
- בדיקת כתיבה
- הגבלת מספר שמורים
- האם זה מקום שהצוות התפעולי יכול לגשת אליו
flowchart TB
accTitle: מונעים dump שלא נכתב בגלל ACL
accDescr: תרשים שמראה ש-Windows Service ו-restricted accounts מגדירים בטעות תיקייה שאי אפשר לכתוב אליה, ה-dump לא נוצר, ומונעים זאת ביצירה מראש, בדיקת כתיבה, הגבלת כמות ובדיקה שהמקום נגיש לצוות התפעולי.
q1["service / restricted account"] --> q2["הגדרת תיקייה שאי אפשר לכתוב אליה"]
q2 --> q3["ה-dump לא נכתב"]
q3 -.->|"למניעה"| q4["יצירה מראש ובדיקת כתיבה"]
q4 --> q5["גם הגבלת כמות ונגישות לצוות"]
איור 17: הגורם המרכזי ל-dump שלא נכתב הוא ACL, ובודקים כתיבה מראש כדי למנוע.
7.4 כשרוצים לצרף את הלוג הנוכחי לדוח WER
אם משתמשים ב-WER report ל-Microsoft או בתפעול WER עצמאי, יש גם אפשרות לרשום דרך WerRegisterFile כדי לכלול את קובץ הלוג הנוכחי ב-error report.
עם זאת, בטוח יותר להתייחס לזה כמסלול נוסף, לא כתחליף לשמירה מקומית. כי מה שבאמת רוצים ברגע הקריסה הוא קודם כול שמירה מקומית ודאית יחסית על המכשיר עצמו.
מבחינת הסדר, זה מעשי יותר:
- לוג רגיל מקומי
- fatal marker מקומי
- dump מקומי
- במידת הצורך, רישום קבצים קשורים גם במסלול השליחה של WER
7.5 שומרים גם ניהול גרסאות, לא רק dump
גם אם לוקחים dump, אם אחר כך —
- אין את ה-EXE / DLL מאותו זמן
- אין PDB
- לא ברור באיזה commit נבנה
זה נחלש מאוד.
לפחות אלה חייבים להישמר:
- הבינארי שהופץ
- ה-PDB המתאים
- גרסה
- זמן ה-build
- מזהה commit
- גרסת המתקין
איסוף dump ושמירת PDB הם חבילה אחת.
flowchart TB
accTitle: איסוף dump ושמירת PDB הם חבילה אחת
accDescr: תרשים שמראה שאם שומרים dump בלבד בלי PDB והבינארי מאותו זמן, לא ניתן לקרוא אותו אחר כך, ולכן שומרים גם את הבינארי שהופץ, ה-PDB, הגרסה ומזהה ה-commit יחד עם איסוף ה-dump.
r1["שומרים dump בלבד"] --> r2["אין PDB או בינארי מאותו זמן"]
r2 --> r3["אי אפשר לקרוא אחר כך"]
r3 -.->|"לכן"| r4["שומרים בינארי שהופץ ו-PDB"]
r4 --> r5["שומרים גם גרסה ומזהה commit"]
איור 18: אפשר לקרוא dump רק כשה-PDB והבינארי מאותו build נשמרו יחד איתו.
7.6 עד לפתיחה הראשונה של dump שנתפס
“יש הרבה dumps באוסף, אבל אף אחד לא פתח אותם” — זה קורה ממש הרבה. את ההעמקה נשאיר למאמר ייעודי; כאן רק עשר הדקות הראשונות.
ההכנה כוללת שני דברים.
- לרכז לתוך תיקייה אחת PDB ובינארי שהופץ מאותו build כמו ה-dump
- להכין את 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.
flowchart TB
accTitle: עשר הדקות הראשונות בפתיחת dump
accDescr: תרשים שמראה שמרכזים PDB ובינארי מאותו build, פותחים את ה-dump ב-WinDbg, עוברים ל-exception context וקוראים ניתוח, ואם לא מתקבל שם פונקציה, זה סימן ל-PDB מ-build אחר וחוזרים לניהול גרסאות.
s1["מרכזים PDB ובינארי מאותו build"] --> s2["פותחים dump ב-WinDbg"]
s2 --> s3["עוברים ל-exception context, קוראים ניתוח"]
s3 -.-> s4["בלי .ecxr: רואים wait stack של WER"]
s3 --> s5{"מתקבל שם פונקציה ומספר שורה"}
s5 -->|"לא"| s6["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 עדיין בריא.
sequenceDiagram
accTitle: לקיחת dump על ידי helper בתהליך נפרד
accDescr: תרשים שמראה שכשה-worker הראשי מזהה תקלה, הוא מודיע ל-helper דרך event או named pipe, וה-helper הבריא לוקח dump של ה-worker, אוסף לוגים וקבצי config, ושם בתור upload אחרי הסיום.
participant W as worker
participant H as helper
W->>H: מודיע על תקלה דרך event או pipe
H->>W: לוקח dump של ה-worker
H->>H: אוסף tail של לוגים וקבצי config
H->>H: שם בתור upload אחרי הסיום
Note over H: גם אם ה-worker שבור, ה-helper בריא
איור 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
flowchart TB
accTitle: מה אפשר להבחין מרישום ה-watchdog
accDescr: תרשים שמראה שכשתהליך הניטור שומר exit code וזמן סיום, קבלת heartbeat אחרונה, ומספר restart-ים, אפשר להבחין מבחוץ בין קריסה אמיתית, shutdown של ה-OS, סיום משתמש, או restart loop.
t1["exit code וזמן סיום"] --> t4["ניתן להבחין מבחוץ"]
t2["heartbeat אחרון"] --> t4
t3["מספר restart-ים"] --> t4
t4 -.-> t5["קריסה, 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 לא תואמים
התוצאה: שלושת קבצי התיעוד נראים כמו שלושה מקרים נפרדים.
flowchart TB
accTitle: כשל של תיעוד שלא מתחבר
accDescr: תרשים שמראה שכשלשם קובץ ה-dump חסר session, ללוג חסרים PID/session, ומספרי ה-build לא תואמים, שלושת קבצי התיעוד — dump, לוג ורישום watchdog — נראים כמקרים נפרדים.
u1["בשם ה-dump חסר session"] --> u4["שלושת קבצי התיעוד נראים נפרדים"]
u2["בלוג חסר PID / session"] --> u4
u3["מספרי build לא תואמים"] --> u4
איור 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 כתשתית
- בסיס: לתעד ולסיים, לא להמשיך
בסופו של דבר, לבנות תצורה שאפשר לעקוב אחריה גם בלי השורה האחרונה חזק יותר מלהתאמץ על השורה האחרונה.
flowchart TB
accTitle: תצורה שלא תלויה בשורה האחרונה
accDescr: תרשים שמראה שהמטרה היא תצורה שאפשר לעקוב אחריה גם בלי השורה האחרונה, כש-crash marker עדיין נשמר בקצרה בקובץ נפרד, והתיעוד העיקרי הוא ה-dump והלוג הרגיל.
v0["המטרה"] --> v2["תצורה שאפשר לעקוב אחריה גם בלי השורה האחרונה"]
v2 --> v3["ה-marker נשמר בקצרה בקובץ נפרד"]
v2 --> v4["התיעוד העיקרי: dump ולוג רגיל"]
איור 23: עדיף לבנות תצורה שלא תלויה בשורה האחרונה, מאשר להתאמץ עליה בלבד.
ובכל זאת רוצים את השורה האחרונה, אז שומרים את ה-fatal crash marker בקצרה בקובץ נפרד. והתיעוד העיקרי האמיתי נשען על ה-dump של WER והלוג הרגיל עד הרגע האחרון. זו שיטה יציבה למדי בפועל עם אפליקציות Windows.
מאמרים קשורים
- איך אוספים crash dump באפליקציית Windows: WER, ProcDump, WinDbg
- Exception לא צפוי ב-.NET: מתי מסיימים תהליך ומתי ממשיכים
מקורות
- 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
מאמרים קשורים
מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.
איך אוספים crash dump באפליקציית Windows: WER, ProcDump, WinDbg
איך בוחרים ומשתמשים ב-WER LocalDumps, ProcDump, MiniDumpWriteDump ו-WinDbg כדי לחקור קריסות באפליקציות Windows שקשה לשחזר, כולל נקודות תפ...
מה Not Responding באמת אומר — איך Windows מחליט שיש hang, ואיך לתכנן אפליקציות שלא נתקעות
Windows מסמן window כ-Not Responding אחרי 5 שניות בלי שליפת message ומציג ghost window: השיפוט, סיבות ה-hang, תכנון ה-UI thread, והחקירה.
WPR/WPA בפועל — מבוא לחקירת "כל ה-PC איטי" על פני כל המערכת
חוקרים PC איטי או startup איטי ש-Task Manager לא מסביר עם ETW trace ברמת ה-OS: מצלמים ב-wpr.exe, ואז קוראים CPU, המתנות ו-I/O דיסק ב-WPA.
Handle leak באפליקציית מצלמה תעשייתית שקורסת אחרי ריצה ארוכה
איך בוחנים אפליקציית Windows שקורסת פתאום אחרי ריצה ארוכה, דרך מקרה של אפליקציית בקרת מצלמה תעשייתית: איך מוצאים handle leak ואיך מתכננים...
TCP Retransmission שעוצר תקשורת עם מצלמה תעשייתית — איך מפרקים את זה
איך מפרקים עצירה של כמה שניות בתקשורת עם מצלמה תעשייתית שנגרמת מ-TCP retransmission: packet loss, RTO, timestamps של RFC1323, ונקודות בדי...
נושאים קשורים
העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.
נושאים טכניים ב-Windows
שער לנושאי פיתוח Windows, חקירת תקלות וניצול נכסים קיימים.
שירותים הקשורים לנושא הזה
המאמר קשור ישירות לשירותים הבאים.
חקירת תקלות ואיתור גורמים
קריסה שמופיעה רק בסביבת הלקוח, סיום חריג עם repro נמוך, וניתוח סיבה על ידי הצלבת dump עם לוגים, מתאימים היטב לחקירת תקלות ולניתוח שורש.
פיתוח יישומי Windows
איך מתכננים לוג רגיל, WER ו-watchdog ב-WPF, WinForms, אפליקציות שרצות ברקע ו-Windows Service קשור ישירות לפיתוח אפליקציות Windows.
שאלות נפוצות
שאלות נפוצות בפניות בנושא המאמר.
- האם האפליקציה שקורסת יכולה להבטיח בעצמה שנשמר לוג?
- לא. ברגע שמכניסים לחשבון 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.