Saída que desaparece: depois de 1024 arquivos abertos, chamadas de terminal do Hermes começam a voltar vazias


Sexta à tarde: o agente avisa que a suíte de testes terminou. Você pede para ele colar a saída e recebe duas aspas vazias — {"output": "", "exit_code": 0, "error": null}. Pede um read_file num config de 5 KB e vem file_size: 0. Você mesmo roda cat no arquivo e está tudo certo. Curiosamente, rodar o mesmo Python via execute_code lê o conteúdo sem problema nenhum. É como se só alguns canais tivessem ficado cegos.

O modelo não está te enrolando e o disco está bem. Foi um bug que ficou aberto por quase dois meses e foi fechado em 21 de outubro de 2026 (issue #94928, PR #142299), e a causa raiz é uma linha: quando um processo passa de 1024 file descriptors, a chamada select() que espera pela saída levanta uma exceção — e a exceção foi tratada como “o pipe está fechado”.

O sintoma: saída vazia, exit code de sucesso

O relato original é preciso. No Linux, depois de o Hermes Desktop ficar um tempo no ar, terminal retornava stdout vazio para todo comando, reportando exit code 0. ls e stat vinham vazios; read_file devolvia file_size: 0 para um arquivo de 5092 bytes; Python dentro de execute_code lia o mesmo caminho corretamente.

Por que saída vazia em vez de erro? Porque a thread leitora desistiu antes de ler qualquer coisa: decidiu que o pipe estava fechado e descartou o que ainda não tinha chegado, enquanto o comando rodava até o fim e retornava 0. Do lado de quem chama, parece uma chamada bem-sucedida que simplesmente não tinha nada a dizer.

O relato traz os números que explicam: o main do Electron segurava 1438 fds, dois processos hermes serve seguravam 1429 e 1410, e um filho git-remote-https segurava 1384. Todos acima da linha.

Causa raiz: o teto de 1024 no select()

O select() do POSIX representa conjuntos de fd como um bitmap de tamanho fixo, cujo comprimento é FD_SETSIZE, normalmente compilado como 1024. Entregue a ele um descritor de 1024 ou mais e o Python levanta:

ValueError: filedescriptor out of range in select()

O Hermes lê a saída de comandos em três lugares — o drain em foreground (_drain_fd_select em tools/environments/base_output.py), o leitor de processos em background (_reader_loop em tools/process_registry.py) e o PtyBridge por trás do painel de terminal do Desktop. Os três esperavam em select.select(), e os três tratavam aquele ValueError como “o fd já está fechado”. É por isso que a mesma saída perdida aparecia em comandos em foreground, em jobs de background e no painel de terminal do desktop.

Você não precisa do Hermes para reproduzir: abra 1100 handles para /dev/null, crie um pipe e chame select.select() no novo descritor. A exceção chega na hora.

A correção: trocar select(), mas não por poll()

O patch adiciona tools/environments/fd_wait.py, um FdWaiter pequeno construído sobre selectors.DefaultSelector — epoll no Linux, kqueue no macOS, nenhum dos dois com teto de FD_SETSIZE.

class FdWaiter:
    """Readability wait on one fd. Owns a selector (epoll/kqueue are fds themselves):
    create it once per reader lifetime and close() it when the reader is done."""

    def __init__(self, fd: int, selector_cls):
        self._selector = selector_cls()
        try:
            self._selector.register(fd, selectors.EVENT_READ)
        except BaseException:
            self._selector.close()
            raise

    def wait(self, timeout_s: float) -> bool:
        return bool(self._selector.select(max(timeout_s, 0)))

Uma escolha contraintuitiva: poll() também não tem limite de 1024, então por que não usá-lo? Porque o poll() do macOS não suporta fd de dispositivo — ele reporta um master PTY como POLLNVAL, ou seja, “pronto agora mesmo”. Um loop de leitura de PTY baseado em poll() giraria em velocidade máxima sempre que estivesse ocioso, queimando um core. Por isso poll() sobrevive só como fallback onde nada mais é possível: ao contrário de epoll e kqueue, ele não precisa de um fd próprio, então continua funcionando sob EMFILE; no macOS, para dispositivo de caractere (PTY), o código levanta erro em vez de aceitar uma espera que nunca espera.

O patch também fecha duas beiradas: falha ao montar o selector nunca pode ser reportada como EOF, e cada leitor tem um único waiter pela sua vida útil e o fecha ao terminar, para que a correção do vazamento antigo não crie um novo.

Como saber se você foi afetado

Uma tabela de sintomas vale mais do que garimpar log: o comando realmente rodou (os efeitos colaterais estão lá), mas output é string vazia, exit_code é 0 e error é null; read_file reporta 0 bytes para um arquivo que não está vazio; execute_code lê o mesmo arquivo sem problema. Os três juntos e você pode parar de adivinhar.

Contar descritores confirma. No Linux:

for p in $(pgrep -f "hermes_cli.main serve"); do
  echo "$p: $(ls /proc/$p/fd | wc -l) fds"
done

No macOS:

lsof -p $(pgrep -f "hermes_cli.main serve" | head -1) | wc -l

ulimit -n é só contexto — o macOS costuma começar em 256, o Linux em 1024 — porque a linha que importa é FD_SETSIZE, não o limite do shell.

Num build antigo o paliativo é grosseiro: reinicie o gateway (e o Desktop), derrube de novo a contagem de fds e a saída volta na hora. E nunca leia “saída vazia com exit 0” como “o comando não imprimiu nada”; um resultado vazio de aparência plausível é a coisa mais fácil de escrever numa conclusão.

Serviços de vida longa acumulam descritores independentemente do que você rode dentro deles, então isso não é sobre containers nem sobre a configuração de container Docker compartilhado — é sobre tempo de uptime do processo e número de conexões. Para terreno operacional vizinho, veja recuperação de processos órfãos e o guia do diretório temporário do terminal; os comandos de gateway estão na referência de hermes gateway.

Já dá para usar hoje?

O patch entrou na main em 21 de outubro de 2026 e ainda não está em release (a última versão publicada, v0.22.2, é anterior). Ele sai no próximo patch. Para ver funcionando agora, segure cerca de 1100 descritores num processo e rode echo: no código antigo a saída desaparece, no novo ela volta.

Resumo rápido

Três coisas para guardar. Saída vazia com exit 0 significa saída descartada, não saída vazia. A causa é o teto de 1024 descritores do select(), não memória, disco ou permissões. A correção usa um selector sem teto e evita de propósito o poll() do macOS, que giraria em falso. Quando um problema só afeta instâncias de vida longa, conte file descriptors antes de ler logs.