একটি বাগ রিপোর্ট এসেছিল যা ডিবাগিংয়ের প্রতিটি সহজাত প্রবৃত্তিকেও হার মানিয়েছিল। লো-এন্ড অ্যান্ড্রয়েড ফোনের ব্যবহারকারীরা বলছিলেন যে অ্যাপটি হঠাৎ করেই গায়েব হয়ে যাচ্ছে। লঞ্চ করার সময় নয়। কোনো নির্দিষ্ট ট্যাপ বা সোয়াইপের সময়ও নয়। সেশন শুরু হওয়ার প্রায় বিশ মিনিট পর স্ক্রিনটি ফ্রিজ হয়ে যায় এবং প্রসেসটি বন্ধ হয়ে যায়। লগগুলো একদম পরিষ্কার ছিল। QA তাদের হাই-এন্ড হার্ডওয়্যারে এটি পুনরায় তৈরি (reproduce) করতে পারছিল না। অনুসরণ করার মতো কোনো নির্দিষ্ট ধাপ ছিল না। তিন ঘণ্টা মেমরি প্রোফাইলিং করার পর অবশেষে বিষয়টি পরিষ্কার হলো। একটি মাত্র ইভেন্ট লিসেনার একটি React hook-এর ভেতরে ছিল। সেই লিসেনারটি একটি বড় ডেটাসেটের ওপর ক্লোজার (closure) তৈরি করেছিল। কম্পোনেন্টটি আনমাউন্ট (unmount) হয়ে গেল। কিন্তু লিসেনারটি থেকে গেল। ডেটাসেটটিও মেমরিতে থেকে গেল। ২জিবি র্যামের একটি ডিভাইসে, এই পুঞ্জীভূত ডেটা হিপ (heap) মেমরি শেষ করে দেয় এবং অপারেটিং সিস্টেম অ্যাপটিকে বন্ধ করে দেয়। এটি কোনো সিনট্যাক্স এরর বা লজিক ত্রুটি ছিল না। এটি ছিল একটি স্কোপ বাগ (scope bug), এবং এটি ছিল মারাত্মক।
কীভাবে একটি ক্লোজার মেমরি লিক হয়ে ওঠে
বেশিরভাগ টিউটোরিয়ালে স্কোপকে একটি তাত্ত্বিক ধাঁধা হিসেবে শেখানো হয় যে একটি ভেরিয়েবল কোথায় দৃশ্যমান। প্রোডাকশনে, স্কোপ হলো মেমরির লাইফটাইম বা জীবনকাল সংক্রান্ত একটি চুক্তি। যখন একটি JavaScript ফাংশন কোনো ভেরিয়েবলের ওপর ক্লোজার তৈরি করে, তখন ইঞ্জিন সেই ভেরিয়েবলটিকে ততক্ষণ পর্যন্ত সচল রাখে যতক্ষণ পর্যন্ত ক্লোজারটি নিজে অ্যাক্সেসযোগ্য থাকে। একটি React কম্পোনেন্টের ক্ষেত্রে, এর মানে হলো ব্যবহারকারী পেজ থেকে চলে যাওয়ার এবং UI নোডটি মুছে যাওয়ার অনেক পরেও আপনার ডেটা টিকে থাকে।
এমন একটি hook-এর কথা চিন্তা করুন যা window অবজেক্টের ওপর একটি লিসেনার রেজিস্টার করে। কম্পোনেন্টটি রেন্ডার হয়, লিসেনারটি যুক্ত করে এবং পরে আনমাউন্ট হয়ে যায়। যদি ক্লিনআপ ফেজ (cleanup phase) বাদ পড়ে বা ভুলভাবে করা হয়, তবে লিসেনারটি থেকে যায়। প্রতিটি নতুন মাউন্ট (mount) ট্র্যাপ করা ডেটার আরও একটি অদৃশ্য কপি র্যামে (RAM) যোগ করে। প্রচুর মেমরি সম্পন্ন একটি ডেভেলপার ওয়ার্কস্টেশনে আপনি হয়তো এই বাড়তি মেমরি ব্যবহার কখনোই লক্ষ্য করবেন না। কিন্তু Android Go চালিত একটি বাজেট ফোনে, সাধারণ বিশ মিনিটের ব্যবহারই উপলব্ধ হিপ মেমরি শেষ করার জন্য যথেষ্ট। অপারেটিং সিস্টেম তখন হস্তক্ষেপ করে এবং প্রসেসটি বন্ধ করে দেয়। লগ করার মতো কোনো এক্সেপশন থাকে না। সিস্টেমটি সরাসরি সংযোগ বিচ্ছিন্ন করে দেয়।
এ কারণেই স্কোপ মানেই হলো মেমরি ম্যানেজমেন্ট। লেক্সিক্যাল এনভায়রনমেন্ট (lexical environment) কোনো দার্শনিক সীমানা নয়। এটি একটি রিটেনশন গ্রাফ (retention graph)। আপনি একটি আন-কালেক্টেড ক্লোজারের ভেতরে যে ভেরিয়েবলটি রেখে যাচ্ছেন, সেটি একটি দেয়ালের ইটের মতো যা শেষ পর্যন্ত আপনার অ্যাপকে আটকে ফেলবে।
তিনটি উপায়ে স্কোপ প্রোডাকশন অ্যাপকে ধ্বংস করে
স্কোপের সমস্যাগুলো সব সময় একরকম হয় না। কিছু মেমরি ধীরে ধীরে ক্ষয় করে, আবার কিছু তাৎক্ষণিকভাবে মেমরি শেষ করে দেয়। নিচে এমন কিছু প্যাটার্ন দেওয়া হলো যা নির্ভরযোগ্যভাবেই অ্যাপকে ডাউন করে দেয়।
গ্লোবাল স্কোপ পলিউশন (Global Scope Pollution)
মাইক্রো-ফ্রন্টএন্ড আর্কিটেকচার টিমগুলোকে স্বাধীনভাবে কাজ করার সুযোগ দেয়, কিন্তু তারা সবাই একই window অবজেক্ট শেয়ার করে। যখন একটি অ্যাপ্লিকেশন window.config-এর মতো একটি গ্লোবাল ভেরিয়েবল সেট করে বা window-তে কোনো শেয়ার্ড ইউটিলিটি প্যাচ করে, তখন সেটি বিচ্ছিন্নভাবে থাকে না। অন্য কোনো টিমের অ্যাপ হয়তো সেই একই গ্লোবাল ভেরিয়েবলের জন্য ভিন্ন কোনো ফরম্যাট আশা করে, অথবা তাদের নিজস্ব বুটস্ট্র্যাপের সময় এটি ওভাররাইট করে ফেলে। এর ফলে ফিচারের সংঘর্ষ (feature collision) ঘটে যা আপনার প্রতিষ্ঠানের আকার বাড়ার সাথে সাথে আরও বড় সমস্যা হয়ে দাঁড়ায়। একটি রিপোজিটরির ডেভেলপার জানতেই পারেন না যে তাদের তৈরি করা একটি শর্টকাট অন্য টিমের জন্য একটি ব্রেকিং চেঞ্জ হয়ে দাঁড়িয়েছে। এরিয়া যত বাড়ে, এই গ্লোবাল ভেরিয়েবলগুলো শেয়ার্ড পরিবেশে লুকিয়ে থাকা মাইন হিসেবে পরিণত হয়।
ক্লোজার মেমরি লিক (Closure Memory Leaks)
সিঙ্গেল পেজ অ্যাপ্লিকেশনগুলো ঘণ্টার পর ঘণ্টা চলার জন্য তৈরি করা হয়। আর এই স্থায়িত্বই ক্লোজার লিককে বিষাক্ত করে তোলে। এই প্যাটার্নটি অত্যন্ত সাধারণ: একটি useEffect একটি গ্লোবাল ইভেন্ট বাস, একটি WebSocket হ্যান্ডলার বা সরাসরি DOM-এর সাথে একটি কলব্যাক রেজিস্টার করে। যদি ডিপেন্ডেন্সি অ্যারে (dependency array) অস্থির হয় বা বাদ দেওয়া হয়, তবে ক্লিনআপ কখনোই মূল সাবস্ক্রিপশনের সাথে মিলবে না। ক্লোজারটি তার লেক্সিক্যাল স্কোপে যা কিছু আছে তা ক্যাপচার করে ফেলে, যার মধ্যে বিশাল পার্সড অ্যারে, ফেচ করা JSON ব্লব বা DOM ট্রি-এর রেফারেন্স থাকতে পারে। প্রতিটি নেভিগেশন আরও ওজন যোগ করে। ব্যবহারকারী বুঝতে পারেন না কেন তাদের ব্রাউজার ট্যাবটি ৮০০ এমবি (800MB) মেমরি ব্যবহার করছে। তারা শুধু বুঝতে পারেন যে অ্যাপটি ধীরগতির হয়ে যাচ্ছে এবং শেষ পর্যন্ত বন্ধ হয়ে যাচ্ছে।
এটি বিশেষ করে বিপজ্জনক যখন প্রতিটি রেন্ডারের সময় ডিপেন্ডেন্সি অ্যারে পরিবর্তিত হয়। প্রতিটি সাইকেলে একটি নতুন ফাংশন রেফারেন্স তৈরি হয়, যা একটি লিসেনারের সাথে রেজিস্টার হয়, কিন্তু পুরনোটি কখনোই রিলিজ হয় না। এর ফলে তৈরি হয় মৃত ক্লোজারের একটি মিউজিয়াম, যেখানে প্রতিটি ক্লোজার তাদের জন্মের সময়কার ডেটা জমিয়ে রাখে।
ডায়নামিক মডিউলে TDZ এরর (TDZ Errors in Dynamic Modules)
Temporal Dead Zone কোনো তাত্ত্বিক প্রান্তিক ক্ষেত্র (edge case) নয়। যখন আপনি কোনো let বা const ডিক্লেয়ার করার আগেই সেটি অ্যাক্সেস করার চেষ্টা করেন, তখন ইঞ্জিন একটি ReferenceError থ্রো করে। সার্কুলার ডিপেন্ডেন্সি এবং ডাইনামিক ইমপোর্ট সমৃদ্ধ বড় বড় monorepo-তে এক্সিকিউশন অর্ডার বা কার্যকর হওয়ার ক্রম প্রায়ই ইমপ্লিসিট (implicit) থাকে। Module A, Module B-কে ইমপোর্ট করে, যা আবার ডাইনামিকভাবে এমন একটি চাঙ্ক (chunk) ইমপোর্ট করে যা পুনরায় Module A-এর ওপর নির্ভর করে। যদি কোনো একটি শাখা এমন একটি ভেরিয়েবল স্পর্শ করে যা এখনও ইনিশিয়ালাইজ হতে শেষ হয়নি, তবে লোড হওয়ার সময় অ্যাপটি ক্র্যাশ করে। এই ব্যর্থতাগুলো অত্যন্ত বিরক্তিকর কারণ এগুলো টাইমিং-নির্ভর। বান্ডলার স্প্লিট পয়েন্টে সামান্য পরিবর্তন, কোড লোডিংয়ে নেটওয়ার্কের বিলম্ব, অথবা চাঙ্ক ক্যাশিংয়ের পরিবর্তন এক্সিকিউশন অর্ডারকে এমনভাবে বদলে দিতে পারে যা ঠিক TDZ ট্রিগার করার জন্য যথেষ্ট। এই ক্র্যাশটি অপ্রত্যাশিত এবং স্ট্যাক ট্রেস সাধারণত একদম নিরপরাধ একটি লাইনের দিকে নির্দেশ করে।
রক্ষণাত্মক কৌশল
স্কোপ সংক্রান্ত বাগ (scope bugs) থেকে বাঁচতে আপনি কেবল স্ট্যাক ট্রেসের ওপর নির্ভর করতে পারেন না। আপনার প্রয়োজন প্রতিরোধ এবং শনাক্তকরণ।
স্ট্যাটিক অ্যানালাইসিস দিয়ে শুরু করুন। কঠোর সীমানা বজায় রাখতে ESLint কনফিগার করুন। no-implicit-globals এবং no-shadow-এর মতো রুলগুলো স্পষ্ট ভুলগুলো ধরে ফেলে। শ্যাডোয়িং (Shadowing) বিশেষভাবে বিপজ্জনক কারণ এটি আপনাকে বিভ্রান্ত করে; আপনি মনে করেন আপনি একটি লোকাল ভেরিয়েবল পরিবর্তন করছেন, কিন্তু আসলে আপনি একটি আউটার ভেরিয়েবলের ওপর ক্লোজার (closure) তৈরি করছেন অথবা ভুলবশত একটি ডুপ্লিকেট তৈরি করছেন। এই রুলগুলো আপনার উদ্দেশ্যকে স্পষ্ট করতে বাধ্য করে এবং নীরব সংঘর্ষ (silent collisions) দূর করে।
ইউনিট টেস্টের ক্ষেত্রে আপনি যেভাবে শৃঙ্খলা বজায় রাখেন, ঠিক একইভাবে আপনার মেমরি প্রোফাইল করুন। Chrome DevTools খুলুন, আপনার শুরুর রুটে একটি হিপ স্ন্যাপশট (heap snapshot) নিন, পাঁচ মিনিট আপনার অ্যাপ্লিকেশনটি ব্যবহার করুন এবং তারপর আরেকটি স্ন্যাপশট নিন। এই দুটি তুলনা করুন। "Closure" ফিল্টার করুন এবং দেখুন যে সংখ্যাটি ক্রমাগত বাড়ছে কি না। ডিটাচড DOM নোড (detached DOM nodes) খুঁজুন যেগুলোতে এখনও ইভেন্ট লিসেনার (event listeners) রয়েছে। যদি দ্বিতীয় স্ন্যাপশটে হাজার হাজার নতুন Closure এন্ট্রি দেখা যায় অথচ আপনার ইউজার সংখ্যা একই থাকে, তবে বুঝতে হবে আপনি এমন কিছু ফাংশন আটকে ফেলেছেন যা ডেটা ধরে রেখেছে। এটাই আপনার মেমরি লিক।
আর্কিটেকচারাল দিক থেকে, কনফিগারেশনের জন্য গ্লোবাল window অবজেক্টের ওপর নির্ভর করা বন্ধ করুন। সেটিংসগুলো props হিসেবে অথবা একটি টাইপড কনটেক্সট (typed context)-এর মাধ্যমে পাস করুন। এখানে ডিপেন্ডেন্সি ইনজেকশন (Dependency injection) কোনো এন্টারপ্রাইজ বাজওয়ার্ড নয়; এটি হলো একটি ফাংশনকে গ্লোবাল স্কোপ থেকে ডেটা খোঁজার সুযোগ না দিয়ে আর্গুমেন্টের মাধ্যমে তার প্রয়োজনীয় সবকিছু প্রদান করার পদ্ধতি। এর ফলে আপনি এমন কোড পাবেন যা ব্রাউজার শিম (browser shims) ছাড়াই টেস্ট করা সম্ভব, এবং এমন মডিউল তৈরি হবে যা একই শেলের মধ্যে একাধিক অ্যাপ চলাকালীন একে অপরের সাথে সংঘর্ষে লিপ্ত হবে না।
পরিশেষে, ক্লিনআপ ফেজকে (cleanup phase) কঠোরভাবে মেনে চলুন। প্রতিটি addEventListener-এর বিপরীতে ইফেক্ট ক্লিনআপের ভেতরে একটি ম্যাচিং removeEventListener থাকা প্রয়োজন। অ্যাসিনক্রোনাস কাজের জন্য AbortController ব্যবহার করুন এবং এর সিগন্যালটি fetch-এ পাস করুন যাতে কম্পোনেন্টটি ধ্বংস হয়ে গেলে চলমান রিকোয়েস্টগুলো বাতিল হয়ে যায়। এই অভ্যাসগুলো সরাসরি নিয়ন্ত্রণ করে যে একটি স্কোপ কতক্ষণ টিকে থাকবে। এগুলো কেবল বয়লারপ্লেট (boilerplate) নয়; এগুলো হলো মেমরি ম্যানেজমেন্ট।
আপনার টিমের জন্য এর অর্থ কী
স্কোপ কোনো হালকা কৌশল নয় যা দিয়ে ইন্টারভিউতে প্রার্থীদের পরীক্ষা করা হবে। প্রোডাকশনে, স্কোপ মানেই হলো মেমরি ম্যানেজমেন্ট। আপনার ডিক্লেয়ার করা প্রতিটি ভেরিয়েবল একটি সম্ভাব্য জিম্মি (hostage)। প্রতিটি ক্লোজার হলো ইঞ্জিনের একটি প্রতিশ্রুতি। যখন আপনি একটি লিসেনার রিলিজ করতে ভুলে যান, তখন আপনি কেবল একটি লাইট জ্বালিয়ে রাখেন না; আপনি আপনার অ্যাপের সাথে একটি ওজন বেঁধে সমুদ্রের গভীরে ফেলে দিচ্ছেন। শক্তিশালী হার্ডওয়্যারে অ্যাপটি তবুও সাঁতার কাটতে পারবে। কিন্তু লো-এন্ড ডিভাইসের ব্যবহারকারীদের জন্য এটি ডুবে যাবে। স্কোপকে একটি সীমিত সম্পদ হিসেবে দেখা শুরু করুন। আপনার ব্যবহারকারী এবং আপনার তিন ঘণ্টার ডিবাগিং সেশন—সবাই আপনাকে ধন্যবাদ জানাবে।
