Steven
Steven7 min de leitura

Uma conexão perdida não deveria derrubar o app inteiro — mas o nosso derrubava

Quando nosso backend ficou offline no meio de uma reunião, ele não só pausou a transcrição — derrubou o app inteiro. A causa era um único evento não tratado, e a correção tinha dez linhas. Este é o grupo de lançamentos que fez o GeekBye continuar logado e continuar conectado através das coisas que antes o matavam.

Engenharia
Confiabilidade
Electron
Lançamentos do GeekBye
Uma conexão perdida não deveria derrubar o app inteiro — mas o nosso derrubava

Alguns bugs interrompem uma funcionalidade. Os piores derrubam tudo ao redor dela. Numa build inicial do GeekBye, se nosso backend ficasse offline enquanto você estava no meio de uma reunião, a transcrição não só parava — o app inteiro fechava. Uma conexão perdida, e a janela toda sumia.

O grupo de lançamentos que corrigiu isso — de v1.6.8 até v1.6.11 — é uma aula magistral sobre o trabalho pouco glamouroso de "continue logado, continue conectado". A correção principal tinha dez linhas. Aqui está o que eram essas dez linhas, e tudo mais que foi entregue ao lado delas.

Dez linhas entre uma desconexão e um crash

O crash morava no código de áudio. Quando uma sessão de transcrição desmontava seus WebSockets, ela chamava removeAllListeners() neles — uma limpeza razoável. Mas aqui está o detalhe do Node.js que transforma a limpeza em catástrofe: um evento error sem listener não é ignorado. Ele é relançado como uma exceção não capturada.

Então imagine a sequência quando o backend fica offline: o socket tem um evento error na fila, pronto para disparar. A limpeza executa removeAllListeners(), removendo o handler que o teria capturado. Um momento depois o error enfileirado dispara — no vazio. O Node vê um evento error com ninguém escutando, e faz a única coisa que seu contrato permite: lança-o como uma exceção não capturada, que fecha o processo. O backend do usuário deu um soluço de dois segundos e o app dele evaporou.

A correção (v1.6.8) é quase anticlimática. Logo após remover os listeners, reanexar um handler de error no-op deliberado:

this.micWebSocket.removeAllListeners()
this.micWebSocket.on('error', () => {}) // absorb late error events

Essa função vazia é o ponto todo. Ela dá ao error pendente um lugar para aterrissar — um sumidouro — para que o Node tenha um listener e nunca relance. O mesmo commit também limpou os sockets meio abertos quando uma tentativa de conexão falha na metade do caminho, para que uma conexão malfeita não possa deixar um socket vivo pendurado com um error esperando para disparar. O comentário do commit diz sem rodeios: "Sem isso, os erros pendentes viram exceções não capturadas e derrubam o app." Dez linhas transformaram um crash total numa desconexão silenciosa e recuperável.

A lição vale mais que a correção: toda vez que você chama removeAllListeners() num socket ou stream que talvez ainda esteja desmontando, reanexe antes um sumidouro de error error no-op. Um evento error não tratado é um crash, não uma linha de log.

Continuar logado: renove antes, e renove no rebote

A metade "continue conectado" tinha uma gêmea "continue logado". Antes deste grupo, o token de auth do GeekBye simplesmente expirava — depois de um tempo você era jogado de volta sem cerimônia para a tela de login, no meio do fluxo de trabalho, sem nenhuma razão que você pudesse ver.

v1.6.8 corrigiu isso das duas direções:

  • Proativo: um timer renova o token antes de ele expirar, agendado na expiração menos uma margem inteligente (uma fração da vida útil do token, com piso e teto). Você é renovado antes mesmo de perceber.
  • Reativo: se uma requisição volta com 401 mesmo assim — desvio de relógio, um timer perdido, um laptop frio — o cliente renova uma vez e refaz a requisição original exatamente uma vez. Um único flag de guarda limita esse retry para que um 401 persistente nunca possa girar num loop infinito.

A parte sutil mas crítica: os dois caminhos estão atrás de um mutex. Se dez requisições recebem um 401 no mesmo instante, elas não disparam dez renovações — todas esperam pela única renovação em andamento e depois refazem. E quando uma renovação genuinamente não pode ser salva, o app expira a sessão com elegância — um aviso claro de dez segundos "sua sessão expirou, por favor faça login de novo" — em vez de um chute silencioso para a tela de login.

Reconexão: adicione no cliente, depois mova para o backend

v1.6.9 tornou uma conexão de transcrição perdida recuperável em vez de fatal. Quando o backend reportava que a conexão de fala upstream tinha caído, o cliente parava de exibir um erro fatal e em vez disso reconectava — com UX deliberadamente calma: um único aviso âmbar "Reconectando…" (não um por tentativa), um aviso verde "Reconectado" quando voltava, e o erro terminal "por favor reinicie" depois que cada tentativa fosse esgotada.

