Steven
Steven8 min de leitura

Nascido e corrigido em doze minutos

O changelog da GeekBye v1.8.10 diz que corrigiu um crash ao editar os atalhos de teclado. Corrigiu — mas o crash foi introduzido e corrigido no mesmo pull request, com doze minutos de diferença, e nunca chegou a um único usuário. A história real é a cascata de confiabilidade que o produziu: uma mudança pequena e correta sobre quando os atalhos são suspensos, e o bug de teardown de React que caiu de uma linha pensada para tornar o editor mais seguro.

Engenharia
React
Desktop
Lançamentos da GeekBye
Nascido e corrigido em doze minutos

Esta é a frase mais honesta que consigo escrever sobre a GeekBye v1.8.10: o crash pela correção do qual ela é famosa nunca aconteceu com ninguém. O changelog diz "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit", o que é verdade, e se lê como um bug que foi publicado, mordeu os usuários, e foi corrigido num follow-up. Não foi. O crash foi introduzido por um commit e apagado por outro no mesmo pull request, com doze minutos de diferença, na mesma manhã de sexta-feira. Viveu inteiramente dentro de um branch. As notas de versão descrevem uma cicatriz que se formou e sarou antes de qualquer um fora do repo poder vê-la.

Isso não é uma falha do changelog; é o formato honesto do trabalho iterativo, e vale a pena contar porque esses doze minutos contêm um bug de React genuinamente instrutivo. Mas para entender o crash você precisa entender para que a release era de verdade — porque o crash foi dano colateral de corrigir outra coisa.

Do que a release realmente tratava

A GeekBye deixa você reatribuir seus atalhos de teclado nas Configurações. Enquanto você grava uma combinação nova, o app tem que impedir que seus atalhos globais disparem — caso contrário, apertar Cmd+B para atribuí-lo apenas alternaria a janela em vez de ser capturado. Então o editor suspende os atalhos globais via IPC: o renderizador chama window.electronAPI.setShortcutsSuspended(true), e o ShortcutsHelper do processo principal responde chamando unregisterAll(), soltando cada registro de globalShortcut para que a tecla chegue ao input de captura em vez de disparar uma ação. Ao retomar, chama registerGlobalShortcuts() para colocá-los de volta.

