Tela preta no vídeo e desincronização de sala: quatro bugs pequenos, um sintoma grande

O que aconteceu

Dois sintomas intermitentes em produção nas videochamadas (Twilio + socket.io): o vídeo do participante remoto virava tela preta com o áudio funcionando normalmente, e a transição da sala de espera para a chamada deixava um usuário sozinho na call enquanto o outro ficava preso esperando (ou impedia a reentrada de quem saiu e voltou).

Nenhum dos dois tinha uma causa única — cada um era a soma de problemas pequenos e independentes que só se manifestavam sob condições específicas (rede fraca, browser antigo, navegação no meio de um request), o que explicava a dificuldade de reproduzir consistentemente.

Causa raiz

Tela preta — quatro causas empilhadas:

  1. Nenhum código monitorava se o <video> ainda estava recebendo frames. O track do Twilio podia continuar subscribed e isEnabled === true sem nenhum frame chegando (keyframe perdido, RTP parado, autoplay bloqueado), e a UI não tinha como saber.
  2. remoteStreamingFactory.ts emitia stream.track em vez do próprio stream em alguns handlers — após um blip de rede que disparava re-subscription, o payload chegava undefined e useRemoteStreams.ts lia .kind de undefined, quebrando o render do React.
  3. participantsFactory.ts usava room.participants.values().some(...) — Iterator helpers em objetos MapIterator só existem em Chrome ≥ 122 e Safari ≥ 18.4. Em browsers mais antigos isso lançava TypeError antes de emitir o evento de participante ausente, deixando o vídeo do participante morto congelado na tela.
  4. Zero mecanismo de recuperação: sem retry de attach, sem retry de play(), sem re-getUserMedia quando o track local morria.

Desincronização de sala — o fluxo dependia de um sinal enviado uma única vez, sem confirmação:

  • Ao clicar “Entrar”, o código emitia um evento de socket e um PUT de presença sem aguardar, navegando na linha seguinte (location.href = ...). A navegação inicia o teardown da página, que pode descartar o frame do WebSocket ainda no buffer de escrita e cancelar o request em voo.
  • O servidor de relay (chat-api) não tem histórico: uma mensagem emitida enquanto o destinatário está desconectado (ou reconectando) é perdida para sempre — não existe reenvio.
  • O cleanup de presença no beforeunload usava axios.put, que raramente completa durante o unload da página, deixando o participante como “presente” por até 4 minutos (a janela de frescor antiga).
  • A reentrada dependia de uma cadeia frágil (isAlone → intervalo de presença → janela de 4 min) que quebrava justamente pelo bug #3 do vídeo, já que o evento de “participante desconectado” nunca era emitido em browsers antigos.

Correção

  • Vídeo: novo hook useVideoHealth monitora frames (requestVideoFrameCallback / getVideoPlaybackQuality) e os eventos mute/unmute/ended do MediaStreamTrack subjacente; recupera com até 3 tentativas (retry de play() + re-attach com backoff) antes de reportar failed, e a UI mostra um fallback visual (avatar + “Câmera indisponível”) em vez de tela preta. resolveTrack() normaliza todo payload de evento do Twilio para sempre entregar o RemoteTrack real. Array.from(room.participants.values()) substitui o uso direto de Iterator helpers.
  • Sala de espera: handshake join_request/join_ack com requestId idempotente sobre o mesmo canal de relay — ninguém navega sem confirmação (ou sem um fallback explícito de compatibilidade/presença). Presença gravada com fetch keepalive (garantida mesmo durante unload/navegação) em vez de axios. Janela de frescor reduzida de 4 minutos para 45 segundos. Quem está dentro da call responde join_ack a qualquer pedido de reentrada, eliminando a dependência da janela de presença para esse caso.

Como evitar de novo

  • Nunca navegar (location.href) logo após disparar um efeito colateral sem aguardá-lo — a navegação pode cancelar requests em voo e descartar frames de socket ainda não enviados. Se o efeito precisa sobreviver à navegação, use fetch com keepalive: true (ou sendBeacon, quando o método permitir).
  • Um relay de mensagens sem histórico não garante entrega — qualquer protocolo construído sobre ele precisa de reenvio + idempotência (id de request) no lugar de confiar em “a mensagem sempre chega”.
  • Iterator helpers (.values().some(), .map() em iteradores) exigem engines recentes — ao iterar sobre Map/Set de bibliotecas de terceiros (como room.participants do Twilio), usar Array.from(...) antes de aplicar métodos de array evita quebrar em browsers mais antigos.
  • Um track “subscribed e enabled” não significa “produzindo frames” — para vídeo em tempo real, monitorar a saúde do stream (frames avançando, eventos mute/ended do MediaStreamTrack) é necessário além de apenas escutar os eventos de alto nível da lib.