Travado no boot: dois autodeadlocks no Hermes e como foram encontrados


Você digita hermes gateway restart e espera. Três segundos, dez, trinta. O terminal te devolve uma linha: Gateway starting: deadline. O app desktop reporta Gateway inaccessible: incomplete_control_response, você aperta retry e recebe a mesma frase. Reinicia a máquina, o serviço volta, e trava exatamente no mesmo ponto — restart atrás de restart, sem nunca subir de fato.

O primeiro instinto é “meu config deve estar quebrado”, “o disco deve estar cheio”, “esse build está ruim”. Existe um modo de falha mais difícil que todos esses: o processo não crashou, a CPU está ociosa, o log não tem erro — ele simplesmente espera por um lock que ele mesmo já segura. É um autodeadlock, e ele se apresenta como “travado”, não como “falhou”.

Por volta de 2026-10-30 o Hermes corrigiu dois deadlocks de boot desse tipo: um na varredura de inicialização do gateway, outro no caminho de boot do backend de config remoto. Eles compartilham a lição que vale guardar: os dois chamavam, de dentro de uma seção crítica que já segurava o lock, algo que precisava do mesmo lock.

Primeiro, separe “lento” de “morto”

Os dois se parecem e pedem respostas opostas, então aqui vai a comparação que vale ter à mão:

Sintoma Lento (espere) Deadlock (esperar não resolve)
Duração do boot termina sozinho em dezenas de segundos nunca passa do mesmo ponto
CPU ocupada, inicializando perto de zero, thread dormindo
Log continua imprimindo linhas novas para seco na última linha
Restart costuma dar certo na segunda vez trava no lugar idêntico toda vez
Estado da thread esperando IO ou rede esperando um lock (futex, aquisição de lock)

O gateway roda um watchdog de vivacidade do loop que despeja a stack atual e sai à força com status 75 quando o event loop trava, para que systemd ou launchd o reiniciem. Esse comportamento está certo — um processo reiniciado é melhor que um congelado —, mas ele disfarça um deadlock como uma sequência de “o gateway caiu de novo”. O que você vê no log é boot após boot, nunca um erro claro.

Deadlock um: um contexto de leitura aninhado dentro da varredura de inicialização

O primeiro caso é a varredura de órfãos da inicialização do gateway, que encerra sessões deixadas por um processo anterior. A corrente é recover_local_sessions() → arm_startup_orphan_closes() → _idle_s(), que decide há quanto tempo uma sessão está ociosa.

O código culpado era assim (simplificado):

with authority.db._read_ctx() as conn:
    row = conn.execute(
        f'SELECT {_sql_session_last_active("s")} FROM sessions s WHERE s.id = ?',
        (_physical_target(authority, session_id),),   # ← abre um SEGUNDO contexto de leitura
    ).fetchone()

O problema está no argumento: _physical_target() lê o recibo local da sessão, e ler o recibo é, por si só, uma leitura de banco. Então, dentro de um bloco que já entrou num contexto de leitura, compor um parâmetro entra num segundo.

Normalmente isso é inofensivo, porque as leituras passam pelo pool de leitura WAL e rodam em paralelo. Mas quando o pool de leitura WAL está indisponível — o banco não está em modo WAL (journal DELETE), a home fica num filesystem de rede como NFS, ou todas as permissões de leitura estão gastas —, o _read_ctx() degrada para “use a conexão de escrita sob SessionDB._lock”. Esse lock é um threading.Lock puro: não é reentrante.

Então uma thread pega o lock e, de dentro dele, pede o mesmo lock, e espera para sempre.

A correção é conceitual, não genial: calcule o alvo primeiro e só depois abra o contexto de leitura, para que a sondagem use exatamente um.

target = _physical_target(authority, session_id)
with authority.db._read_ctx() as conn:
    row = conn.execute(..., (target,)).fetchone()

A evidência em produção é de manual. Num gateway macOS, a thread de boot parou exatamente ali, o socket de controle nunca respondeu, os clientes desktop reportaram Gateway inaccessible: incomplete_control_response e Gateway starting: deadline, o watchdog despejou aquela stack e saiu com 75, o launchd reiniciou, e tudo se repetiu. Isso é um loop de boot, não uma inicialização lenta.

Vale dizer: o caminho de leitura degradado está correto como projetado — uma leitura mais lenta é melhor do que ficar sem file descriptors. O erro era aninhar uma segunda leitura dentro dele.

Deadlock dois: ler config antes de a config poder ser lida

O segundo caso é mais sutil e mais valioso, porque é uma inversão de ordem de locks entre módulos.

Um agente Cloud com o backend de config definido como remoto (HERMES_CONFIG_BACKEND=remote) nunca sobe: a primeira leitura de config no segundo estágio da inicialização pendura para sempre. Só no modo de credencial Nous; o modo IdP não é afetado.

