Steven
Steven8 min citire

Născut și reparat în douăsprezece minute

Changelog-ul GeekBye v1.8.10 spune că a reparat un crash în timpul editării scurtăturilor de tastatură. Chiar a făcut-o — dar crash-ul a fost introdus și reparat în același pull request, la douăsprezece minute distanță, și nu a ajuns niciodată la vreun utilizator. Povestea reală e cascada de fiabilitate care l-a produs: o schimbare mică și corectă a momentului când scurtăturile sunt suspendate, și bug-ul de demontare React care a căzut dintr-o singură linie menită să facă editorul mai sigur.

Inginerie
React
Desktop
Lansări GeekBye
Născut și reparat în douăsprezece minute

Iată cea mai onestă propoziție pe care o pot scrie despre GeekBye v1.8.10: crash-ul pentru repararea căruia e faimos nu i s-a întâmplat niciodată nimănui. Changelog-ul spune "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," ceea ce e adevărat și se citește ca un bug care a fost livrat, a mușcat utilizatorii și a fost peticit într-o versiune ulterioară. Nu a fost așa. Crash-ul a fost introdus de un commit și șters de altul în același pull request, la douăsprezece minute distanță, în aceeași dimineață de vineri. A trăit în întregime în interiorul unui branch. Notele de lansare descriu o cicatrice care s-a format și s-a vindecat înainte ca cineva din afara repo-ului s-o poată vedea.

Asta nu e un eșec al changelog-ului; e forma onestă a muncii iterative și merită povestit pentru că cele douăsprezece minute conțin un bug React cu adevărat instructiv. Dar ca să înțelegi crash-ul trebuie să înțelegi pentru ce era de fapt lansarea — pentru că crash-ul a fost daună colaterală din repararea altui lucru.

Despre ce era de fapt lansarea

GeekBye îți permite să-ți reasignezi scurtăturile de tastatură în Setări. Cât timp înregistrezi o combinație nouă, aplicația trebuie să împiedice declanșarea scurtăturilor sale globale — altfel apăsarea Cmd+B pentru a o atribui ar comuta pur și simplu fereastra în loc să fie capturată. Așa că editorul suspendă scurtăturile globale prin IPC: renderer-ul apelează window.electronAPI.setShortcutsSuspended(true), iar ShortcutsHelper din procesul principal răspunde apelând unregisterAll(), renunțând la fiecare înregistrare globalShortcut astfel încât apăsarea de tastă să ajungă la input-ul de captură în loc să declanșeze o acțiune. La reluare apelează registerGlobalShortcuts() pentru a le pune înapoi.

