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:
- Nenhum código monitorava se o
<video>ainda estava recebendo frames. O track do Twilio podia continuarsubscribedeisEnabled === truesem nenhum frame chegando (keyframe perdido, RTP parado, autoplay bloqueado), e a UI não tinha como saber. remoteStreamingFactory.tsemitiastream.trackem vez do própriostreamem alguns handlers — após um blip de rede que disparava re-subscription, o payload chegavaundefinedeuseRemoteStreams.tslia.kinddeundefined, quebrando o render do React.participantsFactory.tsusavaroom.participants.values().some(...)— Iterator helpers em objetosMapIteratorsó existem em Chrome ≥ 122 e Safari ≥ 18.4. Em browsers mais antigos isso lançavaTypeErrorantes de emitir o evento de participante ausente, deixando o vídeo do participante morto congelado na tela.- Zero mecanismo de recuperação: sem retry de attach, sem retry de
play(), sem re-getUserMediaquando 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
PUTde 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
beforeunloadusavaaxios.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
useVideoHealthmonitora frames (requestVideoFrameCallback/getVideoPlaybackQuality) e os eventosmute/unmute/endeddoMediaStreamTracksubjacente; recupera com até 3 tentativas (retry deplay()+ re-attach com backoff) antes de reportarfailed, 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 oRemoteTrackreal.Array.from(room.participants.values())substitui o uso direto de Iterator helpers. - Sala de espera: handshake
join_request/join_ackcomrequestIdidempotente 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 comfetch keepalive(garantida mesmo durante unload/navegação) em vez deaxios. Janela de frescor reduzida de 4 minutos para 45 segundos. Quem está dentro da call respondejoin_acka 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, usefetchcomkeepalive: true(ousendBeacon, 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 sobreMap/Setde bibliotecas de terceiros (comoroom.participantsdo Twilio), usarArray.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/endeddoMediaStreamTrack) é necessário além de apenas escutar os eventos de alto nível da lib.