Summary
ShadowSnapshot (crates/core/src/snapshot/shadow.rs) can stall a session for minutes and burn tens of CPU-minutes when the working directory contains a large ignored tree (e.g. a Rust target/ dir). Observed: coven-code --print "..." run from src-rust/ in this repo produced no output for 4+ minutes while consuming ~39 CPU-minutes across the tokio worker pool; a sample trace put all the time under ShadowSnapshot in String/slice equality and tokio::fs::metadata.
Root cause (two compounding defects)
1. The repo-root .gitignore is invisible when cwd is a subdirectory.
The shadow repo's --work-tree is the session cwd. When that cwd is a subdirectory of the real repository (here src-rust/, while the src-rust/target/ rule lives in the repo root .gitignore), ls-files --others --exclude-standard in stage() never sees the rule — gitignore chains above the worktree root are not consulted, and sync_excludes() only copies the user-level exclude file. Result: stage() enumerates the entire target/ tree (hundreds of thousands of files).
2. Quadratic ignore intersection.
check_ignore_user() then pipes that list to git check-ignore --stdin -z (which correctly flags all of it against the real repo) but intersects the result with:
let raw: Vec<&str> = r.text.split('\0').filter(|s| !s.is_empty()).collect();
files.iter().copied().filter(|f| raw.contains(f)).collect()
raw.contains(f) is a linear scan per file — O(n*m) string comparisons. With n ~ m ~ 10^5+, that is ~10^10 compares per snapshot, which matches the hot frames in the sample (slice_contains, String::eq, Vec::partial_eq).
Impact
- Any session whose cwd has a large ignored tree (Rust
target/, node_modules/, build output) pays this on every turn: first token latency of minutes and sustained multi-core spin.
- Correctness is unaffected (ignored files do not leak into patches) — this is purely a performance defect.
Suggested fixes
- One-liner for the quadratic term: collect
raw into a HashSet<&str> before the intersection in check_ignore_user().
- Stop enumerating ignored trees at all: make the shadow repo honor the ignore rules the real repository would apply to the worktree — e.g. seed the shadow's
info/exclude from the real repo's effective ignore decisions for the worktree (or run the untracked-file enumeration against the real repo instead of the shadow when the cwd is inside one).
Fix 1 removes the CPU blowup; fix 2 removes the pointless tokio::fs::metadata crawl of ~10^5 files. Both are worth doing.
Repro
cd <coven-code checkout>/src-rust # large target/ present, ignored only by the parent .gitignore
env -u ANTHROPIC_API_KEY ./target/debug/coven-code --print "Reply with exactly one word: pong"
# stalls for minutes before the first token; sample(1) shows ShadowSnapshot hot
Found while smoke-testing fb989ab (claude CLI runtime); unrelated to that change.
Summary
ShadowSnapshot(crates/core/src/snapshot/shadow.rs) can stall a session for minutes and burn tens of CPU-minutes when the working directory contains a large ignored tree (e.g. a Rusttarget/dir). Observed:coven-code --print "..."run fromsrc-rust/in this repo produced no output for 4+ minutes while consuming ~39 CPU-minutes across the tokio worker pool; asampletrace put all the time underShadowSnapshotinString/slice equality andtokio::fs::metadata.Root cause (two compounding defects)
1. The repo-root
.gitignoreis invisible when cwd is a subdirectory.The shadow repo's
--work-treeis the session cwd. When that cwd is a subdirectory of the real repository (heresrc-rust/, while thesrc-rust/target/rule lives in the repo root.gitignore),ls-files --others --exclude-standardinstage()never sees the rule — gitignore chains above the worktree root are not consulted, andsync_excludes()only copies the user-level exclude file. Result:stage()enumerates the entiretarget/tree (hundreds of thousands of files).2. Quadratic ignore intersection.
check_ignore_user()then pipes that list togit check-ignore --stdin -z(which correctly flags all of it against the real repo) but intersects the result with:raw.contains(f)is a linear scan per file — O(n*m) string comparisons. With n ~ m ~ 10^5+, that is ~10^10 compares per snapshot, which matches the hot frames in the sample (slice_contains,String::eq,Vec::partial_eq).Impact
target/,node_modules/, build output) pays this on every turn: first token latency of minutes and sustained multi-core spin.Suggested fixes
rawinto aHashSet<&str>before the intersection incheck_ignore_user().info/excludefrom the real repo's effective ignore decisions for the worktree (or run the untracked-file enumeration against the real repo instead of the shadow when the cwd is inside one).Fix 1 removes the CPU blowup; fix 2 removes the pointless
tokio::fs::metadatacrawl of ~10^5 files. Both are worth doing.Repro
Found while smoke-testing fb989ab (claude CLI runtime); unrelated to that change.