Corrige tela preta intermitente no vídeo e desincronização sala de espera → chamada
TLDR: adiciona detecção + retry + fallback visual para vídeo remoto morto (Bug #1) e torna a transição wait room → call confirmada por ACK bilateral com presença confiável para reentrada (Bug #2), corrigindo no caminho bugs de payload de eventos e de compatibilidade de browser que alimentam os dois sintomas.
Contexto
Dois bugs intermitentes em produção nas videochamadas (Twilio + socket.io):
Bug #1 — tela preta com áudio funcionando. A investigação de root cause encontrou quatro causas combinadas:
- Vídeo remoto sem frames não é detectado (
src/components/Video/Video.tsx+useStreamDisplay.ts). Em rede fraca, o track remoto continuasubscribedeisEnabled === true, mas nenhum frame chega ao<video>: primeiro keyframe perdido no join (o track nunca disparastarted/isStarted), starvation de RTP (oMediaStreamTracksubjacente entra emmute), ouplay()rejeitado por autoplay policy (o<video>remoto não temmuted— Safari/iOS). A UI mantém o<video>comopacity: 1sobre fundo#3c3c3c→ tela preta com áudio normal. Nota: o switch-off automático do Twilio (trackSwitchedOff) não se aplica aqui — exigebandwidthProfilenoconnect(), que o app não passa (connectionFactory.ts:28-30; sem profile o SDK setaclientTrackSwitchOffControl = 'disabled',twilio-video@2.35.0lib/connect.js:342-345). - Payload errado em re-subscription (
remoteStreamingFactory.ts:46+participantsFactory.ts:28-32):publication.on("subscribed", handleRemoteTrackEnabled)entrega umRemoteTrack, e o handler emitestream.track→undefined. EmuseRemoteStreams.ts:27-32o updater lêstream.kinddeundefined→ TypeError dentro do render do React após um blip de rede. Os eventostrackEnabled/trackDisableddo participant entregamRemoteTrackPublicationcujo.trackpode sernullquando ainda não subscrito — mesmo crash. - Nenhum mecanismo de recuperação: sem verificação de frames no
<video>, sem retry de attach/play(), sem re-getUserMediaquando o track local morre (ended/readyState !== "live"). room.participants.values().some(...)(participantsFactory.ts:82-84): Iterator helpers só existem em Chrome ≥ 122 / Safari ≥ 18.4. Em browsers anteriores o handler departicipantDisconnectedlança TypeError antes de emitirRemoteParticipantUnavailable→ a UI mantém o vídeo do participante morto (tela preta congelada) eisAlonenunca viratrue— o que também quebra o Bug #2.
Bug #2 — desincronização wait room → call. O fluxo atual funciona no caminho feliz (sockets conectados, rede boa), mas não tolera nenhuma falha. Causas:
- Redirect fire-and-forget (
WaitingParticipants.tsx:46-50+utils.ts:12): ao clicar “Entrar”, o cliente emite o evento socketWaitingForRemotee umaxios.putde presença sem await/keepalive e navega vialocation.hrefem seguida. A navegação mata socket e request em voo → o outro lado nunca recebe o sinal → um usuário fica sozinho na call e o outro preso na wait room (Cenário A). Não há ACK bilateral. - Presença fantasma: o cleanup em
beforeunload(useMeetingLastSeen.ts:114-125) usaaxios.put, que raramente completa durante unload → o participante que saiu continua “presente” por até 4 minutos (janela dodetect()), fazendo o outro lado redirecionar sozinho (variante do Cenário A). - Reentrada frágil (Cenário B): não existe estado “chamada ativa”. A reentrada depende de uma cadeia: o usuário na call só atualiza
last_seenquandoisAlone === true(Call.tsx,useInterval: isAlone), que depende deRemoteParticipantDisconnected, que quebra pela causa 4 do Bug #1; soma-se polling de 10s + janela de frescor de 4 min → quem volta fica preso na wait room mesmo com o terapeuta na call.
Confirmação server-side (repos chat-api e trgclub-api inspecionados):
- O
chat-api(servidor dochat.trg.club) é um relay puro:io.to(channelID).emit("chat", ...)sem histórico nem replay — mensagem emitida enquanto o outro lado está desconectado (ou antes doknockknockdele) é perdida para sempre. Confirma que o handshake atual não tolera perda de mensagem. - A presença (
PUT /meetings/:id) gravalast_seenem SQLite sem TTL; o delete depende exclusivamente do cleanup nobeforeunloaddo cliente — confirma a presença fantasma. - Na
trgclub-api,Meeting#can_join?é só janela de tempo (10 min antes do início até 2h depois) e o meeting só virafinishedno pós-atendimento — o backend nunca bloqueia reentrada; o travamento do Cenário B é inteiramente client-side.
A solução proposta é 100% client-side, usando o relay existente.
Abordagens consideradas
- A (escolhida) — client-only: handshake de ACK sobre o relay socket existente + presença via
fetch keepalive+ correções nos handlers Twilio + watchdog de vídeo. Não exige mudança no servidorchat.trg.clubnem na API. Trade-off: o handshake continua dependente do relay (mitigado por fallback de presença HTTP). - B — estado de sala no backend: endpoint “call active” com ACK server-side. Mais robusto, porém exige mudar serviço externo fora deste repo.
- C — usar a room Twilio como fonte de verdade (entrar direto na room, wait room só visual). Refactor grande, muda comportamento de produto (pessoas sozinhas na sala de chamada).
Matriz de cenários de desincronização (critério de aceite do Bug #2)
| # | Cenário | Hoje | Garantia do fix |
|---|---|---|---|
| 1 | Segundo clicador navega e o emit socket morre no buffer (teardown da página) | Primeiro preso no spinner; segundo sozinho na call | Ninguém navega sem join_ack; reenvio idempotente a cada 1s |
| 2 | Receptor com socket reconectando no instante do broadcast (relay sem replay) | Mensagem perdida para sempre | Reenvio contínuo cobre a janela de reconexão |
| 3 | Emit após reconexão do socket mas antes do knockknock refazer o join |
Servidor descarta silenciosamente | Reenvio contínuo + log estruturado |
| 4 | PUT de presença cancelado pela navegação | Polling detect() do outro lado fica cego |
Presença gravada com fetch keepalive e aguardada antes do redirect |
| 5 | Usuário fecha a aba e o delete de presença não completa | Presença fantasma por até 4 min → outro entra sozinho | fetch keepalive no pagehide + janela de frescor de 45s |
| 6 | Refresh/saída durante a call e tentativa de reentrada | Cadeia frágil (isAlone → interval → last_seen < 4 min), quebra em browsers antigos |
Presença mantida sempre durante a call + quem está na call responde join_ack → reentrada direta |
| 7 | Mobile em background/tela bloqueada na wait room (timers suprimidos, socket morto) | Sinais perdidos sem detecção | Reenvio retomado no visibilitychange + fallback de presença HTTP |
| 8 | Ambos clicam simultaneamente | Nunca testado | Handshake simétrico com requestId — teste explícito |
Objetivos
- Vídeo remoto morto é detectado automaticamente e recuperado com no máximo 3 tentativas de retry.
- Se o retry esgotar, exibir fallback visual (avatar + mensagem de câmera indisponível) — nunca tela preta.
- Transição wait room → call sincronizada com ACK dos dois lados antes do redirect (matriz acima).
- Reentrada em chamada ativa funciona enquanto o outro participante estiver na call.
- Logs estruturados nos pontos críticos (subscribe/unsubscribe, saúde do track, retry, ACK, presença).
- Testes cobrindo race conditions e reconexão.
Fora de escopo
- Mudanças no servidor socket.io (
chat.trg.club) ou natrgclub-api. - Refactor da arquitetura CallManager/PeersManager além do necessário para os fixes.
- Chat, ciclos, anamnese e demais funcionalidades da call.
bandwidthProfilenoconnect()do Twilio (avaliar depois, separadamente — muda comportamento de banda).
Decisões fechadas
fetch keepalive, nãosendBeacon: o endpoint de presença éPUTesendBeaconsó fazPOST. Usarfetch(url, { method: "PUT", keepalive: true, headers: { "Content-Type": "application/json" }, body })em todos os writes de presença que possam coincidir com navegação/unload.- Retry de presença em página viva: mutation TanStack (
useMutation) comretry: 3e backoff exponencial substitui o retry infinito manual de 3s dosetPresence. No caminho de unload/pré-redirect, usarfetch keepalivedireto (TanStack não roda durante unload). - Janela de frescor de presença: reduzir de 4 min para 45s (
last_seenmais novo que 45s = presente). Compatível com: reenvio de 1s de quem espera, polling de 10s dodetect(), e refresh de 10s de quem está na call. - Semântica do ACK:
join_acksignifica “estou pronto, pode navegar”. Só envia ack quem já clicou “Entrar” (está emrequesting) ou quem já está dentro da call. Receberjoin_requestsem estar pronto não gera ack (o requester continua no spinner, comportamento atual). requestId:crypto.randomUUID()gerado no clique; reenvios reutilizam o mesmo id; o receptor deduplica por id.- Compatibilidade de rollout (cliente novo ↔ cliente velho durante deploy): o cliente novo continua enviando
WaitingForRemotejunto comjoin_request, e trataWaitingForRemoterecebido como hoje (isRemoteWaiting = true). Se estiver emrequestinghá mais de 5s, recebeuWaitingForRemotemas nenhumjoin_ack, assume peer legado e navega pelo fluxo antigo (com presença keepalive aguardada). - Fallback sem socket: se em
requestingo socket não entregar ack, mas odetect()HTTP retornar o remoto comlast_seenfresco (< 45s) em 2 polls consecutivos, navegar mesmo sem ack (presença fresca implica peer ativo).
Mudanças
Bug #1 — tela preta
1. src/infra/CallManager/strategies/twilio/factories/participantsFactory.ts
- Criar helper local
resolveTrack(payload): recebeRemoteTrackouRemoteTrackPublicatione retorna sempre oRemoteTrack(payload.track ?? payload— umRemoteTracktem.attach; uma publication tem.track). Retornanull(comlogger.warnestruturado) se não houver track subscrito. - Usar
resolveTrackem todos os listeners antes de chamar oremoteStreamingFactory; nunca repassar payload sem normalizar. Senull, não emitir nada. - Corrigir
removeParticipantEvents:Array.from(room.participants.values()).some(...)no lugar deroom.participants.values().some(...)(Iterator helpers não existem em Chrome < 122 / Safari < 18.4). - Remover o terceiro argumento acidental dos
room.on(...)emconnectionFactory.ts:43-53está fora deste arquivo — ver item 5.
2. src/infra/CallManager/strategies/twilio/factories/remoteStreamingFactory.ts
- Contrato de payload: todos os handlers recebem e emitem
RemoteTrack(nunca publication, nuncastream.track). handleRemoteTrackEnabled/handleRemoteTrackDisabled: emitir o próprio track recebido (emitter.emit(ECallManagerEvents.RemoteStreamEnabled, track)), removendo o acesso a.track.- Guard clause em todos:
if (!track?.kind) { logger.warn(...); return; }.
3. src/containers/Call/hooks/useRemoteStreams.ts
- Guard clause em
handleRemoteStreamAvailable,handleRemoteStreamUnavailableetoggleRemoteStream:if (!stream?.kind) return;— nunca deixar payload inválido chegar aosetState.
4. Novo hook src/containers/Call/hooks/useVideoHealth.ts
Assinatura: useVideoHealth(stream: TTrack, videoRef: RefObject<HTMLVideoElement>): "healthy" | "recovering" | "failed".
Detecção (watchdog):
- Ao attachar um stream de vídeo habilitado, armar verificação de frames:
- Com
video.requestVideoFrameCallbackdisponível: cada frame recebido re-arma um timeout de 5s; timeout disparado comstream.isEnabled === true⇒ vídeo morto. - Fallback (browsers sem rVFC): interval de 5s comparando
video.getVideoPlaybackQuality?.().totalVideoFrames(ouvideo.currentTimese indisponível) com o valor anterior; sem avanço ⇒ vídeo morto.
- Com
- Ouvir no
stream.mediaStreamTrack(MediaStreamTrack subjacente do track Twilio):mute(suspende o watchdog e loga — sem frames é esperado),unmute(re-arma),ended(⇒ morto, direto para recovery). - Logar
stream.isStartedem cada transição (diagnóstico de keyframe perdido no join). - Watchdog só roda com
stream.isEnabled === true(câmera desligada de propósito não é doença).
Recuperação (máx. 3 tentativas, backoff 1s → 2s → 4s), por tentativa:
videoRef.current.play().catch(...)— cobre autoplay bloqueado;- re-attach:
stream.detach(el)+stream.attach(el); - aguardar o backoff; se frames voltarem (callback do watchdog), estado
healthye zera contador.
Esgotadas as 3 tentativas ⇒ estado failed (a UI mostra fallback). Se frames voltarem sozinhos depois (ex.: banda recuperou), voltar a healthy.
Logs estruturados em todas as transições: logger.info("video health", { state, attempt, kind, isEnabled, isStarted, muted, readyState }).
5. src/infra/CallManager/strategies/twilio/factories/connectionFactory.ts
- Remover o terceiro argumento dos
room.on("participantConnected"/"participantDisconnected", handler, logger.debug(...))— ologger.debugé executado na hora do registro (log enganoso), não no evento.
6. src/components/Video/Video.tsx + styled.ts
- Adicionar
mutedao<VideoComponent>(os elementos de vídeo nunca carregam áudio aqui — áudio sai por<audio>separado;muteddestrava autoplay em Safari/iOS). - Nova prop
healthState?: "healthy" | "recovering" | "failed". Quandofailed: renderizar oVideoOffContainer(avatar) com legenda “Câmera indisponível” por cima do<video>. Quandorecovering: manter vídeo + indicador discreto (spinner pequeno) — sem tela preta seca. RemoteParticipant.tsx: instanciaruseVideoHealthpara o stream de vídeo remoto e repassarhealthState.
7. src/containers/Call/hooks/useLocalStreams.ts
- Ouvir
endednomediaStreamTrackdo track de vídeo local. Ao disparar com a câmera habilitada: uma tentativa de re-aquisição —callManager.createStreams({ video: getVideoStreamConfig(...) }), parar o track antigo,setLocalStreamscom o novo ecallManager.startStreaming(novoTrack)(republish). Logar sucesso/falha.
Bug #2 — sincronização wait room → call
8. src/infra/PeersManager/types.ts
EPeersManagerAttempToJoinTypes: adicionarJoinRequest = "join_request"eJoinAck = "join_ack"(manter os existentes).EPeersManagerEvents: adicionarJoinRequestReceivedeJoinAckReceived.TSendEventData: adicionarrequestId?: string.
9. src/infra/PeersManager/strategies/socket-io/index.ts
- No handler de
chatrecebido: além dos dois eventos atuais, rotearevent === "join_request"→emitter.emit(JoinRequestReceived, { requestId, user })eevent === "join_ack"→emitter.emit(JoinAckReceived, { requestId, user }). - No
send: incluirrequestIdnomessagequando presente. - Remover os
console.logsoltos (linhas 30-32, 39) →logger.debugestruturado.
10. src/hooks/useMeetingLastSeen.ts
setPresence(isPresent):- trocar
axios.putpor mutation TanStack comretry: 3+ backoff (uso em página viva); - expor variante
persistPresenceBeforeLeave(isPresent): Promise<void>que usafetch(url, { method: "PUT", keepalive: true, ... })com timeout de 2s (AbortSignal.timeout(2000)) — para pré-redirect e unload; - remover o retry infinito de
setTimeout3s.
- trocar
handleBeforeUnload: registrar empagehide(além debeforeunload) e usarpersistPresenceBeforeLeave(false).detect(): janela de frescor< 45segundos (hoje< 4minutos) — constante nomeadaPRESENCE_FRESHNESS_SECONDS = 45.
11. src/containers/WaitingRoom/hooks/useParticipantDetection.ts
- Expor
sendJoinRequest(requestId: string)esendJoinAck(requestId: string)(viapeersManagerEmitter.emit(AttempToJoin, { type, requestId, ... })). - Assinar
JoinRequestReceived/JoinAckReceivede repassar via callbacks (onJoinRequest,onJoinAck) para o componente. - Manter envio/recepção de
WaitingForRemote(compat de rollout — decisão 6).
12. src/containers/WaitingRoom/components/WaitingParticipants/WaitingParticipants.tsx
Máquina de estados do join (substitui o efeito atual de linhas 52-71):
idle
└─ clique "Entrar" → requesting (gera requestId, seta waitingConnection)
requesting
├─ a cada 1s: sendJoinRequest(requestId) + WaitingForRemote (legado) + setPresence(true)
├─ recebeu join_ack(requestId) → confirmed
├─ recebeu join_request do remoto → responde join_ack + confirmed
├─ fallback legado (decisão 6): >5s com WaitingForRemote recebido e sem ack → confirmed
├─ fallback HTTP (decisão 7): 2 polls consecutivos com last_seen < 45s → confirmed
└─ cancelar → idle (sendAttempToJoin(false))
confirmed
└─ await persistPresenceBeforeLeave(true) → preservePresenceOnRedirect()
→ clearTracksBeforeCall() → location.href = /app/video/{id}
- Quem recebe
join_requestestando emrequestingsempre respondejoin_ackantes de navegar (cobre clique simultâneo — cenário 8). visibilitychange→ ao voltar visível emrequesting, disparar reenvio imediato (cenário 7).- Sem estar em
requesting,join_requestrecebido não gera ack (decisão 4) — apenasisRemoteWaiting = truena UI. onJoinRoom(clique comisRemoteWaiting === true): entra na mesma máquina (requesting) — na prática o ack chega no primeiro reenvio; não navegar mais de forma síncrona no clique. Remover oif (!isMobile) e.preventDefault()assimétrico: sempree.preventDefault()e navegar via máquina (oLinkButtondeixa de navegar nativamente).-
Logs: transição de estado, requestId, motivo do confirmed(acklegacyhttp-fallback).
13. src/containers/Call/Call.tsx + novo hook src/containers/Call/hooks/useCallPresence.ts
useMeetingLastSeen(...): passaruseInterval: trueeisAloneInCall: truesempre (não só quandoisAlone) — quem está na call mantémlast_seenfresco a cada 10s, viabilizando o fallback HTTP de reentrada (cenário 6).- Novo
useCallPresence(meetingId, localPid, remotePid): instanciaPeersManagerno canal${meetingId}_waiting_room(mesmoformatChannelId) e respondejoin_acka todojoin_requestrecebido (com orequestIdrecebido). Isso dá reentrada imediata a quem voltou para a wait room. Destruir a instância no unmount.
Transversal
- Testes (arquivos e casos na seção abaixo).
- Nenhuma mudança de endpoint, payload de servidor ou variável de ambiente.
Testes
Padrão existente: jest + __tests__/ ao lado do código (ver src/infra/CallManager/strategies/twilio/factories/__tests__/connectionFactory.test.ts).
| Arquivo | Casos |
|---|---|
factories/__tests__/participantsFactory.test.ts |
resolveTrack com RemoteTrack, publication com .track, publication com .track = null (não emite, loga); removeParticipantEvents com room.participants como Map real (sem Iterator helpers — mock que só tem .values() retornando iterator puro); dedup de participante ainda presente |
factories/__tests__/remoteStreamingFactory.test.ts |
payload sempre RemoteTrack nos 4 handlers; guard com payload inválido |
Call/hooks/__tests__/useVideoHealth.test.ts |
fake timers + mock de rVFC: frames ok → healthy; timeout de 5s → recovering → 3 retries → failed; frames voltam após failed → healthy; mute/unmute suspende/rearma; isEnabled false não dispara watchdog |
Call/hooks/__tests__/useRemoteStreams.test.ts |
payload undefined/sem kind não altera state nem lança |
WaitingRoom/hooks/__tests__/useParticipantDetection.test.ts |
roteamento join_request/join_ack com requestId; dedup por id; compat WaitingForRemote |
WaitingParticipants (component test) |
máquina de estados: ack → confirmed; request cruzado (clique simultâneo) → ambos confirmed; perda de mensagem (nenhum ack) → permanece requesting e reenvia; fallback legado 5s; fallback HTTP 2 polls; cancelar volta a idle |
hooks/__tests__/useMeetingLastSeen.test.ts |
persistPresenceBeforeLeave usa fetch com keepalive: true e método PUT; retry limitado a 3; janela de 45s no detect(); pagehide registrado |
Como verificar
make run.testverde, incluindo os novos testes de race/reconexão.- Manual (2 browsers, perfis diferentes):
- Ambos na wait room → um clica “Entrar” → o outro recebe e ambos entram; nenhum fica preso.
- Simular perda do socket (DevTools offline) no momento do clique → quem clicou permanece no spinner reenviando; ao voltar a rede, ambos entram; ninguém navega sem confirmação.
- Na call, degradar a rede de um lado (throttling agressivo) até os frames de vídeo pararem de chegar → watchdog detecta, tenta recuperar e, se não conseguir, UI mostra fallback (não tela preta); recupera ao voltar a banda.
- Sair da call e voltar → reentrada direta com o outro participante ainda na call (ack respondido pela call).
- Refresh na call → retorna à sessão ativa.
- Fechar a aba na wait room → em até ~45s o outro lado vê “ainda não está na sala”.
- Logs estruturados visíveis (LogRocket/console) em cada ponto crítico.
Documentação
- Criar
.project/docs/rules/meetings/waiting_room_transition.mdcom a regra de negócio da transição (ACK bilateral, semântica do ack, reentrada, janela de presença de 45s). - Criar learning em
.project/docs/learnings/sobre as causas (Iterator helpers, payloads Twilio, fire-and-forget + navegação, autoplay/keepalive).