Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Bound concurrency of prompt file discovery reads - #331855

Merged
Martin Aeschlimann (aeschli) merged 3 commits into
microsoft:mainfrom
abmahdy:fix/bound-prompt-discovery-concurrency
Aug 26, 2026
Merged

Bound concurrency of prompt file discovery reads#331855
Martin Aeschlimann (aeschli) merged 3 commits into
microsoft:mainfrom
abmahdy:fix/bound-prompt-discovery-concurrency

Conversation

@abmahdy

Copy link
Copy Markdown
Contributor

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 through parseNewfileService.readFile. Every file is therefore opened at once.

Because a discovery pass can be re-triggered before the previous one settles (CachedPromise clears 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 with EMFILE — including settings.json, mcp.json and 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:

  • computeSlashCommandDiscoveryInfo
  • computeAgentDiscoveryInfo
  • computeHooks

computeSkillDiscoveryInfo and getInstructionsDiscoveryInfo in the same file already iterate sequentially and are unchanged.

Bounding discovery concurrency with Limiter is already the established pattern in this codebase — it is used 35+ times under src/vs/platform, including for comparable discovery work in agentService.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 CachedPromise changes 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.

Copilot AI balanced review requested due to automatic review settings August 20, 2026 18:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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, CachedPromise permits another computeAgentDiscoveryInfo call 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 computeHooks invocation 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);

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.

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]>
@abmahdy
Ahmed Mahdy (abmahdy) force-pushed the fix/bound-prompt-discovery-concurrency branch from 56339d8 to 8814e6c Compare August 20, 2026 18:42
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good refactor. Readability is much improved.

@aeschli
Martin Aeschlimann (aeschli) merged commit bc80672 into microsoft:main Aug 26, 2026
27 checks passed
@vs-code-engineering vs-code-engineering Bot added this to the 1.136.0 milestone Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unbounded concurrent reads in prompt discovery exhaust file handles (EMFILE) and hang the window

7 participants