Skip to content

Commit 7f56eac

Browse files
committed
fix(commands): reject an in-process dispatch that overlaps a running one
Each in-process dispatch primes its command's options into the CLI-wide options service and restores the table when it ends. The restore closes over a snapshot, which is only right when dispatches nest. Two dispatches overlapping without nesting - a plugin awaiting two runCommand calls under one Promise.all - restored each other's snapshots and left an option declared by only one of them in the table for the rest of the process. A dispatch may now start only when nothing is in flight or from inside the innermost dispatch in flight, which the dispatcher tells apart with an AsyncLocalStorage context per dispatch. Anything else is rejected, before any state is touched, with an error naming both commands and saying that dispatches must nest.
1 parent 5295cd7 commit 7f56eac

4 files changed

Lines changed: 256 additions & 42 deletions

File tree

‎defining-commands.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,15 @@ declarations into it rewrites the values the host process is still running on
889889
— `open|ios` declares `watch: false`, which would otherwise leave an `ns start`
890890
out of watch mode for the rest of its life.
891891

892+
In-process dispatches nest; they never overlap. The options are put back in
893+
the order the dispatches were entered, which only restores the right values
894+
when each one finishes before the dispatch it was started from. A dispatch
895+
started while another is in flight, and not from inside it — two
896+
`runCommand` calls under one `Promise.all`, say — is rejected with
897+
`Cannot dispatch '…' in process while '…' is still running: in-process
898+
dispatches must nest, not overlap; await the running one first.` Await one
899+
before starting the next.
900+
892901
There is deliberately no free `runCommand()` function: one that silently fell
893902
back to the CLI's root injector outside an injection context would dispatch
894903
through the wrong scope from exactly the places — after an `await`, inside a

