כל טעינת PDF קרסה עם אותה שגיאת ReferenceError. ה-stack trace לא הצביע על שום דבר מועיל, והמפרט (specification) שהנחה את הקוד נראה הגיוני לחלוטין על הנייר. הוא אמר לבדוק feature flag, ולהשוות כל אזור לחצי מ-pageWidth. הוא תיאר מה אמור לקרות. הוא לא תיאר מאיפה אמור להגיע pageWidth, והשמטה הבודדת הזו הספיקה כדי להפיל את כל ה-pipeline.
מסמכי ארכיטקטורה טובים בהסבר של התנהגות. הם לעיתים קרובות גרועים מאוד בהסבר של גבולות (boundaries). משפט שאומר "הפונקציה בודקת את x מול pageWidth" אינו חוזה טכני; זהו נרטיב שמסתיר תלות בתוך אנגלית פשוטה. כשמפתח קורא את המשפט הזה וכותב פונקציה ברמת המודול (module-level function) המתייחסת ל-pageWidth בשמו, הקוד נראה נכון כי הוא מקיים את התיאור. לאחר מכן ה-runtime מנסה לפתור את השם, לא מוצא דבר בטווח ההכרה (scope), וזורק שגיאה.
הרפקטורור שהיה אמור להיות פשוט
ראיתי בדיוק את התבנית הזו במהלך רפקטורור של הרכבת דף (page assembly). המפרט הציג שתי דרישות שנראו נקיות לחלוטין:
- בדוק את
FEATURE_LAYOUT. - השווה כל אזור מול
pageWidth / 2.
המפתח פעל לפי ההוראות בנאמנות. הוא חילץ פונקציית עזר בטווח של המודול (module scope) והכניס את pageWidth ישירות לגוף הפונקציה מבלי להגדיר אותו כפרמטר. המפרט לא ציין ש-pageWidth חייב להגיע דרך רשימת ארגומנטים. הוא לא ציין שהפונקציה נמצאת בטווח של המודול, שבו pageWidth כבר לא היה גלוי. הוא פשוט הניח שהמממש מבין את הקשר הביצוע (execution context) באופן משתמע.
התוצאה הייתה ReferenceError בכל טעינת PDF. מכיוון שהמשתנה לא היה קיים בטווח המודול, הפונקציה זרקה שגיאה מיד. לו המפרט היה מציין במפורש את גבול הפונקציה ואת הקלטים שלה, המפתח היה מעביר את pageWidth פנימה, והבאג היה בלתי אפשרי מבחינה מבנית. במקום זאת, ההוראה פעלה כמעין מלכודת, שהזמינה את המממש להושיט יד אל טווח אב שלא קיים.
ארבע המשמעויות של "משתמש ב-"
הבעיה העמוקה יותר היא שלפרוזה חסרה מערכת טיפוסים (type system). כשמפרט אומר "הפונקציה משתמשת ב-X", המשפט עמום בלפחות ארבע דרכים ספציפיות בתוך codebase של JavaScript מודרני:
- הפונקציה מקבלת את X כפרמטר רשמי.
- הפונקציה קוראת את X ממשתנה ברמת המודול המוגדר באותו קובץ.
- הפונקציה יוצרת closure על X מתוך טווח אב מקונן.
- הפונקציה מחלצת את X מתוך אובייקט גדול יותר המועבר אליה.
כל אחת מהאפשרויות הללו מקיימת את הניסוח של המפרט. כל אחת מהן עוברת ניתוח סטטי (static analysis). אך רק אחת מהן נכונה עבור גבול נתון, והבחירה השגויה מחדירה הנחות מעבר לגבול הזה בצורה שמתקמפלת בשקט.
מפתחים בדרך כלל בוחרים בדרך הקלה ביותר ברגע הכתיבה. אם pageWidth במקרה נמצא בטווח חיצוני, הם יקראו אותו משם במקום לשנות את חתימת הפונקציה. closure מסתיר את התלות. הקוד עובד בהרצה הראשונה, עובר את סדרת הבדיקות, ונשלח לייצור. שבועות לאחר מכן, מישהו מעביר את אותה פונקציה לקובץ אחר לצורך שימוש חוזר, או כדי לשפר את הקריאות. טווח האב נעלם. הקוד נשבר, והשבירה נראית כמו רגרסיה חדשה למרות ששורש הבעיה היה התלות הנסתרת המקורית.
Web Workers מוחקים את הראיות
הבעיה הזו הופכת למרושעת באמת ברגע ש-Web Workers נכנסים לארכיטקטורה. כשמתרחשת שגיאה בתוך worker, הדפדפן מסיר את המידע שאתה זקוק לו ביותר.
הנה מה שקורה בפועל. בתוך worker, חריגה שלא נתפסה (uncaught exception) מפעילה ErrorEvent. אם ה-worker מעביר את השגיאה הזו ל-main thread, התבנית הטיפוסית היא לקחת את מחרוזת ה-message ולהעביר אותה מעבר לגבול. ה-main thread מקבל את המחרוזת הזו, בונה אובייקט Error חדש ממנה, ומתעד או זורק אותה מחדש. מה שמופיע ב-DevTools היא השגיאה המשוחזרת בתוך מנהל ההודעות (message handler) של ה-main thread. שם הקובץ המקורי, מספר השורה וה-stack trace נזרקים. המיקום האמיתי של הכשל הופך לבלתי נראה.
כך שכאשר ה-pageWidth החסר גרם ל-ReferenceError בתוך ה-worker, ה-main thread דיווח רק על הטקסט "pageWidth is not defined" בנקודה שבה טופלה ההודעה. הפונקציה ברמת המודול עצמה הייתה ב...
