Estava terminando o release 0.3.8 do NAVI quando notei que tinha um processo navi rodando com CPU alta. Nada de outro mundo — processo headless que não terminou, provavelmente alguma task presa. Fui investigar.
O diagnóstico
btop mostrou duas threads em ~70% de CPU cada. ps confirmou:
| |
Duas tokio-rt-worker threads com mais de 10 horas de CPU acumulada. A main thread estava dormindo (S). Essas duas estavam correndo (R) sem parar.
lsof mostrou dezenas de file descriptors abertos para o mesmo path:
| |
umu-default é um prefixo Wine/Proton. Dentro dele tem um symlink que é padrão dessa estrutura:
| |
pfx aponta para o próprio diretório. Então pfx/pfx é o mesmo diretório. pfx/pfx/pfx também. E assim infinitamente.
O bug
O NAVI estava rodando headless (navi --no-tui). O modelo tinha chamado search (list ou find) num path que incluía esse prefixo. O directory traversal era o código padrão que você encontra em qualquer lugar:
| |
O detalhe que mata: path.is_dir() resolve symlinks. Quando encontrou pfx, viu um diretório, entrou, e lá dentro tinha pfx de novo.
| |
A função nunca parava. tokio::task::spawn_blocking nunca retornava. O runtime do Tokio não shutdowna enquanto threads do blocking pool não terminam. Processo virou zombie.
O mesmo padrão existia em três lugares:
search_tool.rs:collect_files_recursive(list/find),collect_matches(grep),build_tree(tree)repo_intelligence.rs:collect_source_files(indexação do repositório)
Reproduzir antes de consertar
Em vez de sair aplicando if, escrevi testes que reproduziam o cenário exato:
| |
Antes do fix, devolveu 42 entradas. Todas a.txt, mas com paths tipo pfx/pfx/pfx/.../a.txt. O loop estava confirmado.
Para repo_intelligence (build_index), o teste nem retornou. Timeout. O mesmo symlink derrubava a indexação inteira.
O fix
A regra é simples: em recursive traversal, use DirEntry::file_type() em vez de path.is_dir(). file_type() não resolve symlinks — te diz o tipo real da entrada:
| |
A ação tree agora renderiza symlinks como folhas com type: "symlink" e children: 0, em vez de descer. Também coloquei limites de profundidade (100 níveis) em search e repo_intelligence — não é o fix principal, mas é cinto de segurança para árvores genuinamente profundas.
Depois do fix:
| |
Commit: fix(core): prevent infinite directory traversal on symlink cycles.
Por que isso é fácil de errar
Path::is_dir() e Path::is_file() seguem symlinks. A documentação diz isso, mas é o tipo de coisa que você não pensa quando escreve um recursive traversal pela enésima vez. O código funciona para 99% dos diretórios. O problema só aparece quando alguém tem um prefixo Wine/Proton no path — e pfx -> . é um symlink que o Wine cria por design.
spawn_blocking complica porque uma task presa prende o processo inteiro. A main thread pode ter terminado, mas se uma blocking thread estiver em loop infinito, o runtime do Tokio não desliga. Você fecha a TUI, fecha o terminal, e o processo continua lá. Zombie.
Se você escreve ferramentas que andam pelo filesystem, vale revisar seus loops. is_dir() segue symlinks. file_type() não. A diferença parece pequena até alguém ter um prefixo Wine no path de busca.
See you in the Wired.