Skip to content

Bound gitignore-matching concurrency to cap regex_automata Pool high-water (APP-5636) - #15571

Draft
warp-agent-staging[bot] wants to merge 1 commit into
masterfrom
factory/app-5636-bound-gitignore-pool-concurrency
Draft

Bound gitignore-matching concurrency to cap regex_automata Pool high-water (APP-5636)#15571
warp-agent-staging[bot] wants to merge 1 commit into
masterfrom
factory/app-5636-bound-gitignore-pool-concurrency

Conversation

@warp-agent-staging

Copy link
Copy Markdown
Contributor

Summary

Bounds an unbounded live-memory retention bug in repo_metadata's gitignore matching (APP-5636, sub-issue of APP-4828). Sentry event 5d09c712 showed total_footprint ≈ 23.4 GB retained (resident ≈ 1.04 GB, warp.application_stage = Inactive) on a post-#15240 build, i.e. the existing Arc<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 against regex-automata 0.4.9 src/util/pool.rs:

  • Pool is MAX_POOL_STACKS = 8 sharded stacks indexed by thread_id % 8 — not a per-thread map, and it doesn't use thread_local.
  • PoolGuard::drop returns the cache to its shard; a contended get_slow may return a discard: true transient guard whose cache is thrown away, not retained.
  • The crate's own comments say the design intentionally scales with peak concurrent callers, not the number of threads that ever existed — and the stacks never shrink.

So retained bytes ≈ (peak concurrent callers whose checkout succeeded) × (per-meta::Cache size), held for the life of the matcher, for every distinct matcher.

The two multipliers, and a third one triage didn't have

  1. Peak concurrency per matcher. Tree builds run via ctx.spawn on Warp's shared Background executor — a tokio multi-thread runtime with worker_threads = num_cpus::get() (crates/warpui_core/src/async/native/executor.rs), not spawn_blocking's much larger blocking pool. So the natural ceiling here is core count (commonly 8–24, not "hundreds").
  2. Per-Cache size. globset compiles its combined glob-set regex with hybrid_cache_capacity(10 * (1<<20)) (globset-0.4.18/src/lib.rs), i.e. each regex_automata::meta::Cache can grow up to ~10 MiB via its lazy-DFA sub-cache alone.
  3. Matcher fan-out (missed by the existing gitignore_cache.rs bound). Entry::evaluate_entry accumulates every .gitignore found anywhere in a repo's tree (not just direct ancestors) into gitignores: &mut Vec<Arc<Gitignore>>, and that Vec becomes FileTreeState.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-lived Arc clones, so a large monorepo with hundreds/thousands of nested .gitignore files keeps that many independent Pools 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 .gitignore matchers 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_entry is now async and acquires a permit from a process-wide tokio::sync::Semaphore around the matches_gitignores call 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 to 4 × ~10 MiB ≈ 40 MB.

Rejected:

  • TTL/recycling of cached matchers (drop + re-parse periodically): would only reduce the already-bounded per-matcher floor further, at the cost of re-parse overhead and complexity. Not needed to satisfy "bounded" — omitted.
  • create_cache() injection: confirmed unavailable — neither globset 0.4.18 nor ignore 0.4.24 expose the pool or accept an externally-owned cache.
  • Bounding the watcher's own descend-filter (should_watch_repo_directory / repo_watch_filter): left untouched. It's invoked synchronously by the notify-based watcher outside the async tree-build path (not spawned via ctx.spawn), so wiring it into the same tokio::sync::Semaphore would require restructuring the WatchFilter integration to be async — out of scope for this fix. matches_gitignores there 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.rs module 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 a Gitignore is cloned into state.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_entry is now async; gitignore matches acquire a permit from a new process-wide GITIGNORE_MATCH_SEMAPHORE (GITIGNORE_MATCH_CONCURRENCY_LIMIT = 4) before calling matches_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: added tokio (sync feature only, which is wasm-safe) as a direct dependency.

Verification

  • Root-caused via reading the pinned regex-automata 0.4.9 (Pool, meta::Cache), globset 0.4.18, and ignore 0.4.24 sources directly (see mechanism section above), plus the repo_metadata call graph (entry.rs, local_model.rs, file_tree_store.rs, gitignore_cache.rs, crates/warpui_core/src/async/native/executor.rs).
  • Added a regression test (gitignore_match_semaphore_caps_concurrent_holders in entry_tests.rs) that exercises the exact concurrency mechanism: it drives GITIGNORE_MATCH_CONCURRENCY_LIMIT * 4 concurrent 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 asserting regex_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. without local_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 without local_fs,test-util): clean.
  • ./script/format: clean (no diff).
  • Not run: a full build of the warp/app binary (GUI/GPU deps make this expensive); relied on the crate-level checks above plus feature-matrix checks (local_fs on/off) matching how app/Cargo.toml gates repo_metadata/local_fs to non-wasm builds.

Left out of scope

  • The filesystem watcher's own descend-filter call path (should_watch_repo_directory) is not gated by the new semaphore — see rationale above.
  • No visual/UI verification: this is an internal memory-retention fix with no user-facing surface.

Linear: APP-5636 (parent: APP-4828)

…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.
@cla-bot cla-bot Bot added the cla-signed label Aug 26, 2026
@warp-agent-staging warp-agent-staging Bot added the area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. label Aug 26, 2026
@warp-agent-staging
warp-agent-staging Bot requested a review from acarl005 August 26, 2026 15:51
@warp-agent-staging

warp-agent-staging Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

This PR was generated with Warp.

Comment @warp-factory on this PR to send it follow-up work.

View run View conversation

@warp-agent-staging warp-agent-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_automata 0.4.9's Pool keeps one owner value plus 8 independently retained shard stacks indexed thread_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 is 1 + Σ_shard min(4, callers), up to 33 per matcher, so the PR's 4 × 10 MiB = 40 MiB becomes 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_event spawns compute_file_tree_mutations (local_model.rs:641, 1539, 1819), which calls path_is_ignored/matches_gitignores on every added or moved path against the same long-lived state.gitignores before ever reaching Entry::build_tree*; should_watch_repo_directory/repo_watch_filter is 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_matchers is the term that is still unbounded. evaluate_entry accumulates every nested .gitignore in a repo into the Vec<Arc<Gitignore>> that becomes FileTreeState.gitignores, held for the life of the loaded repo; gitignore_cache's 384 KiB LRU bounds only its own map and cannot release those Arc clones. Total retention stays N_matchers × caches_per_pool × cache_size even 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question4 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));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:performance:memory Memory usage, allocation, leaks, and memory-bound performance. cla-signed factory:wilson

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants