Ping periódico no servidor WebSocket

TLDR: adiciona ping periódico do servidor para manter conexões WebSocket ociosas vivas, evitando que o timeout de keep-alive do Gunicorn as derrube.

Contexto

O worker do Gunicorn usa --keep-alive 30, o que significa que conexões WebSocket ociosas podem ser derrubadas após ~30 segundos. O loop principal em _ws_handler bloqueava indefinidamente em await ws.receive_json(), então não conseguia enviar pings enquanto esperava mensagens do cliente. Quando o usuário abria o chat e não digitava por 30s, a conexão caía silenciosamente.

O cliente mobile já reconecta automaticamente (onion-mobile PR #83), mas um ping do lado do servidor evita reconexões desnecessárias e mantém a conexão viva durante a janela de resposta da LLM.

Objetivos

  • Enviar um frame {"type": "ping"} para o cliente a cada 20 segundos de ociosidade
  • Detectar conexões mortas do lado do servidor (cliente não responde) e fechá-las de forma limpa
  • Não interferir no fluxo normal de mensagens

Fora de escopo

  • Medir latência round-trip do ping/pong
  • Intervalo de ping configurável via variável de ambiente (20s fixo é suficiente)
  • Mudanças no cliente mobile (tratado na PR #83 do onion-mobile)

Mudanças

onion_chat/controllers/v2/websocket_controller.py

Substitui o await ws.receive_json() bloqueante por uma chamada asyncio.wait_for(..., timeout=20) dentro do loop:

  • Se o timeout expirar sem mensagem → envia {"type": "ping"} e volta a esperar
  • Se asyncio.TimeoutError for lançado com um ping já pendente sem pong → fecha a conexão (cliente morto)
  • Se chegar uma mensagem {"type": "pong"} → zera o estado de ping pendente e volta a esperar (não processa como mensagem de chat)
  • Todo o tratamento de mensagens existente permanece inalterado

Como verificar

  1. Conectar em /ws/chat com um token válido
  2. Não enviar nenhuma mensagem por 25 segundos — confirmar que chega um frame {"type": "ping"}
  3. Responder com {"type": "pong"} — confirmar que a conexão permanece viva
  4. Não responder ao ping por mais 20s — confirmar que o servidor fecha a conexão
  5. Confirmar que o fluxo normal de chat (enviar mensagem, receber resposta) continua funcionando sem interrupção

Documentação

Nenhuma mudança de documentação adicional necessária.