Steven
Steven6 min de lectura

La comprobación de seguridad que hizo imposible cerrar nuestra app

La actualización automática fue la función más difícil que hemos lanzado — seis versiones en cuatro días para que dejara de dejar la app inservible. El peor error fue uno que introdujimos por querer ser prudentes: una comprobación "de seguridad" de 500 milisegundos que convertía una actualización fallida en un proceso que, literalmente, no podías cerrar.

Ingeniería
Electron
Fiabilidad
Lanzamientos de GeekBye
La comprobación de seguridad que hizo imposible cerrar nuestra app

Todo desarrollador de apps de escritorio subestima la actualización automática exactamente una vez. Parece un problema resuelto — una librería descarga una versión nueva y reinicia tu app. Luego la lanzas y aprendes que "reinicia tu app" es una de las cosas más peligrosas que se le pueden pedir a un programa, porque ocurre justo en el momento en que tu app se está desmontando a sí misma y tiene el menor margen de error posible.

La actualización automática de GeekBye necesitó seis versiones en cuatro días — de la v1.5.14 a la v1.5.19 — para estabilizarse. Esta es la historia del peor error de ese tramo, que provocamos nosotros mismos por querer ser prudentes.

Seis versiones, y la que importaba

El arco empezó de forma anodina. La v1.5.14 arregló un error de nivel errata vergonzoso: el feed de actualizaciones apuntaba al nombre de un repo de GitHub que no existía, así que el actualizador estaba consultando un 404. La v1.5.15 añadió un botón manual de "Check for Updates" y un mensaje de error de verdad. Después empezaron los errores de quitAndInstall, y las versiones llegaron rápido — porque cuando tu mecanismo de actualización está roto, no puedes lanzar su arreglo a través del mecanismo de actualización. Cada iteración es una apuesta de reinstalación manual.

La que importa es la v1.5.18. Todo su contenido fue un único commit con un título que aún me hace encogerme: restaurar el comportamiento original de quitAndInstall para evitar una app imposible de matar.

Cómo "ser prudentes" dejó la app inservible

Este es el planteamiento. Cuando se descarga una actualización, se supone que quitAndInstall de Electron cierra la app e intercambia la versión nueva. En una versión anterior, alguien — razonablemente — se preocupó de que cerrar incondicionalmente fuera arriesgado. ¿Y si la instalación daba error? ¿No sería más seguro cerrar solo si todo pintaba bien?

Así que el código creció una guarda que parecía sensata:

autoUpdater.quitAndInstall(false, true)
setTimeout(() => {
  if (this.updateDownloaded)
    app.quit() // only quit if the update is still "good"
  else console.log('Error detected — keeping app open')
}, 500)

La lógica: dispara la instalación, espera medio segundo, y solo fuerza el app.quit() final si la bandera updateDownloaded sigue siendo verdadera — de lo contrario mantén la app abierta para que el usuario no quede tirado.

La trampa está a una línea de distancia, en el manejador de errores. Ese manejador establecía this.updateDownloaded = false. Así que imagina una instalación fallida: el evento error se dispara y borra la bandera. Pero quitAndInstall ya había empezado el desmontaje — había cerrado las ventanas y eliminado los escuchadores de cierre de la app. Entonces el temporizador de 500ms despierta, comprueba la bandera ahora falsa, decide "error detectado, mantener la app abierta" y omite app.quit().

Ahora tienes, en macOS en concreto, el peor estado posible. macOS no cierra una app solo porque su última ventana se haya cerrado — ese es el comportamiento window-all-closed en el que se apoya toda app de Mac. Así que el proceso sigue vivo, pero no tiene ventana, ni ruta de barra de menú, y sus escuchadores de cierre han sido arrancados. No hay nada que pulsar. Cmd-Q no tiene con quién hablar. La única salida es Force Quit desde Activity Monitor. La comprobación "de seguridad" había convertido una actualización fallida — una molestia recuperable — en un zombi que no podías matar.

El arreglo: el desmontaje debe ser incondicional

La corrección de la v1.5.18 es casi agresivamente aburrida, y ese es el punto. Elimina la astucia:

  1. Eliminar los escuchadores window-all-closed y before-quit que podían interferir.
  2. Destruir cada ventana — window.destroy(), no window.close(). El cierre puede ser vetado por un manejador; la destrucción no. Cuando te estás comprometiendo a apagar, no lo pides con educación.
  3. Llamar a quitAndInstall.
  4. Llamar a app.quit() incondicionalmente.

Sin bandera, sin temporizador, sin "déjalo abierto por si acaso". Porque la verdad sobre un camino de apagado es que un apagado a medias es peor que cualquiera de los dos resultados. Cerrar del todo está bien. Quedarse abierto del todo está bien. El único estado que nunca debes alcanzar es desmontado pero aún en ejecución — y ese es exactamente el estado en el que un cierre condicional puede dejarte tirado.

Dos lecciones más que enseñó la misma semana

El error de la app imposible de matar es el titular, pero la carrera de seis versiones endureció otros dos hábitos que merece la pena robar.

Filtra tu telemetría de cierres por firmas exactas, no por palabras clave amplias. A mitad de la carrera descubrimos que nuestro reporte de errores estaba configurado para descartar cualquier cosa que contuviera palabras como permission, token o microphone — un intento de recortar ruido que estaba tragándose en silencio cierres reales que casualmente mencionaban esas palabras. Arrancamos los filtros generales y los reemplazamos por cadenas de denegación exactas (el mensaje concreto que macOS emite cuando se rechaza un permiso) y códigos de red transitorios específicos como ERR_NETWORK_CHANGED. La reducción de ruido y la ocultación de errores son el mismo mando girado en sentidos opuestos; si filtras por intuición, filtrarás justo aquello que necesitabas ver.

Cada camino automático necesita una vía de escape manual. La actualización automática es de mejor esfuerzo por naturaleza — las redes fallan, las instalaciones se caen. Así que cada modo de fallo recibió un plan B humano: el botón manual de "Check for Updates", reintentos con retroceso exponencial, un temporizador de reverificación y — reservado específicamente para el caso en que el intento manual de un usuario fallaba — un mensaje en lenguaje llano de "borra la app y reinstálala desde la web". El camino automático es una comodidad; el camino manual es la garantía.

La conclusión

  1. Una guarda alrededor de una acción irreversible es más peligrosa que la acción. El cierre condicional intentó evitar que una mala actualización cerrara la app, y en su lugar creó un estado peor que cerrar o no cerrar. Los caminos de apagado e instalación deberían ser incondicionales e idempotentes — nunca condicionados a una bandera mutable que otro manejador pueda cambiarte por debajo.
  2. En macOS, "sin ventanas" no es "sin app". Toda lógica de desmontaje tiene que tener en cuenta la plataforma donde un proceso sin ventanas sigue ejecutándose. Prueba el camino del fallo, en el sistema operativo real, no solo el camino feliz.
  3. La función que lanzas a través del sistema de actualización no puede probarse a través del sistema de actualización. Esa asimetría es la razón por la que la actualización automática merece código paranoico, incondicional y verificado a mano a fondo. Solo puedes arreglarla por la vía fácil después de que ya funcione.

Este es el capítulo más temprano del trabajo de fiabilidad que con el tiempo se convirtió en GeekBye v2. Para ver a dónde llevó ese camino, mira lo que de verdad cuesta una versión 2 (v2.0.0) y todo el arco en la anatomía de lanzar software a la perfección.