एक असा बग रिपोर्ट आला ज्याने डिबगिंगच्या प्रत्येक कल्पनेला छेद दिला. कमी क्षमतेच्या (low-end) Android फोन्सवरील वापरकर्त्यांनी सांगितले की ॲप अचानक गायब होत आहे. ॲप सुरू होताना नाही, किंवा एखाद्या विशिष्ट टॅप किंवा स्वाइप दरम्यानही नाही. सत्र सुरू झाल्यानंतर साधारण वीस मिनिटांनी स्क्रीन फ्रीज होत होती आणि प्रोसेस बंद पडत होती. लॉग्समध्ये काहीही त्रुटी दिसत नव्हती. QA टीमला त्यांच्या हाय-एंड हार्डवेअरवर हे पुन्हा तयार करता येत नव्हते. ते फॉलो करण्यासाठी कोणतेही स्टेप्स नव्हते. तीन तासांच्या मेमरी प्रोफाइलिंगनंतर, चित्र अखेर स्पष्ट झाले. एका React hook मध्ये एक सिंगल इव्हेंट लिसनर (event listener) होता. त्या लिसनरने एका मोठ्या डेटासेटवर क्लोजर (closure) तयार केला होता. जेव्हा कंपोनंट अनमाउंट (unmount) झाला, तेव्हा तो लिसनर तिथेच राहिला. तो डेटासेट मेमरीमध्ये तसाच राहिला. 2GB RAM असलेल्या डिव्हाइसवर, या साठवणुकीमुळे हीप (heap) संपला आणि ऑपरेटिंग सिस्टमने ॲप बंद केले. ही कोणतीही सिंटॅक्स एरर किंवा लॉजिकमधील चूक नव्हती. हा एक 'स्कोप बग' (scope bug) होता आणि तो घातक ठरला.
क्लोजर (Closure) लीकेजमध्ये कसे रूपांतरित होते
बहुतेक ट्युटोरियल्समध्ये 'स्कोप' (scope) हे एखादा व्हेरिएबल कुठे दिसू शकतो, याचे एक शैक्षणिक कोडे म्हणून शिकवले जाते. परंतु प्रोडक्शनमध्ये, स्कोप हा मेमरीच्या लाइफटाइमबद्दलचा एक करार (contract) असतो. जेव्हा एखादे JavaScript फंक्शन एखाद्या व्हेरिएबलवर क्लोजर तयार करते, तेव्हा जोपर्यंत तो क्लोजर उपलब्ध आहे, तोपर्यंत इंजिन त्या व्हेरिएबलला जिवंत ठेवते. React कंपोनंटमध्ये, याचा अर्थ असा की वापरकर्ता त्या पेजवरून निघून गेल्यावर आणि UI नोड निघून गेल्यावरही तुमचा डेटा मेमरीमध्ये टिकून राहतो.
अशा एका hook चा विचार करा जो window ऑब्जेक्टवर एक लिसनर रजिस्टर करतो. कंपोनंट रेंडर होतो, लिसनर अटॅच करतो आणि नंतर अनमाउंट होतो. जर क्लीनअप फेज (cleanup phase) गहाळ असेल किंवा चुकीचा असेल, तर तो लिसनर तिथेच राहतो. प्रत्येक नवीन माउंट (mount) त्या अडकलेल्या डेटाची आणखी एक अदृश्य प्रत (ghost copy) RAM मध्ये जोडतो. भरपूर मेमरी असलेल्या डेव्हलपर वर्कस्टेशनवर तुम्हाला ही वाढ कदाचित कधीच लक्षात येणार नाही. परंतु Android Go चालवणाऱ्या बजेट फोनवर, वीस मिनिटांचा सामान्य वापर उपलब्ध हीप संपवण्यासाठी पुरेसा असतो. त्यानंतर OS हस्तक्षेप करते आणि प्रोसेस बंद करते. लॉग करण्यासाठी कोणतीही एक्सेप्शन (exception) मिळत नाही. सिस्टम थेट प्रक्रिया बंद करते.
म्हणूनच, स्कोप म्हणजे मेमरी मॅनेजमेंट आहे. लेक्सिकल एन्व्हायरनमेंट (lexical environment) ही केवळ एक तात्विक सीमा नाही. तो एक रिटेंशन ग्राफ (retention graph) आहे. तुम्ही एखाद्या अनकलेक्टेड क्लोजरमध्ये जो व्हेरिएबल सोडता, तो एका भिंतीचा विटांसारखा असतो जो कालांतराने तुमच्या ॲपला कोंडून टाकतो.
स्कोपमुळे प्रोडक्शन ॲप्स निकामी होण्याची तीन कारणे
स्कोपच्या समस्या सर्व सारख्या नसतात. काही मेमरी हळूहळू कमी करतात, तर काही लगेच स्फोट घडवून आणतात. ॲप्स निकामी करणारी काही प्रमुख पॅटर्न खालीलप्रमाणे आहेत.
ग्लोबल स्कोप पोल्युशन (Global Scope Pollution)
मायक्रो-फ्रंटएंड आर्किटेक्चरमुळे टीम्सना स्वतंत्रपणे काम करता येते, परंतु ते सर्व एकाच window ऑब्जेक्टचा वापर करतात. जेव्हा एखादे ॲप्लिकेशन window.config सारखा ग्लोबल व्हेरिएबल सेट करते किंवा window वर एखादी शेअर केलेली युटिलिटी पॅच करते, तेव्हा ते विलगीकरणात राहत नाही. दुसऱ्या टीमचे ॲप्लिकेशन त्याच ग्लोबलसाठी वेगळ्या स्वरूपावर अवलंबून असू शकते, किंवा स्वतःच्या बूटस्ट्रॅप दरम्यान त्याला ओव्हरराईट करू शकते. याचा परिणाम असा होतो की, जसजसे तुमचे ऑर्गनायझेशन वाढते, तसतसे फिचर कोलिजन (feature collision) वाढते. एका रिपॉझिटरीमधील डेव्हलपरला कल्पना नसते की त्यांचा शॉर्टकट दुसऱ्या टीमसाठी 'ब्रेकिंग चेंज' ठरू शकतो. जसा वापर वाढतो, तसे हे ग्लोबल्स सामायिक जागेत गाडलेल्या लँडमाइन्ससारखे बनतात.
क्लोजर मेमरी लीक्स (Closure Memory Leaks)
सिंगल पेज ॲप्लिकेशन्स (SPAs) तासनतास चालण्यासाठी बनवलेली असतात. याच टिकाऊपणामुळे क्लोजर लीकेज अत्यंत घातक ठरते. ही पद्धत खूप सामान्य आहे: एक useEffect ग्लोबल इव्हेंट बस, WebSocket हँडलर किंवा स्वतः DOM सोबत एक कॉलबॅक रजिस्टर करते. जर डिपेंडन्सी ॲरे (dependency array) अस्थिर असेल किंवा वगळला असेल, तर क्लीनअप कधीच मूळ सबस्क्रिप्शनशी जुळत नाही. क्लोजर त्याच्या लेक्सिकल स्कोपमध्ये जे काही आहे ते कॅप्चर करतो, ज्यामध्ये प्रचंड मोठे पार्स केलेले ॲरे (arrays), फेच केलेले JSON ब्लब्स किंवा DOM ट्रीजचे संदर्भ असू शकतात. प्रत्येक नेव्हिगेशनमुळे भार वाढतो. वापरकर्त्याला कळत नाही की त्यांचे ब्राउझर टॅब 800MB का वापरत आहे. त्यांना फक्त इतकेच समजते की ॲप मंद झाले आहे आणि शेवटी बंद पडले आहे.
जेव्हा प्रत्येक रेंडरवर डिपेंडन्सी ॲरे बदलतात, तेव्हा हे विशेषतः धोकादायक असते. प्रत्येक सायकलमध्ये एक नवीन फंक्शन रेफरन्स तयार होतो, तो लिसनरसोबत रजिस्टर केला जातो आणि जुना कधीच मुक्त केला जात नाही. याचा परिणाम म्हणजे मृत क्लोजर्सचे एक संग्रहालय तयार होते, ज्यातील प्रत्येक क्लोजर त्याच्या जन्माच्या वेळी असलेला डेटा साठवून ठेवतो.
डायनॅमिक मॉड्यूल्समधील TDZ एरर्स (TDZ Errors in Dynamic Modules)
Temporal Dead Zone ही केवळ एक सैद्धांतिक edge case नाही. जेव्हा तुम्ही त्याच्या declaration च्या अंमलबजावणीपूर्वी let किंवा const वापरता, तेव्हा engine ReferenceError देतो. circular dependencies आणि dynamic imports असलेल्या मोठ्या monorepos मध्ये, अंमलबजावणीचा नेमका क्रम अनेकदा implicit असतो. Module A हे Module B ला import करते, जे अशा एका chunk ला dynamically import करते जो पुन्हा Module A वर अवलंबून असतो. जर एखाद्या branch ने अशा variable ला स्पर्श केला जो अजून initialize झालेला नाही, तर app load होताना crash होते. हे failures अत्यंत त्रासदायक असतात कारण ते timing वर अवलंबून असतात. bundler split points मधील छोटा बदल, code-loading मधील network delay, किंवा chunk caching मधील बदल TDZ trigger करण्यासाठी पुरेसा क्रम बदलू शकतो. हा crash अनपेक्षित असतो आणि stack trace सहसा कोडच्या एखाद्या अगदी निरपराध ओळीकडे निर्देश करतो.
Defensive Tactics
scope bugs पासून वाचण्यासाठी तुम्ही केवळ stack traces वर अवलंबून राहू शकत नाही. तुम्हाला प्रतिबंध (prevention) आणि शोध (detection) यांची गरज आहे.
static analysis ने सुरुवात करा. कडक मर्यादा लागू करण्यासाठी ESLint कॉन्फिगर करा. no-implicit-globals आणि no-shadow सारखे rules स्पष्ट चुका पकडतात. Shadowing हे विशेषतः धोकादायक आहे कारण ते तुम्हाला असे भासवते की तुम्ही local variable मध्ये बदल करत आहात, परंतु प्रत्यक्षात तुम्ही बाहेरील variable वर closure तयार करत असता किंवा चुकून duplicate तयार करत असता. हे rules स्पष्ट हेतू (explicit intent) वापरण्यास भाग पाडतात आणि शांतपणे होणारे collisions टाळतात.
unit tests साठी तुम्ही ज्या शिस्तीचा वापर करता, त्याच शिस्तीने तुमच्या memory चे profiling करा. Chrome DevTools उघडा, तुमच्या starting route वर एक heap snapshot घ्या, पाच मिनिटे तुमच्या application मध्ये navigate करा आणि पुन्हा एक snapshot घ्या. त्या दोघांची तुलना करा. "Closure" साठी filter करा आणि ज्यांची संख्या मर्यादेशिवाय वाढत आहे अशा counts कडे लक्ष द्या. असे detached DOM nodes शोधा जे अजूनही event listeners धरून आहेत. जर दुसऱ्या snapshot मध्ये हजारो नवीन Closure entries दिसत असतील आणि तुमचा user count स्थिर असेल, तर याचा अर्थ तुम्ही असे functions अडकवले आहेत जे डेटा अडकवून ठेवत आहेत. तोच तुमचा leak आहे.
Architecturally, configuration साठी global window object चा वापर करणे थांबवा. settings props म्हणून किंवा typed context द्वारे पास करा. Dependency injection हा येथे केवळ एक enterprise buzzword नाही; तर function ला global scope मध्ये शोधण्याऐवजी arguments द्वारे आवश्यक गोष्टी पुरवण्याची ही एक पद्धत आहे. याचा परिणाम म्हणजे असा कोड जो तुम्ही browser shims शिवाय test करू शकता आणि असे modules जे एकाच shell मध्ये अनेक apps mount होताना एकमेकांशी टकरात नाहीत.
शेवटी, cleanup phase चा कडकपणे आदर करा. प्रत्येक addEventListener साठी effect cleanup मध्ये एक जुळणारा removeEventListener असणे आवश्यक आहे. asynchronous कामासाठी, AbortController वापरा आणि त्याचा signal fetch ला पास करा, जेणेकरून component नष्ट झाल्यावर चालू असलेले (in-flight) requests रद्द होतील. या सवयी scope किती काळ टिकतो यावर थेट नियंत्रण ठेवतात. हे केवळ boilerplate नाही, तर ते memory management आहे.
तुमच्या टीमसाठी याचा अर्थ काय
Scope ही मुलाखती दरम्यान उमेदवारांची परीक्षा घेण्यासाठी वापरली जाणारी एखादी साधी युक्ती नाही. Production मध्ये, scope म्हणजे memory management आहे. तुम्ही घोषित केलेले प्रत्येक variable एक संभाव्य ओलीस (hostage) आहे. प्रत्येक closure ही engine ने पाळायची एक वचनबद्धता (promise) आहे. जेव्हा तुम्ही listener मुक्त करायला विसरता, तेव्हा तुम्ही फक्त एखादा दिवा चालू ठेवत नाही आहात. तुम्ही तुमच्या app ला एका वजनाशी बांधून समुद्रात फेकत आहात. शक्तिशाली hardware वर, app तरीही तरंगते. पण कमी क्षमतेच्या (low-end) devices वापरणाऱ्या युजर्ससाठी, ते बुडते. scope ला एक मर्यादित संसाधन (finite resource) म्हणून मानण्यास सुरुवात करा. तुमचे युजर्स आणि तुमचे तीन तासांचे debugging sessions तुमचे आभार मानतील.