A corrente:

  1. A primeira leitura de config de um perfil pega o fetch lock daquele perfil e então roda o fetch de boot.
  2. O fetch resolve o seu bearer — o access token da Nous —, o que importa a frio o hermes_cli.auth.
  3. O hermes_cli.auth importa o hermes_cli.config em tempo de import, e esse módulo preenchia OPTIONAL_ENV_VARS (a metadata de “qual variável de ambiente este provider precisa”) a partir dos perfis de provider.
  4. Listar providers roda a descoberta de providers, e a descoberta lê config (plugins.enabled, plugins.isolation).
  5. Essa leitura pede ao backend o mesmo perfil e espera o fetch lock do passo 1 — segurado pela própria thread que está fazendo tudo isso.

Numa frase: para ler config, era preciso ler config. Isso não é reentrância simples; é um módulo fazendo IO com efeito colateral em tempo de import, e depois sendo importado por outro módulo que já segura um lock.

A correção remove a leitura de config em tempo de import em vez de contorná-la:

  • Importar hermes_cli.config ou hermes_cli.auth não lê config. O catálogo de variáveis de ambiente dos providers é montado na primeira leitura: cada plugin de provider embutido é executado num módulo privado, seus registros são coletados — sem config, sem descoberta, sem mutação do registro —, e o resultado é cacheado de forma idempotente.
  • O backend remoto resolve a credencial antes de pegar o fetch lock, e segura esse lock só durante o GET; leitores concorrentes fazem fila ali e compartilham o resultado.
  • Nada é logado enquanto um lock de backend está preso. Um handler de log pode ler config, então as mensagens são coletadas e emitidas depois que o lock é liberado.
  • O formatador de log não lê config. O RedactingFormatter consultava a política de redação do perfil roteado a cada registro, sob o lock do próprio handler de logging. Agora ele lê apenas o escopo de segredos vivo e a política registrada quando o .env ou o config daquele perfil foi carregado; desconhecido significa redigir.

Esse último é, silenciosamente, um ganho de segurança: “não foi possível determinar a política” e “a política é não redigir” eram fáceis de confundir, e agora o padrão é o seguro.

Como confirmar que é um deadlock

Não adivinhe. Um dump de stack vale mais que dez leituras de log:

  1. Ache o processo: hermes gateway status lista os serviços e processos em execução.
  2. Tire um snapshot com py-spy (um profiler de terceiros, pip install py-spy): py-spy dump --pid $GATEWAY_PID.
  3. Olhe onde a thread de boot está parada. Os frames do topo de um deadlock são aquisição de lock (threading.Lock.acquire, futex, _read_ctx, with self._lock), não IO de rede ou de disco.
  4. Se a stack da mesma thread mostra o dono do lock e o esperador — um bloco with cujo corpo chama algo que pega o mesmo lock —, você tem sua resposta.

Há também um teste bem prático: tente outra home. Suba HERMES_HOME=/tmp/hermes-probe hermes gateway contra uma home vazia. Se ele sobe na hora, o código está bem e o problema é o estado ou o filesystem da sua home — que é justamente o gatilho do caso um (sem WAL, ou uma home em NFS).

O que fazer quando você esbarrar nisso

  • Caso um (autolock na varredura de inicialização): o gatilho é uma home cujo pool de leitura WAL está indisponível. Se você está preso a um build sem a correção, reiniciar não ajuda — ele trava igual toda vez. A mitigação prática é manter o HERMES_HOME num disco local em modo WAL, e não em NFS ou outro compartilhamento de rede.
  • Caso dois (deadlock de boot com config remoto): o backend remoto não tem fallback local, então voltar o HERMES_CONFIG_BACKEND para file só funciona se aquele perfil ainda tiver um config.yaml local utilizável. Sem ele, a mudança só produz um erro diferente. A resposta real é atualizar para um build com a correção.
  • As duas correções estão na main (#144845 é P0, #146130 é P2) e já estão na v0.22.6 (2026-11-02); se você está no v0.22.5 ou anterior, atualizar resolve.

O que isso ensina a quem escreve plugins

Se você escreve plugins ou backends de config para o Hermes, esses dois casos são bons modelos:

  • Não leia config, não faça chamadas de rede e não mute registros globais em tempo de import. Você nunca sabe quem está segurando um lock enquanto te importa.
  • Não faça IO nem logging segurando um lock. Um handler de log é a coisa que lê config mais fácil de esquecer.
  • Prepare os argumentos antes de entrar numa seção crítica. Foi exatamente assim que o caso um morreu: compor um parâmetro reentrou na mesma seção.
  • É nos caminhos degradados que esses bugs moram. O caminho rápido era concorrente, então o bug só aparecia quando o pool estava indisponível — e é por isso que ele sobreviveu tanto tempo em produção.

Para os modos de falha vizinhos, leia as oito razões pelas quais o event loop do gateway congela e como ajustar os limiares do watchdog do loop. Junto com este texto, eles cobrem boa parte do que “o gateway parou de responder” pode significar.