
On İki Dakikada Doğdu ve Düzeltildi
GeekBye v1.8.10'in changelog'u klavye kısayollarını düzenlerken bir çökmeyi düzelttiğini söyler. Düzeltti — ama çökme aynı pull request'te, on iki dakika arayla eklendi ve düzeltildi, ve tek bir kullanıcıya bile ulaşmadı. Asıl hikâye onu üreten güvenilirlik çağlayanıdır: kısayolların ne zaman askıya alındığına dair küçük, doğru bir değişiklik, ve editörü daha güvenli kılmak için tasarlanmış tek bir satırdan düşen React teardown bug'ı.
GeekBye v1.8.10 hakkında yazabileceğim en dürüst cümle şu: düzelttiğiyle ünlü olduğu çökme aslında hiç kimsenin başına gelmedi. Changelog "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," diyor, ki bu doğru, ve sevk edilen, kullanıcıları ısıran, ve bir takip sürümünde yamalanan bir bug gibi okunur. Öyle değildi. Çökmeyi bir commit ekledi ve bir başkası aynı pull request içinde, on iki dakika arayla, aynı Cuma sabahı sildi. Tamamen bir branch'in içinde yaşadı. Sürüm notları, repo dışından kimse görebilmeden önce oluşup iyileşen bir yara izini betimler.
Bu changelog'un bir başarısızlığı değil; yinelemeli çalışmanın dürüst şeklidir, ve anlatmaya değer çünkü o on iki dakika gerçekten öğretici bir React bug'ı içerir. Ama çökmeyi anlamak için sürümün aslında ne için olduğunu anlaman gerekir — çünkü çökme, başka bir şeyi düzeltmenin yan hasarıydı.
Sürüm aslında neyle ilgiliydi
GeekBye, klavye kısayollarını Ayarlar'da yeniden atamana izin verir. Yeni bir kombinasyon kaydederken, uygulama global kısayollarının tetiklenmesini durdurmak zorundadır — yoksa onu atamak için Cmd+B'ye basmak, yakalanmak yerine sadece pencereyi açıp kapatırdı. Bu yüzden editör global kısayolları IPC üzerinden askıya alır: renderer window.electronAPI.setShortcutsSuspended(true)'i çağırır, ve ana sürecin ShortcutsHelper'ı unregisterAll()'i çağırarak yanıt verir, her globalShortcut kaydını düşürür ki tuş basışı bir eylemi tetiklemek yerine yakalama girdisine ulaşsın. Devam ederken onları geri koymak için registerGlobalShortcuts()'i çağırır.
Tüm bunları başlatan bug (issue #233) o askıya almanın kapsamıyla ilgiliydi. Eski kod kısayolları, kısayol ayarları sayfası açık olduğu tüm süre boyunca askıya alıyordu — yalnızca aktif olarak bir tuş kaydederken değil. Çoğu zaman bu görünmezdir. Ama sayfayı açar, başka bir şey yapmaya başlar, ve bir düzenlemeyi hiç bitirmeden uzaklaşırsan, uygulama tüm global kısayolları kayıtsız — sessizce ölü — halde kalabilirdi, sen geri dönene dek. Onu düzelten commit belirti konusunda açık sözlü: çok geniş askıya almak "making the app appear broken if the user forgot they had the page open." riskini taşıyordu.
Düzeltme tek satırlık bir daraltmadır: yalnızca bir satır gerçekten düzenlenirken askıya al — kaydettiğin satırın id'sini tutan hook state'i olan !!editingShortcut'a bağlı — tüm ziyaret boyunca değil. İyi değişiklik. Ama "yalnızca düzenlerken askıya al" hemen bir soru doğurur: bir düzenlemeyi bitirmek ne sayılır? Bir tuşa basmak onu bitirir. Escape'e basmak onu iptal eder. Ama ya sadece… uzağa tıklarsan, veya yakalama ortasında tümüyle başka bir uygulamaya geçersen? Hiçbir şey düzenlemeyi iptal etmezse, odakta olmayan bir pencereye kısayol kaydeder halde kalırsın, ve kısayollar askıda kalır. Bu yüzden aynı PR bir emniyet valfi ekledi: odak ayrıldığında düzenlemeyi iptal et. Ve çöken satır işte odur.
On iki dakikalık bug
Emniyet valfi, bir satır düzenlenirken blur'ü dinleyen ShortcutsSettings.tsx içindeki bir useEffect'ti:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
İki dinleyici. Biri window üzerinde — başka bir uygulamaya geçtiğinde tetiklenir. Biri doğrudan, ham bir DOM dinleyicisi olarak, yakalama <input>'una bağlı — başka bir elemana uzağa tıkladığında tetiklenmesi amaçlanmış. Gereksiz ve zararsız görünüyorlar. Değiller.
Tam changelog'un ifadesi olan "switching windows mid-edit," için diziyi adım adım izle:
- Bir kısayol kaydediyorsun;
<input>odakta. Başka bir uygulamaya Cmd+Tab yapıyorsun. - Pencere blur'ü tetiklenir.
handleBlur,editingShortcut'ı tekrarnull'a ayarlayancancelEditing()'i çalıştırır. - O state'i ayarlamak
<input>'u unmount eder — satır düzenleme modundan çıkar, dolayısıyla girdi DOM'dan kaldırılır. - Odaktaki bir elemanı DOM'dan kaldırmak o eleman üzerinde senkron olarak bir
blurolayı gönderir. HaminputRefdinleyicisi onu yakalar vecancelEditing()'i tekrar çağırır — bu sefer React'in commit fazının ortasında, bileşen ağacı sökülürken. - Teardown ortasındaki bir fiber üzerinde bir state setter çağırmak React'in dahili değişmezlerinden birine takılır, ve fırlatır:
"Should have a queue". Ayarlar görünümü çöker.
Bug pencere dinleyicisi değil ve gerçekte "blur'da iptal" kavramı da değil. İki dinleyiciden birinin React-yönetimli bir eleman üzerinde ham bir addEventListener olmasıdır. React o dinleyiciyi bilmez, unmount'ta onu temizlemez, ve — can alıcı biçimde — tarayıcı, React'in gerçekleştirdiği tam o DOM kaldırması sırasında blur'ü tetikler, dolayısıyla handler state mantığına mümkün olan en kötü anda yeniden girer. Düzeltme commit'inin mesajı bunu benim söyleyebileceğimden daha kesin söyler: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Düzeltme
bf28a50, çökme doğduktan on iki dakika sonra, iki küçük şey yapar.
Önce, ham input dinleyicisini siler ve yalnızca pencere olanını tutar, ki o zaten hiçbir şekilde yalnızca manuel bir DOM dinleyicisi olması gereken tek dinleyiciydi ("tüm pencere odağını kaybetti" için bir React onBlur yoktur):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
İkinci olarak, tıkla-uzaklaş durumunu — "kullanıcı başka bir elemana tıkladı" — girdi üzerindeki React'in kendi onBlur prop'una taşır, ve onu bir tık erteler:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Her iki parça da önemlidir. Ham bir dinleyici yerine React'in onBlur prop'unu kullanmak, handler'ın yaşam döngüsüne React'in sahip olduğu ve onu sarkıtta bırakmayacağı anlamına gelir. Ve setTimeout(…, 0), bu blur bir unmount tarafından tetiklendiğinde bile, cancelEditing() çağrısının bir sonraki tıka indiğini — mevcut render ve commit bittikten sonra — garanti eder, böylece hâlâ sökülmekte olan bir bileşende asla state güncelleyemez. Bileşen o zamana dek zaten gitmişse, ertelenen çağrı zararsız bir no-op'tur.
Bütün düzeltme bu. React'in göremediği dinleyiciyi sil; kalanı React'in zamanlamasına bırak.
Bu neden iyi türden bir sıkıcılık
Bunu "önemsiz" olarak dosyalayıp — bir setTimeout, silinmiş bir satır — geçme cazibesi var. Ama şekli, React masaüstü uygulamalarının çöktüğü en yaygın yollardan biridir, ve içselleştirmeye değer:
- Çökme yalnızca bir yaşam döngüsü sınırında (unmount) ortaya çıkar, framework'ün zamanlamadığı bir olay (DOM kaldırması sırasında yerel bir blur) tarafından tetiklenir. Bir tıkla-geç testinde görünmez; belirli "düzenlerken odak ayrılır" yoluna ihtiyaç duyar.
- Kök neden, aynı eleman üzerinde iki sahiplik modelini karıştırmaktır — ham DOM olayları ve React'in sentetik, zamanlanmış olayları. Handler'ın o elemanı unmount edebilecek state'i mutasyona uğrattığı an, ham yol yeniden-girişli hâle gelir.
- Düzeltme "bir koruma bayrağı ekle" değildir (bir
isMountedref'i onu maskeleyebilirdi de); ham dinleyiciyi tamamen kaldırmaktır, ki bu daha küçük değişikliktir ve doğru olandır. Yine çıkarma.
Ve bütün bu serinin dönüp durduğu meta-ders: changelog'u değil, diff'i oku. "Fixed a crash while editing keyboard shortcuts" doğrudur, ama çökmenin kendi kendine verilmiş, aynı-sabah, ve hiç sevk edilmemiş olduğunu gizler — ve asıl sürümün, bir global kısayolun ne zaman askıya alınacağına dair sessiz bir doğruluk zaferi olduğunu.
Başka ne sevk edildi
v1.8.10'da iki şey daha yanında geldi, ikisi de bir satıra değer. Kısayol sistemi ayrıca uyku/uyanma boyunca kendi kendini iyileştirme kazandı: macOS, makine uyuduğunda bir uygulamanın global kısayol kayıtlarını sessizce düşürebilir, bu yüzden bir devam handler'ı artık onları yeniden doğrular ve yeniden kaydeder, yani kısayolların dizüstü uyandıktan sonra yeniden başlatma olmadan hâlâ çalışır. Ve AI tarafında, istemci hız limitleri konusunda akıllandı — bir 429'a çarpan streaming istekleri artık üstel olarak geri çekilir ve kaydı durdurduğunda sayaçlarını sıfırlar, sabit bir gecikmeyi beklemek yerine. Farklı alt sistemlere üç güvenilirlik düzeltmesi, bunlardan biri hiç çökme olmayan çökme.
v1 hikâyesinin bir önceki bölümü için, bir toplantıyı PDF kütüphanesi olmadan PDF'e yazdırmak (v1.8.9); ve tüm yay için, yazılımı kusursuzluğa kadar sevk etmenin anatomisi.