A Tua Ligação Parece Viva, Mas Não Está Ninguém em Casa: Como os Heartbeats do Hermes Curam as Silent Disconnects


Fechas o portátil, vais almoçar e voltas. O Hermes continua no ecrã — histórico da sessão, cursor, tudo parece normal — por isso escreves uma mensagem e carregas em Enter. Não acontece nada: sem erro, sem spinner, sem timeout. Apenas silêncio. A tua mensagem desapareceu num buraco negro.

A essa situação de «interface viva, ligação morta» chama-se silent disconnect. Não é uma peculiaridade específica do Hermes — é uma doença de todos os programas em rede: a rede desapareceu, mas nenhuma das duas pontas sabe disso, por isso um lado espera e o outro espera também. Um conjunto de alterações integrado recentemente no Hermes permite que o TUI e o Desktop detetem estas ligações-zombie e as reconstruam sozinhos. Eis o que corrigiram de facto e porque é que isto interessa a qualquer pessoa que execute o Hermes remotamente.

Porque é que as ligações fingem a própria morte

Primeiro, o básico: a tua interface Hermes (o TUI de terminal ou a aplicação Desktop) fala com o verdadeiro «cérebro» — o processo do gateway a correr num servidor ou em segundo plano na tua máquina — através de uma única ligação WebSocket. Cada mensagem que envias e cada token com que ele responde viajam por esse canal.

O problema está na rede que fica por baixo. Imagina que o teu Mac dorme durante dez minutos, o Wi-Fi cai entretanto e ele acorda com um novo IP. Ou que mudas do Wi-Fi do escritório para o hotspot do telemóvel. Ou que a tua VPN se restabelece. Em todos estes casos, a antiga ligação TCP já não existe — mas a tua máquina não sabe disso. O jargão é half-open connection: uma das pontas do canal desapareceu e a outra continua a acreditar que está tudo bem.

Porque é que nenhum dos lados repara? O TCP até tem um mecanismo de keepalive, mas por omissão ele toca no fio demasiado raramente para fazer diferença, e os browsers nem sequer expõem o ping/pong aos programadores de WebSocket. Assim, o cliente continua a escrever numa ligação morta — operações de escrita que não falham de forma ruidosa: simplesmente não chegam a lado nenhum. É a tua mensagem a entrar no buraco negro.

A correção: um heartbeat em três camadas

O PR #93792, integrado a 24 de agosto (juntando três contribuições do developer da comunidade @100yenadmin — #89958, #90012, #89984), ataca exatamente isto: todas as ligações de cliente passam agora a fazer ping ao gateway numa base regular, declaram a ligação morta quando não chega nenhuma resposta e reconstroem-na automaticamente. Funciona em três camadas, cada uma a resolver um problema.

Camada 1: o servidor aprende a responder aos pings

Do lado do servidor (tui_gateway/ws.py), há três pequenas mudanças. Primeiro, o handshake de arranque (gateway.ready) passa a anunciar heartbeat: true, dizendo aos clientes «suporto heartbeats». Segundo, adiciona um método gateway.ping que é respondido inline no read loop — sem filas, sem agendamento, responde-se ao receber. Terceiro, carimba todas as ligações com last_inbound_at (o momento em que chegaram os últimos dados), o que facilita o diagnóstico mais tarde.

Camada 2: o cliente faz ping numa base regular e declara a morte em caso de timeout

O cliente do TUI (ui-tui/src/gatewayClient.ts) e o JsonRpcGatewayClient partilhado do Desktop usam os mesmos parâmetros: enviar um gateway.ping a cada 15 segundos; se não chegar nenhum dado do gateway em 45 segundos (não apenas acks de heartbeat — qualquer dado conta), declarar a ligação morta e encerrá-la para tentar restabelecer a ligação.

Porque é que são 45 segundos e não 15? As idas e voltas na rede têm latência, e um único heartbeat perdido não significa que a ligação desapareceu. Se não ouviste nada em 45 segundos (três ciclos de heartbeat), quase de certeza que não é jitter — a ligação desapareceu mesmo.

Camada 3: exponential backoff e dados obsoletos deitados fora

