Skip to content

Commit a7be714

Browse files
committed
perf(@angular/cli): implement semaphore backpressure throttling in PackageManager
Execute package manager subprocess invocations using a semaphore-based backpressure throttle. When callers perform concurrent registry lookups (such as running Promise.all over candidate update packages), this prevents unbounded child subprocess spawning. Clamping active concurrent CLI commands to a fixed limit protects the operating system against process table exhaustion, V8 heap saturation, and upstream registry rate-limiting while maintaining fast execution.
1 parent 583736a commit a7be714

1 file changed

Lines changed: 57 additions & 31 deletions

File tree

packages/angular/cli/src/package-managers/package-manager.ts

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ export class PackageManager {
9393
readonly #initializationError?: Error;
9494
#dependencyCache: Map<string, InstalledPackage> | null = null;
9595
#version: string | undefined;
96+
#activeTasks = 0;
97+
readonly #pendingTasks: { resolve: () => void; reject: (err: unknown) => void }[] = [];
98+
readonly #maxConcurrent = 5;
9699

97100
/**
98101
* Creates a new `PackageManager` instance.
@@ -159,49 +162,72 @@ export class PackageManager {
159162
* @param options Options for the child process.
160163
* @returns A promise that resolves with the standard output and standard error of the command.
161164
*/
165+
async #runWithThrottle<T>(action: () => Promise<T>): Promise<T> {
166+
if (this.#activeTasks >= this.#maxConcurrent) {
167+
await new Promise<void>((resolve, reject) => {
168+
this.#pendingTasks.push({ resolve, reject });
169+
});
170+
} else {
171+
this.#activeTasks++;
172+
}
173+
174+
try {
175+
return await action();
176+
} finally {
177+
const next = this.#pendingTasks.shift();
178+
if (next) {
179+
next.resolve();
180+
} else {
181+
this.#activeTasks--;
182+
}
183+
}
184+
}
185+
162186
async #run(
163187
args: readonly string[],
164188
options: { timeout?: number; registry?: string; cwd?: string } = {},
165189
): Promise<{ stdout: string; stderr: string }> {
166-
this.ensureInstalled();
190+
return this.#runWithThrottle(async () => {
191+
this.ensureInstalled();
192+
193+
const { registry, cwd, ...runOptions } = options;
194+
const finalArgs = [...args];
195+
let finalEnv: Record<string, string> | undefined;
196+
197+
if (registry) {
198+
const registryOptions = this.descriptor.getRegistryOptions?.(registry);
199+
if (!registryOptions) {
200+
throw new Error(
201+
`The configured package manager, '${this.descriptor.binary}', does not support a custom registry.`,
202+
);
203+
}
167204

168-
const { registry, cwd, ...runOptions } = options;
169-
const finalArgs = [...args];
170-
let finalEnv: Record<string, string> | undefined;
205+
if (registryOptions.args) {
206+
finalArgs.push(...registryOptions.args);
207+
}
208+
if (registryOptions.env) {
209+
finalEnv = registryOptions.env;
210+
}
211+
}
171212

172-
if (registry) {
173-
const registryOptions = this.descriptor.getRegistryOptions?.(registry);
174-
if (!registryOptions) {
175-
throw new Error(
176-
`The configured package manager, '${this.descriptor.binary}', does not support a custom registry.`,
213+
const executionDirectory = cwd ?? this.cwd;
214+
if (this.options.dryRun) {
215+
this.options.logger?.info(
216+
`[DRY RUN] Would execute in [${executionDirectory}]: ${this.descriptor.binary} ${finalArgs.join(' ')}`,
177217
);
178-
}
179218

180-
if (registryOptions.args) {
181-
finalArgs.push(...registryOptions.args);
182-
}
183-
if (registryOptions.env) {
184-
finalEnv = registryOptions.env;
219+
return { stdout: '', stderr: '' };
185220
}
186-
}
187221

188-
const executionDirectory = cwd ?? this.cwd;
189-
if (this.options.dryRun) {
190-
this.options.logger?.info(
191-
`[DRY RUN] Would execute in [${executionDirectory}]: ${this.descriptor.binary} ${finalArgs.join(' ')}`,
192-
);
222+
const commandResult = await this.host.runCommand(this.descriptor.binary, finalArgs, {
223+
...runOptions,
224+
cwd: executionDirectory,
225+
stdio: 'pipe',
226+
env: finalEnv,
227+
});
193228

194-
return { stdout: '', stderr: '' };
195-
}
196-
197-
const commandResult = await this.host.runCommand(this.descriptor.binary, finalArgs, {
198-
...runOptions,
199-
cwd: executionDirectory,
200-
stdio: 'pipe',
201-
env: finalEnv,
229+
return { stdout: commandResult.stdout.trim(), stderr: commandResult.stderr.trim() };
202230
});
203-
204-
return { stdout: commandResult.stdout.trim(), stderr: commandResult.stderr.trim() };
205231
}
206232

207233
/**

0 commit comments

Comments
 (0)