Bug-ul care a pornit tot asta (issue #233) era despre domeniul acelei suspendări. Codul vechi suspenda scurtăturile pe tot timpul cât pagina de setări a scurtăturilor era deschisă — nu doar cât timp înregistrai activ o tastă. De cele mai multe ori asta e invizibil. Dar dacă deschideai pagina, începeai să faci altceva și navigai în altă parte fără să termini vreodată o editare, aplicația putea rămâne cu toate scurtăturile globale neînregistrate — moarte în tăcere — până când te întorceai. Commit-ul care o repară e sincer despre simptom: suspendarea prea largă risca "making the app appear broken if the user forgot they had the page open."

Fix-ul e o îngustare de o linie: suspendă doar când un rând e efectiv editat — cheiat pe !!editingShortcut, starea hook-ului care ține id-ul rândului pe care-l înregistrezi — în loc de pe toată vizita. Schimbare bună. Dar „suspendă doar cât timp editezi" ridică o întrebare imediată: ce contează drept terminarea unei editări? Apăsarea unei taste o termină. Apăsarea Escape o anulează. Dar ce se întâmplă dacă pur și simplu… dai click în altă parte sau treci complet la altă aplicație, în mijlocul capturii? Dacă nimic nu anulează editarea, rămâi înregistrând o scurtătură într-o fereastră care nu e focalizată, iar scurtăturile rămân suspendate. Așa că același PR a adăugat o supapă de siguranță: anulează editarea când focus-ul pleacă. Și asta e linia care a dat crash.

Bug-ul de douăsprezece minute

Supapa de siguranță era un useEffect în ShortcutsSettings.tsx care, cât timp un rând era editat, asculta pentru blur:

const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine

Doi listeneri. Unul pe window — se declanșează când treci la altă aplicație. Unul atașat direct, ca listener DOM brut, la <input>-ul de captură — menit să se declanșeze când dai click în altă parte pe alt element. Par redundanți și inofensivi. Nu sunt.

Parcurge secvența pentru "switching windows mid-edit," care e exact expresia din changelog:

  1. Înregistrezi o scurtătură; <input>-ul e focalizat. Faci Cmd+Tab către altă aplicație.
  2. Blur-ul de window se declanșează. handleBlur rulează cancelEditing(), care setează editingShortcut înapoi la null.
  3. Setarea acelei stări demontează <input>-ul — rândul iese din modul de editare, așa că input-ul e eliminat din DOM.
  4. Eliminarea unui element focalizat din DOM declanșează sincron un eveniment blur pe acel element. Listener-ul brut inputRef îl prinde și apelează cancelEditing() din nou — de data asta în mijlocul fazei de commit a React, în timp ce arborele de componente e demontat.
  5. Apelarea unui setter de stare pe un fiber aflat în mijlocul demontării declanșează unul dintre invarianții interni ai React, și aruncă: "Should have a queue". View-ul de setări dă crash.

Bug-ul nu e listener-ul de window și nu e cu adevărat conceptul de „anulează la blur." E că unul dintre cei doi listeneri era un addEventListener brut pe un element gestionat de React. React nu știe de acel listener, nu-l curăță la demontare și — crucial — browser-ul declanșează blur chiar în timpul eliminării din DOM pe care React o efectuează, așa că handler-ul reintră în logica ta de stare în cel mai prost moment posibil. Mesajul commit-ului de fix o spune mai precis decât pot eu: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Fix-ul

bf28a50, la douăsprezece minute după ce crash-ul s-a născut, face două lucruri mici.

Întâi, șterge listener-ul de input brut și păstrează doar pe cel de window, care e singurul ce trebuia să fie deloc un listener DOM manual (nu există un onBlur React pentru „întreaga fereastră și-a pierdut focus-ul"):

const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
  window.removeEventListener('blur', handleWindowBlur)
}

Al doilea, mută cazul de click-în-altă-parte — „utilizatorul a dat click pe alt element" — pe prop-ul onBlur propriu al React de pe input, și îl amână un tick:

onBlur={() => {
  // Deferred to avoid state updates during React's render cycle.
  setTimeout(() => cancelEditing(), 0)
}}

Ambele părți contează. Folosirea prop-ului onBlur al React în loc de un listener brut înseamnă că React deține ciclul de viață al handler-ului și nu-l va lăsa să atârne. Iar setTimeout(…, 0) garantează că, și atunci când acest blur e declanșat de o demontare, apelul cancelEditing() aterizează la următorul tick — după ce render-ul și commit-ul curente s-au terminat — așa că nu poate niciodată actualiza starea pe o componentă care încă se demontează. Dacă componenta a dispărut deja până atunci, apelul amânat e un no-op inofensiv.

Ăsta e tot fix-ul. Șterge listener-ul pe care React nu-l putea vedea; lasă React să-l programeze pe cel rămas.

De ce ăsta e genul bun de plictisitor

Există tentația de a cataloga asta drept „trivial" — un setTimeout, o linie ștearsă — și de a merge mai departe. Dar forma lui e unul dintre cele mai comune moduri în care aplicațiile desktop React dau crash, și merită interiorizat:

  • Crash-ul apare doar la o graniță de ciclu de viață (demontare), declanșat de un eveniment pe care framework-ul nu-l programează (un blur nativ în timpul eliminării din DOM). Nu va apărea într-un test de click-through; are nevoie de calea specifică „focus-ul pleacă în timpul editării".
  • Cauza-rădăcină e amestecarea a două modele de proprietate — evenimente DOM brute și evenimentele sintetice, programate ale React — pe același element. În momentul în care handler-ul tău mută stare care poate demonta acel element, calea brută devine reentrantă.
  • Fix-ul nu e „adaugă un flag de gardă" (deși un ref isMounted l-ar fi mascat); e eliminarea completă a listener-ului brut, care e schimbarea mai mică și cea corectă. Scădere, din nou.

Iar meta-lecția, cea în jurul căreia se învârte întreaga serie: citește diff-ul, nu nota de lansare. "Fixed a crash while editing keyboard shortcuts" e exact, dar ascunde că crash-ul a fost autoprovocat, în aceeași dimineață, și nu a fost livrat niciodată — și că lansarea reală e o victorie liniștită de corectitudine despre când să suspenzi o scurtătură globală.

Ce altceva a fost livrat

Încă două lucruri au venit la pachet în v1.8.10, ambele merită o linie. Sistemul de scurtături a căpătat de asemenea autovindecare peste sleep/wake: macOS poate renunța în tăcere la înregistrările de scurtături globale ale unei aplicații când mașina intră în sleep, așa că un handler de reluare acum le reverifică și le reînregistrează, ceea ce înseamnă că scurtăturile tale încă funcționează după ce laptopul se trezește, fără o relansare. Iar pe partea de AI, clientul a devenit mai deștept la limitele de rată — cererile de streaming care lovesc un 429 acum se retrag exponențial și își resetează contorul când oprești înregistrarea, în loc să aștepte o întârziere fixă. Trei fix-uri de fiabilitate la subsisteme diferite, unul dintre care e crash-ul care n-a fost niciodată un crash.

Pentru capitolul anterior al poveștii v1, tipărirea unei ședințe în PDF fără o bibliotecă PDF (v1.8.9); iar pentru întregul arc, anatomia livrării de software până la perfecțiune.

Articole Similare

Tipărirea unei ședințe în PDF fără o bibliotecă PDF
Steven
Steven9 min citire

Tipărirea unei ședințe în PDF fără o bibliotecă PDF

GeekBye exportă o ședință ca PDF, și nu există nicăieri în cod vreo bibliotecă PDF. Randează HTML într-o fereastră de browser invizibilă și o tipărește. Alegerea aceea e toată povestea: a făcut funcționalitatea ușor de construit și i-a dat fiecare eșec pe care-l are un browser real — un flash alb, o limită de lungime a URL-ului și o întrerupere de pagină care tăia capturile de ecran în două. Fix-ul pentru cel mai urât bug a fost o singură linie de CSS.

Inginerie
Electron
Desktop
De la OCR la pixeli fără a pierde fallback-ul
Steven
Steven8 min citire

De la OCR la pixeli fără a pierde fallback-ul

GeekBye a încetat să-ți citească capturile de ecran cu OCR și a început să trimită imaginea propriu-zisă către modelele cu viziune — iar asta e partea ușoară. Partea instructivă e optimizarea care a sărit complet peste OCR pentru utilizatorii cu viziune, a rupt pe tăcute fallback-ul către modelele fără viziune și a trebuit readusă la o versiune în care plasa de siguranță rulează în paralel și nu costă nimic.

Inginerie
AI
Desktop
Cele două moduri de eșec ale unui overlay click-through
Steven
Steven9 min citire

Cele două moduri de eșec ale unui overlay click-through

Fereastra GeekBye plutește peste tot și lasă click-urile tale să treacă prin ea — cu excepția locurilor unde are butoane. Ăsta e un contract cu două fețe, iar v1.8.5 și v1.8.14 arată cum e când se rupe fiecare față: o lansare în care overlay-ul a înghițit un dialog de sistem, una în care ți-a furat tastele. Fix-ul câștigător pentru a doua a fost ștergerea de cod.

Inginerie
Electron
Desktop