Steven
Steven8 min de lecture

Né et corrigé en douze minutes

Le changelog de GeekBye v1.8.10 dit qu'il a corrigé un crash pendant l'édition des raccourcis clavier. C'est vrai — mais le crash a été introduit et corrigé dans le même pull request, à douze minutes d'intervalle, et n'a jamais atteint un seul utilisateur. La vraie histoire est la cascade de fiabilité qui l'a produit : un petit changement correct sur le moment où les raccourcis sont suspendus, et le bug de teardown de React tombé d'une seule ligne censée rendre l'éditeur plus sûr.

Ingénierie
React
Desktop
Versions de GeekBye
Né et corrigé en douze minutes

Voici la phrase la plus honnête que je puisse écrire sur GeekBye v1.8.10 : le crash pour la correction duquel elle est célèbre n'est jamais arrivé à personne. Le changelog dit "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", ce qui est vrai, et se lit comme un bug qui a été livré, a mordu les utilisateurs, et a été patché dans un suivi. Ça ne l'était pas. Le crash a été introduit par un commit et supprimé par un autre dans le même pull request, à douze minutes d'intervalle, le même vendredi matin. Il a vécu entièrement à l'intérieur d'une branche. Les notes de version décrivent une cicatrice qui s'est formée et guérie avant que quiconque hors du dépôt ne puisse la voir.

Ce n'est pas un échec du changelog ; c'est la forme honnête du travail itératif, et ça vaut la peine d'être raconté parce que ces douze minutes contiennent un bug React vraiment instructif. Mais pour comprendre le crash, il faut comprendre à quoi la version servait vraiment — parce que le crash était un dommage collatéral de la correction d'autre chose.

De quoi la version parlait vraiment

GeekBye vous laisse réassigner ses raccourcis clavier dans les Réglages. Pendant que vous enregistrez une nouvelle combinaison, l'app doit empêcher ses raccourcis globaux de se déclencher — sinon, appuyer sur Cmd+B pour l'assigner ne ferait que basculer la fenêtre au lieu d'être capturé. Alors l'éditeur suspend les raccourcis globaux via IPC : le renderer appelle window.electronAPI.setShortcutsSuspended(true), et le ShortcutsHelper du processus principal répond en appelant unregisterAll(), lâchant chaque enregistrement de globalShortcut pour que la frappe atteigne l'input de capture au lieu de déclencher une action. À la reprise, il appelle registerGlobalShortcuts() pour les remettre.

Le bug qui a tout déclenché (issue #233) concernait la portée de cette suspension. L'ancien code suspendait les raccourcis pendant tout le temps où la page des réglages de raccourcis était ouverte — pas seulement pendant que vous enregistriez activement une touche. La plupart du temps c'est invisible. Mais si vous ouvriez la page, vous mettiez à faire autre chose, et partiez sans jamais finir une édition, l'app pouvait se retrouver avec tous ses raccourcis globaux non enregistrés — silencieusement morts — jusqu'à ce que vous reveniez. Le commit qui le corrige est franc sur le symptôme : suspendre trop largement risquait de "making the app appear broken if the user forgot they had the page open."

Le correctif est un resserrement d'une ligne : suspendre seulement quand une ligne est réellement en cours d'édition — clé sur !!editingShortcut, l'état du hook qui contient l'id de la ligne que vous enregistrez — au lieu de toute la visite. Bon changement. Mais « suspendre seulement pendant l'édition » soulève une question immédiate : qu'est-ce qui compte comme finir une édition ? Appuyer sur une touche la finit. Appuyer sur Escape l'annule. Mais si vous… cliquez ailleurs, ou passez à une autre app entièrement, en pleine capture ? Si rien n'annule l'édition, vous vous retrouvez à enregistrer un raccourci dans une fenêtre qui n'a pas le focus, et les raccourcis restent suspendus. Alors le même PR a ajouté une soupape de sécurité : annuler l'édition quand le focus s'en va. Et c'est la ligne qui a crashé.

Le bug de douze minutes

La soupape de sécurité était un useEffect dans ShortcutsSettings.tsx qui, pendant qu'une ligne était en cours d'édition, écoutait le blur :

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

Deux listeners. Un sur la window — se déclenche quand vous passez à une autre app. Un attaché directement, comme un listener du DOM brut, au <input> de capture — censé se déclencher quand vous cliquez ailleurs vers un autre élément. Ils paraissent redondants et inoffensifs. Ils ne le sont pas.

Parcourez la séquence de « switching windows mid-edit », qui est exactement la phrase du changelog :

  1. Vous enregistrez un raccourci ; le <input> a le focus. Vous faites Cmd+Tab vers une autre app.
  2. Le blur de la fenêtre se déclenche. handleBlur exécute cancelEditing(), qui remet editingShortcut à null.
  3. Fixer cet état démonte le <input> — la ligne quitte le mode édition, donc l'input est retiré du DOM.
  4. Retirer du DOM un élément qui a le focus dispatche de façon synchrone un événement blur sur cet élément. Le listener brut de inputRef l'attrape et appelle cancelEditing() de nouveau — cette fois au milieu de la phase de commit de React, pendant que l'arbre de composants est en cours de démontage.
  5. Appeler un setter d'état sur un fiber en plein teardown déclenche l'un des invariants internes de React, et il lève : "Should have a queue". La vue des réglages crashe.

Le bug n'est pas le listener de la fenêtre et ce n'est pas vraiment le concept d'« annuler au blur ». C'est que l'un des deux listeners était un addEventListener brut sur un élément géré par React. React ne connaît pas ce listener, ne le nettoie pas au démontage, et — crucialement — le navigateur déclenche le blur pendant le retrait du DOM même que React effectue, donc le handler rentre de nouveau dans votre logique d'état au pire instant possible. Le message du commit du correctif le dit plus précisément que je ne le peux : "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

Le correctif

bf28a50, douze minutes après la naissance du crash, fait deux petites choses.

D'abord, il supprime le listener brut de l'input et ne garde que celui de la fenêtre, qui est le seul qui avait besoin d'être un listener manuel du DOM (il n'y a pas d'onBlur React pour « toute la fenêtre a perdu le focus ») :

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

Ensuite, il déplace le cas du clic-ailleurs — « l'utilisateur a cliqué sur un autre élément » — vers la propre prop onBlur de React sur l'input, et le diffère d'un tick :

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

Les deux parties comptent. Utiliser la prop onBlur de React au lieu d'un listener brut signifie que React possède le cycle de vie du handler et ne le laissera pas traîner. Et le setTimeout(…, 0) garantit que même quand ce blur est déclenché par un démontage, l'appel à cancelEditing() atterrit au tick suivant — après que le render et le commit actuels sont finis — donc il ne peut jamais mettre à jour l'état d'un composant encore en train de se démonter. Si le composant a déjà disparu à ce moment-là, l'appel différé est un no-op inoffensif.

C'est tout le correctif. Supprimer le listener que React ne pouvait pas voir ; laisser React planifier celui qui reste.

Pourquoi c'est le bon genre d'ennuyeux

Il y a une tentation de classer ça sous « trivial » — un setTimeout, une ligne supprimée — et de passer à autre chose. Mais sa forme est l'une des façons les plus courantes dont les apps de bureau React crashent, et ça vaut la peine de l'intérioriser :

  • Le crash n'apparaît qu'à une frontière de cycle de vie (démontage), déclenché par un événement que le framework ne planifie pas (un blur natif pendant le retrait du DOM). Il ne se montrera pas dans un test de clics ; il faut la voie spécifique « le focus part pendant l'édition ».
  • La cause racine est de mélanger deux modèles de propriété — les événements du DOM bruts et les événements synthétiques et planifiés de React — sur le même élément. Dès l'instant où votre handler mute un état qui peut démonter cet élément, la voie brute devient réentrante.
  • Le correctif n'est pas « ajouter un flag de garde » (même si un ref isMounted l'aurait masqué) ; c'est retirer entièrement le listener brut, ce qui est le plus petit changement et le bon. La soustraction, encore.

