Steven
Steven8 min de lectura

Nascut i arreglat en dotze minuts

El changelog de GeekBye v1.8.10 diu que va arreglar un crash mentre s'editaven les dreceres de teclat. Ho va fer — però el crash es va introduir i arreglar en la mateixa pull request, amb dotze minuts de diferència, i mai no va arribar a cap usuari. La història real és la cascada de fiabilitat que el va produir: un canvi petit i correcte sobre quan se suspenen les dreceres, i el bug de teardown de React que va caure d'una sola línia pensada per fer l'editor més segur.

Engineering
React
Desktop
GeekBye Releases
Nascut i arreglat en dotze minuts

Aquí tens la frase més honesta que puc escriure sobre GeekBye v1.8.10: el crash pel qual és famós per haver arreglat no li va passar mai a ningú. El changelog diu "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," cosa que és certa, i es llegeix com un bug que es va publicar, va mossegar usuaris, i es va apedaçar en un seguiment. No ho era. El crash el va introduir un commit i el va eliminar un altre en la mateixa pull request, amb dotze minuts de diferència, el mateix divendres al matí. Va viure enterament dins d'una branca. Les notes de la versió descriuen una cicatriu que es va formar i es va curar abans que ningú de fora del repositori la pogués veure.

Això no és un fracàs del changelog; és la forma honesta del treball iteratiu, i val la pena explicar-ho perquè aquells dotze minuts contenen un bug de React genuïnament instructiu. Però per entendre el crash has d'entendre per a què servia realment la versió — perquè el crash va ser dany col·lateral d'arreglar una altra cosa.

De què anava realment la versió

GeekBye et permet reassignar les seves dreceres de teclat a Configuració. Mentre estàs enregistrant una combinació nova, l'app ha d'aturar el disparament de les seves dreceres globals — altrament, prémer Cmd+B per assignar-la simplement commutaria la finestra en comptes de ser capturada. Així que l'editor suspèn les dreceres globals per IPC: el renderer crida window.electronAPI.setShortcutsSuspended(true), i el ShortcutsHelper del procés principal respon cridant unregisterAll(), deixant caure cada registre de globalShortcut perquè la pulsació arribi a l'input de captura en comptes de disparar una acció. En reprendre crida registerGlobalShortcuts() per tornar-les a posar.

