Bound concurrency of prompt file discovery reads - #331855
Bound concurrency of prompt file discovery reads#331855Martin Aeschlimann (aeschli) merged 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Bounds concurrent prompt-discovery file reads to mitigate file-handle exhaustion.
Changes:
- Adds limiters for agent, slash-command, and hook discovery.
- Adds an agent-discovery concurrency test.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
promptsServiceImpl.ts |
Applies per-pass discovery concurrency limits. |
promptsService.test.ts |
Tests bounded parallel agent reads. |
Suppressed comments (2)
src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts:757
- This per-call limiter does not enforce the advertised maximum when agent discovery overlaps: after invalidation,
CachedPromisepermits anothercomputeAgentDiscoveryInfocall while the old one continues, and each call gets its own quota. Two scans can therefore issue 20 reads, and repeated scans remain unbounded. Share a service-owned limiter across agent-discovery invocations and test the overlapping-pass case.
const agentLimiter = new Limiter<IAgentDiscoveryResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY);
src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts:1281
- Hook discovery has the same cross-pass gap: every overlapping
computeHooksinvocation creates an independent limiter, so invalidations multiply the number of simultaneous reads instead of enforcing a stable bound. Keep the limiter at service scope so all hook scans share the configured quota, and add an overlapping-pass test.
const hookLimiter = new Limiter<HookFileResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY);
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ]; | ||
|
|
||
| const parseResults = await Promise.all(slashCommandFiles.map(async promptPath => { | ||
| const slashCommandLimiter = new Limiter<ISlashCommandDiscoveryResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY); |
There was a problem hiding this comment.
Good catch, and you're right — this was a real gap in the fix rather than a nit.
A limiter created inside each discovery call bounds one pass, but CachedPromise invalidates without cancelling the computation it was tracking, so passes overlap. Each overlapping pass got its own quota and the aggregate still grew with the number of passes — which is exactly the exhaustion this change was meant to prevent. In the profile that motivated the PR there were roughly fifteen passes in flight at once, so the per-call bound would have permitted ~150 concurrent reads.
Fixed in d210641 by moving the limiter to the service so every discovery read shares one quota:
private readonly _discoveryLimiter = this._register(new Limiter<unknown>(PROMPT_FILE_DISCOVERY_CONCURRENCY));
private queueDiscoveryRead<T>(task: () => Promise<T>): Promise<T> {
return this._discoveryLimiter.queue(task) as Promise<T>;
}All three call sites (slash command, agent and hook discovery) now go through queueDiscoveryRead, so the bound also holds across types rather than per type.
The regression test now covers the overlapping case as well. It records the peak concurrency of a single pass, then starts a second pass while the first is still reading — invalidating via registerContributedFile — and asserts the peak does not rise:
assert.ok(
maxInFlight <= singlePassPeak,
`Overlapping discovery passes must share one quota, but read ${maxInFlight} concurrently versus ${singlePassPeak} for a single pass.`,
);That assertion fails against the previous per-call-limiter version.
Worth noting for anyone reading this thread: the non-cancelling invalidation in CachedPromise is itself arguably the deeper bug. I left it out of this PR because it changes cancellation semantics and deserves separate review — it's called out in #331853.
Agent, slash command and hook discovery each fanned out over every visible prompt file with an unbounded Promise.all, opening one file handle per file. Because a discovery pass can be re-triggered before the previous one settles, several passes can be in flight at once, which on installations with large plugin or skill collections exhausts the process file handle limit and fails unrelated reads with EMFILE. Route those three call sites through a Limiter, matching how discovery concurrency is already bounded elsewhere in the codebase. Co-authored-by: Copilot <[email protected]>
56339d8 to
8814e6c
Compare
A limiter created per invocation gave each discovery pass its own quota, so overlapping passes still scaled the number of simultaneous reads with the number of passes. Move the limiter to the service so all discovery reads share a single bound, and cover the overlapping-pass case in the test.
Mohammad javad Dianat (dianatofficial)
left a comment
There was a problem hiding this comment.
Good refactor. Readability is much improved.
bc80672
into
microsoft:main
Fixes #331853
Agent, slash command and hook discovery each fan out over every discovered prompt file with an unbounded
Promise.all, and each iteration reads the file throughparseNew→fileService.readFile. Every file is therefore opened at once.Because a discovery pass can be re-triggered before the previous one settles (
CachedPromiseclears its cached promise on invalidation without cancelling the in-flight computation), several full passes can be in flight simultaneously. On profiles with large agent-plugin/skill collections this exhausts the process file handle limit: the main process reached ~150k pending libuv filesystem requests and ~9.5k handles, after which unrelated reads failed withEMFILE— includingsettings.json,mcp.jsonand chat session writes — and the pty host and extension hosts stopped responding. This reproduces with no folder open, since the files involved are global.This change routes the three unbounded sites through a
Limiter:computeSlashCommandDiscoveryInfocomputeAgentDiscoveryInfocomputeHookscomputeSkillDiscoveryInfoandgetInstructionsDiscoveryInfoin the same file already iterate sequentially and are unchanged.Bounding discovery concurrency with
Limiteris already the established pattern in this codebase — it is used 35+ times undersrc/vs/platform, including for comparable discovery work inagentService.ts(new Limiter<boolean>(4)). The limit is set high enough that typical installations (a handful of files) behave exactly as before, while keeping the worst case bounded.This PR intentionally addresses only the unbounded fan-out. The non-cancelling invalidation in
CachedPromisechanges cancellation semantics and is better reviewed separately.Test
Adds a unit test asserting that agent discovery never exceeds the configured concurrency while still reading files in parallel.