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:

  1. Vídeo remoto sem frames não é detectado (src/components/Video/Video.tsx + useStreamDisplay.ts). Em rede fraca, o track remoto continua subscribed e isEnabled === true, mas nenhum frame chega ao <video>: primeiro keyframe perdido no join (o track nunca dispara started/isStarted), starvation de RTP (o MediaStreamTrack subjacente entra em mute), ou play() rejeitado por autoplay policy (o <video> remoto não tem muted — Safari/iOS). A UI mantém o <video> com opacity: 1 sobre fundo #3c3c3c → tela preta com áudio normal. Nota: o switch-off automático do Twilio (trackSwitchedOff) não se aplica aqui — exige bandwidthProfile no connect(), que o app não passa (connectionFactory.ts:28-30; sem profile o SDK seta clientTrackSwitchOffControl = 'disabled', twilio-video@2.35.0 lib/connect.js:342-345).
  2. Payload errado em re-subscription (remoteStreamingFactory.ts:46 + participantsFactory.ts:28-32): publication.on("subscribed", handleRemoteTrackEnabled) entrega um RemoteTrack, e o handler emite stream.track → undefined. Em useRemoteStreams.ts:27-32 o updater lê stream.kind de undefined → TypeError dentro do render do React após um blip de rede. Os eventos trackEnabled/trackDisabled do participant entregam RemoteTrackPublication cujo .track pode ser null quando ainda não subscrito — mesmo crash.
  3. Nenhum mecanismo de recuperação: sem verificação de frames no <video>, sem retry de attach/play(), sem re-getUserMedia quando o track local morre (ended/readyState !== "live").
  4. room.participants.values().some(...) (participantsFactory.ts:82-84): Iterator helpers só existem em Chrome ≥ 122 / Safari ≥ 18.4. Em browsers anteriores o handler de participantDisconnected lança TypeError antes de emitir RemoteParticipantUnavailable → a UI mantém o vídeo do participante morto (tela preta congelada) e isAlone nunca vira true — 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:

  1. Redirect fire-and-forget (WaitingParticipants.tsx:46-50 + utils.ts:12): ao clicar “Entrar”, o cliente emite o evento socket WaitingForRemote e um axios.put de presença sem await/keepalive e navega via location.href em 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.
  2. Presença fantasma: o cleanup em beforeunload (useMeetingLastSeen.ts:114-125) usa axios.put, que raramente completa durante unload → o participante que saiu continua “presente” por até 4 minutos (janela do detect()), fazendo o outro lado redirecionar sozinho (variante do Cenário A).
  3. 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_seen quando isAlone === true (Call.tsx, useInterval: isAlone), que depende de RemoteParticipantDisconnected, 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 do chat.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 do knockknock dele) é perdida para sempre. Confirma que o handshake atual não tolera perda de mensagem.
  • A presença (PUT /meetings/:id) grava last_seen em SQLite sem TTL; o delete depende exclusivamente do cleanup no beforeunload do 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ó vira finished no 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 servidor chat.trg.club nem 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 na trgclub-api.
  • Refactor da arquitetura CallManager/PeersManager além do necessário para os fixes.
  • Chat, ciclos, anamnese e demais funcionalidades da call.
  • bandwidthProfile no connect() do Twilio (avaliar depois, separadamente — muda comportamento de banda).

Decisões fechadas

  1. fetch keepalive, não sendBeacon: o endpoint de presença é PUT e sendBeacon só faz POST. Usar fetch(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.
  2. Retry de presença em página viva: mutation TanStack (useMutation) com retry: 3 e backoff exponencial substitui o retry infinito manual de 3s do setPresence. No caminho de unload/pré-redirect, usar fetch keepalive direto (TanStack não roda durante unload).
  3. Janela de frescor de presença: reduzir de 4 min para 45s (last_seen mais novo que 45s = presente). Compatível com: reenvio de 1s de quem espera, polling de 10s do detect(), e refresh de 10s de quem está na call.
  4. Semântica do ACK: join_ack significa “estou pronto, pode navegar”. Só envia ack quem já clicou “Entrar” (está em requesting) ou quem já está dentro da call. Receber join_request sem estar pronto não gera ack (o requester continua no spinner, comportamento atual).
  5. requestId: crypto.randomUUID() gerado no clique; reenvios reutilizam o mesmo id; o receptor deduplica por id.
  6. Compatibilidade de rollout (cliente novo ↔ cliente velho durante deploy): o cliente novo continua enviando WaitingForRemote junto com join_request, e trata WaitingForRemote recebido como hoje (isRemoteWaiting = true). Se estiver em requesting há mais de 5s, recebeu WaitingForRemote mas nenhum join_ack, assume peer legado e navega pelo fluxo antigo (com presença keepalive aguardada).
  7. Fallback sem socket: se em requesting o socket não entregar ack, mas o detect() HTTP retornar o remoto com last_seen fresco (< 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): recebe RemoteTrack ou RemoteTrackPublication e retorna sempre o RemoteTrack (payload.track ?? payload — um RemoteTrack tem .attach; uma publication tem .track). Retorna null (com logger.warn estruturado) se não houver track subscrito.
  • Usar resolveTrack em todos os listeners antes de chamar o remoteStreamingFactory; nunca repassar payload sem normalizar. Se null, não emitir nada.
  • Corrigir removeParticipantEvents: Array.from(room.participants.values()).some(...) no lugar de room.participants.values().some(...) (Iterator helpers não existem em Chrome < 122 / Safari < 18.4).
  • Remover o terceiro argumento acidental dos room.on(...) em connectionFactory.ts:43-53 está 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, nunca stream.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, handleRemoteStreamUnavailable e toggleRemoteStream: if (!stream?.kind) return; — nunca deixar payload inválido chegar ao setState.

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.requestVideoFrameCallback disponível: cada frame recebido re-arma um timeout de 5s; timeout disparado com stream.isEnabled === true ⇒ vídeo morto.
    • Fallback (browsers sem rVFC): interval de 5s comparando video.getVideoPlaybackQuality?.().totalVideoFrames (ou video.currentTime se indisponível) com o valor anterior; sem avanço ⇒ vídeo morto.
  • 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.isStarted em 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:

  1. videoRef.current.play().catch(...) — cobre autoplay bloqueado;
  2. re-attach: stream.detach(el) + stream.attach(el);
  3. aguardar o backoff; se frames voltarem (callback do watchdog), estado healthy e 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(...)) — o logger.debug é executado na hora do registro (log enganoso), não no evento.

