Bound gitignore-matching concurrency to cap regex_automata Pool high-water (APP-5636) - #15571
Conversation
…water The per-thread scratch-cache Pool backing each shared, cached Gitignore matcher never shrinks: it retains one cache per peak-concurrent caller for the life of the process. Tree builds run on the shared multi-core background executor, and a repository's accumulated gitignore stack (one Arc<Gitignore> per nested .gitignore, kept alive for the life of the loaded repo) is shared across every concurrently-running build task, so matching concurrency against any one matcher scaled with the number of background worker threads and never came back down. Gate every gitignore match in the async tree-build path through a small process-wide semaphore (GITIGNORE_MATCH_CONCURRENCY_LIMIT = 4). Matching is a fast, CPU-only check relative to the directory I/O a build also does, so serializing just that step caps each matcher's retained Pool memory to a small constant without meaningfully slowing down the (I/O-bound) rest of a tree build. Also corrects the gitignore_cache module's doc comments, which claimed the source-byte LRU bound caps retained heap via an unrelated ratio; that bound only limits this cache's own map and re-parse churn, since callers commonly clone the returned Arc<Gitignore> into longer-lived lists that outlive the cache entry.
|
This PR was generated with Warp. Comment |
There was a problem hiding this comment.
Overview
Bounds gitignore-matching concurrency with a 4-permit semaphore to cap regex_automata pool retention on shared, long-lived Gitignore matchers. Review found the bound does not hold as described, and the remaining correction turns on a design decision that needs a human.
Concerns
- The semaphore does not bound a pool to 4 caches.
regex_automata0.4.9'sPoolkeeps one owner value plus 8 independently retained shard stacks indexedthread_id % 8, so a caller landing on an empty shard mints a fresh cache even when it is the only caller in flight — 8 sequential callers on distinct shards retain 9 caches at a peak concurrency of 1. With 4 permits the worst case is1 + Σ_shard min(4, callers), up to 33 per matcher, so the PR's4 × 10 MiB = 40 MiBbecomes 90–330 MiB and ~75–250 pathological nested matchers still reproduce the observed ~23.4 GB. - The watcher-storm path bypasses the new gate entirely.
handle_watcher_eventspawnscompute_file_tree_mutations(local_model.rs:641,1539,1819), which callspath_is_ignored/matches_gitignoreson every added or moved path against the same long-livedstate.gitignoresbefore ever reachingEntry::build_tree*;should_watch_repo_directory/repo_watch_filteris synchronous and also ungated. Both populate the same pools across background workers with no permit, so the incident's actual route stays open. Routing every access to the shared matcher stack through one bounded mechanism is the correction. N_matchersis the term that is still unbounded.evaluate_entryaccumulates every nested.gitignorein a repo into theVec<Arc<Gitignore>>that becomesFileTreeState.gitignores, held for the life of the loaded repo;gitignore_cache's 384 KiB LRU bounds only its own map and cannot release thoseArcclones. Total retention staysN_matchers × caches_per_pool × cache_sizeeven with a correct per-matcher cap.
Decision needed: whether APP-5636's bar is a per-matcher cap or a total per-repo/process retention guarantee. If it must prevent the 23 GB class, the fix needs a fixed-worker execution strategy (an async permit released across .await cannot pin work to a bounded thread set) plus a matcher-lifetime policy, and 4 needs a latency budget behind it — a process-wide limit of 4 also queues unrelated repos behind one pathological match. Holding here rather than guessing that tradeoff.
Verdict
Checks: build pass (crate + feature matrix; full binary not built), tests pass (142/142 repo_metadata), CI green (CodeQL/CLA only), visual proof n/a
Found: 2 critical, 1 important, 0 suggestions, 0 nits, 1 question
Responding as wilson: Open session · View in factory
| || is_git_internal_path(curr_path) | ||
| || matches_gitignores( | ||
| let path_is_ignored = ancestor_is_ignored || is_git_internal_path(curr_path) || { | ||
| let _permit = GITIGNORE_MATCH_SEMAPHORE |
There was a problem hiding this comment.
critical — This permit caps simultaneous callers, but the pool it is meant to bound retains per shard: get_slow pops only from stacks[thread_id % 8], so a caller hashing to a shard another caller filled mints a fresh cache regardless of how few are in flight. Retained caches per matcher are 1 + Σ_shard min(4, callers) — up to 33, not 4 — so this does not establish the high-water the doc comment claims. Bounding the thread set that touches each matcher (or shortening matcher lifetime) is what actually caps it; an async permit held across .await resumes on arbitrary Tokio workers and cannot.
| /// CPU-only check relative to the directory reads a build also does, so | ||
| /// serializing it to a small constant bounds that retained memory per matcher | ||
| /// without meaningfully slowing down the (I/O-bound) rest of the build. | ||
| pub(crate) const GITIGNORE_MATCH_CONCURRENCY_LIMIT: usize = 4; |
There was a problem hiding this comment.
question — 4 has no measured latency or memory budget behind it, and as a process-wide limit it serializes unrelated repositories behind a single pathological ignore match. Recompute the target from the real owner-plus-8-shard model and state the per-matcher and per-repo budget the constant is chosen to satisfy.
|
|
||
| const WORKERS: usize = super::GITIGNORE_MATCH_CONCURRENCY_LIMIT * 4; | ||
|
|
||
| let semaphore = Arc::new(Semaphore::new(super::GITIGNORE_MATCH_CONCURRENCY_LIMIT)); |
There was a problem hiding this comment.
important — This builds a fresh Semaphore and never calls evaluate_entry, GITIGNORE_MATCH_SEMAPHORE, or a Gitignore, so it asserts Tokio's semaphore contract rather than this crate's behavior and passes unchanged if the acquire in evaluate_entry is deleted. It cannot fail before the fix, so it protects nothing and masks the sharding gap above. Exercise the real tree/update path with distinct worker identities against a long-lived shared matcher so removing the production bound fails the test.


Summary
Bounds an unbounded live-memory retention bug in
repo_metadata's gitignore matching (APP-5636, sub-issue of APP-4828). Sentry event 5d09c712 showedtotal_footprint ≈ 23.4 GBretained (resident ≈ 1.04 GB,warp.application_stage = Inactive) on a post-#15240 build, i.e. the existingArc<Gitignore>caching fix (#15240) is present and did not stop this.Corrected mechanism
The originally-reported cause (regex-automata's
Pool"retains one cache per OS thread that ever called.matched()") is not what the pinned source does. Verified againstregex-automata0.4.9src/util/pool.rs:PoolisMAX_POOL_STACKS = 8sharded stacks indexed bythread_id % 8— not a per-thread map, and it doesn't usethread_local.PoolGuard::dropreturns the cache to its shard; a contendedget_slowmay return adiscard: truetransient guard whose cache is thrown away, not retained.So retained bytes ≈ (peak concurrent callers whose checkout succeeded) × (per-
meta::Cachesize), held for the life of the matcher, for every distinct matcher.The two multipliers, and a third one triage didn't have
ctx.spawnon Warp's sharedBackgroundexecutor — atokiomulti-thread runtime withworker_threads = num_cpus::get()(crates/warpui_core/src/async/native/executor.rs), notspawn_blocking's much larger blocking pool. So the natural ceiling here is core count (commonly 8–24, not "hundreds").Cachesize.globsetcompiles its combined glob-set regex withhybrid_cache_capacity(10 * (1<<20))(globset-0.4.18/src/lib.rs), i.e. eachregex_automata::meta::Cachecan grow up to ~10 MiB via its lazy-DFA sub-cache alone.gitignore_cache.rsbound).Entry::evaluate_entryaccumulates every.gitignorefound anywhere in a repo's tree (not just direct ancestors) intogitignores: &mut Vec<Arc<Gitignore>>, and thatVecbecomesFileTreeState.gitignores— held for the entire lifetime of the loaded repository (file_tree_store.rs).gitignore_cache.rs's 384 KiB source-byte LRU only bounds its own map; it does not drop these long-livedArcclones, so a large monorepo with hundreds/thousands of nested.gitignorefiles keeps that many independentPools alive indefinitely.Arithmetic:
N_matchers × peak_concurrency × per_Cache_size. A single matcher's worst case (num_cpus × 10 MiB, e.g.16 × 10 MiB ≈ 160 MB) doesn't reach 23 GB alone — but multiplied across a few hundred distinct nested-package.gitignorematchers in a large monorepo, it plausibly does. Both multipliers matter; the matcher-fan-out one was not in the original bound at all.Option chosen, and why
Chosen: bound matching concurrency (
GITIGNORE_MATCH_CONCURRENCY_LIMIT = 4,crates/repo_metadata/src/entry.rs).evaluate_entryis nowasyncand acquires a permit from a process-widetokio::sync::Semaphorearound thematches_gitignorescall only — not around directory I/O. This converts the per-matcher high-water from "unbounded / scales with core count" to a small fixed constant, for every matcher (not just one), directly addressing multiplier 3 as well as 1: no matter how many distinct.gitignores a repo accumulates, none of their pools can exceed 4 concurrently-checked-out caches.Latency reasoning for the constant: matching is a fast, CPU-only regex check; the slow part of a tree build is directory I/O (
async_fs::read_dir), which stays fully parallel across all worker threads. Serializing only the matching step to 4 concurrent holders (not 1) still allows real overlap during watcher storms while capping worst-case retained memory per matcher to4 × ~10 MiB ≈ 40 MB.Rejected:
create_cache()injection: confirmed unavailable — neitherglobset0.4.18 norignore0.4.24 expose the pool or accept an externally-owned cache.should_watch_repo_directory/repo_watch_filter): left untouched. It's invoked synchronously by thenotify-based watcher outside the async tree-build path (not spawned viactx.spawn), so wiring it into the sametokio::sync::Semaphorewould require restructuring theWatchFilterintegration to be async — out of scope for this fix.matches_gitignoresthere is called against just the root + global gitignores (gitignores_for_directory), a much smaller fan-out than the full per-repo accumulated stack.Also fixed the
gitignore_cache.rsmodule comment, which incorrectly claimed the 384 KiB source-byte LRU bound caps retained heap to "~60 MiB" via a "~163x" ratio — that reasoning ignored both multipliers above and, per finding 3, doesn't hold once aGitignoreis cloned intostate.gitignores. The bound is kept (it still limits this cache's own re-parse churn) with corrected documentation.Changes
crates/repo_metadata/src/entry.rs:evaluate_entryis nowasync; gitignore matches acquire a permit from a new process-wideGITIGNORE_MATCH_SEMAPHORE(GITIGNORE_MATCH_CONCURRENCY_LIMIT = 4) before callingmatches_gitignores.crates/repo_metadata/src/gitignore_cache.rs: corrected doc comments describing what the source-byte bound does and does not achieve.crates/repo_metadata/Cargo.toml: addedtokio(syncfeature only, which is wasm-safe) as a direct dependency.Verification
regex-automata0.4.9 (Pool,meta::Cache),globset0.4.18, andignore0.4.24 sources directly (see mechanism section above), plus therepo_metadatacall graph (entry.rs,local_model.rs,file_tree_store.rs,gitignore_cache.rs,crates/warpui_core/src/async/native/executor.rs).gitignore_match_semaphore_caps_concurrent_holdersinentry_tests.rs) that exercises the exact concurrency mechanism: it drivesGITIGNORE_MATCH_CONCURRENCY_LIMIT * 4concurrent workers against a semaphore of that size and asserts the observed concurrent-holder count reaches exactly the limit — never more (the semaphore's own contract) and never less (proving the bound isn't accidentally over-serialized to 1). Asserting the concurrency bound this way is more durable than assertingregex_automata's private, implementation-defined retained-byte counts.cargo test -p repo_metadata --features local_fs,test-util --lib: 142 passed, 0 failed (2 pre-existing, unrelated#[ignore]d flaky tests untouched).cargo check -p repo_metadata(default features, i.e. withoutlocal_fs, matching the non-local-filesystem/wasm build) and--features local_fs,test-util: both clean.cargo clippy -p repo_metadata --all-targets -- -D warnings(with and withoutlocal_fs,test-util): clean../script/format: clean (no diff).warp/appbinary (GUI/GPU deps make this expensive); relied on the crate-level checks above plus feature-matrix checks (local_fson/off) matching howapp/Cargo.tomlgatesrepo_metadata/local_fsto non-wasm builds.Left out of scope
should_watch_repo_directory) is not gated by the new semaphore — see rationale above.Linear: APP-5636 (parent: APP-4828)