‎lib/common/contracts/commands-service.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export abstract class CommandsService {
2727
* registered — the typed way to refer to a command. A parent name is routed
2828
* to its subcommand as the command line routes it, and a subcommand fires
2929
* its full hook name (`before-open-ios`) as well as its parent's.
30+
*
31+
* Dispatches nest: one may start from inside a running dispatch, but one
32+
* started while another is in flight and not from inside it is rejected.
3033
*/
3134
abstract runCommand(
3235
command: CommandReference,

‎lib/common/services/commands-service.ts‎

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ERROR_NO_VALID_SUBCOMMAND_FORMAT,
66
} from "../constants";
77
import { EOL } from "os";
8+
import { AsyncLocalStorage } from "node:async_hooks";
89
import * as _ from "lodash";
910
import { IOptions, IOptionsTracker } from "../../declarations";
1011
import { IErrors, IHooksService, IAnalyticsService } from "../declarations";
@@ -22,6 +23,10 @@ import {
2223
ISimilarCommand,
2324
} from "../definitions/commands";
2425

26+
interface IInProcessDispatch {
27+
commandName: string;
28+
}
29+
2530
class CommandArgumentsValidationHelper {
2631
constructor(
2732
public isValid: boolean,
@@ -42,10 +47,19 @@ export class CommandsService
4247
}
4348

4449
private commands: ICommandData[] = [];
45-
private inProcessDepth: number = 0;
50+
51+
/**
52+
* The in-process dispatches in flight, innermost last, and the one whose
53+
* async context the running code belongs to. Each dispatch primes the
54+
* options service and puts it back when it ends, which only restores the
55+
* right table when dispatches nest; a dispatch may therefore start only from
56+
* inside the innermost one in flight.
57+
*/
58+
private inProcessDispatches: IInProcessDispatch[] = [];
59+
private dispatchContext = new AsyncLocalStorage<IInProcessDispatch>();
4660

4761
public get isExecutingInProcess(): boolean {
48-
return this.inProcessDepth > 0;
62+
return this.inProcessDispatches.length > 0;
4963
}
5064

5165
constructor(
@@ -299,50 +313,49 @@ export class CommandsService
299313
// Known before the lookup, so a failure to resolve reports under the name
300314
// the caller used.
301315
let commandName = typeof reference === "string" ? reference : undefined;
302-
this.inProcessDepth++;
303316
try {
304-
const resolved = this.resolveReference(reference, commandArguments);
305-
const command = resolved.command;
306-
commandName = resolved.commandName;
307-
commandArguments = resolved.commandArguments;
317+
await this.dispatchInProcess(reference, async (dispatch) => {
318+
const resolved = this.resolveReference(reference, commandArguments);
319+
const command = resolved.command;
320+
commandName = dispatch.commandName = resolved.commandName;
321+
commandArguments = resolved.commandArguments;
322+
323+
this.commands.push({ commandName, commandArguments });
324+
const restoreOptions = this.primeOptions(command);
325+
try {
326+
if (
327+
!(await this.canExecuteResolvedCommand(
328+
commandName,
329+
commandArguments,
330+
undefined,
331+
command,
332+
))
333+
) {
334+
let commandWithArgs = commandName;
335+
if (commandArguments && commandArguments.length) {
336+
commandWithArgs += ` ${commandArguments.join(" ")}`;
337+
}
338+
this.$errors.failWithHelp(
339+
`Command '${commandWithArgs}' cannot be executed.`,
340+
);
341+
}
308342

309-
this.commands.push({ commandName, commandArguments });
310-
const restoreOptions = this.primeOptions(command);
311-
try {
312-
if (
313-
!(await this.canExecuteResolvedCommand(
343+
await this.runResolvedCommandInProcess(
344+
command,
314345
commandName,
315346
commandArguments,
316-
undefined,
317-
command,
318-
))
319-
) {
320-
let commandWithArgs = commandName;
321-
if (commandArguments && commandArguments.length) {
322-
commandWithArgs += ` ${commandArguments.join(" ")}`;
323-
}
324-
this.$errors.failWithHelp(
325-
`Command '${commandWithArgs}' cannot be executed.`,
326347
);
348+
} finally {
349+
restoreOptions();
350+
this.commands.pop();
327351
}
328-
329-
await this.runResolvedCommandInProcess(
330-
command,
331-
commandName,
332-
commandArguments,
333-
);
334-
} finally {
335-
restoreOptions();
336-
this.commands.pop();
337-
}
352+
});
338353
} catch (ex) {
339354
await this.$errors.reportCommandError(ex, () =>
340355
this.printHelpSuggestion(commandName),
341356
);
342357

343358
throw ex;
344-
} finally {
345-
this.inProcessDepth--;
346359
}
347360
}
348361

@@ -357,10 +370,10 @@ export class CommandsService
357370
reference: CommandReference,
358371
commandArguments: string[] = [],
359372
): Promise<boolean> {
360-
this.inProcessDepth++;
361-
try {
373+
return this.dispatchInProcess(reference, async (dispatch) => {
362374
const resolved = this.resolveReference(reference, commandArguments);
363375
const { commandName, command } = resolved;
376+
dispatch.commandName = commandName;
364377
commandArguments = resolved.commandArguments;
365378

366379
this.commands.push({ commandName, commandArguments });
@@ -376,11 +389,47 @@ export class CommandsService
376389
restoreOptions();
377390
this.commands.pop();
378391
}
392+
});
393+
}
394+
395+
/**
396+
* Runs `body` as an in-process dispatch, rejecting it when another dispatch
397+
* is in flight and this one was not started from inside it.
398+
*/
399+
private async dispatchInProcess<T>(
400+
reference: CommandReference,
401+
body: (dispatch: IInProcessDispatch) => Promise<T>,
402+
): Promise<T> {
403+
const running = _.last(this.inProcessDispatches);
404+
if (running && this.dispatchContext.getStore() !== running) {
405+
throw new Error(
406+
`Cannot dispatch '${this.describeReference(reference)}' in process ` +
407+
`while '${helpers.stringReplaceAll(running.commandName, "|", " ")}' ` +
408+
"is still running: in-process dispatches must nest, not overlap; " +
409+
"await the running one first.",
410+
);
411+
}
412+
413+
const dispatch: IInProcessDispatch = {
414+
commandName: this.describeReference(reference),
415+
};
416+
this.inProcessDispatches.push(dispatch);
417+
try {
418+
return await this.dispatchContext.run(dispatch, () => body(dispatch));
379419
} finally {
380-
this.inProcessDepth--;
420+
this.inProcessDispatches.pop();
381421
}
382422
}
383423

424+
private describeReference(reference: CommandReference): string {
425+
const definition =
426+
typeof reference === "string" ? null : toCommandDefinition(reference);
427+
const name = definition
428+
? _.castArray(definition.name)[0]
429+
: String(reference);
430+
return helpers.stringReplaceAll(name, "|", " ");
431+
}
432+
384433
/** @deprecated Use {@link runCommand}. */
385434
public executeCommandInProcess(
386435
commandName: string,

0 commit comments

Comments
 (0)