6. src/components/Video/Video.tsx + styled.ts

  • Adicionar muted ao <VideoComponent> (os elementos de vídeo nunca carregam áudio aqui — áudio sai por <audio> separado; muted destrava autoplay em Safari/iOS).
  • Nova prop healthState?: "healthy" | "recovering" | "failed". Quando failed: renderizar o VideoOffContainer (avatar) com legenda “Câmera indisponível” por cima do <video>. Quando recovering: manter vídeo + indicador discreto (spinner pequeno) — sem tela preta seca.
  • RemoteParticipant.tsx: instanciar useVideoHealth para o stream de vídeo remoto e repassar healthState.

7. src/containers/Call/hooks/useLocalStreams.ts

  • Ouvir ended no mediaStreamTrack do 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, setLocalStreams com o novo e callManager.startStreaming(novoTrack) (republish). Logar sucesso/falha.

Bug #2 — sincronização wait room → call

8. src/infra/PeersManager/types.ts

  • EPeersManagerAttempToJoinTypes: adicionar JoinRequest = "join_request" e JoinAck = "join_ack" (manter os existentes).
  • EPeersManagerEvents: adicionar JoinRequestReceived e JoinAckReceived.
  • TSendEventData: adicionar requestId?: string.

9. src/infra/PeersManager/strategies/socket-io/index.ts

  • No handler de chat recebido: além dos dois eventos atuais, rotear event === "join_request" → emitter.emit(JoinRequestReceived, { requestId, user }) e event === "join_ack" → emitter.emit(JoinAckReceived, { requestId, user }).
  • No send: incluir requestId no message quando presente.
  • Remover os console.log soltos (linhas 30-32, 39) → logger.debug estruturado.

10. src/hooks/useMeetingLastSeen.ts

  • setPresence(isPresent):
    • trocar axios.put por mutation TanStack com retry: 3 + backoff (uso em página viva);
    • expor variante persistPresenceBeforeLeave(isPresent): Promise<void> que usa fetch(url, { method: "PUT", keepalive: true, ... }) com timeout de 2s (AbortSignal.timeout(2000)) — para pré-redirect e unload;
    • remover o retry infinito de setTimeout 3s.
  • handleBeforeUnload: registrar em pagehide (além de beforeunload) e usar persistPresenceBeforeLeave(false).
  • detect(): janela de frescor < 45 segundos (hoje < 4 minutos) — constante nomeada PRESENCE_FRESHNESS_SECONDS = 45.

11. src/containers/WaitingRoom/hooks/useParticipantDetection.ts

  • Expor sendJoinRequest(requestId: string) e sendJoinAck(requestId: string) (via peersManagerEmitter.emit(AttempToJoin, { type, requestId, ... })).
  • Assinar JoinRequestReceived / JoinAckReceived e 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_request estando em requesting sempre responde join_ack antes de navegar (cobre clique simultâneo — cenário 8).
  • visibilitychange → ao voltar visível em requesting, disparar reenvio imediato (cenário 7).
  • Sem estar em requesting, join_request recebido não gera ack (decisão 4) — apenas isRemoteWaiting = true na UI.
  • onJoinRoom (clique com isRemoteWaiting === 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 o if (!isMobile) e.preventDefault() assimétrico: sempre e.preventDefault() e navegar via máquina (o LinkButton deixa de navegar nativamente).
  • Logs: transição de estado, requestId, motivo do confirmed (ack legacy http-fallback).

13. src/containers/Call/Call.tsx + novo hook src/containers/Call/hooks/useCallPresence.ts

  • useMeetingLastSeen(...): passar useInterval: true e isAloneInCall: true sempre (não só quando isAlone) — quem está na call mantém last_seen fresco a cada 10s, viabilizando o fallback HTTP de reentrada (cenário 6).
  • Novo useCallPresence(meetingId, localPid, remotePid): instancia PeersManager no canal ${meetingId}_waiting_room (mesmo formatChannelId) e responde join_ack a todo join_request recebido (com o requestId recebido). 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.test verde, incluindo os novos testes de race/reconexão.
  • Manual (2 browsers, perfis diferentes):
    1. Ambos na wait room → um clica “Entrar” → o outro recebe e ambos entram; nenhum fica preso.
    2. 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.
    3. 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.
    4. Sair da call e voltar → reentrada direta com o outro participante ainda na call (ack respondido pela call).
    5. Refresh na call → retorna à sessão ativa.
    6. 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.md com 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).