karma-runner/karma

View on GitHub
lib/launchers/process.js

Summary

Maintainability
C
1 day
Test Coverage
const path = require('path')
const log = require('../logger').create('launcher')
const env = process.env

function ProcessLauncher (spawn, tempDir, timer, processKillTimeout) {
  const self = this
  let onExitCallback
  const killTimeout = processKillTimeout || 2000
  // Will hold output from the spawned child process
  const streamedOutputs = {
    stdout: '',
    stderr: ''
  }

  this._tempDir = tempDir.getPath(`/karma-${this.id.toString()}`)

  this.on('start', function (url) {
    tempDir.create(self._tempDir)
    self._start(url)
  })

  this.on('kill', function (done) {
    if (!self._process) {
      return process.nextTick(done)
    }

    onExitCallback = done
    self._process.kill()
    self._killTimer = timer.setTimeout(self._onKillTimeout, killTimeout)
  })

  this._start = function (url) {
    self._execCommand(self._getCommand(), self._getOptions(url))
  }

  this._getCommand = function () {
    return env[self.ENV_CMD] || self.DEFAULT_CMD[process.platform]
  }

  this._getOptions = function (url) {
    return [url]
  }

  // Normalize the command, remove quotes (spawn does not like them).
  this._normalizeCommand = function (cmd) {
    if (cmd.charAt(0) === cmd.charAt(cmd.length - 1) && '\'`"'.includes(cmd.charAt(0))) {
      cmd = cmd.slice(1, -1)
      log.warn(`The path should not be quoted.\n  Normalized the path to ${cmd}`)
    }

    return path.normalize(cmd)
  }

  this._onStdout = function (data) {
    streamedOutputs.stdout += data
  }

  this._onStderr = function (data) {
    streamedOutputs.stderr += data
  }

  this._execCommand = function (cmd, args) {
    if (!cmd) {
      log.error(`No binary for ${self.name} browser on your platform.\n  Please, set "${self.ENV_CMD}" env variable.`)

      // disable restarting
      self._retryLimit = -1

      return self._clearTempDirAndReportDone('no binary')
    }

    cmd = this._normalizeCommand(cmd)

    log.debug(cmd + ' ' + args.join(' '))
    self._process = spawn(cmd, args)
    let errorOutput = ''

    self._process.stdout.on('data', self._onStdout)

    self._process.stderr.on('data', self._onStderr)

    self._process.on('exit', function (code, signal) {
      self._onProcessExit(code, signal, errorOutput)
    })

    self._process.on('error', function (err) {
      if (err.code === 'ENOENT') {
        self._retryLimit = -1
        errorOutput = `Can not find the binary ${cmd}\n\tPlease set env variable ${self.ENV_CMD}`
      } else if (err.code === 'EACCES') {
        self._retryLimit = -1
        errorOutput = `Permission denied accessing the binary ${cmd}\n\tMaybe it's a directory?`
      } else {
        errorOutput += err.toString()
      }
      self._onProcessExit(-1, null, errorOutput)
    })

    self._process.stderr.on('data', function (errBuff) {
      errorOutput += errBuff.toString()
    })
  }

  this._onProcessExit = function (code, signal, errorOutput) {
    if (!self._process) {
      // Both exit and error events trigger _onProcessExit(), but we only need one cleanup.
      return
    }
    log.debug(`Process ${self.name} exited with code ${code} and signal ${signal}`)

    let error = null

    if (self.state === self.STATE_BEING_CAPTURED) {
      log.error(`Cannot start ${self.name}\n\t${errorOutput}`)
      error = 'cannot start'
    }

    if (self.state === self.STATE_CAPTURED) {
      log.error(`${self.name} crashed.\n\t${errorOutput}`)
      error = 'crashed'
    }

    if (error) {
      log.error(`${self.name} stdout: ${streamedOutputs.stdout}`)
      log.error(`${self.name} stderr: ${streamedOutputs.stderr}`)
    }

    self._process = null
    streamedOutputs.stdout = ''
    streamedOutputs.stderr = ''
    if (self._killTimer) {
      timer.clearTimeout(self._killTimer)
      self._killTimer = null
    }
    self._clearTempDirAndReportDone(error)
  }

  this._clearTempDirAndReportDone = function (error) {
    tempDir.remove(self._tempDir, function () {
      self._done(error)
      if (onExitCallback) {
        onExitCallback()
        onExitCallback = null
      }
    })
  }

  this._onKillTimeout = function () {
    if (self.state !== self.STATE_BEING_KILLED && self.state !== self.STATE_BEING_FORCE_KILLED) {
      return
    }

    log.warn(`${self.name} was not killed in ${killTimeout} ms, sending SIGKILL.`)
    self._process.kill('SIGKILL')

    // NOTE: https://github.com/karma-runner/karma/pull/1184
    // NOTE: SIGKILL is just a signal.  Processes should never ignore it, but they can.
    // If a process gets into a state where it doesn't respond in a reasonable amount of time
    // Karma should warn, and continue as though the kill succeeded.
    // This a certainly suboptimal, but it is better than having the test harness hang waiting
    // for a zombie child process to exit.
    self._killTimer = timer.setTimeout(function () {
      log.warn(`${self.name} was not killed by SIGKILL in ${killTimeout} ms, continuing.`)
      self._onProcessExit(-1, null, '')
    }, killTimeout)
  }
}

ProcessLauncher.decoratorFactory = function (timer) {
  return function (launcher, processKillTimeout) {
    const spawn = require('child_process').spawn

    function spawnWithoutOutput () {
      const proc = spawn.apply(null, arguments)
      proc.stdout.resume()
      proc.stderr.resume()

      return proc
    }

    ProcessLauncher.call(launcher, spawnWithoutOutput, require('../temp_dir'), timer, processKillTimeout)
  }
}

module.exports = ProcessLauncher