Quando uma ligação é declarada morta, o cliente não martela o servidor com tentativas de reconexão. Usa exponential backoff: tenta ao fim de 1 segundo, depois 2, depois 4, 8, 16… até ao máximo de 30 segundos, e continua a tentar até ligar. Cada tentativa publica um evento gateway.reconnecting (número da tentativa + atraso em milissegundos), o que facilita o troubleshooting.

O Desktop acrescenta um mecanismo mais fino: socket generation invalidation. Quando uma ligação é reconstruída, o cliente entrega ao novo socket um token de generation novo; quaisquer frames atrasados que cheguem ao socket antigo — por exemplo, as últimas mensagens que o gateway emitiu mesmo antes da queda — são descartados porque trazem o token antigo. A nova ligação começa limpa, por isso o tráfego sobrante de um socket morto não pode poluir a nova sessão, e nenhum trabalho é repetido duas vezes.

Os gateways mais antigos não são afetados

Todo o sistema de heartbeat é capability-gated: os clientes só começam a fazer heartbeat depois de receberem heartbeat: true no handshake do gateway.ready. Se ainda estiveres ligado a um gateway mais antigo — por exemplo, um servidor remoto que ainda não foi atualizado — o cliente comporta-se exatamente como antes. Isto significa que esta correção pode viajar com os clientes novos sem partir os servidores antigos.

Que cenários ficam cobertos

  • Sleep/wake: os ciclos de sleep do macOS e do Windows reconstroem a stack de rede — a fonte mais comum de ligações-zombie. Antes, o Desktop apenas corria um probe único ao acordar (isso foi o #93694, integrado mais cedo no mesmo dia, que impediu que as atualizações do gateway remoto deixassem a app presa num socket morto). Agora há um heartbeat contínuo por baixo.
  • Mudanças de rede: de Wi-Fi para hotspot, desligar/voltar a ligar o cabo Ethernet, roaming no escritório.
  • Reconexões de VPN: quando o túnel se reconstroi, todos os pacotes TCP da ligação antiga perdem-se.
  • Restarts silenciosos do servidor: o processo do gateway reinicia, mas o cliente nunca recebe um aviso de fecho.

O servidor também ativou o TCP keepalive nos sockets WebSocket (deteção de peers mortos) como segunda linha de defesa.

Quando é que isto chega até ti

Para sermos honestos sobre o estado das coisas: estas alterações vivem apenas na branch mainainda não saíram em nenhuma release com tag (a mais recente, v0.20.5, foi etiquetada a 19 de agosto, antes deste lote chegar). Por isso, se correres hermes update hoje, ainda não vais ver logs de heartbeat. Atualiza quando sair a próxima versão — o nosso guia de instalação e atualização mostra como, e podes consultar as notas da release v0.20.5 para ver o que a release anterior mudou.

Como confirmas que o heartbeat está a funcionar depois de atualizar? Procura as linhas [lifecycle] no TUI: em funcionamento normal ficam silenciosas; quando a rede hesita, vais ver websocket silent drop detected; forcing reconnect e scheduling gateway reconnect in Xms. Isso já não é uma má notícia — é o cliente a salvar-se a si próprio.

E se a tua preocupação é o outro tipo de paragem — o loop de raciocínio do agente a ficar preso (não é um problema de ligação, é o próprio agente a parar de trabalhar) — esse é um mecanismo separado: o loop watchdog do lado do gateway, que abordámos no guia de afinação do loop watchdog. Lê os dois em conjunto e ficas com a fiabilidade coberta tanto na camada de transporte como na camada de execução.

Resumo

As silent disconnects são o assassino invisível das ferramentas remotas: sem erro, sem pista, apenas o teu olhar confuso. A correção do Hermes é refrescantemente simples no espírito — faz ping numa base regular, volta a ligar quando ninguém responde e recua educadamente enquanto o faz — mas é exatamente essa simplicidade que a faz funcionar. Transforma «a ligação fingiu a própria morte» de folclore num evento detetável e recuperável. Da próxima vez que a tua rede hesitar, deixa de olhar para o buraco negro: o cliente já está a trabalhar num caminho de volta.