Então algo que eu gosto neste grupo: um lançamento depois, na v1.6.10, deletamos aquela lógica de reconexão do lado do cliente — cerca de 113 linhas — e movemos a reconexão para o backend, que agora emite mensagens de status discretas de "reconectando" que o cliente apenas reflete. Construímos a recuperação no cliente, decidimos que o backend era o dono certo, e a movemos. Isso não é ficar em cima do muro; é o ciclo de vida honesto de um problema difícil. Lógica de confiabilidade dividida entre as duas pontas de uma conexão é um mau cheiro — escolha um dono. (O backend acabou se tornando o dono definitivo disso, que é a história de reconexão contada em por que seu notetaker com IA para com Wi-Fi ruim.)

O mesmo lançamento tornou os limites de tempo de gravação humanos: em vez de um erro cru, um aviso de alerta que deixa a gravação continuar, e uma mensagem clara "Limite de Gravação Atingido" quando ela de fato para — a frase amigável exibida, o prefixo interno removido.

Um retry para governar todos

Na v1.6.11, a mesma lógica de retry-com-backoff tinha sido copiada e colada em três lugares diferentes — auth, atualizações, desmontagem de sessão. Então ela foi consolidada num único módulo com uma só regra compartilhada sobre o que sequer vale a pena repetir: falhas de rede transitórias (conexão resetada, timeout, soluço de DNS, socket que desligou) são repetidas com backoff exponencial; um erro real do servidor falha rápido, porque repetir um 4xx genuíno só desperdiça o tempo do usuário. Um classificador, alguns presets nomeados, três chamadores migrados para ele.

O que este grupo ensinou

  1. Um evento error não tratado é um crash, não uma linha de log. O Node relança os eventos error que não têm listener. Remova os listeners de um socket que talvez ainda esteja desmontando, e você precisa reanexar um sumidouro de error no-op — ou uma conexão perdida leva o app inteiro junto.
  2. Renove as credenciais antes da expiração e reativamente num 401 — com um mutex nos dois. O proativo te mantém logado; o reativo pega os casos limite; o mutex significa que N chamadores simultâneos causam exatamente uma renovação, e um teto de retry significa que um token ruim não pode dar loop para sempre.
  3. Repita só em erros transitórios, e centralize o classificador. "Isto vale a pena repetir?" é uma única decisão que pertence a um único lugar. Repetir um erro real é só uma falha mais lenta.
  4. Decida quem é o dono da reconexão. Nós a adicionamos no cliente, depois a movemos para o backend. Lógica de recuperação vivendo nas duas pontas é um gerador de bugs; dê a ela um único lar.

Este é o terceiro capítulo da história de confiabilidade que se torna o GeekBye v2. Para o capítulo anterior, veja deletamos 5.000 linhas de código de áudio e as transcrições começaram a aparecer duas vezes (v1.6.0); para onde a reconexão finalmente se assentou, por que seu notetaker com IA para com Wi-Fi ruim; e para o arco inteiro, a anatomia de entregar software até a perfeição.

Artigos Relacionados

A verificação de segurança que tornou nosso app impossível de fechar
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
Uma variável CSS, cinco rodadas de revisão e uma cadeia de ferramentas Swift que mentiu
Steven
Steven8 min de leitura

Uma variável CSS, cinco rodadas de revisão e uma cadeia de ferramentas Swift que mentiu

A v2.0.7 do GeekBye tornou todo o overlay translúcido de forma ajustável — o que soa como uma mudança de CSS de uma linha e definitivamente não foi. A história de verdade é o que a revisão de código pegou: duas superfícies que se recusavam a esmaecer, uma barra de gravação que parecia errada na mesma opacidade e um binário compilado que oscilou 672 bytes porque nossa própria documentação disse a um revisor a versão errada do Swift.

Engenharia
Design
Build
Apagámos 5,000 Linhas de Código de Áudio — e as Transcrições Começaram a Aparecer em Duplicado
Steven
Steven6 min de leitura

Apagámos 5,000 Linhas de Código de Áudio — e as Transcrições Começaram a Aparecer em Duplicado

O GeekBye v1.6 removeu dois transcritores Swift on-device e substituiu-os por um único pipeline unificado — uma eliminação líquida de mais de 5,000 linhas, feita enquanto as pessoas estavam a meio de reuniões na app. Depois as transcrições começaram a aparecer em duplicado, porque o bug migrou para a única camada que ainda pensava existirem dois motores.

Engenharia
Transcrição
Arquitetura