נראה שדיבור על ניפוי באגים של חריגות באפליקציות אינטרנט הוא פשוט: משהים את הביצוע כשמשהו משתבש ומחפשים את הבעיה. אבל האופי האסינכרוני של JavaScript הופך את הפעולה הזו למסובכת להפתיע. איך כלי הפיתוח ל-Chrome יכולים לדעת מתי ואיפה להשהות כשחריגות עוברות דרך הבטחות ופונקציות אסינכררוניות?
בפוסט הזה נסביר על האתגרים של catch prediction – היכולת של DevTools לחזות אם יהיה צורך לתפוס חריגה בהמשך הקוד. נבדוק למה זה כל כך מסובך ואיך השיפורים האחרונים ב-V8 (מנוע ה-JavaScript שמפעיל את Chrome) משפרים את הדיוק שלו, וכך משפרים את חוויית ניפוי הבאגים.
למה חשוב לחזות את הנתונים של הבקשות לתפיסה
ב-Chrome DevTools יש אפשרות להשהות את ביצוע הקוד רק במקרים של חריגות שלא תפסתם, ולדלג על חריגות שתפסתם.
מאחורי הקלעים, מנתח הבאגים מפסיק מיד כשמתרחשת חריגה כדי לשמור על ההקשר. זוהי תחזית כי בשלב הזה אי אפשר לדעת בוודאות אם החריג ייתפס או לא בהמשך הקוד, במיוחד בתרחישים אסינכרונים. אי-הוודאות הזו נובעת מהקושי הטבעי לחזות את התנהגות התוכנית, בדומה לבעיית ההפסקה.
נניח את הדוגמה הבאה: איפה צריך להשהות את מנתח הבאגים? (התשובה מופיעה בקטע הבא).
async function inner() {
throw new Error(); // Should the debugger pause here?
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ... or should the debugger pause here?
}
}
השהיה על חריגות במעבד באגים עלולה להפריע ולגרום להפרעות תכופות ולקפיצות לקוד לא מוכר. כדי לצמצם את הבעיה, אפשר לבחור לנפות באגים רק בחריגות שלא נלכדו, כי יש סיכוי גבוה יותר שהן יצוינו באגים אמיתיים. עם זאת, הדבר תלוי בדיוק של תחזית הנתונים.
חיזויים שגויים מובילים לתסכול:
- תוצאות שליליות שגויות (חיזוי של 'לא תתפס' כשהיא תתפס). עצירות מיותרות בכלי לניפוי באגים.
- תוצאות חיוביות כוזבות (חיזוי של 'תפסיק' כשהיא לא תפסיק). החמצת הזדמנויות לזהות שגיאות קריטיות, וכתוצאה מכך ייתכן שתצטרכו לנפות באגים בכל החריגות, כולל חריגות צפויות.
שיטה נוספת לצמצום ההפרעות בניפוי באגים היא שימוש ברשימת ההתעלמות, שמונעת הפסקות עקב חריגות בקוד ספציפי של צד שלישי. עם זאת, חיזוי מדויק של נתוני הצפייה עדיין חיוני כאן. אם חריג שמקורו בקוד של צד שלישי חומק משפיע על הקוד שלכם, תרצו להיות מסוגלים לנפות באגים בו.
איך פועל קוד אסינכרוני
הבטחות (Promises), פונקציות async
ו-await
ודפוסים אסינכרונים אחרים עלולים להוביל לתרחישים שבהם חריגה או דחייה, לפני הטיפול בהן, עשויות לעבור נתיב ביצוע שקשה לקבוע בזמן ההשלכה של החריגה. הסיבה לכך היא שלא ניתן להמתין להבטחה או להוסיף לה טיפולי catch עד שהחריג כבר התרחש. נבחן את הדוגמה הקודמת:
async function inner() {
throw new Error();
}
async function outer() {
try {
const promise = inner();
// ...
await promise;
} catch (e) {
// ...
}
}
בדוגמה הזו, outer()
קודם קורא ל-inner()
, שמציג מיד חריג. על סמך המידע הזה, מנתח הבאגים יכול להסיק ש-inner()
יחזיר הבטחה שנדחתה, אבל בשלב הזה אין שום דבר שממתין להבטחה הזו או מטפל בה בדרך אחרת. מנתח הבאגים יכול לנחש ש-outer()
ימתין לו, וגם לנחש שהוא יעשה זאת בבלוק try
הנוכחי שלו, ולכן יטפל בו, אבל מנתח הבאגים לא יכול להיות בטוח בכך עד שההבטחה שנדחתה תוחזר והצהרת await
תגיע בסופו של דבר.
הכלי לניפוי באגים לא יכול להבטיח שהתחזיות של ה-catch יהיו מדויקות, אבל הוא משתמש במגוון שיטות ניתוח נתונים (heuristics) לדפוסי תכנות נפוצים כדי לחזות בצורה נכונה. כדי להבין את הדפוסים האלה, כדאי ללמוד איך פועלות הבטחות.
ב-V8, Promise
של JavaScript מיוצג כאובייקט שיכול להיות באחד משלושת המצבים הבאים: הוגדר, נדחה או בהמתנה. אם הבטחה נמצאת במצב 'הושמה' ומפעילים את השיטה .then()
, נוצרת הבטחה חדשה בהמתנה ומתוזמנת משימה חדשה של תגובה להבטחה. המשימה הזו תפעיל את הטיפול ואז תגדיר את ההבטחה כ'הושמה' עם התוצאה של הטיפול, או תגדיר אותה כ'נדחתה' אם הטיפול יגרום להשלכת חריגה. אותו דבר קורה אם קוראים לשיטה .catch()
על הבטחה שנדחתה. לעומת זאת, קריאה ל-.then()
על הבטחה שנדחתה או ל-.catch()
על הבטחה שהושגה תחזיר הבטחה באותו מצב ולא תפעיל את הטיפול.
הבטחה בהמתנה מכילה רשימת תגובות, שבכל אובייקט תגובה יש טיפול בהשלמה או טיפול בדחייה (או שניהם) והבטחה לתגובה. לכן, קריאה ל-.then()
בהבטחה בהמתנה תוסיף תגובה עם טיפול שהושלם, וגם הבטחה חדשה בהמתנה להבטחת התגובה, שתוחזר על ידי .then()
. קריאה ל-.catch()
תוסיף תגובה דומה, אבל עם טיפול בדחייה. קריאה ל-.then()
עם שני ארגומנטים יוצרת תגובה עם שני הטיפולים, וקריאה ל-.finally()
או המתנה להבטחה תוסיף תגובה עם שני טיפולים שהם פונקציות מובנות שספציפיות להטמעת התכונות האלה.
כשהבטחה בהמתנה תתבצע או תידחה בסופו של דבר, יבוצע תזמון של משימות התגובה לכל פונקציות הטיפול שהן ביצעו או לכל פונקציות הטיפול שדחו אותן. לאחר מכן, הבטחות התגובה התואמות יעודכנו, וייתכן שהן יפעילו משימות תגובה משלהם.
דוגמאות
הקוד הבא הוא דוגמה לכך:
return new Promise(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
יכול להיות שלא ברור שהקוד הזה כולל שלושה אובייקטים נפרדים של Promise
. הקוד שלמעלה שווה לקוד הבא:
const promise1 = new Promise(() => {throw new Error();});
const promise2 = promise1.then(() => console.log('Never happened'));
const promise3 = promise2.catch(() => console.log('Caught'));
return promise3;
בדוגמה הזו, מתבצעים השלבים הבאים:
- ה-constructor של
Promise
נקרא. - נוצר
Promise
חדש בהמתנה. - הפונקציה האנונימית מופעלת.
- תופיע הודעה על חריגה. בשלב הזה, מנתח הבאגים צריך להחליט אם לעצור או לא.
- ה-constructor של ההבטחה תופס את החריג הזה ואז משנה את המצב של ההבטחה ל-
rejected
עם הערך שהוגדר לשגיאה שהוצגה. הפונקציה מחזירה את ההבטחה הזו, שמאוחסנת ב-promise1
. .then()
לא מתזמן משימה של תגובה כיpromise1
נמצא במצבrejected
. במקום זאת, המערכת מחזירה הבטחה חדשה (promise2
) שנמצאת גם היא במצב 'נדחתה' עם אותה שגיאה..catch()
מתזמן משימה של תגובה עם הטיפולן שסופק והבטחה חדשה של תגובה בהמתנה, שמוחזרת כ-promise3
. בשלב הזה, מניפוי הבאגים יודע שהשגיאה תטופל.- כשמשימת התגובה פועלת, הטיפול חוזר באופן רגיל והמצב של
promise3
משתנה ל-fulfilled
.
לדוגמה הבאה יש מבנה דומה, אבל הביצוע שלה שונה למדי:
return Promise.resolve()
.then(() => {throw new Error();})
.then(() => console.log('Never happened'))
.catch(() => console.log('Caught'));
זה שווה ערך ל-:
const promise1 = Promise.resolve();
const promise2 = promise1.then(() => {throw new Error();});
const promise3 = promise2.then(() => console.log('Never happened'));
const promise4 = promise3.catch(() => console.log('Caught'));
return promise4;
בדוגמה הזו, מתבצעים השלבים הבאים:
- ה-
Promise
נוצר במצבfulfilled
ונשמר ב-promise1
. - משימה של תגובה להבטחה מתזמנת באמצעות הפונקציה האנונימית הראשונה, וההבטחה לתגובה
(pending)
שלה מוחזרת כ-promise2
. - תגובה נוספת ל-
promise2
עם טיפול שהושלם והבטחה לתגובה, שמוחזרת כ-promise3
. - תגובה נוספת נוספת ל-
promise3
עם טיפול נדחה והבטחה נוספת לתגובה, שמוחזרת כ-promise4
. - משימה התגובה שתזמנתם בשלב 2 מופעלת.
- הטיפול גורם לחריגה. בשלב הזה, מנתח הבאגים צריך להחליט אם לעצור או לא. נכון לעכשיו, הטיפול הוא קוד ה-JavaScript היחיד שפועל.
- מכיוון שהמשימה מסתיימת עם חריגה, הבטחת התגובה המשויכת (
promise2
) מוגדרת למצב 'נדחתה' והערך שלה מוגדר לשגיאה שהוצגה. - מכיוון של-
promise2
הייתה תגובה אחת, ולתגובה הזו לא היה טיפול שנדחה, הבטחת התגובה שלו (promise3
) מוגדרת גם היא ל-rejected
עם אותה שגיאה. - מכיוון של-
promise3
הייתה תגובה אחת, ולתגובה הזו היה טיפול שנדחה, מתבצע תזמון של משימה של תגובה להבטחה עם הטיפול הזה והבטחת התגובה שלו (promise4
). - כשמשימת התגובה הזו פועלת, הטיפול חוזר באופן רגיל והמצב של
promise4
משתנה ל'בוצע'.
שיטות לחיזוי נתוני תפיסה
יש שני מקורות פוטנציאליים למידע על חיזוי של כמות הדגים שנתפסו. אחד מהם הוא סטאק הקריאות. הפתרון הזה תקין לחריגות סינכרוניות: מנתח הבאגים יכול לעבור על סטאק הקריאות באותו אופן שבו קוד הביטול של החריגה יעשה זאת, והוא יעצור אם הוא ימצא מסגרת שבה הוא נמצא בבלוק try...catch
. במקרים של הבטחות או חריגות שנדחו בקונסטרוקטור של הבטחה או בפונקציות אסינכררוניות שמעולם לא הושהו, מנתח הבאגים מסתמך גם על סטאק הקריאות, אבל במקרה הזה התחזית שלו לא יכולה להיות מהימנה בכל המקרים. הסיבה לכך היא שבמקום להפעיל חריגה ל-handler הקרוב ביותר, קוד אסינכררוני יחזיר חריגה שנדחתה, ומאתר הבאגים צריך להניח כמה הנחות לגבי מה ששולח הקריאה יעשה איתה.
קודם כול, מנתח הבאגים מניח שפונקציה שמקבלת promise מוחזר צפויה להחזיר את ה-promise הזה או promise נגזר, כדי שלפונקציות אסינכררוניות שנמצאות גבוה יותר ב-stack תהיה הזדמנות להמתין לו. שנית, מנתח הבאגים מניח שאם המערכת מחזירה הבטחה לפונקציה אסינכררונית, היא תמתין לה בקרוב בלי להיכנס או לצאת קודם לכן מבלוק try...catch
. לא בטוח שההנחות האלה נכונות, אבל הן מספיקות כדי לחזות את התוצאות הנכונות לתבניות הקוד הנפוצות ביותר עם פונקציות אסינכררוניות. בגרסה 125 של Chrome הוספנו שיטת ניתוח נוספת: מנתח הבאגים בודק אם הגורם הנקרא עומד לבצע קריאה ל-.catch()
בערך שיוחזר (או ל-.then()
עם שני ארגומנטים, או שרשרת של קריאות ל-.then()
או ל-.finally()
ואחריה קריאה ל-.catch()
או ל-.then()
עם שני ארגומנטים). במקרה כזה, מנתח הבאגים מניח שאלה השיטות בהבטחה שאנחנו עוקבים אחריה או שיטה שקשורה אליה, כך שהדחייה תתועד.
המקור השני של המידע הוא עץ התגובות של ההבטחות. תהליך ניפוי הבאגים מתחיל בהבטחה ברמה הבסיסית. לפעמים מדובר בהבטחה ששיטת reject()
שלה נקראת זה עתה. במקרים נפוצים יותר, כשמתרחשת חריגה או דחייה במהלך משימת תגובה של הבטחה, ונראה שאין דבר ב-call stack שתופס אותה, תהליך ניפוי הבאגים עוקב מההבטחה שמשויכת לתגובה. מנתח הבאגים בודק את כל התגובות להבטחה בהמתנה, ומחפש אם יש להן טיפולי דחייה. אם אחת מהתגובות לא עומדת בדרישות, המערכת בודקת את הבטחת התגובה ומבצעת ניתוח רפלוקטיבי ממנה. אם כל התגובות מובילות בסופו של דבר לטיפול בדחייה, מנתח הבאגים מתייחס לדחייה של ההבטחה כאל תפיסת שגיאה. יש כמה מקרים מיוחדים שצריך להתייחס אליהם, למשל, לא כולל טיפול הדחייה המובנה של קריאה ל-.finally()
.
עץ התגובות להבטחה הוא בדרך כלל מקור מהימן למידע, אם המידע הזה קיים. במקרים מסוימים, כמו קריאה ל-Promise.reject()
או ב-constructor של Promise
או בפונקציה אסינכררונית שעדיין לא חיכתה למשהו, לא יהיו תגובות למעקב, והכלי לניפוי באגים צריך להסתמך על סטאק הקריאות בלבד. במקרים אחרים, עץ התגובה של ההבטחה בדרך כלל מכיל את הטיפולים הנדרשים כדי להסיק תחזית לתפיסה, אבל תמיד יכול להיות שיתווספו טיפולים נוספים מאוחר יותר שיגרמו לשינוי של החריגה מ'נתפסה' ל'לא נתפסה' או להפך. יש גם הבטחות כמו אלה שנוצרו על ידי Promise.all/any/race
, שבהן הבטחות אחרות בקבוצה עשויות להשפיע על האופן שבו נדחה הבקשה. בשיטות האלה, מנתח הבאגים מניח שדחייה של הבטחה תועבר אם הבטחה עדיין בהמתנה.
כדאי לעיין בשתי הדוגמאות הבאות:
שתי הדוגמאות האלה לחריגים שנתפסו נראות דומות, אבל הן דורשות שיטות ניתוח שונות למדי לחיזוי תפיסת החריגים. בדוגמה הראשונה, נוצרת הבטחה שהושלמה, ואז מתזמנים משימה של תגובה ל-.then()
שתשליך חריגה, ואז .catch()
נקראת כדי לצרף טיפול בדחייה להבטחה של התגובה. כשמשימה התגובה מופעלת, החריג יושלך ועץ התגובה של ההבטחה יכיל את הטיפול ב-catch, כך שהוא יזוהה כנתפס. בדוגמה השנייה, הבטחה נדחית באופן מיידי לפני שהקוד להוספת טיפול באירוע catch מופעל, כך שאין טיפולי דחייה בעץ התגובות של הבטחה. מנתח הבאגים צריך לבדוק את סטאק הקריאות, אבל גם אין בו חסימה מסוג try...catch
. כדי לחזות זאת בצורה נכונה, מנתח הבאגים סורק מעבר למיקום הנוכחי בקוד כדי למצוא את הקריאה ל-.catch()
, ומניח על סמך זאת שהדחייה תטופל בסופו של דבר.
סיכום
אנחנו מקווים שההסבר הזה עזר לכם להבין איך פועלת תחזית היתירות בכלי הפיתוח ל-Chrome, מהן נקודות החוזקה שלה ומהן המגבלות שלה. אם נתקלתם בבעיות בניפוי באגים בגלל תחזיות שגויות, כדאי לנסות את האפשרויות הבאות:
- לשנות את דפוס הקוד למשהו פשוט יותר לחזוי, כמו שימוש בפונקציות אסינכרניות.
- בוחרים להפסיק בכל החריגות אם כלי הפיתוח לא מפסיקים כשהם אמורים.
- אם תתבצע עצירה של מנתח הבאגים במקום שאתם לא רוצים, תוכלו להשתמש בנקודת עצירה מסוג 'אין להשהות כאן' או בנקודת עצירה מותנית.
תודות
אנחנו רוצים להודות מקרב לב ל-Sofia Emelianova ול-Jecelyn Yeen על העזרה החשובה בעריכת הפוסט הזה.