Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[node-core-library] Add ability to list processes on Windows and Unix-based platforms #4443

Merged
merged 16 commits into from
Dec 7, 2023
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"changes": [
{
"packageName": "@rushstack/node-core-library",
"comment": "Add the ability to list all process trees using `Executable.listProcessInfoById` and `Executable.listProcessInfoByName` methods",
"comment": "Add functions inside the `Executable` API to list all process trees (`getProcessInfoById`, `getProcessInfoByIdAsync`, `getProcessInfoByName`, and `getProcessInfoByNameAsync`).",
"type": "minor"
}
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/node-core-library",
"comment": "Add functions inside the `Text` API to split iterables (or async iterables) that produce strings or buffers on newlines (`readLinesFromIterable` and `readLinesFromIterableAsync`).",
"type": "minor"
}
],
"packageName": "@rushstack/node-core-library"
}
42 changes: 37 additions & 5 deletions common/reviews/api/node-core-library.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,16 @@ export class EnvironmentMap {

// @public
export class Executable {
static listProcessInfoById(): Map<number, IProcessInfo>;
static listProcessInfoByIdAsync(): Promise<Map<number, IProcessInfo>>;
static listProcessInfoByName(): Map<string, IProcessInfo[]>;
static listProcessInfoByNameAsync(): Promise<Map<string, IProcessInfo[]>>;
static getProcessInfoById(): Map<number, IProcessInfo>;
static getProcessInfoByIdAsync(): Promise<Map<number, IProcessInfo>>;
static getProcessInfoByName(): Map<string, IProcessInfo[]>;
static getProcessInfoByNameAsync(): Promise<Map<string, IProcessInfo[]>>;
static spawn(filename: string, args: string[], options?: IExecutableSpawnOptions): child_process.ChildProcess;
static spawnSync(filename: string, args: string[], options?: IExecutableSpawnSyncOptions): child_process.SpawnSyncReturns<string>;
static tryResolve(filename: string, options?: IExecutableResolveOptions): string | undefined;
static waitForExitAsync(childProcess: child_process.ChildProcess, options: IWaitForExitWithStringOptions): Promise<IWaitForExitResult<string>>;
static waitForExitAsync(childProcess: child_process.ChildProcess, options: IWaitForExitWithBufferOptions): Promise<IWaitForExitResult<Buffer>>;
static waitForExitAsync(childProcess: child_process.ChildProcess, options?: IWaitForExitOptions): Promise<IWaitForExitResult<never>>;
}

// @public
Expand Down Expand Up @@ -672,7 +675,7 @@ export interface IProtectableMapParameters<K, V> {
// @public
export interface IReadLinesFromIterableOptions {
encoding?: Encoding;
skipEmptyLines?: boolean;
ignoreEmptyLines?: boolean;
}

// @beta (undocumented)
Expand Down Expand Up @@ -736,6 +739,35 @@ export interface ITerminalWritableOptions {
writableOptions?: WritableOptions;
}

// @public
export interface IWaitForExitOptions {
encoding?: BufferEncoding | 'buffer';
throwOnNonZeroExitCode?: boolean;
}

// @public
export interface IWaitForExitResult<T extends Buffer | string | never = never> {
exitCode: number | null;
stderr: T;
stdout: T;
}

// Warning: (ae-unresolved-inheritdoc-reference) The @inheritDoc reference could not be resolved: The package "@rushstack/node-core-library" does not have an export "IRunToCompletionOptions"
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
//
// @public (undocumented)
export interface IWaitForExitWithBufferOptions extends IWaitForExitOptions {
// (undocumented)
encoding: 'buffer';
}

// Warning: (ae-unresolved-inheritdoc-reference) The @inheritDoc reference could not be resolved: The package "@rushstack/node-core-library" does not have an export "IRunToCompletionOptions"
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
//
// @public (undocumented)
export interface IWaitForExitWithStringOptions extends IWaitForExitOptions {
// (undocumented)
encoding: BufferEncoding;
}

// @public
export class JsonFile {
// @internal (undocumented)
Expand Down
200 changes: 164 additions & 36 deletions libraries/node-core-library/src/Executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,64 @@ export interface IExecutableSpawnOptions extends IExecutableResolveOptions {
stdio?: ExecutableStdioMapping;
}

/**
* The options for running a process to completion using {@link Executable.(waitForExitAsync:3)}.
*
* @public
*/
export interface IWaitForExitOptions {
/**
* Whether or not to throw when the process completes with a non-zero exit code. Defaults to false.
*/
throwOnNonZeroExitCode?: boolean;

/**
* The encoding of the output. If not provided, the output will not be collected.
*/
encoding?: BufferEncoding | 'buffer';
}

/**
* {@inheritDoc IRunToCompletionOptions}
*
* @public
*/
export interface IWaitForExitWithStringOptions extends IWaitForExitOptions {
encoding: BufferEncoding;
}

/**
* {@inheritDoc IRunToCompletionOptions}
*
* @public
*/
export interface IWaitForExitWithBufferOptions extends IWaitForExitOptions {
encoding: 'buffer';
}

/**
* The result of running a process to completion using {@link Executable.(waitForExitAsync:3)}.
*
* @public
*/
export interface IWaitForExitResult<T extends Buffer | string | never = never> {
/**
* The process stdout output, if encoding was specified.
*/
stdout: T;

/**
* The process stderr output, if encoding was specified.
*/
stderr: T;

/**
* The process exit code. If the process was terminated, this will be null.
*/
// eslint-disable-next-line @rushstack/no-new-null
exitCode: number | null;
}

// Common environmental state used by Executable members
interface IExecutableContext {
currentWorkingDirectory: string;
Expand Down Expand Up @@ -136,10 +194,12 @@ export interface IProcessInfo {
* On Unix, the process name will be empty if the process is the root process.
*/
processName: string;

/**
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
* The process ID.
*/
processId: number;

/**
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
* The parent process info.
*
Expand All @@ -159,7 +219,7 @@ export async function parseProcessListOutputAsync(
): Promise<Map<number, IProcessInfo>> {
const processInfoById: Map<number, IProcessInfo> = new Map<number, IProcessInfo>();
let seenHeaders: boolean = false;
for await (const line of Text.readLinesFromIterableAsync(stream, { skipEmptyLines: true })) {
for await (const line of Text.readLinesFromIterableAsync(stream, { ignoreEmptyLines: true })) {
if (!seenHeaders) {
seenHeaders = true;
} else {
Expand All @@ -173,7 +233,7 @@ export async function parseProcessListOutputAsync(
export function parseProcessListOutput(output: Iterable<string | null>): Map<number, IProcessInfo> {
const processInfoById: Map<number, IProcessInfo> = new Map<number, IProcessInfo>();
let seenHeaders: boolean = false;
for (const line of Text.readLinesFromIterable(output, { skipEmptyLines: true })) {
for (const line of Text.readLinesFromIterable(output, { ignoreEmptyLines: true })) {
if (!seenHeaders) {
seenHeaders = true;
} else {
Expand Down Expand Up @@ -438,56 +498,125 @@ export class Executable {
return child_process.spawn(normalizedCommandLine.path, normalizedCommandLine.args, spawnOptions);
}

/* eslint-disable @rushstack/no-new-null */
/** {@inheritDoc Executable.(waitForExitAsync:3)} */
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options: IWaitForExitWithStringOptions
): Promise<IWaitForExitResult<string>>;

/** {@inheritDoc Executable.(waitForExitAsync:3)} */
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options: IWaitForExitWithBufferOptions
): Promise<IWaitForExitResult<Buffer>>;

/**
* Get the list of processes currently running on the system, keyed by the process ID. The underlying
* implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
* Wait for a child process to exit and return the result.
*
* @param childProcess - The child process to wait for.
* @param options - Options for waiting for the process to exit.
*/
public static async listProcessInfoByIdAsync(): Promise<Map<number, IProcessInfo>> {
const { path: command, args } = getProcessListProcessOptions();
const process: child_process.ChildProcess = Executable.spawn(command, args, {
stdio: ['ignore', 'pipe', 'ignore']
});
if (process.stdout === null) {
throw new InternalError('Child process did not provide stdout');
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options?: IWaitForExitOptions
): Promise<IWaitForExitResult<never>>;

public static async waitForExitAsync<T extends Buffer | string | never = never>(
childProcess: child_process.ChildProcess,
options: IWaitForExitOptions = {}
): Promise<IWaitForExitResult<T>> {
const { throwOnNonZeroExitCode = false, encoding } = options;
if (encoding && (!childProcess.stdout || !childProcess.stderr)) {
throw new Error(
'An encoding was specified, but stdout and/or stderr are not piped. Did you set stdio?'
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
);
}

const collectedStdout: T[] = [];
const collectedStderr: T[] = [];
const useBufferEncoding: boolean = encoding === 'buffer';
iclanton marked this conversation as resolved.
Show resolved Hide resolved

function normalizeChunk<TChunk extends Buffer | string>(chunk: Buffer | string): TChunk {
if (typeof chunk === 'string') {
return (useBufferEncoding ? Buffer.from(chunk) : chunk) as TChunk;
} else {
return (useBufferEncoding ? chunk : chunk.toString(encoding as BufferEncoding)) as TChunk;
}
}

let errorThrown: boolean = false;
const processFinishedPromise: Promise<void> = new Promise<void>(
(resolve: () => void, reject: (error: Error) => void) => {
process.on('error', (error: Error) => {
const exitCode: number | null = await new Promise<number | null>(
(resolve: (result: number | null) => void, reject: (error: Error) => void) => {
if (encoding) {
childProcess.stdout!.on('data', (chunk: Buffer | string) => {
collectedStdout.push(normalizeChunk(chunk));
});
childProcess.stderr!.on('data', (chunk: Buffer | string) => {
collectedStderr.push(normalizeChunk(chunk));
});
}
childProcess.on('error', (error: Error) => {
errorThrown = true;
reject(new InternalError(`Unable to list processes: ${command} failed with error ${error}`));
reject(error);
});
process.on('exit', (code: number | null) => {
childProcess.on('exit', (code: number | null) => {
if (errorThrown) {
// We've already rejected the promise
return;
}
if (code !== 0) {
reject(new InternalError(`Unable to list processes: ${command} exited with code ${code}`));
if (code !== 0 && throwOnNonZeroExitCode) {
reject(new Error(`Process exited with code ${code}`));
} else {
resolve();
resolve(code);
}
});
}
);

const result: IWaitForExitResult<T> = {
exitCode
} as IWaitForExitResult<T>;

if (encoding === 'buffer') {
result.stdout = Buffer.concat(collectedStdout as Buffer[]) as T;
result.stderr = Buffer.concat(collectedStderr as Buffer[]) as T;
} else if (encoding) {
result.stdout = collectedStdout.join('') as T;
result.stderr = collectedStderr.join('') as T;
}

return result;
}
/* eslint-enable @rushstack/no-new-null */

/**
* Get the list of processes currently running on the system, keyed by the process ID.
*
* @remarks The underlying implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
*/
public static async getProcessInfoByIdAsync(): Promise<Map<number, IProcessInfo>> {
const { path: command, args } = getProcessListProcessOptions();
const process: child_process.ChildProcess = Executable.spawn(command, args, {
stdio: ['ignore', 'pipe', 'ignore']
});
if (process.stdout === null) {
throw new InternalError('Child process did not provide stdout');
}
const [processInfoByIdMap] = await Promise.all([
parseProcessListOutputAsync(process.stdout),
iclanton marked this conversation as resolved.
Show resolved Hide resolved
processFinishedPromise
// Don't collect output in the result since we process it directly
Executable.waitForExitAsync(process, { throwOnNonZeroExitCode: true })
]);
return processInfoByIdMap;
}

/**
* Get the list of processes currently running on the system, keyed by the process ID. The underlying
* implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
* {@inheritDoc Executable.getProcessInfoByIdAsync}
*/
public static listProcessInfoById(): Map<number, IProcessInfo> {
public static getProcessInfoById(): Map<number, IProcessInfo> {
const { path: command, args } = getProcessListProcessOptions();
const processOutput: child_process.SpawnSyncReturns<string> = Executable.spawnSync(command, args);
if (processOutput.error) {
Expand All @@ -501,23 +630,22 @@ export class Executable {

/**
* Get the list of processes currently running on the system, keyed by the process name. All processes
* with the same name will be grouped. The underlying implementation depends on the operating system:
* with the same name will be grouped.
*
* @remarks The underlying implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
D4N14L marked this conversation as resolved.
Show resolved Hide resolved
* - On Unix, this uses the `ps` utility.
*/
public static async listProcessInfoByNameAsync(): Promise<Map<string, IProcessInfo[]>> {
const processInfoById: Map<number, IProcessInfo> = await Executable.listProcessInfoByIdAsync();
public static async getProcessInfoByNameAsync(): Promise<Map<string, IProcessInfo[]>> {
const processInfoById: Map<number, IProcessInfo> = await Executable.getProcessInfoByIdAsync();
return convertToProcessInfoByNameMap(processInfoById);
}

/**
* Get the list of processes currently running on the system, keyed by the process name. All processes
* with the same name will be grouped. The underlying implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
* {@inheritDoc Executable.getProcessInfoByNameAsync}
*/
public static listProcessInfoByName(): Map<string, IProcessInfo[]> {
const processInfoByIdMap: Map<number, IProcessInfo> = Executable.listProcessInfoById();
public static getProcessInfoByName(): Map<string, IProcessInfo[]> {
const processInfoByIdMap: Map<number, IProcessInfo> = Executable.getProcessInfoById();
return convertToProcessInfoByNameMap(processInfoByIdMap);
}

Expand Down
Loading
Loading