|
| 1 | +import * as _ from 'lodash'; |
| 2 | +import * as path from 'path'; |
| 3 | +import { spawn } from 'child_process'; |
| 4 | + |
| 5 | +import { delay } from './util'; |
| 6 | + |
| 7 | +// Spawn a command, and resolve with all output as strings when it terminates |
| 8 | +export function spawnToResult(command: string, args: string[] = [], options = {}, inheritOutput = false): Promise<{ |
| 9 | + exitCode?: number, |
| 10 | + stdout: string, |
| 11 | + stderr: string |
| 12 | +}> { |
| 13 | + return new Promise((resolve, reject) => { |
| 14 | + const childProc = spawn(command, args, Object.assign({ stdio: 'pipe' } as const, options)); |
| 15 | + const { stdout, stderr } = childProc; |
| 16 | + |
| 17 | + const stdoutData: Buffer[] = []; |
| 18 | + stdout.on('data', (d) => stdoutData.push(d)); |
| 19 | + const stderrData: Buffer[] = []; |
| 20 | + stderr.on('data', (d) => stderrData.push(d)); |
| 21 | + |
| 22 | + if (inheritOutput) { |
| 23 | + stdout.pipe(process.stdout); |
| 24 | + stderr.pipe(process.stderr); |
| 25 | + } |
| 26 | + |
| 27 | + childProc.once('error', reject); |
| 28 | + childProc.once('close', (code?: number) => { |
| 29 | + // Note that we do _not_ check the error code, we just return it |
| 30 | + resolve({ |
| 31 | + exitCode: code, |
| 32 | + stdout: Buffer.concat(stdoutData).toString(), |
| 33 | + stderr: Buffer.concat(stderrData).toString() |
| 34 | + }); |
| 35 | + }); |
| 36 | + }); |
| 37 | +}; |
| 38 | + |
| 39 | +type Proc = { |
| 40 | + pid: number, |
| 41 | + command: string, |
| 42 | + bin: string | undefined, |
| 43 | + args: string | undefined |
| 44 | +}; |
| 45 | + |
| 46 | +const getOutputLines = (stdout: string) => |
| 47 | + stdout |
| 48 | + .split('\n') |
| 49 | + .map(line => line.trim()) |
| 50 | + .filter(line => !!line); |
| 51 | + |
| 52 | +/** |
| 53 | + * Attempts to get a list of pid + command + binary + args for every process running |
| 54 | + * on the machine owned by the current user (not *all* processes!). |
| 55 | + * |
| 56 | + * This is best efforts, due to the lack of guarantees on 'ps'. Notably args may be |
| 57 | + * undefined, if we're unable to work out which part of the command is the command |
| 58 | + * and which is args. |
| 59 | + */ |
| 60 | +export async function listRunningProcesses(): Promise<Array<Proc>> { |
| 61 | + if (process.platform !== 'win32') { |
| 62 | + const [psCommResult, psFullResult] = await Promise.all([ |
| 63 | + spawnToResult('ps', ['xo', 'pid=,comm=']), // Prints pid + bin only |
| 64 | + spawnToResult('ps', ['xo', 'pid=,command=']), // Prints pid + command + args |
| 65 | + ]); |
| 66 | + |
| 67 | + if (psCommResult.exitCode !== 0 || psFullResult.exitCode !== 0) { |
| 68 | + throw new Error(`Could not list running processes, ps exited with code ${ |
| 69 | + psCommResult.exitCode || psFullResult.exitCode |
| 70 | + }`); |
| 71 | + } |
| 72 | + |
| 73 | + const processes = getOutputLines(psCommResult.stdout).map(line => { |
| 74 | + const firstSpaceIndex = line.indexOf(' '); |
| 75 | + if (firstSpaceIndex === -1) { |
| 76 | + throw new Error('No space in PS output'); |
| 77 | + } |
| 78 | + |
| 79 | + const pid = parseInt(line.substring(0, firstSpaceIndex), 10); |
| 80 | + const command = line.substring(firstSpaceIndex + 1); |
| 81 | + |
| 82 | + return { pid, command } as Proc; |
| 83 | + }); |
| 84 | + |
| 85 | + const processesByPid = _.keyBy(processes, p => p.pid); |
| 86 | + |
| 87 | + getOutputLines(psFullResult.stdout).forEach(line => { |
| 88 | + const firstSpaceIndex = line.indexOf(' '); |
| 89 | + if (firstSpaceIndex === -1) throw new Error('No space in PS output'); |
| 90 | + |
| 91 | + const pid = parseInt(line.substring(0, firstSpaceIndex), 10); |
| 92 | + const binAndArgs = line.substring(firstSpaceIndex + 1); |
| 93 | + |
| 94 | + const proc = processesByPid[pid]; |
| 95 | + if (!proc) return; |
| 96 | + |
| 97 | + if (proc.command.includes(path.sep)) { |
| 98 | + // Proc.command is a fully qualified path (as on Mac) |
| 99 | + if (binAndArgs.startsWith(proc.command)) { |
| 100 | + proc.bin = proc.command; |
| 101 | + proc.args = binAndArgs.substring(proc.bin.length + 1); |
| 102 | + } |
| 103 | + } else { |
| 104 | + // Proc.command is a plain binary name (as on Linux) |
| 105 | + const commandMatch = binAndArgs.match( |
| 106 | + // Best guess: first instance of the command name followed by a space |
| 107 | + new RegExp(_.escapeRegExp(proc.command) + '( |$)') |
| 108 | + ); |
| 109 | + |
| 110 | + if (!commandMatch) { |
| 111 | + // We can't work out which bit is the command, don't set args, treat |
| 112 | + // the whole command line as the command and give up |
| 113 | + proc.command = binAndArgs; |
| 114 | + return; |
| 115 | + } |
| 116 | + |
| 117 | + const commandIndex = commandMatch.index!; |
| 118 | + proc.bin = binAndArgs.substring(0, commandIndex + proc.command.length); |
| 119 | + proc.args = binAndArgs.substring(proc.bin.length + 1); |
| 120 | + } |
| 121 | + }); |
| 122 | + |
| 123 | + return processes; |
| 124 | + } else { |
| 125 | + const wmicOutput = await spawnToResult('wmic', [ |
| 126 | + 'Process', 'Get', 'processid,commandline' |
| 127 | + ]); |
| 128 | + |
| 129 | + if (wmicOutput.exitCode !== 0) { |
| 130 | + throw new Error(`WMIC exited with unexpected error code ${wmicOutput.exitCode}`); |
| 131 | + } |
| 132 | + |
| 133 | + return getOutputLines(wmicOutput.stdout) |
| 134 | + .slice(1) // Skip the header line |
| 135 | + .filter((line) => line.includes(' ')) // Skip lines where the command line isn't available (just pids) |
| 136 | + .map((line) => { |
| 137 | + const pidIndex = line.lastIndexOf(' ') + 1; |
| 138 | + const pid = parseInt(line.substring(pidIndex), 10); |
| 139 | + |
| 140 | + const command = line.substring(0, pidIndex).trim(); |
| 141 | + const bin = command[0] === '"' |
| 142 | + ? command.substring(1, command.substring(1).indexOf('"') + 1) |
| 143 | + : command.substring(0, command.indexOf(' ')); |
| 144 | + const args = command[0] === '"' |
| 145 | + ? command.substring(bin.length + 3) |
| 146 | + : command.substring(bin.length + 1); |
| 147 | + |
| 148 | + return { |
| 149 | + pid, |
| 150 | + command, |
| 151 | + bin, |
| 152 | + args |
| 153 | + }; |
| 154 | + }); |
| 155 | + } |
| 156 | +} |
| 157 | + |
| 158 | +export async function waitForExit(pid: number, timeout: number = 5000): Promise<void> { |
| 159 | + const startTime = Date.now(); |
| 160 | + |
| 161 | + while (true) { |
| 162 | + try { |
| 163 | + process.kill(pid, 0) as void | boolean; |
| 164 | + |
| 165 | + // Didn't throw. If we haven't timed out, check again after 250ms: |
| 166 | + if (Date.now() - startTime > timeout) { |
| 167 | + throw new Error("Process did not exit before timeout"); |
| 168 | + } |
| 169 | + await delay(250); |
| 170 | + } catch (e) { |
| 171 | + if ((e as Error & { code?: string }).code === 'ESRCH') { |
| 172 | + return; // Process doesn't exist! We're done. |
| 173 | + } |
| 174 | + else throw e; |
| 175 | + } |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +// Cleanly close (simulate closing the main window) on a specific windows process |
| 180 | +export async function windowsClose(pid: number) { |
| 181 | + await spawnToResult('taskkill', [ |
| 182 | + '/pid', pid.toString(), |
| 183 | + ]); |
| 184 | +} |
| 185 | + |
| 186 | +// Harshly kill a windows process by some WMIC matching string e.g. |
| 187 | +// "processId=..." or "CommandLine Like '%...%'" |
| 188 | +export async function windowsKill(processMatcher: string) { |
| 189 | + await spawnToResult('wmic', [ |
| 190 | + 'Path', 'win32_process', |
| 191 | + 'Where', processMatcher, |
| 192 | + 'Call', 'Terminate' |
| 193 | + ], { }, true); |
| 194 | +} |
0 commit comments