Steven
Steven8 min de lectura

Nacido y arreglado en doce minutos

El changelog de GeekBye v1.8.10 dice que arregló un crash al editar los atajos de teclado. Lo hizo — pero el crash se introdujo y se arregló en el mismo pull request, con doce minutos de diferencia, y nunca llegó a un solo usuario. La historia real es la cascada de fiabilidad que lo produjo: un cambio pequeño y correcto sobre cuándo se suspenden los atajos, y el bug de teardown de React que se desprendió de una línea pensada para hacer el editor más seguro.

Ingeniería
React
Desktop
Lanzamientos de GeekBye
Nacido y arreglado en doce minutos

Esta es la frase más honesta que puedo escribir sobre GeekBye v1.8.10: el crash por cuyo arreglo es famosa nunca le pasó a nadie. El changelog dice "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", lo cual es cierto, y se lee como un bug que se publicó, mordió a los usuarios, y se parcheó en un follow-up. No lo fue. El crash lo introdujo un commit y lo borró otro en el mismo pull request, con doce minutos de diferencia, la misma mañana de un viernes. Vivió por completo dentro de una rama. Las notas de la versión describen una cicatriz que se formó y sanó antes de que nadie fuera del repo pudiera verla.

Eso no es un fallo del changelog; es la forma honesta del trabajo iterativo, y vale la pena contarlo porque esos doce minutos contienen un bug de React genuinamente instructivo. Pero para entender el crash tienes que entender para qué era realmente la release — porque el crash fue daño colateral de arreglar otra cosa.

De qué iba realmente la release

GeekBye te deja reasignar sus atajos de teclado en Ajustes. Mientras grabas una combinación nueva, la app tiene que impedir que se disparen sus atajos globales — de lo contrario, pulsar Cmd+B para asignarlo simplemente alternaría la ventana en vez de ser capturado. Así que el editor suspende los atajos globales por IPC: el renderizador llama a window.electronAPI.setShortcutsSuspended(true), y el ShortcutsHelper del proceso principal responde llamando a unregisterAll(), soltando cada registro de globalShortcut para que la pulsación llegue al input de captura en vez de disparar una acción. Al reanudar llama a registerGlobalShortcuts() para volver a ponerlos.

