/ src / buffered-process.js
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  };