
बारह मिनट में जन्मा और ठीक हुआ
GeekBye v1.8.10 का changelog कहता है कि उसने keyboard shortcuts को edit करते वक़्त होने वाले एक crash को ठीक किया। किया भी — पर वह crash एक ही pull request में, बारह मिनट के फ़ासले पर, आया और ठीक हुआ, और एक भी user तक कभी नहीं पहुँचा। असली कहानी वह reliability श्रृंखला है जिसने उसे पैदा किया: shortcuts कब suspend हों, इस पर एक छोटा, सही बदलाव, और editor को ज़्यादा सुरक्षित बनाने के इरादे वाली एक लाइन से गिरा वह React teardown bug।
GeekBye v1.8.10 के बारे में सबसे ईमानदार वाक्य जो मैं लिख सकता हूँ वह यह है: जिस crash को ठीक करने के लिए वह मशहूर है, वह किसी के साथ कभी हुआ ही नहीं। changelog कहता है "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," जो सच है, और ऐसे पढ़ा जाता है मानो एक bug जो ship हुआ, users को काटा, और एक follow-up में patch हुआ। वह ऐसा नहीं था। वह crash उसी शुक्रवार सुबह, उसी pull request में, बारह मिनट के फ़ासले पर, एक commit द्वारा लाया गया और दूसरे द्वारा मिटाया गया। वह पूरा का पूरा एक branch के भीतर ही जीया। release notes एक ऐसे निशान का वर्णन कर रहे हैं जो repo के बाहर किसी के देख पाने से पहले ही बना और भर गया।
यह changelog की नाकामी नहीं है; यह पुनरावृत्ति-आधारित काम की ईमानदार शक्ल है, और बताने लायक़ इसलिए है क्योंकि उन बारह मिनटों में एक सचमुच शिक्षाप्रद React bug भरा है। पर crash समझने के लिए आपको यह समझना होगा कि release असल में किस लिए था — क्योंकि crash किसी और चीज़ को ठीक करने का collateral नुक़सान था।
release असल में किस बारे में था
GeekBye आपको Settings में अपने keyboard shortcuts दोबारा bind करने देता है। जब आप एक नया combo record कर रहे होते हैं, app को अपने global shortcuts को fire होने से रोकना पड़ता है — वरना उसे assign करने के लिए Cmd+B दबाना बस window को toggle कर देगा, capture होने के बजाय। तो editor global shortcuts को IPC के ज़रिए suspend करता है: renderer window.electronAPI.setShortcutsSuspended(true) को call करता है, और main process का ShortcutsHelper unregisterAll() को call करके जवाब देता है, हर globalShortcut registration को गिराते हुए ताकि keypress किसी action को trigger करने के बजाय capture input तक पहुँचे। resume पर वह उन्हें वापस रखने के लिए registerGlobalShortcuts() को call करता है।
जिस bug ने यह सब शुरू किया (issue #233) वह उस suspension के दायरे के बारे में था। पुराना code shortcuts को उतनी पूरी देर suspend करता था जब तक shortcuts settings page खुला था — सिर्फ़ तब नहीं जब आप सक्रिय रूप से कोई key record कर रहे थे। ज़्यादातर वक़्त वह अदृश्य होता है। पर अगर आपने page खोला, कुछ और करना शुरू किया, और एक edit कभी पूरा किए बिना कहीं और चले गए, तो जब तक आप वापस न आएँ, app अपने सारे global shortcuts unregister किए हुए — चुपचाप मरे हुए — छूट सकता था। जो commit इसे ठीक करता है वह लक्षण के बारे में बेलाग है: बहुत चौड़ा suspend करना "making the app appear broken if the user forgot they had the page open." का जोखिम उठा रहा था।
fix एक-लाइन का सिकोड़ना है: सिर्फ़ तभी suspend करो जब कोई row वाक़ई edit हो रही हो — !!editingShortcut पर keyed, वह hook state जो उस row की id थामे है जिसे आप record कर रहे हैं — न कि पूरी यात्रा भर। अच्छा बदलाव। पर «सिर्फ़ editing के दौरान suspend करो» तुरंत एक सवाल खड़ा करता है: किसे एक edit का पूरा होना माना जाए? एक key दबाना उसे पूरा करता है। Escape दबाना उसे cancel करता है। पर अगर आप बस… कहीं और click कर दें, या capture के बीच में पूरी तरह किसी और app पर चले जाएँ तो? अगर कुछ भी edit को cancel न करे, तो आप एक ऐसी window में shortcut record करते रह जाते हैं जो focused नहीं है, और shortcuts suspend ही रह जाते हैं। तो उसी PR ने एक safety valve जोड़ा: focus के जाने पर editing cancel करो। और वही वह लाइन है जो crash हुई।
बारह मिनट वाला bug
safety valve ShortcutsSettings.tsx में एक useEffect था जो, जब एक row edit हो रही होती थी, blur के लिए सुनता था:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
दो listeners। एक window पर — तब fire होता है जब आप किसी और app पर जाते हैं। एक सीधे, एक raw DOM listener के तौर पर, capture वाले <input> से जुड़ा — जिसका मतलब था तब fire होना जब आप किसी और element पर click करके हट जाएँ। वे बेमानी और बेज़रर लगते हैं। वे नहीं हैं।
"switching windows mid-edit," का क्रम चलकर देखिए, जो हूबहू changelog का जुमला है:
- आप एक shortcut record कर रहे हैं;
<input>focused है। आप Cmd+Tab से किसी और app पर जाते हैं। - window का blur fire होता है।
handleBlurcancelEditing()चलाता है, जोeditingShortcutको वापसnullकर देता है। - उस state को set करना
<input>को unmount कर देता है — row edit mode से निकल जाती है, तो input DOM से हटा दिया जाता है। - DOM से एक focused element हटाना synchronous रूप से उस element पर एक
blurevent dispatch करता है। rawinputReflistener उसे पकड़ता है औरcancelEditing()को फिर से call करता है — इस बार React के commit phase के बीचोंबीच, जब component tree गिराया जा रहा होता है। - teardown के बीच वाले एक fiber पर एक state setter call करना React के internal invariants में से एक से टकरा जाता है, और वह throw करता है:
"Should have a queue"। settings view crash कर जाती है।
bug window listener नहीं है और वह असल में «blur पर cancel» का विचार भी नहीं है। यह है कि दो listeners में से एक React-द्वारा-प्रबंधित element पर एक raw addEventListener था। React उस listener के बारे में नहीं जानता, unmount पर उसे साफ़ नहीं करता, और — निर्णायक रूप से — browser blur को ठीक उसी DOM हटाव के दौरान fire करता है जिसे React अंजाम दे रहा होता है, तो handler सबसे बुरे संभव पल पर आपके state logic में दोबारा घुस जाता है। fix commit का message इसे मुझसे ज़्यादा सटीकता से कहता है: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
fix
bf28a50, crash के जन्म के बारह मिनट बाद, दो छोटी चीज़ें करता है।
पहला, वह raw input listener को मिटा देता है और सिर्फ़ window वाला रखता है, जो अकेला ऐसा था जिसे शुरू से एक manual DOM listener होने की ज़रूरत थी ही (« पूरी window ने focus खो दिया» के लिए कोई React onBlur है ही नहीं):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
दूसरा, वह click-away वाले मामले — «user ने किसी और element पर click किया» — को input पर React के अपने onBlur prop पर ले जाता है, और उसे एक tick टाल देता है:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
दोनों हिस्से मायने रखते हैं। एक raw listener के बजाय React का onBlur prop इस्तेमाल करने का मतलब है कि React handler के lifecycle का मालिक है और उसे लटका हुआ नहीं छोड़ेगा। और setTimeout(…, 0) इस बात की गारंटी देता है कि तब भी जब यह blur एक unmount द्वारा trigger होता है, cancelEditing() call अगले tick पर उतरता है — मौजूदा render और commit के ख़त्म होने के बाद — तो वह कभी भी एक ऐसे component पर state update नहीं कर सकता जो अब भी गिराया जा रहा है। अगर तब तक component जा ही चुका है, तो टाला गया call एक बेज़रर no-op है।
बस यही पूरा fix है। जिस listener को React देख नहीं सकता था उसे मिटा दो; जो बचा है उसे React schedule करने दो।
यह अच्छी क़िस्म का उबाऊपन क्यों है
एक लालच होता है कि इसे «मामूली» के खाते में डाल दें — एक setTimeout, एक मिटाई हुई लाइन — और आगे बढ़ जाएँ। पर इसकी शक्ल उन सबसे आम तरीक़ों में से एक है जिनसे React desktop apps crash करती हैं, और इसे आत्मसात करने लायक़ है:
- crash सिर्फ़ एक lifecycle सीमा (unmount) पर प्रकट होता है, जो एक ऐसे event द्वारा trigger होता है जिसे framework schedule नहीं करता (DOM हटाव के दौरान एक native blur)। वह किसी click-through test में नहीं दिखेगा; उसे «editing के दौरान focus का जाना» वाला ख़ास path चाहिए।
- मूल कारण एक ही element पर दो ownership models को मिलाना है — raw DOM events, और React के synthetic, scheduled events। जिस पल आपका handler ऐसा state बदलता है जो उस element को unmount कर सकता है, raw path re-entrant बन जाता है।
- fix «एक guard flag जोड़ो» नहीं है (हालाँकि एक
isMountedref उसे ढँक देता); वह raw listener को पूरी तरह हटाना है, जो छोटा बदलाव है और सही वाला। फिर से घटाव।
और वह meta-सबक़, जिसके इर्द-गिर्द यह पूरी series चक्कर लगाती रहती है: diff पढ़ो, release note नहीं। "Fixed a crash while editing keyboard shortcuts" सटीक है, पर वह छिपा जाता है कि crash ख़ुद का बुलाया हुआ था, उसी सुबह का, और कभी ship नहीं हुआ — और यह कि असली release एक global shortcut को कब suspend करना है, इसके बारे में एक शांत correctness जीत है।
और क्या ship हुआ
दो और चीज़ें v1.8.10 के साथ आ लगीं, दोनों एक-एक लाइन के क़ाबिल। shortcut system को sleep/wake के आर-पार ख़ुद-को-ठीक-करना भी मिला: macOS मशीन के sleep होने पर किसी app के global shortcut registrations को चुपचाप गिरा सकता है, तो अब एक resume handler उन्हें दोबारा जाँचता और दोबारा register करता है, यानी laptop के जागने के बाद भी आपके shortcuts बिना relaunch के काम करते रहते हैं। और AI की तरफ़, client rate limits के बारे में समझदार हो गया — जो streaming requests एक 429 से टकराती हैं वे अब एक तयशुदा देरी का इंतज़ार करने के बजाय exponentially back off करती हैं और जब आप recording रोकते हैं तो अपना counter reset कर लेती हैं। अलग-अलग subsystems के तीन reliability fixes, जिनमें से एक वह crash है जो कभी crash था ही नहीं।
v1 कहानी के पिछले अध्याय के लिए, देखें बिना किसी PDF library के एक meeting को PDF में print करना (v1.8.9); और पूरे चाप के लिए, सॉफ़्टवेयर को परिपूर्णता तक पहुँचाकर भेजने की शारीर-रचना।