Skip to content

Commit d210641

Browse files
committed
Share one discovery limiter across overlapping passes
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.
1 parent 8814e6c commit d210641

2 files changed

Lines changed: 47 additions & 6 deletions

File tree

src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ const PROMPT_FILE_DISCOVERY_CONCURRENCY = 10;
6868
export class PromptsService extends Disposable implements IPromptsService {
6969
public declare readonly _serviceBrand: undefined;
7070

71+
/**
72+
* Bounds how many prompt files discovery reads in parallel.
73+
*
74+
* Owned by the service rather than created per invocation on purpose: an
75+
* invalidation clears the cached discovery promise without cancelling the
76+
* computation it was tracking, so several passes can run at once. A limiter
77+
* per invocation would give each pass its own quota and the aggregate would
78+
* still grow with the number of passes, which is the exhaustion this bound
79+
* exists to prevent.
80+
*/
81+
private readonly _discoveryLimiter = this._register(new Limiter<unknown>(PROMPT_FILE_DISCOVERY_CONCURRENCY));
82+
83+
/**
84+
* Queues a discovery file read on the shared, service-wide limiter.
85+
*/
86+
private queueDiscoveryRead<T>(task: () => Promise<T>): Promise<T> {
87+
return this._discoveryLimiter.queue(task) as Promise<T>;
88+
}
89+
7190
/**
7291
* Prompt files locator utility.
7392
*/
@@ -546,8 +565,7 @@ export class PromptsService extends Disposable implements IPromptsService {
546565
...enabledSkills,
547566
];
548567

549-
const slashCommandLimiter = new Limiter<ISlashCommandDiscoveryResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY);
550-
const parseResults = await Promise.all(slashCommandFiles.map(promptPath => slashCommandLimiter.queue(async () => {
568+
const parseResults = await Promise.all(slashCommandFiles.map(promptPath => this.queueDiscoveryRead(async () => {
551569
try {
552570
const parsedPromptFile = await this.parseNew(promptPath.uri, token);
553571
let rawName: string;
@@ -746,8 +764,7 @@ export class PromptsService extends Disposable implements IPromptsService {
746764
const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path;
747765
const defaultFolder = this.workspaceService.getWorkspace().folders[0];
748766

749-
const agentLimiter = new Limiter<IAgentDiscoveryResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY);
750-
const files = await Promise.all(allAgentFiles.map(promptPath => agentLimiter.queue(async (): Promise<IAgentDiscoveryResult> => {
767+
const files = await Promise.all(allAgentFiles.map(promptPath => this.queueDiscoveryRead(async (): Promise<IAgentDiscoveryResult> => {
751768
const uri = promptPath.uri;
752769
const isEnabled = !disabledAgents.has(uri);
753770

@@ -1270,8 +1287,7 @@ export class PromptsService extends Disposable implements IPromptsService {
12701287
sourceUri?: URI;
12711288
hasDisabledClaudeHooks?: boolean;
12721289
};
1273-
const hookLimiter = new Limiter<HookFileResult>(PROMPT_FILE_DISCOVERY_CONCURRENCY);
1274-
const fileResults = await Promise.all(hookFiles.map(hookFile => hookLimiter.queue(async (): Promise<HookFileResult> => {
1290+
const fileResults = await Promise.all(hookFiles.map(hookFile => this.queueDiscoveryRead(async (): Promise<HookFileResult> => {
12751291
const name = basename(hookFile.uri);
12761292

12771293
// Plugins are handled separately down below because they do their own parsing+interpolation

src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,31 @@ suite('PromptsService', () => {
972972
maxInFlight < agentCount,
973973
`Must not read all ${agentCount} agent files at once, but read ${maxInFlight} concurrently.`,
974974
);
975+
976+
// A discovery pass can be invalidated while it is still running, which
977+
// starts a second pass alongside the first. Both passes must share the
978+
// same quota, otherwise the number of open files grows with the number
979+
// of passes.
980+
const singlePassPeak = maxInFlight;
981+
maxInFlight = 0;
982+
983+
const firstPass = service.getCustomAgents(CancellationToken.None);
984+
const contributedAgent = URI.joinPath(rootFolderUri, '.github/agents/agent0.agent.md');
985+
const registered = service.registerContributedFile(
986+
PromptsType.agent,
987+
contributedAgent,
988+
{ identifier: new ExtensionIdentifier('test.extension'), name: 'test' } as IExtensionDescription,
989+
undefined,
990+
undefined,
991+
);
992+
const secondPass = service.getCustomAgents(CancellationToken.None);
993+
await Promise.all([firstPass, secondPass]);
994+
registered.dispose();
995+
996+
assert.ok(
997+
maxInFlight <= singlePassPeak,
998+
`Overlapping discovery passes must share one quota, but read ${maxInFlight} concurrently versus ${singlePassPeak} for a single pass.`,
999+
);
9751000
});
9761001

9771002

0 commit comments

Comments
 (0)