هر بار که یک PDF بارگذاری می‌شد، با همان ReferenceError کرش می‌کرد. stack trace به جای مفیدی اشاره نمی‌کرد و مشخصاتی که راهنمای کد بود، روی کاغذ کاملاً منطقی به نظر می‌رسید. در آن مشخصات آمده بود که یک پرچم ویژگی (feature flag) را بررسی کنید و هر ناحیه را با نصف pageWidth مقایسه کنید. مشخصات توضیح می‌داد که چه اتفاقی باید بیفتد، اما توضیح نداده بود که pageWidth قرار است از کجا بیاید؛ و همین یک موردِ حذف‌شده کافی بود تا کل خط لوله (pipeline) از کار بیفتد.

اسناد معماری در توضیح «رفتار» خوب هستند، اما اغلب در توضیح «مرزها» بسیار ضعیف عمل می‌کنند. جمله‌ای که می‌گوید «تابع x را با pageWidth مقایسه می‌کند»، یک قرارداد فنی نیست؛ بلکه روایتی است که یک وابستگی را در دل کلمات ساده انگلیسی پنهان می‌کند. وقتی یک توسعه‌دهنده آن جمله را می‌خواند و تابعی در سطح ماژول می‌نویسد که به pageWidth ارجاع می‌دهد، کد درست به نظر می‌رسد چون با توصیف مطابقت دارد. سپس زمان اجرا (runtime) سعی می‌کند آن نام را پیدا کند، چیزی در محدوده (scope) پیدا نمی‌کند و خطا پرتاب می‌کند.

بازنویسی‌ای که باید ساده می‌بود

من دقیقاً همین الگو را در طول یک بازنویسی (refactor) در بخش مونتاژ صفحه دیدم. مشخصات، دو الزام ظاهراً ساده را لیست کرده بود:

  • بررسی FEATURE_LAYOUT.
  • مقایسه هر ناحیه با pageWidth / 2.

توسعه‌دهنده دستورالعمل‌ها را با وفاداری کامل اجرا کرد. او یک تابع کمکی (utility function) در سطح ماژول استخراج کرد و pageWidth را مستقیماً بدون تعریف کردن آن به عنوان یک پارامتر، درون بدنه تابع قرار داد. در مشخصات ذکر نشده بود که pageWidth باید از طریق لیست آرگومان‌ها وارد شود. ذکر نشده بود که تابع در سطح ماژول قرار دارد، جایی که pageWidth دیگر قابل مشاهده نیست. مشخصات صرفاً فرض کرده بود که پیاده‌ساز، بافت اجرا (execution context) را به صورت ضمنی درک می‌کند.

نتیجه، یک ReferenceError در هر بار بارگذاری PDF بود. از آنجایی که متغیر در محدوده ماژول وجود نداشت، تابع بلافاصله خطا پرتاب می‌کرد. اگر در مشخصات، مرز تابع و ورودی‌های آن به صراحت نام برده شده بود، توسعه‌دهنده pageWidth را به تابع پاس می‌داد و این باگ از نظر ساختاری غیرممکن می‌شد. در عوض، دستورالعمل مانند یک تله عمل کرد و پیاده‌ساز را وسوسه کرد تا به محدوده والد (parent scope) دسترسی پیدا کند، در حالی که آن محدوده وجود خارجی نداشت.

چهار معنای کلمه "استفاده می‌کند"

مشکل عمیق‌تر این است که متن‌های توصیفی فاقد یک سیستم تایپ (type system) هستند. وقتی یک مشخصات می‌گوید «تابع از X استفاده می‌کند»، این جمله در یک کدبیس مدرن JavaScript حداقل به چهار روش خاص ابهام دارد:

  • تابع X را به عنوان یک پارامتر رسمی دریافت می‌کند.
  • تابع X را از یک متغیر در سطح ماژول که در همان فایل تعریف شده، می‌خواند.
  • تابع از طریق یک کلوژر (closure)، X را از یک محدوده والدِ تودرتو می‌گیرد.
  • تابع X را از یک شیء بزرگ‌تر که به آن پاس داده شده، استخراج می‌کند.

هر یک از این گزینه‌ها با عبارتِ مشخصات مطابقت دارد. هر یک از آن‌ها از تحلیل ایستا (static analysis) عبور می‌کند. اما تنها یکی از آن‌ها برای یک مرز مشخص صحیح است، و انتخاب اشتباه باعث نشت فرض‌ها از آن مرز می‌شود، به گونه‌ای که بدون هیچ خطای کامپایلی، در سکوت اجرا می‌شود.

توسعه‌دهندگان معمولاً در لحظه نوشتن، مسیر کم‌مقاومت را انتخاب می‌کنند. اگر pageWidth از قضا در یک محدوده بیرونی قرار داشته باشد، آن‌ها آن را از همان‌جا می‌خوانند تا اینکه امضای تابع (function signature) را تغییر دهند. یک کلوژر، وابستگی را پنهان می‌کند. کد در اولین اجرا کار می‌کند، تست‌ها را پاس می‌کند و منتشر می‌شود. هفته‌ها بعد، کسی همان تابع را برای استفاده مجدد یا بهبود خوانایی به فایل دیگری منتقل می‌کند. محدوده والد ناپدید می‌شود. کد می‌شکند و این شکست، شبیه به یک پس‌رفت (regression) جدید به نظر می‌رسد، در حالی که علت اصلی همان وابستگی پنهان اولیه بوده است.

Web Workers شواهد را از بین می‌برند

این مشکل زمانی که Web Workers وارد معماری می‌شوند، واقعاً بدخیم می‌شود. وقتی خطایی در داخل یک worker رخ می‌دهد، مرورگر اطلاعاتی را که بیش از همه به آن‌ها نیاز دارید، حذف می‌کند.

اتفاقی که واقعاً می‌افتد این است: در داخل یک worker، یک استثنای (exception) مدیریت‌نشده، یک ErrorEvent صادر می‌کند. اگر worker آن خطا را به رشته اصلی (main thread) ارسال کند، الگوی معمول این است که رشته message گرفته شده و از مرز عبور داده شود. رشته اصلی، آن رشته را دریافت می‌کند، یک شیء Error جدید از روی آن می‌سازد و آن را لاگ کرده یا دوباره پرتاب می‌کند. آنچه در DevTools ظاهر می‌شود، خطای بازسازی‌شده در داخل هندلرِ پیامِ رشته اصلی است. نام فایل اصلی، شماره خط و stack trace اصلی دور ریخته می‌شوند. مکان واقعی خطا نامرئی می‌شود.

بنابراین وقتی نبودِ pageWidth باعث ایجاد یک ReferenceError در داخل worker شد، رشته اصلی فقط متن "pageWidth is not defined" را در جایی که پیام مدیریت می‌شد گزارش کرد. تابع اصلی در سطح ماژول در...