
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.
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 já 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:
- Remover os ouvintes
window-all-closedebefore-quitque podiam interferir. - Destruir cada janela —
window.destroy(), nãowindow.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. - Chamar o
quitAndInstall. - 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
- 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ê.
- 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.
- 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.