Skip to content

fix(core/server): captura perdida encerra a direcao e running para de anunciar direcao morta (#62) - #63

Draft
caioross wants to merge 4 commits into
mainfrom
auto/issue-62-stop-zumbi-workers
Draft

fix(core/server): captura perdida encerra a direcao e running para de anunciar direcao morta (#62)#63
caioross wants to merge 4 commits into
mainfrom
auto/issue-62-stop-zumbi-workers

Conversation

@caioross

@caioross caioross commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Contexto

A #45 estabeleceu a doutrina: falha isolada nao mata a direcao, e a morte de verdade seta _stop antes de sair, para nao deixar thread zumbi. A #62 aponta os caminhos vizinhos que ainda nao a seguiam. O Curador reduziu o escopo desta issue aos itens 1 e 3 — o item 2 (error.play recuperavel) e da #54, e nao esta aqui.

O que mudou

Item 1 — laguna_core.py, captura perdida (_capture)
Esgotado o orcamento de CAPTURE_MAX_RETRIES, o laco emitia error.capture_lost e saia sem setar _stop: segmentador, laco de traducao e os _OutputSink seguiam girando para sempre com a fila vazia — app "vivo", nada traduzindo, devices de saida presos, e o Parar desabilitado na UI (o evento nao e recuperavel). Agora self._stop.set() vem antes do evento, igual ao caminho fatal do _translation_loop.

Junto, uma linha de rede final: o finally de _run tambem seta _stop. Isso cobre a falha de setup (modelo que nao carrega) — caminho que nunca chega ao laco de traducao e ate agora saia com _stop limpo, deixando um worker morto que o servidor seguia listando. Sem esse pedaco, o item 3 continuaria mentindo nesse cenario.

Item 3 — laguna_server.py, _workers que nao solta direcao morta
_workers so perdia entrada no /api/stop. Direcao que morre sozinha continuava listada e o hello do WS respondia com ela em running: um F5 repintava o painel de verde "Em execucao", com o Parar habilitado, sobre uma direcao que nao traduz mais. _running_directions() filtra por is_alive() e despeja o worker morto; /api/status e o hello passam a usa-la.

Contrato REST/WS inalterado: running continua sendo uma lista de nomes de direcao — so para de incluir direcao morta. Nenhuma mudanca em static/.

Decisoes de projeto:

  • is_alive() publico em DirectionWorker para o servidor nao ler _stop (privado) de fora.
  • O despejo nao chama stop(): o worker morto ja fechou os proprios sinks no finally de _run, e stop() faz join de ate 2s por thread — caro demais para um handler async.

Gate (HANDBOOK §6) — resultado real

  • T1 — verde. python -m compileall -q .COMPILE_OK; import fase0_poc, laguna_core, laguna_serverIMPORTS_OK (C:\Python313\python.exe).
  • T2 — verde (toca laguna_core.py). test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto: rodou em cuda/float16, out_gate.wav gerado. STT 1069ms / MT 929ms / TTS 4730ms — cold start da worktree (primeiro uso baixou os modelos); nao ha numero de latencia comparavel aqui e nenhuma constante de VAD/latencia ou default de modelo foi tocada, entao nao se aplica o benchmark antes/depois.
  • T3 — nao se aplica: nenhum arquivo em static/.
  • Testes unitarios — verdes: pytest tests_unit/ -q63 passed (55 antes + 8 novos). Verificado que os testes novos pegam a regressao: com o diff de laguna_core.py/laguna_server.py revertido, test_captura_perdida_seta_stop_e_emite_erro_terminal e test_falha_de_setup_tambem_encerra_a_direcao falham.

Novos: tests_unit/test_capture_retry.py (captura perdida seta _stop; falha transitoria nao encerra a direcao; stop do usuario nao vira erro; falha de setup tambem mata) e tests_unit/test_running_directions.py (viva fica, morta some do running e do dicionario, irma viva sobrevive, stop() nao e chamado no despejo). Sem sounddevice, modelo, GPU ou HTTP real — rodam no job tests-unit da CI.

Riscos

  • _stop no finally de _run e o mais abrangente do diff: passa a valer para todo caminho de saida, inclusive a falha de setup. Em stop() e no fatal do laco _stop ja estava setado — nao muda nada la. O efeito novo e so: worker que acabou passa a se declarar morto.
  • Falha transitoria de captura nao pode virar morte. Era o erro facil aqui (setar _stop no except generico em vez de dentro do if attempt >= CAPTURE_MAX_RETRIES). Coberto por teste dedicado.
  • UI: quem estiver com a tela aberta continua vendo o erro error.capture_lost como terminal (comportamento atual, correto — agora o backend de fato parou). O que muda e o F5: passa a mostrar a direcao como parada, que e a verdade.

Solicito quórum (HANDBOOK §7)

Closes #62

…zumbi (#62)

Esgotado o orcamento de CAPTURE_MAX_RETRIES, `_capture` emitia
`error.capture_lost` e saia SEM setar `_stop`: segmentador, laco de
traducao e os `_OutputSink` seguiam girando para sempre com a fila
vazia — app "vivo", nada traduzindo, devices de saida presos e o Parar
desabilitado na UI (o evento nao e recuperavel). E a descricao literal
do zumbi que a #45 corrigiu no laco de traducao, na porta ao lado.

Agora `_stop.set()` vem ANTES do evento, como no caminho fatal do
`_translation_loop`. O `finally` de `_run` tambem passa a setar `_stop`:
rede final que cobre a falha de SETUP (modelo que nao carrega), que nao
passa pelo laco e ate agora saia com `_stop` limpo.

`is_alive()` expoe esse estado para o servidor sem que ele leia `_stop`.

Cobertura em tests_unit/test_capture_retry.py (sem sounddevice, modelo
ou device real): captura perdida seta `_stop`, falha transitoria NAO
encerra a direcao, stop do usuario nao vira erro e falha de setup
tambem mata a direcao.
`_workers` so perdia entrada no `/api/stop`. Uma direcao que morre por
conta propria (captura perdida, falhas consecutivas, falha de setup)
continuava listada, e o `hello` do WS respondia com ela em `running`:
um F5 — reacao natural de quem acabou de ver um erro — repintava o
painel de verde "Em execucao", com o Parar habilitado, sobre uma
direcao que nao traduz mais. O usuario fala e nao sai nada, sem
nenhuma pista na tela.

`_running_directions()` filtra por `is_alive()` e despeja o worker
morto do dicionario; `/api/status` e o `hello` passam a usa-la. O
formato da resposta nao muda (lista de direcoes) — so para de mentir.
Nao chama `stop()` no despejo: o worker morto ja fechou os proprios
sinks no `finally` de `_run` e `stop()` faz join de ate 2s por thread,
caro demais para um handler async.

Cobertura em tests_unit/test_running_directions.py.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

O quorum adversarial vetou o despejo: `_running_directions` fazia
`_workers.pop()` para o worker com `is_alive() == False`, e isso joga fora a
UNICA referencia ao worker num instante em que ele AINDA segura device.
`_stop` e setado no comeco do `finally` de `_run`; `_close_sinks()` so roda
depois que o laco de traducao retorna — pode levar segundos (`_play` espera ate
`len(pcm)/sr + OUT_SINK_WAIT_MARGIN_S`) ou nunca, se um engine pendurar.

Sem a referencia, o `/api/stop` seguinte cai em `pop(..., None) is None` e
devolve `{"ok": true}` sem chamar `stop()`, e o `existing.stop()` do
`/api/start` e pulado: o device de saida fica preso ate o processo morrer e o
start novo reabre o mesmo device por cima do antigo. Era regressao direta da
doutrina da #38 ("parar/reiniciar nao pode vazar device").

Filtrar resolve a #62 igual e nao muta estado: `_stop` nunca e limpo, entao
`is_alive()` e monotonico e a direcao morta jamais reaparece em `running`.
Quem solta o worker do dicionario continua sendo `/api/stop` ou o proximo
start. De quebra, `/api/status` volta a ser um GET sem efeito colateral.

Testes ajustados para afirmar a invariante certa (a referencia sobrevive ao
despejo da lista) + um novo cobrindo que consultar `running` nao muta nada.
…t caido (#62)

O quorum apontou que a correcao do backend nao chegava ao usuario no caminho
sem F5. O handler de `hello` em app.js era ADITIVO: iterava so sobre
`ev.running` e so sabia LIGAR painel. Como o WS reconecta sozinho (`onclose` →
`scheduleReconnect` → `connectWS`), o `hello` corrigido era descartado
justamente quando era a unica fonte de verdade.

Cenario: aba aberta com FALAR rodando, o socket cai, o mic some e a captura
morre durante a queda. O `error.capture_lost` e transmitido para um socket
morto e a UI nunca o ve. O WS volta, o `hello` chega com `running: []` — e o
painel continua verde "Em execucao" com o Parar habilitado sobre uma direcao
que nao traduz ha minutos. Era o sintoma da #62 por outra porta.

Agora o `hello` e autoritativo nos dois sentidos. Desliga apenas o que o
cliente ACHAVA que estava rodando (`state.running`): painel em erro ja saiu de
`state.running` e mantem o erro na tela, e start otimista ainda nao entrou —
nao sofre stomp com o POST em voo. Sem chave nova de i18n (`status.idle` e
`status.running` ja existem em PT e EN).
@caioross

caioross commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Parecer do PR Doctor — quórum §7.2, duas rodadas

head avaliado: 4e71a72. Diff lido inteiro; gate local completo rodado na worktree.

Resultado: 2× APROVA, 1× VETO confirmado. Sem 3× APROVA, não mergeio. Pela §7.2 (veto persiste após um reparo e uma re-convocação), a PR vai para DRAFT + decisao-dono. A decisão que falta é pequena e está no fim deste parecer.


Rodada 1 — 3× VETO

As três lentes vetaram, com dois defeitos distintos e reais:

(a) Pipeline/Latência e Privacidade/Robustez, convergentes: o _workers.pop() em _running_directions() descartava a única referência a um worker que ainda segurava device. _stop é setado no começo do finally de _run (laguna_core.py:433), mas _close_sinks() só roda depois que o laço de tradução retorna — segundos, se _play estiver esperando len(pcm)/sr + OUT_SINK_WAIT_MARGIN_S, ou nunca, se um engine pendurar. Despejado o worker, o /api/stop seguinte cai em pop(..., None) is None e devolve {"ok": true} sem nunca chamar stop(), e o existing.stop() do /api/start é pulado. Device de saída preso até o processo morrer, e start novo reabrindo o mesmo device por cima do antigo — regressão direta da doutrina da #38. O docstring que justificava o despejo ("o worker morto já fechou os próprios sinks") era falso em todos os três caminhos de morte.

(b) Produto/UX: o handler de hello em static/app.js era aditivo — só sabia LIGAR painel. Como o WS reconecta sozinho (onclosescheduleReconnectconnectWS), o hello corrigido era descartado justamente no caminho sem F5, que é onde ele seria a única fonte de verdade.

Reparo aplicado (f53ba68, 4e71a72)

  • _running_directions() passou a filtrar sem mutar: return [n for n, w in _workers.items() if w.is_alive()]. Resolve a core/server: aplicar a doutrina da #45 aos caminhos irmãos — captura perdida vira zumbi e falha de saída derruba a UI à toa #62 igual, porque _stop nunca é limpo e is_alive() é monotônico — direção morta jamais reaparece em running. Preserva a referência para /api/stop e para o existing.stop() do start, e devolve /api/status à condição de GET sem efeito colateral.
  • hello do app.js virou autoritativo nos dois sentidos, guardado por state.running.has(d).
  • Testes ajustados para afirmar a invariante certa (a referência sobrevive) + um novo cobrindo que consultar running não muta nada.

Rodada 2 — 2× APROVA, 1× VETO

  • Pipeline/Latência: APROVA. Sem vetor. Confirmou que _play só é chamado da própria thread de _run, então o _stop.set() no finally não pode truncar TTS em voo; que os _OutputSink têm _stop próprio, não afetado pelo do worker; que nenhuma constante de VAD/latência ou default de modelo foi tocada (nada a rebenchmarkar); e que o acúmulo de worker morto é limitado a 2 entradas por construção (api_start rejeita chave fora de falar/escutar e já dá pop na anterior) — sem vazamento de memória, thread ou device.
  • Privacidade/Robustez: APROVA. Sem vetor. Os três vetores da rodada 1 fechados; lifespan volta a iterar um _workers íntegro. Nada de rede, socket ou telemetria no diff — o payload do hello até encolhe. _lock segue não-reentrante e correto (só a thread do event loop o adquire; _broadcast não pega lock). O _stop no finally não quebra fallback CUDA→CPU porque a decisão inteira acontece em detect_device() antes do try.
  • Produto/UX: VETO.

O veto que sobrou — verificado por mim, não é especulação

O guard state.running.has(d) não é a condição certa: existe caminho real em que a direção está viva, o painel está verde, e state.running não contém a direção. Nesse estado o hello da reconexão fica mudo e o sintoma da #62 volta.

Confirmei linha a linha:

  • error.play é emitido em laguna_core.py:959 sem recoverable — o único recoverable=True do arquivo está em laguna_core.py:488, no laço de tradução.
  • Logo app.js:699-700 trata como terminal: pinta vermelho, desabilita Parar e faz state.running.delete(dir).
  • Mas o worker continua vivo: _close_stream() (laguna_core.py:185) força reabertura na próxima frase.
  • A próxima frase repinta o painel de verde pelo case 'status', e nada readiciona a direção a state.running — os únicos add são o start (app.js:491) e o próprio hello (app.js:628).

Dessincronizado assim, o WS cai, a captura morre fora do ar, o hello volta com running: [], o else iffalse e o painel permanece verde sobre direção morta.

Vale registrar: essa dessincronia já existe na main e a raiz dela é o item 2 da #62, fatiado para a #54 (error.play recuperável). O reparo não a criou — herdou. Mas enquanto ela existir, o guard escolhido não cobre esse caminho.

Segundo ponto da mesma lente, menor: o ramo que desliga o painel não zera os meters, ao contrário do que stopDirection faz (app.js:506-509). O usuário volta ao PC e lê "parado" com a barra de entrada congelada em ~40%.

Gate (HANDBOOK §6) — resultado real no 4e71a72

  • T1 verde: compileallCOMPILE_OK; import fase0_poc, laguna_core, laguna_serverIMPORTS_OK.
  • T2 verde (toca laguna_core.py): test_offline.py dry_pt2en.wav --direction pt2en --model small --device auto rodou em cuda/float16, out_gate.wav gerado. STT 986ms / MT 947ms / TTS 4744ms — worktree com cache já quente, mas são números de pipeline offline, não de latência de runtime; nenhuma constante de VAD/latência nem default de modelo foi tocada, então não se aplica benchmark antes/depois.
  • T3 verde (o reparo passou a tocar static/): node --check em app.js e i18n.jsJS_OK; paridade PT/EN pelo teste versionado, dentro da suíte.
  • Suíte unitária: 64 passed (63 da PR + 1 novo do reparo).

O que o @caioross precisa decidir

Os itens 1–3 (o _stop.set() na captura perdida, o _stop.set() no finally de _run e o is_alive()) passaram nas três lentes nas duas rodadas e são melhoria real. O item 4 (backend) foi reparado e aprovado. A pendência é só o guard de 2 linhas no app.js. Três saídas:

  1. Trocar o guard para olhar a tela, não o Setstate.running.has(d) || $role(p,'status').classList.contains('running'). Cobre a dessincronia do error.play e continua imune ao start otimista, que pinta a classe loading, não running (app.js:478). Somar o zeramento dos meters. É a correção que a lente propôs e que eu aplicaria.
  2. Mergear como está e abrir issue de follow-up: a PR já é melhoria líquida sobre a main nos outros três itens, e o furo restante é um caminho estreito que a audio: falha transitória em UM device de saída marca a direção como parada e desabilita o Stop — com o worker ainda rodando #54 elimina na raiz ao tornar error.play recuperável.
  3. Cortar o app.js da PR, mergear só backend + core, e tratar a UI inteira dentro da audio: falha transitória em UM device de saída marca a direção como parada e desabilita o Stop — com o worker ainda rodando #54.

Não mergeio por conta própria porque a §7.2 exige 3× APROVA e o veto é legítimo — mas nenhuma das três saídas encosta no núcleo irredutível da §7.1.

@caioross
caioross marked this pull request as draft August 2, 2026 19:29
@caioross caioross added the decisao-dono Espera decisão do @caioross — agentes não resolvem/mergeiam label Aug 2, 2026
@caioross

caioross commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Nota do PR Doctor (não muda o status desta PR — segue DRAFT + decisao-dono, é sua a decisão): o quórum adversarial da #64, mergeada hoje, chegou de forma independente ao mesmo buraco que esta PR ataca, e as três lentes o citaram sem combinar entre si.

  1. Terminal de saída não seta _stop_on_sink_error emite error.play e retorna; app.js derruba os botões e faz running.delete, e captura/passthrough seguem vivos. É o irmão exato do caminho que esta PR conserta em _capture (e o _stop no finally de _run, item 3, já cobriria a parte do running mentiroso).
  2. O hello do WS não carrega o último erro terminal por direção — apontado como buraco sistêmico, valendo igual para error.direction_lost. Depois que a fix(core): soluco de UM device de saida nao para mais a direcao — retry recuperavel antes do terminal (#54) #64 parou de re-emitir o terminal a cada frase (a trava por device que o quórum exigiu), um F5 pós-terminal repinta o painel de VERDE "Rodando"; antes, o cliente reconectado reaprendia o erro na frase seguinte. O _running_directions() desta PR resolve o caso do worker morto, mas não o do worker vivo com uma saída perdida.

Ou seja: o que falta decidir aqui ganhou um vizinho concreto. A pergunta de produto que trava as duas é a mesma — perder 1 de N saídas deve matar a direção inteira, ou ela segue degradada com aviso persistente? Enquanto isso não for decidido, _stop no caminho de saída seria chute; por isso a #64 não o tocou e esta PR continua esperando você.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

decisao-dono Espera decisão do @caioross — agentes não resolvem/mergeiam

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core/server: aplicar a doutrina da #45 aos caminhos irmãos — captura perdida vira zumbi e falha de saída derruba a UI à toa

1 participant