fix(list_files): replace O(n^2) parent-directory dedup with a set - #787
Conversation
`_list_files` synthesizes the intermediate directory entries that `rg --files` omits, and checked for duplicates by scanning the whole results list once per path component of every file. That made recursive listings O(n^2): the pure-Python loop holds the GIL, so on large trees the agent session freezes at 100% CPU with no output, no error, and no timeout (the existing 30s guard only bounds ripgrep enumeration, not this loop). Measured on synthetic trees: 1k files 0.11s, 5k 1.76s, 10k 7.01s, 20k 28.64s - runtime quadruples as the input doubles. A 560k-file home directory extrapolates to several hours, which reads as a permanent hang. Tracking the already-added directories in a set makes the check O(1) and the listing O(n). Output is unchanged - verified byte-identical against the previous implementation across several real and synthetic trees. Adds correctness tests for the parent-directory synthesis, which had no direct coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed this properly — checked out the branch into a clean worktree, built a venv, installed ripgrep, and verified the claims rather than just reading the diff. The bug is real and nasty. Verification I did myself:
One nuance, already correctly called out in the PR description: the old dedup scanned the whole Small, correct, well-tested, and unusually honest about its own edge cases (including why a timing-based regression test wasn't added. LGTM. |
PR mpfaffenberger#787 replaced the O(n^2) parent-directory dedup scan with a set, but the set was only populated inside the parent-synthesis branch. A directory can also reach the main append site directly if something on disk swaps a listed path for a directory between rg's enumeration and the os.path.isfile()/isdir() recheck (rg --files lists files only, so this never happens in normal operation). In that TOCTOU race, the main append site could add a directory entry seen_dir_paths didn't know about, so a sibling file synthesizing the same path as a parent (in either order) would duplicate it. The old O(n^2) scan happened to catch this because it checked all of results, not just synthesized parents. Record main-site directory entries in seen_dir_paths too, and skip if one is already there. Covered by two new deterministic tests that mock rg's output to force the race in both orderings; both fail on the pre-fix code with a duplicated directory line and pass after.
Summary
_list_filesde-duplicates synthesized parent-directory entries with a linear scan over theresults list, executed once per path component of every file. That makes recursive listings
O(n²).
On large trees the agent session freezes completely: one thread at 100% CPU, no output, no
error, and no timeout — the existing
subprocess.run(..., timeout=30)bounds only ripgrep'senumeration, not the Python loop that processes its output. Because the loop holds the GIL, the
asyncio loop, message bus, and renderer threads are all starved, so the TUI locks up rather than
showing a slow tool.
This replaces the scan with a set. Output is unchanged.
Reproducing
Any
list_filescall on a directory tree with tens of thousands of files after ignorefiltering.
list_filesdefaults torecursive=Trueand is typically the first tool an agentreaches for, so this usually lands on the first tool call of a session.
The severe case is launching code-puppy from a home directory that looks like a project.
_list_filesguards against recursing$HOME:but
is_project_directory()returns true if any ofpackage.json,pyproject.toml,Cargo.toml,.git,Makefile,requirements.txt,setup.py,go.mod,Gemfile,pom.xml,build.gradle,CMakeLists.txt, orcomposer.jsonis present. A single stray~/package.jsonfrom an accidentalnpm installdisables the guard. In the case that led tothis PR, a 53-byte
package.jsonexposed a 560,987-file recursive walk.The hang is not limited to the launch directory — the model supplies the
directoryargument,so it can wedge a session by listing a large path even when code-puppy was started somewhere
small.
py-spy dumpof a hung process (captured on 0.0.531, where this code sat at lines 278–279;it is unchanged on current
mainat lines 347–348, withlist_filescalling in at 1145):Root cause
code_puppy/tools/file_operations.py, in_list_files():rg --filesreturns files only, so the intermediate directory entries are synthesized here. Theduplicate check scans all of
results, which grows as the walk proceeds — O(k) for the k-thentry, repeated per path component. Total work is O(n²), with every comparison a Python-level
attribute read on a Pydantic model.
Measured scaling
Synthetic trees, before vs after (measured on stock 0.0.676/0.0.728; the function is unchanged
on current
main):Runtime quadruples as the file count doubles. Extrapolating from 20k, the 560,987-file case
needs several hours of CPU — indistinguishable from a permanent hang.
The fix
Track already-added directory paths in a
setand test membership against it: O(1) instead ofO(n), making the listing O(n) overall.
The set only needs the synthesized parents. In the recursive branch every entry originates from
rg --files, soentry_typeis always"file"there — I verified this rather than assuming it,and deliberately did not add a
seen_dir_pathsupdate at the main append site: it would beunreachable in practice (only a race between rg's enumeration and the later stat could produce a
directory there, with a one-line cosmetic duplicate as the worst outcome), and no test could
exercise it.
The non-recursive branch is untouched; it does not use this de-duplication path.
Tests
Adds
TestListFilesParentDirectorySynthesistotests/tools/test_file_operations_coverage.py— 5 tests usingtmp_path, following the existingconventions in that file (small hand-built trees, no timing assertions, milliseconds to run):
The de-duplication itself had no coverage before (an existing test covers that nested
directories appear in a recursive listing, but nothing exercised the duplicate check).
On what these tests do and don't do. They pin the behaviour of the parent-directory
synthesis, which is what this change puts at risk — the fix alters complexity, not output, so the
real hazard is silently changing a listing. They do not fail on the pre-fix code, because the
old and new implementations produce identical output. A guard against reintroducing the quadratic
scan would have to assert on complexity, which means either wall-clock timing (machine-dependent,
flaky in CI) or counting operations through white-box instrumentation. Both seemed worse than the
code comment for a repo whose test suite is deterministic throughout. Happy to add one if you'd
prefer.
Verification
same directories —
ListFileOutput.contentbyte-identical oncode_puppy/,tests/, the reporoot, and synthetic trees at 1k/5k/10k/20k files.
pexpect/pytest-asyncio; identical on unmodified HEAD.)ruff checkreports 27 pre-existing issues on these two files both before and after —none introduced.
ruff format --checkclean.