Skip to content

Commit

Permalink
refactor(launcher): extract timeout, retry, process
Browse files Browse the repository at this point in the history
So that we can easily reuse only some features.
  • Loading branch information
vojtajina committed Dec 23, 2013
1 parent 4e4bba8 commit e6f36ca
Show file tree
Hide file tree
Showing 12 changed files with 956 additions and 427 deletions.
35 changes: 27 additions & 8 deletions lib/launcher.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
var log = require('./logger').create('launcher');
var baseBrowserDecoratorFactory = require('./launchers/base').decoratorFactory;

var baseDecorator = require('./launchers/base').decoratorFactory;
var captureTimeoutDecorator = require('./launchers/capture_timeout').decoratorFactory;
var retryDecorator = require('./launchers/retry').decoratorFactory;
var processDecorator = require('./launchers/process').decoratorFactory;


// TODO(vojta): remove once nobody uses it
var baseBrowserDecoratorFactory = function(baseLauncherDecorator, captureTimeoutLauncherDecorator,
retryLauncherDecorator, processLauncherDecorator) {
return function(launcher) {
baseLauncherDecorator(launcher);
captureTimeoutLauncherDecorator(launcher);
retryLauncherDecorator(launcher);
processLauncherDecorator(launcher);
};
};

var Launcher = function(emitter, injector) {
var browsers = [];
var lastUrl;
var lastStartTime;

var getBrowserById = function(id) {
Expand All @@ -19,14 +33,18 @@ var Launcher = function(emitter, injector) {

this.launch = function(names, hostname, port, urlRoot) {
var browser;
var url = (lastUrl = 'http://' + hostname + ':' + port + urlRoot);
var url = 'http://' + hostname + ':' + port + urlRoot;

lastStartTime = Date.now();

names.forEach(function(name) {
var locals = {
id: ['value', Launcher.generateId()],
name: ['value', name],
baseLauncherDecorator: ['factory', baseDecorator],
captureTimeoutLauncherDecorator: ['factory', captureTimeoutDecorator],
retryLauncherDecorator: ['factory', retryDecorator],
processLauncherDecorator: ['factory', processDecorator],
baseBrowserDecorator: ['factory', baseBrowserDecoratorFactory]
};

Expand All @@ -48,6 +66,9 @@ var Launcher = function(emitter, injector) {
return;
}

// TODO(vojta): remove in v1.0
browser.forceKill = browser.forceKill || browser.kill;

log.info('Starting browser %s', browser.name);
browser.start(url);
browsers.push(browser);
Expand All @@ -68,7 +89,7 @@ var Launcher = function(emitter, injector) {
return false;
}

browser.kill(callback);
browser.forceKill().then(callback);
return true;
};

Expand All @@ -80,9 +101,7 @@ var Launcher = function(emitter, injector) {
return false;
}

browser.kill(function() {
browser.start(lastUrl);
});
browser.restart();
return true;
};

Expand All @@ -104,7 +123,7 @@ var Launcher = function(emitter, injector) {

browsers.forEach(function(browser) {
remaining++;
browser.kill(finish);
browser.forceKill().then(finish);
});
};

Expand Down
243 changes: 79 additions & 164 deletions lib/launchers/base.js
Original file line number Diff line number Diff line change
@@ -1,215 +1,130 @@
var spawn = require('child_process').spawn;
var path = require('path');
var fs = require('fs');
var rimraf = require('rimraf');

var KarmaEventEmitter = require('../events').EventEmitter;
var EventEmitter = require('events').EventEmitter;
var q = require('q');
var log = require('../logger').create('launcher');
var env = process.env;

var BEING_CAPTURED = 1;
var CAPTURED = 2;
var BEING_KILLED = 3;
var FINISHED = 4;
var BEING_TIMEOUTED = 5;
var RESTARTING = 5;
var BEING_FORCE_KILLED = 6;


var BaseBrowser = function(id, emitter, captureTimeout, retryLimit) {
var self = this;
var capturingUrl;
var exitCallbacks = [];
/**
* Base launcher that any custom launcher extends.
*/
var BaseLauncher = function(id, emitter) {
if (this.start) {
return;
}

// TODO(vojta): figure out how to do inheritance with DI
Object.keys(EventEmitter.prototype).forEach(function(method) {
this[method] = EventEmitter.prototype[method];
}, this);
KarmaEventEmitter.call(this);

this.killTimeout = 2000;
this.id = id;
this.state = null;
this._tempDir = path.normalize((env.TMPDIR || env.TMP || env.TEMP || '/tmp') + '/karma-' +
id.toString());

this.start = function(url) {
capturingUrl = url;
self.state = BEING_CAPTURED;

try {
log.debug('Creating temp dir at ' + self._tempDir);
fs.mkdirSync(self._tempDir);
} catch (e) {}
this.error = null;

self._start(capturingUrl + '?id=' + self.id);

if (captureTimeout) {
setTimeout(self._onTimeout, captureTimeout);
}
};
var self = this;
var killingPromise;
var previousUrl;

this.start = function(url) {
previousUrl = url;

this._start = function(url) {
self._execCommand(self._getCommand(), self._getOptions(url));
this.error = null;
this.state = BEING_CAPTURED;
this.emit('start', url + '?id=' + this.id);
};


this.markCaptured = function() {
if (self.state === BEING_CAPTURED) {
self.state = CAPTURED;
this.kill = function() {
// Already killed, or being killed.
if (killingPromise) {
return killingPromise;
}
};


this.isCaptured = function() {
return self.state === CAPTURED;
};

killingPromise = this.emitAsync('kill').then(function() {
self.state = FINISHED;
});

this.kill = function(callback) {
var exitCallback = callback || function() {};
this.state = BEING_KILLED;

log.debug('Killing %s', self.name);
if (self.state === FINISHED) {
process.nextTick(exitCallback);
} else if (self.state === BEING_KILLED) {
exitCallbacks.push(exitCallback);
} else {
self.state = BEING_KILLED;
self._process.kill();
exitCallbacks.push(exitCallback);
setTimeout(self._onKillTimeout, self.killTimeout);
}
return killingPromise;
};

this.forceKill = function() {
this.kill();
this.state = BEING_FORCE_KILLED;

this._onKillTimeout = function() {
if (self.state !== BEING_KILLED) {
return;
}

log.warn('%s was not killed in %d ms, sending SIGKILL.', self.name, self.killTimeout);

self._process.kill('SIGKILL');
return killingPromise;
};

this._onTimeout = function() {
if (self.state !== BEING_CAPTURED) {
this.restart = function() {
if (this.state === BEING_FORCE_KILLED) {
return;
}

log.warn('%s have not captured in %d ms, killing.', self.name, captureTimeout);

self.state = BEING_TIMEOUTED;
self._process.kill();
};


this.toString = function() {
return self.name;
};


this._getCommand = function() {
var cmd = path.normalize(env[self.ENV_CMD] || self.DEFAULT_CMD[process.platform]);

if (!cmd) {
log.error('No binary for %s browser on your platform.\n\t' +
'Please, set "%s" env variable.', self.name, self.ENV_CMD);
}

return cmd;
};


this._execCommand = function(cmd, args) {
// normalize the cmd, remove quotes (spawn does not like them)
if (cmd.charAt(0) === cmd.charAt(cmd.length - 1) && '\'`"'.indexOf(cmd.charAt(0)) !== -1) {
cmd = cmd.substring(1, cmd.length - 1);
log.warn('The path should not be quoted.\n Normalized the path to %s', cmd);
if (!killingPromise) {
killingPromise = this.emitAsync('kill');
}

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

var errorOutput = '';

self._process.on('close', function(code) {
self._onProcessExit(code, errorOutput);
});

self._process.on('error', function(err) {
if (err.code === 'ENOENT') {
retryLimit = 0;
errorOutput = 'Can not find the binary ' + cmd + '\n\t' +
'Please set env variable ' + self.ENV_CMD;
killingPromise.then(function() {
if (self.state === BEING_FORCE_KILLED) {
self.state = FINISHED;
} else {
errorOutput += err.toString();
killingPromise = null;
log.debug('Restarting %s', self.name);
self.start(previousUrl);
}
});

// Node 0.8 does not emit the error
if (process.versions.node.indexOf('0.8') === 0) {
self._process.stderr.on('data', function(data) {
var msg = data.toString();

if (msg.indexOf('No such file or directory') !== -1) {
retryLimit = 0;
errorOutput = 'Can not find the binary ' + cmd + '\n\t' +
'Please set env variable ' + self.ENV_CMD;
} else {
errorOutput += msg;
}
});
}
self.state = RESTARTING;
};


this._onProcessExit = function(code, errorOutput) {
log.debug('Process %s exitted with code %d', self.name, code);

if (self.state === BEING_CAPTURED) {
log.error('Cannot start %s\n\t%s', self.name, errorOutput);
}

if (self.state === CAPTURED) {
log.error('%s crashed.\n\t%s', self.name, errorOutput);
this.markCaptured = function() {
if (this.state === BEING_CAPTURED) {
this.state = CAPTURED;
}
};

retryLimit--;

if (self.state === BEING_CAPTURED || self.state === BEING_TIMEOUTED) {
if (retryLimit > 0) {
return self._cleanUpTmp(function() {
log.info('Trying to start %s again.', self.name);
self.start(capturingUrl);
});
} else {
emitter.emit('browser_process_failure', self);
}
}
this.isCaptured = function() {
return this.state === CAPTURED;
};

self.state = FINISHED;
self._cleanUpTmp(function(err) {
exitCallbacks.forEach(function(exitCallback) {
exitCallback(err);
});
exitCallbacks = [];
});
this.toString = function() {
return this.name;
};

this._done = function(error) {
killingPromise = killingPromise || q();

this._cleanUpTmp = function(done) {
log.debug('Cleaning temp dir %s', self._tempDir);
rimraf(self._tempDir, done);
};
this.error = this.error || error;
this.emit('done');

if (this.error && this.state !== BEING_FORCE_KILLED && this.state !== RESTARTING) {
emitter.emit('browser_process_failure', this);
}

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

this.STATE_BEING_CAPTURED = BEING_CAPTURED;
this.STATE_CAPTURED = CAPTURED;
this.STATE_BEING_KILLED = BEING_KILLED;
this.STATE_FINISHED = FINISHED;
this.STATE_RESTARTING = RESTARTING;
this.STATE_BEING_FORCE_KILLED = BEING_FORCE_KILLED;
};

var baseBrowserDecoratorFactory = function(id, emitter, timeout) {
return function(self) {
BaseBrowser.call(self, id, emitter, timeout, 3);
BaseLauncher.decoratorFactory = function(id, emitter) {
return function(launcher) {
BaseLauncher.call(launcher, id, emitter);
};
};
baseBrowserDecoratorFactory.$inject = ['id', 'emitter', 'config.captureTimeout'];


// PUBLISH
exports.BaseBrowser = BaseBrowser;
exports.decoratorFactory = baseBrowserDecoratorFactory;
module.exports = BaseLauncher;
Loading

0 comments on commit e6f36ca

Please sign in to comment.