Et la méta-leçon, celle autour de laquelle toute cette série tourne : lisez le diff, pas la note de version. "Fixed a crash while editing keyboard shortcuts" est exact, mais ça cache que le crash était auto-infligé, du même matin, et jamais livré — et que la vraie version est une victoire discrète de correction sur quand suspendre un raccourci global.

Ce qui a été livré d'autre

Deux autres choses ont voyagé avec la v1.8.10, toutes deux méritant une ligne. Le système de raccourcis a aussi gagné une auto-réparation à travers la veille/le réveil : macOS peut lâcher silencieusement les enregistrements de raccourcis globaux d'une app quand la machine se met en veille, alors un handler de reprise les revérifie et les réenregistre maintenant, ce qui veut dire que vos raccourcis marchent encore après le réveil du portable sans relancer. Et côté IA, le client est devenu plus malin sur les limites de débit — les requêtes en streaming qui heurtent un 429 font maintenant un back off exponentiel et réinitialisent leur compteur quand vous arrêtez d'enregistrer, au lieu d'attendre un délai fixe. Trois correctifs de fiabilité sur des sous-systèmes différents, dont l'un est le crash qui n'a jamais été un crash.

Pour le chapitre précédent de l'histoire v1, imprimer une réunion en PDF sans bibliothèque PDF (v1.8.9) ; et pour tout l'arc, l'anatomie de livrer un logiciel jusqu'à la perfection.

Articles Connexes

Imprimer une réunion en PDF sans bibliothèque PDF
Steven
Steven10 min de lecture

Imprimer une réunion en PDF sans bibliothèque PDF

GeekBye exporte une réunion en PDF, et il n'y a aucune bibliothèque PDF nulle part dans le code. Il rend du HTML dans une fenêtre de navigateur invisible et l'imprime. Ce choix est toute l'histoire : il a rendu la fonctionnalité facile à construire et lui a donné toutes les défaillances qu'a un vrai navigateur — un flash blanc, une limite de longueur d'URL, et un saut de page qui coupait les captures en deux. Le correctif du bug le plus laid a tenu en une seule ligne de CSS.

Ingénierie
Electron
Desktop
De l'OCR aux pixels sans perdre le fallback
Steven
Steven9 min de lecture

De l'OCR aux pixels sans perdre le fallback

GeekBye a cessé de lire vos captures d'écran avec l'OCR et s'est mis à envoyer l'image réelle à des modèles de vision — ce qui est la partie facile. La partie instructive, c'est l'optimisation qui a sauté l'OCR entièrement pour les utilisateurs vision, a cassé sans bruit le fallback vers les modèles sans vision, et a dû être défaite jusqu'à une version où le filet de sécurité tourne en parallèle et ne coûte rien.

Ingénierie
AI
Desktop
Les deux modes de défaillance d'un overlay click-through
Steven
Steven10 min de lecture

Les deux modes de défaillance d'un overlay click-through

La fenêtre de GeekBye flotte au-dessus de tout et laisse vos clics la traverser — sauf là où elle a des boutons. C'est un contrat à deux faces, et la v1.8.5 et la v1.8.14 sont ce que ça donne quand chaque face casse : une release où l'overlay a avalé une boîte de dialogue système, une autre où il a volé vos frappes clavier. Le correctif gagnant pour la seconde a été de supprimer du code.

Ingénierie
Electron
Desktop