El bug que empezó todo esto (issue #233) tenía que ver con el alcance de esa suspensión. El código antiguo suspendía los atajos durante todo el tiempo que la página de ajustes de atajos estuviera abierta — no solo mientras grababas activamente una tecla. La mayor parte del tiempo eso es invisible. Pero si abrías la página, te ponías a hacer otra cosa, y te ibas sin llegar a terminar una edición, la app podía quedarse con todos sus atajos globales sin registrar — muertos en silencio — hasta que volvieras. El commit que lo arregla es franco sobre el síntoma: suspender de forma demasiado amplia arriesgaba "making the app appear broken if the user forgot they had the page open."

El arreglo es un estrechamiento de una línea: suspender solo cuando una fila se está editando de verdad — con clave en !!editingShortcut, el estado del hook que guarda el id de la fila que estás grabando — en vez de durante toda la visita. Buen cambio. Pero «suspender solo mientras se edita» plantea una pregunta inmediata: ¿qué cuenta como terminar una edición? Pulsar una tecla la termina. Pulsar Escape la cancela. ¿Pero qué pasa si simplemente… haces clic fuera, o cambias a otra app por completo, a mitad de captura? Si nada cancela la edición, te quedas grabando un atajo en una ventana que no tiene el foco, y los atajos siguen suspendidos. Así que el mismo PR añadió una válvula de seguridad: cancelar la edición cuando el foco se va. Y esa es la línea que crasheó.

El bug de doce minutos

La válvula de seguridad era un useEffect en ShortcutsSettings.tsx que, mientras una fila se estaba editando, escuchaba el blur:

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

Dos listeners. Uno en la window — se dispara cuando cambias a otra app. Uno adjuntado directamente, como un listener del DOM crudo, al <input> de captura — pensado para dispararse cuando haces clic fuera hacia otro elemento. Parecen redundantes e inofensivos. No lo son.

Recorre la secuencia de «switching windows mid-edit», que es exactamente la frase del changelog:

  1. Estás grabando un atajo; el <input> tiene el foco. Haces Cmd+Tab a otra app.
  2. El blur de la ventana se dispara. handleBlur ejecuta cancelEditing(), que devuelve editingShortcut a null.
  3. Fijar ese estado desmonta el <input> — la fila sale del modo edición, así que el input se elimina del DOM.
  4. Eliminar del DOM un elemento con el foco despacha síncronamente un evento blur sobre ese elemento. El listener crudo de inputRef lo captura y llama a cancelEditing() otra vez — esta vez en mitad de la fase de commit de React, mientras el árbol de componentes se está desmontando.
  5. Llamar a un setter de estado sobre un fiber que está a mitad de teardown activa uno de los invariantes internos de React, y lanza: "Should have a queue". La vista de ajustes crashea.

El bug no es el listener de la ventana y no es realmente el concepto de «cancelar al perder el foco». Es que uno de los dos listeners era un addEventListener crudo sobre un elemento gestionado por React. React no sabe de ese listener, no lo limpia al desmontar, y — de forma crucial — el navegador dispara el blur durante la misma eliminación del DOM que React está realizando, así que el handler reentra en tu lógica de estado en el peor instante posible. El mensaje del commit del arreglo lo dice con más precisión de la que yo puedo: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

El arreglo

bf28a50, doce minutos después de que naciera el crash, hace dos cosas pequeñas.

Primero, borra el listener crudo del input y conserva solo el de la ventana, que es el único que necesitaba ser un listener manual del DOM (no hay un onBlur de React para «toda la ventana perdió el foco»):

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

Segundo, mueve el caso de clic-fuera — «el usuario hizo clic en otro elemento» — a la propia prop onBlur de React sobre el input, y lo difiere un tick:

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

Ambas partes importan. Usar la prop onBlur de React en vez de un listener crudo significa que React es dueño del ciclo de vida del handler y no lo dejará colgando. Y el setTimeout(…, 0) garantiza que, incluso cuando este blur lo dispara un desmontaje, la llamada a cancelEditing() aterriza en el siguiente tick — después de que el render y el commit actuales hayan terminado — así que nunca puede actualizar el estado de un componente que todavía se está desmontando. Si el componente ya no está para entonces, la llamada diferida es un no-op inofensivo.

Ese es todo el arreglo. Borra el listener que React no podía ver; deja que React planifique el que queda.

Por qué este es el buen tipo de aburrido

Hay una tentación de archivar esto como «trivial» — un setTimeout, una línea borrada — y seguir adelante. Pero su forma es una de las maneras más comunes en que las apps de escritorio en React crashean, y vale la pena interiorizarla:

  • El crash solo aparece en un límite del ciclo de vida (desmontaje), disparado por un evento que el framework no planifica (un blur nativo durante la eliminación del DOM). No aparecerá en un test de clics; necesita la ruta específica de «el foco se va mientras se edita».
  • La causa raíz es mezclar dos modelos de propiedad — eventos del DOM crudos y los eventos sintéticos y planificados de React — sobre el mismo elemento. En el momento en que tu handler muta un estado que puede desmontar ese elemento, la ruta cruda se vuelve reentrante.
  • El arreglo no es «añadir un flag de guarda» (aunque un ref isMounted lo habría enmascarado); es quitar el listener crudo por completo, que es el cambio más pequeño y el correcto. Resta, otra vez.

Y la meta-lección, la que toda esta serie no deja de rondar: lee el diff, no la nota de la versión. "Fixed a crash while editing keyboard shortcuts" es exacto, pero oculta que el crash fue autoinfligido, de la misma mañana, y que nunca se publicó — y que la release real es una victoria silenciosa de corrección sobre cuándo suspender un atajo global.

Qué más se publicó

Otras dos cosas viajaron junto en la v1.8.10, ambas merecen una línea. El sistema de atajos también ganó autorreparación a través del ciclo de suspensión/reactivación: macOS puede descartar en silencio los registros de atajos globales de una app cuando la máquina se suspende, así que un handler de reanudación ahora los vuelve a verificar y a registrar, lo que significa que tus atajos siguen funcionando después de que el portátil despierta sin necesidad de reiniciar. Y del lado de la IA, el cliente se volvió más listo con los límites de tasa — las peticiones en streaming que topan con un 429 ahora hacen back off exponencial y reinician su contador cuando dejas de grabar, en vez de esperar un retardo fijo. Tres arreglos de fiabilidad en subsistemas distintos, uno de los cuales es el crash que nunca fue un crash.

Para el capítulo anterior de la historia v1, imprimir una reunión a PDF sin una librería de PDF (v1.8.9); y para todo el arco, la anatomía de publicar software hasta la perfección.

Artículos Relacionados

Imprimir una reunión a PDF sin una librería de PDF
Steven
Steven10 min de lectura

Imprimir una reunión a PDF sin una librería de PDF

GeekBye exporta una reunión como PDF, y no hay ninguna librería de PDF en ninguna parte del código. Renderiza HTML en una ventana de navegador invisible y la imprime. Esa elección es toda la historia: hizo la funcionalidad fácil de construir y le dio todos los fallos que tiene un navegador de verdad — un destello blanco, un límite de longitud de URL, y un salto de página que cortaba las capturas por la mitad. El arreglo del bug más feo fue una sola línea de CSS.

Ingeniería
Electron
Desktop
De OCR a píxeles sin perder el fallback
Steven
Steven9 min de lectura

De OCR a píxeles sin perder el fallback

GeekBye dejó de leer tus capturas con OCR y empezó a enviar la imagen real a modelos de visión — que es la parte fácil. La parte instructiva es la optimización que se saltó el OCR por completo para los usuarios con visión, rompió sin ruido el fallback a modelos sin visión, y hubo que deshacerla hasta una versión donde la red de seguridad corre en paralelo y no cuesta nada.

Ingeniería
AI
Desktop
Los dos modos de fallo de un overlay click-through
Steven
Steven10 min de lectura

Los dos modos de fallo de un overlay click-through

La ventana de GeekBye flota por encima de todo y deja que tus clics la atraviesen — salvo donde tiene botones. Es un contrato de dos caras, y la v1.8.5 y la v1.8.14 son lo que se ve cuando cada cara se rompe: una release donde el overlay se tragó un diálogo del sistema, otra donde te robó las pulsaciones de teclas. El arreglo ganador para la segunda fue borrar código.

Ingeniería
Electron
Desktop