Steven
Steven6 min de leitura

A verificação de segurança que tornou nosso app impossível de fechar

A atualização automática foi a funcionalidade mais difícil que já lançamos — seis versões em quatro dias para impedir que ela deixasse o app inutilizável. O pior bug foi um que nós mesmos introduzimos ao tentar ser cuidadosos: uma verificação "de segurança" de 500 milissegundos que transformava uma atualização falha em um processo que, literalmente, não dava para fechar.

Engenharia
Electron
Confiabilidade
Lançamentos do GeekBye
A verificação de segurança que tornou nosso app impossível de fechar

Todo desenvolvedor de app de desktop subestima a atualização automática exatamente uma vez. Parece um problema resolvido — uma biblioteca baixa uma versão nova e reinicia seu app. Aí você lança, e aprende que "reinicie seu app" é uma das coisas mais perigosas que se pode pedir a um programa, porque acontece no momento exato em que seu app está se desmontando e tem a menor margem de erro possível.

A atualização automática do GeekBye precisou de seis versões em quatro dias — da v1.5.14 à v1.5.19 — para estabilizar. Esta é a história do pior bug desse trecho, que nós mesmos causamos ao tentar ser cuidadosos.

Seis versões, e a que importava

O arco começou banal. A v1.5.14 corrigiu um bug de nível erro de digitação vergonhoso: o feed de atualizações apontava para um nome de repositório do GitHub que não existia, então o atualizador estava consultando um 404. A v1.5.15 adicionou um botão manual de "Check for Updates" e uma mensagem de erro de verdade. Depois começaram os bugs do quitAndInstall, e as versões vieram rápido — porque quando seu mecanismo de atualização está quebrado, você não pode lançar a correção dele através do mecanismo de atualização. Cada iteração é uma aposta de reinstalação manual.

A que importa é a v1.5.18. Todo o seu conteúdo foi um único commit com um título que ainda me faz encolher: restaurar o comportamento original do quitAndInstall para evitar um app impossível de matar.

Como "ser cuidadoso" deixou o app inutilizável

Eis o cenário. Quando uma atualização é baixada, o quitAndInstall do Electron deveria fechar o app e trocar pela versão nova. Numa versão anterior, alguém — razoavelmente — se preocupou de que fechar incondicionalmente fosse arriscado. E se a instalação desse erro? Não seria mais seguro fechar só se tudo parecesse saudável?

Então o código ganhou uma guarda que parecia 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)

A lógica: dispara a instalação, espera meio segundo, e só força o app.quit() final se a flag updateDownloaded ainda for verdadeira — caso contrário mantém o app aberto para o usuário não ficar na mão.

A armadilha está a uma linha de distância, no tratador de erros. Esse tratador definia this.updateDownloaded = false. Então imagine uma instalação falha: o evento error dispara e limpa a flag. Mas o quitAndInstall tinha começado o desmonte — tinha fechado as janelas e removido os ouvintes de fechamento do app. Aí o temporizador de 500ms acorda, verifica a flag agora falsa, decide "erro detectado, manter o app aberto" e pula o app.quit().

Agora você tem, no macOS especificamente, o pior estado possível. O macOS não fecha um app só porque sua última janela fechou — esse é o comportamento window-all-closed no qual todo app de Mac se apoia. Então o processo continua vivo, mas não tem janela, nem caminho pela barra de menu, e seus ouvintes de fechamento foram arrancados. Não há nada para clicar. O Cmd-Q não tem com quem falar. A única saída é o Force Quit pelo Activity Monitor. A verificação "de segurança" tinha convertido uma atualização falha — um aborrecimento recuperável — em um zumbi que você não conseguia matar.

A correção: o desmonte tem que ser incondicional

A correção da v1.5.18 é quase agressivamente entediante, e esse é o ponto. Ela remove a esperteza:

  1. Remover os ouvintes window-all-closed e before-quit que podiam interferir.
  2. Destruir cada janela — window.destroy(), não window.close(). O fechamento pode ser vetado por um tratador; a destruição não. Quando você está se comprometendo a desligar, não pede com educação.
  3. Chamar o quitAndInstall.
  4. Chamar o app.quit() incondicionalmente.

Sem flag, sem temporizador, sem "deixa aberto por via das dúvidas". Porque a verdade sobre um caminho de desligamento é que um desligamento pela metade é pior do que qualquer um dos dois resultados. Fechar por completo tudo bem. Ficar aberto por completo tudo bem. O único estado que você nunca pode alcançar é desmontado mas ainda em execução — e é exatamente o estado em que um fechamento condicional pode te deixar na mão.

Mais duas lições que a mesma semana ensinou

O bug do app impossível de matar é a manchete, mas a correria de seis versões endureceu outros dois hábitos que vale a pena roubar.

Filtre sua telemetria de travamentos por assinaturas exatas, não por palavras-chave amplas. No meio da correria descobrimos que nosso reporte de erros estava configurado para descartar qualquer coisa que contivesse palavras como permission, token ou microphone — uma tentativa de cortar ruído que estava engolindo silenciosamente travamentos reais que por acaso mencionavam essas palavras. Arrancamos os filtros genéricos e os substituímos por strings de negação exatas (a mensagem específica que o macOS emite quando uma permissão é recusada) e códigos de rede transitórios específicos como ERR_NETWORK_CHANGED. Redução de ruído e ocultação de bugs são o mesmo botão girado em sentidos opostos; se você filtrar no feeling, vai filtrar justamente aquilo que precisava ver.

Todo caminho automático precisa de uma saída de emergência manual. A atualização automática é de melhor esforço por natureza — redes falham, instalações caem. Então cada modo de falha ganhou um plano B humano: o botão manual de "Check for Updates", tentativas com recuo exponencial, um temporizador de reverificação e — reservado especificamente para o caso em que a tentativa manual de um usuário falhava — uma mensagem em linguagem simples de "apague o app e reinstale pelo site". O caminho automático é uma conveniência; o caminho manual é a garantia.

A conclusão

  1. Uma guarda em volta de uma ação irreversível é mais perigosa do que a ação. O fechamento condicional tentou impedir que uma atualização ruim fechasse o app, e em vez disso criou um estado pior do que fechar ou não fechar. Caminhos de desligamento e instalação deveriam ser incondicionais e idempotentes — nunca condicionados a uma flag mutável que outro tratador pode virar por baixo de você.
  2. No macOS, "sem janelas" não é "sem app". Toda lógica de desmonte tem que levar em conta a plataforma onde um processo sem janelas continua rodando. Teste o caminho da falha, no sistema operacional de verdade, não só o caminho feliz.
  3. A funcionalidade que você lança pelo sistema de atualização não pode ser testada pelo sistema de atualização. Essa assimetria é o motivo pelo qual a atualização automática merece código paranoico, incondicional e verificado à mão a fundo. Você só pode consertá-la pelo jeito fácil depois de ela já funcionar.

Este é o capítulo mais antigo do trabalho de confiabilidade que acabou virando o GeekBye v2. Para ver aonde esse caminho levou, veja o que uma versão 2 realmente exige (v2.0.0) e todo o arco em a anatomia de entregar software à perfeição.