buffered-process.js
1 const _ = require('underscore-plus'); 2 const ChildProcess = require('child_process'); 3 const { Emitter } = require('event-kit'); 4 const path = require('path'); 5 6 // Extended: A wrapper which provides standard error/output line buffering for 7 // Node's ChildProcess. 8 // 9 // ## Examples 10 // 11 // ```js 12 // {BufferedProcess} = require('atom') 13 // 14 // const command = 'ps' 15 // const args = ['-ef'] 16 // const stdout = (output) => console.log(output) 17 // const exit = (code) => console.log("ps -ef exited with #{code}") 18 // const process = new BufferedProcess({command, args, stdout, exit}) 19 // ``` 20 module.exports = class BufferedProcess { 21 /* 22 Section: Construction 23 */ 24 25 // Public: Runs the given command by spawning a new child process. 26 // 27 // * `options` An {Object} with the following keys: 28 // * `command` The {String} command to execute. 29 // * `args` The {Array} of arguments to pass to the command (optional). 30 // * `options` {Object} (optional) The options {Object} to pass to Node's 31 // `ChildProcess.spawn` method. 32 // * `stdout` {Function} (optional) The callback that receives a single 33 // argument which contains the standard output from the command. The 34 // callback is called as data is received but it's buffered to ensure only 35 // complete lines are passed until the source stream closes. After the 36 // source stream has closed all remaining data is sent in a final call. 37 // * `data` {String} 38 // * `stderr` {Function} (optional) The callback that receives a single 39 // argument which contains the standard error output from the command. The 40 // callback is called as data is received but it's buffered to ensure only 41 // complete lines are passed until the source stream closes. After the 42 // source stream has closed all remaining data is sent in a final call. 43 // * `data` {String} 44 // * `exit` {Function} (optional) The callback which receives a single 45 // argument containing the exit status. 46 // * `code` {Number} 47 // * `autoStart` {Boolean} (optional) Whether the command will automatically start 48 // when this BufferedProcess is created. Defaults to true. When set to false you 49 // must call the `start` method to start the process. 50 constructor({ 51 command, 52 args, 53 options = {}, 54 stdout, 55 stderr, 56 exit, 57 autoStart = true 58 } = {}) { 59 this.emitter = new Emitter(); 60 this.command = command; 61 this.args = args; 62 this.options = options; 63 this.stdout = stdout; 64 this.stderr = stderr; 65 this.exit = exit; 66 if (autoStart === true) { 67 this.start(); 68 } 69 this.killed = false; 70 } 71 72 start() { 73 if (this.started === true) return; 74 75 this.started = true; 76 // Related to joyent/node#2318 77 if (process.platform === 'win32' && this.options.shell === undefined) { 78 this.spawnWithEscapedWindowsArgs(this.command, this.args, this.options); 79 } else { 80 this.spawn(this.command, this.args, this.options); 81 } 82 this.handleEvents(this.stdout, this.stderr, this.exit); 83 } 84 85 // Windows has a bunch of special rules that node still doesn't take care of for you 86 spawnWithEscapedWindowsArgs(command, args, options) { 87 let cmdArgs = []; 88 // Quote all arguments and escapes inner quotes 89 if (args) { 90 cmdArgs = args 91 .filter(arg => arg != null) 92 .map(arg => { 93 if (this.isExplorerCommand(command) && /^\/[a-zA-Z]+,.*$/.test(arg)) { 94 // Don't wrap /root,C:\folder style arguments to explorer calls in 95 // quotes since they will not be interpreted correctly if they are 96 return arg; 97 } else { 98 // Escape double quotes by putting a backslash in front of them 99 return `"${arg.toString().replace(/"/g, '\\"')}"`; 100 } 101 }); 102 } 103 104 // The command itself is quoted if it contains spaces, &, ^, | or # chars 105 cmdArgs.unshift( 106 /\s|&|\^|\(|\)|\||#/.test(command) ? `"${command}"` : command 107 ); 108 109 const cmdOptions = _.clone(options); 110 cmdOptions.windowsVerbatimArguments = true; 111 112 this.spawn( 113 this.getCmdPath(), 114 ['/s', '/d', '/c', `"${cmdArgs.join(' ')}"`], 115 cmdOptions 116 ); 117 } 118 119 /* 120 Section: Event Subscription 121 */ 122 123 // Public: Will call your callback when an error will be raised by the process. 124 // Usually this is due to the command not being available or not on the PATH. 125 // You can call `handle()` on the object passed to your callback to indicate 126 // that you have handled this error. 127 // 128 // * `callback` {Function} callback 129 // * `errorObject` {Object} 130 // * `error` {Object} the error object 131 // * `handle` {Function} call this to indicate you have handled the error. 132 // The error will not be thrown if this function is called. 133 // 134 // Returns a {Disposable} 135 onWillThrowError(callback) { 136 return this.emitter.on('will-throw-error', callback); 137 } 138 139 /* 140 Section: Helper Methods 141 */ 142 143 // Helper method to pass data line by line. 144 // 145 // * `stream` The Stream to read from. 146 // * `onLines` The callback to call with each line of data. 147 // * `onDone` The callback to call when the stream has closed. 148 bufferStream(stream, onLines, onDone) { 149 stream.setEncoding('utf8'); 150 let buffered = ''; 151 152 stream.on('data', data => { 153 if (this.killed) return; 154 155 let bufferedLength = buffered.length; 156 buffered += data; 157 let lastNewlineIndex = data.lastIndexOf('\n'); 158 159 if (lastNewlineIndex !== -1) { 160 let lineLength = lastNewlineIndex + bufferedLength + 1; 161 onLines(buffered.substring(0, lineLength)); 162 buffered = buffered.substring(lineLength); 163 } 164 }); 165 166 stream.on('close', () => { 167 if (this.killed) return; 168 if (buffered.length > 0) onLines(buffered); 169 onDone(); 170 }); 171 } 172 173 // Kill all child processes of the spawned cmd.exe process on Windows. 174 // 175 // This is required since killing the cmd.exe does not terminate child 176 // processes. 177 killOnWindows() { 178 if (!this.process) return; 179 180 const parentPid = this.process.pid; 181 const cmd = 'wmic'; 182 const args = [ 183 'process', 184 'where', 185 `(ParentProcessId=${parentPid})`, 186 'get', 187 'processid' 188 ]; 189 190 let wmicProcess; 191 192 try { 193 wmicProcess = ChildProcess.spawn(cmd, args); 194 } catch (spawnError) { 195 this.killProcess(); 196 return; 197 } 198 199 wmicProcess.on('error', () => {}); // ignore errors 200 201 let output = ''; 202 wmicProcess.stdout.on('data', data => { 203 output += data; 204 }); 205 wmicProcess.stdout.on('close', () => { 206 for (let pid of output.split(/\s+/)) { 207 if (!/^\d{1,10}$/.test(pid)) continue; 208 pid = parseInt(pid, 10); 209 210 if (!pid || pid === parentPid) continue; 211 212 try { 213 process.kill(pid); 214 } catch (error) {} 215 } 216 217 this.killProcess(); 218 }); 219 } 220 221 killProcess() { 222 if (this.process) this.process.kill(); 223 this.process = null; 224 } 225 226 isExplorerCommand(command) { 227 if (command === 'explorer.exe' || command === 'explorer') { 228 return true; 229 } else if (process.env.SystemRoot) { 230 return ( 231 command === path.join(process.env.SystemRoot, 'explorer.exe') || 232 command === path.join(process.env.SystemRoot, 'explorer') 233 ); 234 } else { 235 return false; 236 } 237 } 238 239 getCmdPath() { 240 if (process.env.comspec) { 241 return process.env.comspec; 242 } else if (process.env.SystemRoot) { 243 return path.join(process.env.SystemRoot, 'System32', 'cmd.exe'); 244 } else { 245 return 'cmd.exe'; 246 } 247 } 248 249 // Public: Terminate the process. 250 kill() { 251 if (this.killed) return; 252 253 this.killed = true; 254 if (process.platform === 'win32') { 255 this.killOnWindows(); 256 } else { 257 this.killProcess(); 258 } 259 } 260 261 spawn(command, args, options) { 262 try { 263 this.process = ChildProcess.spawn(command, args, options); 264 } catch (spawnError) { 265 process.nextTick(() => this.handleError(spawnError)); 266 } 267 } 268 269 handleEvents(stdout, stderr, exit) { 270 if (!this.process) return; 271 272 const triggerExitCallback = () => { 273 if (this.killed) return; 274 if ( 275 stdoutClosed && 276 stderrClosed && 277 processExited && 278 typeof exit === 'function' 279 ) { 280 exit(exitCode); 281 } 282 }; 283 284 let stdoutClosed = true; 285 let stderrClosed = true; 286 let processExited = true; 287 let exitCode = 0; 288 289 if (stdout) { 290 stdoutClosed = false; 291 this.bufferStream(this.process.stdout, stdout, () => { 292 stdoutClosed = true; 293 triggerExitCallback(); 294 }); 295 } 296 297 if (stderr) { 298 stderrClosed = false; 299 this.bufferStream(this.process.stderr, stderr, () => { 300 stderrClosed = true; 301 triggerExitCallback(); 302 }); 303 } 304 305 if (exit) { 306 processExited = false; 307 this.process.on('exit', code => { 308 exitCode = code; 309 processExited = true; 310 triggerExitCallback(); 311 }); 312 } 313 314 this.process.on('error', error => { 315 this.handleError(error); 316 }); 317 } 318 319 handleError(error) { 320 let handled = false; 321 322 const handle = () => { 323 handled = true; 324 }; 325 326 this.emitter.emit('will-throw-error', { error, handle }); 327 328 if (error.code === 'ENOENT' && error.syscall.indexOf('spawn') === 0) { 329 error = new Error( 330 `Failed to spawn command \`${this.command}\`. Make sure \`${ 331 this.command 332 }\` is installed and on your PATH`, 333 error.path 334 ); 335 error.name = 'BufferedProcessError'; 336 } 337 338 if (!handled) throw error; 339 } 340 };