El bug amb què va començar tot això (issue #233) anava sobre l'abast d'aquella suspensió. El codi antic suspenia les dreceres durant tota l'estona que la pàgina de configuració de dreceres estava oberta — no només mentre enregistraves activament una tecla. La major part del temps això és invisible. Però si obries la pàgina, començaves a fer una altra cosa, i navegaves fora sense acabar mai una edició, l'app podia quedar amb totes les seves dreceres globals sense registrar — silenciosament mortes — fins que hi tornaves. El commit que ho arregla és sincer sobre el símptoma: suspendre de manera massa àmplia arriscava "making the app appear broken if the user forgot they had the page open."

La solució és un estrenyiment d'una línia: suspèn només quan una fila s'està editant de veritat — indexat a !!editingShortcut, l'estat del hook que conté l'id de la fila que enregistres — en comptes de durant tota la visita. Bon canvi. Però "només suspendre mentre s'edita" planteja una pregunta immediata: què compta com a acabar una edició? Prémer una tecla l'acaba. Prémer Escape la cancel·la. Però què passa si simplement… fas clic fora, o canvies a una altra app del tot, a mitja captura? Si res no cancel·la l'edició, et quedes enregistrant una drecera dins d'una finestra que no té el focus, i les dreceres queden suspeses. Així que la mateixa PR va afegir una vàlvula de seguretat: cancel·lar l'edició quan el focus se'n va. I aquesta és la línia que va fer crash.

El bug de dotze minuts

La vàlvula de seguretat era un useEffect a ShortcutsSettings.tsx que, mentre una fila s'editava, escoltava el blur:

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

Dos listeners. Un al window — dispara quan canvies a una altra app. Un adjuntat directament, com a listener DOM cru, a l'<input> de captura — pensat per disparar quan fas clic fora cap a un altre element. Semblen redundants i inofensius. No ho són.

Recorre la seqüència per a "switching windows mid-edit," que és exactament la frase del changelog:

  1. Estàs enregistrant una drecera; l'<input> té el focus. Fas Cmd+Tab cap a una altra app.
  2. El blur de la finestra dispara. handleBlur executa cancelEditing(), que torna a posar editingShortcut a null.
  3. Establir aquell estat desmunta l'<input> — la fila surt del mode d'edició, així que l'input s'elimina del DOM.
  4. Eliminar un element enfocat del DOM envia sincrònicament un event blur sobre aquell element. El listener inputRef cru el captura i crida cancelEditing() una altra vegada — aquesta vegada al mig de la fase de commit de React, mentre l'arbre de components s'està desmuntant.
  5. Cridar un setter d'estat en un fiber que està a mitja teardown activa una de les invariants internes de React, i llança: "Should have a queue". La vista de configuració fa crash.

El bug no és el listener de la finestra i no és realment el concepte de "cancel·lar en el blur." És que un dels dos listeners era un addEventListener cru sobre un element gestionat per React. React no sap res d'aquell listener, no el neteja en desmuntar, i — de manera crucial — el navegador dispara el blur durant la mateixa eliminació del DOM que React està fent, així que el handler reentra a la teva lògica d'estat en l'instant més dolent possible. El missatge del commit de la solució ho diu més precisament del que jo puc: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

La solució

bf28a50, dotze minuts després que el crash nasqués, fa dues coses petites.

Primer, elimina el listener cru de l'input i conserva només el de la finestra, que és l'únic que necessitava ser un listener DOM manual (no hi ha cap React onBlur per a "tota la finestra ha perdut el focus"):

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

Segon, mou el cas de clic-fora — "l'usuari ha fet clic en un altre element" — a la pròpia prop onBlur de React sobre l'input, i l'ajorna un tic:

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

Totes dues parts importen. Fer servir la prop onBlur de React en comptes d'un listener cru vol dir que React és propietari del cicle de vida del handler i no el deixarà penjant. I el setTimeout(…, 0) garanteix que fins i tot quan aquest blur que és disparat per un desmuntatge, la crida cancelEditing() aterra al tic següent — després que el render i el commit actuals hagin acabat — de manera que mai no pot actualitzar estat en un component que encara s'està desmuntant. Si el component ja ha desaparegut per llavors, la crida ajornada és un no-op inofensiu.

Aquesta és tota la solució. Elimina el listener que React no podia veure; deixa que React programi el que queda.

Per què aquesta és la bona mena d'avorriment

Hi ha la temptació d'arxivar això com a "trivial" — un setTimeout, una línia eliminada — i seguir endavant. Però la seva forma és una de les maneres més comunes en què les apps d'escriptori de React fan crash, i val la pena interioritzar-la:

  • El crash només apareix en una frontera de cicle de vida (desmuntatge), disparat per un event que el framework no programa (un blur natiu durant l'eliminació del DOM). No apareixerà en un test de clics; necessita el camí específic de "el focus se'n va mentre s'edita."
  • La causa arrel és barrejar dos models de propietat — events DOM crus i els events sintètics i programats de React — sobre el mateix element. En el moment que el teu handler muta estat que pot desmuntar aquell element, el camí cru es torna reentrant.
  • La solució no és "afegir una bandera de guarda" (encara que una ref isMounted ho hauria emmascarat); és eliminar el listener cru del tot, que és el canvi més petit i el correcte. Resta, un altre cop.

I la metalliçó, la que tota aquesta sèrie no para de voltar: llegeix el diff, no la nota de la versió. "Fixed a crash while editing keyboard shortcuts" és exacte, però amaga que el crash va ser autoinfligit, del mateix matí, i mai no es va publicar — i que la versió de veritat és una victòria silenciosa de correcció sobre quan suspendre una drecera global.

Què més es va publicar

Dues coses més van viatjar de gorra a la v1.8.10, totes dues d'una línia. El sistema de dreceres també va guanyar autoreparació a través de dormir/despertar: macOS pot deixar caure silenciosament els registres de dreceres globals d'una app quan la màquina dorm, així que ara un gestor de represa els torna a verificar i registrar, cosa que vol dir que les teves dreceres encara funcionen després que el portàtil es desperti sense un rellançament. I pel costat de la IA, el client es va tornar més llest amb els límits de ràtio — les peticions en streaming que topen amb un 429 ara fan backoff exponencial i reinicien el seu comptador quan atures l'enregistrament, en comptes d'esperar un retard fix. Tres arreglaments de fiabilitat a subsistemes diferents, un dels quals és el crash que mai no va ser un crash.

Per al capítol anterior de la història de la v1, imprimir una reunió a PDF sense biblioteca de PDF (v1.8.9); i per a tot l'arc, l'anatomia de publicar programari fins a la perfecció.

Articles Relacionats

Imprimir una reunió a PDF sense biblioteca de PDF
Steven
Steven10 min de lectura

Imprimir una reunió a PDF sense biblioteca de PDF

GeekBye exporta una reunió com a PDF, i no hi ha cap biblioteca de PDF enlloc del codi. Renderitza HTML en una finestra de navegador invisible i la imprimeix. Aquella tria és tota la història: va fer la funció fàcil de construir i li va donar cada fallada que té un navegador de veritat — un flaix blanc, un límit de longitud d'URL, i un salt de pàgina que tallava els screenshots per la meitat. La solució per al bug més lleig va ser una línia de CSS.

Engineering
Electron
Desktop
D'OCR als píxels sense perdre el fallback
Steven
Steven8 min de lectura

D'OCR als píxels sense perdre el fallback

GeekBye va deixar de llegir els teus screenshots amb OCR i va començar a enviar la imatge real als models de visió — que és la part fàcil. La part instructiva és l'optimització que es va saltar l'OCR completament per als usuaris de visió, va trencar silenciosament el fallback als models sense visió, i es va haver de desfer fins a una versió on la xarxa de seguretat corre en paral·lel i no costa res.

Engineering
AI
Desktop
Els dos modes de fallada d'un overlay click-through
Steven
Steven10 min de lectura

Els dos modes de fallada d'un overlay click-through

La finestra de GeekBye flota per damunt de tot i deixa que els teus clics la travessin — excepte on té botons. Això és un contracte de dues cares, i la v1.8.5 i la v1.8.14 mostren com és quan cada cara es trenca: una release on l'overlay es va empassar un diàleg del sistema, una altra on et robava les pulsacions de tecla. La solució guanyadora per a la segona va ser esborrar codi.

Engineering
Electron
Desktop