O bug que começou tudo isso (issue #233) era sobre o escopo dessa suspensão. O código antigo suspendia os atalhos durante todo o tempo em que a página de configurações de atalhos estava aberta — não só enquanto você gravava ativamente uma tecla. Na maior parte do tempo isso é invisível. Mas se você abrisse a página, começasse a fazer outra coisa, e navegasse para longe sem nunca terminar uma edição, o app podia ficar com todos os seus atalhos globais sem registro — mortos em silêncio — até você voltar. O commit que corrige isso é franco sobre o sintoma: suspender de forma ampla demais arriscava "making the app appear broken if the user forgot they had the page open."

A correção é um estreitamento de uma linha: suspender só quando uma linha está de fato sendo editada — chaveado em !!editingShortcut, o estado do hook que guarda o id da linha que você está gravando — em vez de durante toda a visita. Boa mudança. Mas «suspender só enquanto edita» levanta uma pergunta imediata: o que conta como terminar uma edição? Apertar uma tecla a termina. Apertar Escape a cancela. Mas e se você simplesmente… clicar fora, ou trocar para outro app inteiramente, no meio da captura? Se nada cancela a edição, você fica gravando um atalho numa janela que não está com o foco, e os atalhos continuam suspensos. Então o mesmo PR adicionou uma válvula de segurança: cancelar a edição quando o foco vai embora. E essa é a linha que crashou.

O bug de doze minutos

A válvula de segurança era um useEffect em ShortcutsSettings.tsx que, enquanto uma linha estava sendo editada, escutava o blur:

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

Dois listeners. Um na window — dispara quando você troca para outro app. Um anexado diretamente, como um listener do DOM cru, ao <input> de captura — pensado para disparar quando você clica fora para outro elemento. Parecem redundantes e inofensivos. Não são.

Percorra a sequência de «switching windows mid-edit», que é exatamente a frase do changelog:

  1. Você está gravando um atalho; o <input> está com o foco. Você faz Cmd+Tab para outro app.
  2. O blur da janela dispara. handleBlur executa cancelEditing(), que devolve editingShortcut para null.
  3. Definir esse estado desmonta o <input> — a linha sai do modo de edição, então o input é removido do DOM.
  4. Remover do DOM um elemento com o foco despacha de forma síncrona um evento blur nesse elemento. O listener cru de inputRef o captura e chama cancelEditing() de novo — desta vez no meio da fase de commit do React, enquanto a árvore de componentes está sendo desmontada.
  5. Chamar um setter de estado sobre um fiber que está no meio do teardown aciona um dos invariantes internos do React, e ele lança: "Should have a queue". A visão de configurações crasha.

O bug não é o listener da janela e não é bem o conceito de «cancelar no blur». É que um dos dois listeners era um addEventListener cru sobre um elemento gerenciado pelo React. O React não conhece esse listener, não o limpa no desmonte, e — crucialmente — o navegador dispara o blur durante a própria remoção do DOM que o React está realizando, então o handler reentra na sua lógica de estado no pior instante possível. A mensagem do commit da correção diz de forma mais precisa do que eu consigo: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."

A correção

bf28a50, doze minutos depois de o crash nascer, faz duas coisinhas.

Primeiro, apaga o listener cru do input e mantém só o da janela, que é o único que precisava mesmo ser um listener manual do DOM (não existe um onBlur do React para «a janela inteira perdeu o foco»):

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

Segundo, move o caso do clique-fora — «o usuário clicou em outro elemento» — para a própria prop onBlur do React no input, e o adia um tick:

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

Ambas as partes importam. Usar a prop onBlur do React em vez de um listener cru significa que o React é dono do ciclo de vida do handler e não vai deixá-lo pendurado. E o setTimeout(…, 0) garante que, mesmo quando esse blur é disparado por um desmonte, a chamada a cancelEditing() aterrissa no próximo tick — depois de o render e o commit atuais terminarem — então nunca pode atualizar o estado de um componente que ainda está se desmontando. Se o componente já sumiu até lá, a chamada adiada é um no-op inofensivo.

É essa a correção inteira. Apague o listener que o React não conseguia ver; deixe o React agendar o que sobrou.

Por que este é o bom tipo de chato

Há uma tentação de arquivar isto como «trivial» — um setTimeout, uma linha apagada — e seguir em frente. Mas o formato disso é uma das maneiras mais comuns de apps de desktop em React crasharem, e vale a pena internalizar:

  • O crash só aparece num limite de ciclo de vida (desmonte), disparado por um evento que o framework não agenda (um blur nativo durante a remoção do DOM). Não vai aparecer num teste de cliques; ele precisa do caminho específico «o foco sai enquanto se edita».
  • A causa raiz é misturar dois modelos de propriedade — eventos do DOM crus e os eventos sintéticos e agendados do React — no mesmo elemento. No momento em que seu handler muta um estado que pode desmontar aquele elemento, o caminho cru se torna reentrante.
  • A correção não é «adicionar uma flag de guarda» (embora um ref isMounted a tivesse mascarado); é remover o listener cru por completo, que é a mudança menor e a correta. Subtração, de novo.

E a meta-lição, aquela que esta série inteira fica rondando: leia o diff, não a nota de versão. "Fixed a crash while editing keyboard shortcuts" é exato, mas esconde que o crash foi autoinfligido, da mesma manhã, e nunca publicado — e que a release real é uma vitória silenciosa de correção sobre quando suspender um atalho global.

O que mais foi publicado

Mais duas coisas pegaram carona na v1.8.10, ambas merecendo uma linha. O sistema de atalhos também ganhou autorreparo ao longo do ciclo de suspender/acordar: o macOS pode descartar em silêncio os registros de atalhos globais de um app quando a máquina suspende, então um handler de retomada agora os reverifica e reregistra, o que significa que seus atalhos ainda funcionam depois que o laptop acorda sem reiniciar. E do lado da IA, o cliente ficou mais esperto com os limites de taxa — as requisições em streaming que batem num 429 agora fazem back off exponencial e reiniciam seu contador quando você para de gravar, em vez de esperar um atraso fixo. Três correções de confiabilidade em subsistemas diferentes, uma das quais é o crash que nunca foi um crash.

Para o capítulo anterior da história v1, imprimir uma reunião em PDF sem uma biblioteca de PDF (v1.8.9); e para todo o arco, a anatomia de entregar software até a perfeição.

Artigos Relacionados

Os três verbos que mantêm o Web Audio vivo
Steven
Steven10 min de leitura

Os três verbos que mantêm o Web Audio vivo

Duas releases pontuais da GeekBye, com dois meses de diferença e em dois arquivos distintos, ensinaram ao nosso código de áudio a mesma lição a partir de extremos opostos: parar de tratar o AudioContext do navegador como descartável. Uma release aprendeu a fazer resume() de um contexto que o macOS havia suspendido silenciosamente no meio da gravação; a outra aprendeu a fazer suspend() em vez de close() para que sessões consecutivas parem de se espatifar contra o teto de aproximadamente seis contextos do Chromium. Resume, suspend, close — é esse o enredo inteiro.

Engenharia
Audio
Desktop
Distinguir uma chamada de um app aberto
Steven
Steven9 min de leitura

Distinguir uma chamada de um app aberto

A GeekBye pode notar que você entrou em uma reunião por vídeo e oferecer gravá-la. A detecção acaba sendo a metade fácil — um binário Swift que lê títulos de janela a cada dez segundos. A metade difícil é a precisão: não disparar quando o Zoom está meramente aberto, não oferecer gravar uma reunião que você já está gravando, e não silenciar o microfone na chamada em que você realmente está. Três releases, e cada uma é uma salvaguarda que teve que aprender a não se sabotar.

Engenharia
macOS
Desktop
Tirando o backend do caminho de upload
Steven
Steven9 min de leitura

Tirando o backend do caminho de upload

A GeekBye grava sua tela e salva o vídeo no seu Google Drive. A primeira versão enviava cada gravação pelos próprios servidores da GeekBye no caminho; uma release depois, o arquivo ia direto da sua máquina para o Drive, e o backend foi rebaixado a segurar um único ponteiro. A parte interessante é o quão pouco código a versão «direta e retomável» realmente contém — porque a retomabilidade veio de apagar um proxy, não de escrever um.

Engenharia
Arquitetura
Desktop