UNPKG

combohandler

Version:

Simple Yahoo!-style combo handler.

794 lines (593 loc) 29.6 kB
/*global describe, before, after, beforeEach, afterEach, it, sinon */ var path = require('path'); var rimraf = require('rimraf'); var mkdirp = require('mkdirp'); var should = require('should'); var ComboBase = require('../lib/cluster/base'); var ComboMaster = require('../lib/cluster'); describe("cluster master", function () { /*jshint expr:true */ var PIDS_DIR = 'test/fixtures/pids'; after(cleanPidsDir); describe("instantiation", function () { it("should support empty options arg with correct defaults", function () { var instance = new ComboMaster(); instance.should.have.property('options'); instance.options.should.eql(ComboMaster.defaults); }); it("should support factory-style (no new)", function () { /*jshint newcap: false */ var instance = ComboMaster(); instance.should.be.an.instanceOf(ComboMaster); instance.should.have.property('options'); }); it("should be an instance of ComboBase", function () { var instance = new ComboMaster(); instance.should.be.an.instanceOf(ComboBase); }); it("should call constructor callback if passed after config", function (done) { var instance = new ComboMaster({}, done); }); it("should detect constructor callback if passed instead of config", function (done) { var instance = new ComboMaster(done); }); it("should setup instance properties", function () { var instance = new ComboMaster(); instance.should.have.property('startupTimeout'); instance.should.have.property('closingTimeout'); instance.should.have.property('flameouts'); instance.startupTimeout.should.eql([]); instance.closingTimeout.should.eql([]); instance.flameouts.should.equal(0); }); }); describe("on 'start'", function () { before(cleanPidsDir); beforeEach(function () { var instance = new ComboMaster({ pids: PIDS_DIR }); sinon.stub(instance.cluster, "setupMaster"); sinon.stub(instance.pidutil, "writePidFileSync"); sinon.spy(instance.cluster, "on"); sinon.spy(instance.process, "on"); this.instance = instance; }); afterEach(function () { var instance = this.instance; instance.cluster.setupMaster.restore(); instance.pidutil.writePidFileSync.restore(); instance.cluster.on.restore(); instance.process.on.restore(); instance.emit('cleanup'); this.instance = instance = null; }); after(cleanPidsDir); it("should setupMaster with exec config", function (done) { var instance = this.instance; var setupMaster = instance.cluster.setupMaster; instance.start(function () { setupMaster.calledOnce.should.be.ok; setupMaster.calledWith(sinon.match.object); setupMaster.firstCall.args[0] .should.have.property('exec', path.resolve(__dirname, '../lib/cluster/worker.js')); done(); }); }); it("should create master.pid", function (done) { var instance = this.instance; var writePidFileSync = instance.pidutil.writePidFileSync; instance.start(function () { writePidFileSync.calledOnce.should.be.ok; writePidFileSync.calledWith(instance.options.pids, 'master', instance.process.pid).should.be.ok; done(); }); }); it("should attach cluster and process events", function (done) { var instance = this.instance; instance.start(function () { verifyCluster(instance.cluster.on); verifyProcess(instance.process.on); done(); }); }); }); describe("on 'destroy'", function () { beforeEach(function () { var instance = new ComboMaster(); sinon.spy(instance.cluster, "removeListener"); sinon.spy(instance.process, "removeListener"); sinon.stub(instance.cluster, "disconnect").yields(); this.instance = instance; }); afterEach(function () { var instance = this.instance; instance.cluster.removeListener.restore(); instance.process.removeListener.restore(); instance.cluster.disconnect.restore(); this.instance = instance = null; }); it("should detach cluster and process events", function (done) { var instance = this.instance; sinon.stub(instance.cluster, "setupMaster"); sinon.stub(instance.pidutil, "writePidFileSync"); instance.start(); instance.destroy(function () { verifyCluster(instance.cluster.removeListener); verifyProcess(instance.process.removeListener); instance.cluster.setupMaster.restore(); instance.pidutil.writePidFileSync.restore(); done(); }); }); it("should not error when destroy callback missing", function () { var instance = this.instance; /*jshint immed:false */ (function () { instance.destroy(); }).should.not.throwError(); }); }); describe("signal methods:", function () { beforeEach(createInstance); afterEach(cleanupInstance); describe("#status()", function () { describe("when master.pid missing", function () { it("should log ENOENT error", function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); var processExit = sinon.stub(instance.process, "exit"); sinon.stub(instance.pidutil, "getMasterPid").yields({ code: "ENOENT" }); instance.status(); // verify that error message was sent consoleError.calledOnce.should.be.ok; consoleError.calledWith('combohandler master not running!').should.be.ok; processExit.calledOnce.should.be.ok; processExit.firstCall.calledWith(1).should.be.ok; consoleError.restore(); processExit.restore(); instance.pidutil.getMasterPid.restore(); }); it("should throw non-ENOENT error", function () { var instance = this.instance; sinon.stub(instance.pidutil, "getMasterPid").yields({ code: "FOO" }); /*jshint immed:false */ (function () { instance.status(); }).should.throwError(); instance.pidutil.getMasterPid.restore(); }); }); it("should log master state", function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); sinon.stub(instance.pidutil, "getWorkerPidsSync").returns([]); sinon.stub(instance.pidutil, "getMasterPid") .yields(null, process.pid, instance.options.pids); instance.status(); consoleError.calledOnce.should.be.ok; consoleError.firstCall.args[0].should.equal('%s\u001b[90m %d\u001b[0m \u001b[36m%s\u001b[0m'); consoleError.firstCall.args[1].should.equal('master'); consoleError.firstCall.args[2].should.equal(process.pid); consoleError.firstCall.args[3].should.equal('alive'); consoleError.restore(); instance.pidutil.getWorkerPidsSync.restore(); instance.pidutil.getMasterPid.restore(); }); }); describe("#restart()", function () { describe("when master.pid missing", function () { it("should log ENOENT error", function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); var processExit = sinon.stub(instance.process, "exit"); sinon.stub(instance.pidutil, "getMasterPid").yields({ code: "ENOENT" }); instance.restart(); // verify that error messages were sent consoleError.calledTwice.should.be.ok; consoleError.calledWith("Error sending signal %s to combohandler master process", 'SIGUSR2'); consoleError.calledWith('combohandler master not running!').should.be.ok; processExit.calledOnce.should.be.ok; processExit.firstCall.calledWith(1).should.be.ok; consoleError.restore(); processExit.restore(); instance.pidutil.getMasterPid.restore(); }); it("should throw non-ENOENT error", function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); sinon.stub(instance.pidutil, "getMasterPid").yields({ code: "FOO" }); /*jshint immed:false */ (function () { instance.restart(); }).should.throwError(); // verify that error message was sent consoleError.calledOnce.should.be.ok; consoleError.calledWith("Error sending signal %s to combohandler master process", 'SIGUSR2'); consoleError.restore(); instance.pidutil.getMasterPid.restore(); }); }); describe("when previous master did not clean pidfile", function () { it("should log ESRCH error", function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); sinon.stub(instance.pidutil, "getMasterPid") .yields(null, process.pid, instance.options.pids); sinon.stub(instance.process, "kill").throws({ code: "ESRCH" }); instance.restart(); // verify that error message was sent consoleError.calledOnce.should.be.ok; consoleError.calledWith('combohandler master not running!').should.be.ok; consoleError.restore(); instance.pidutil.getMasterPid.restore(); instance.process.kill.restore(); }); it("should throw non-ESRCH error", function () { var instance = this.instance; sinon.stub(instance.pidutil, "getMasterPid") .yields(null, process.pid, instance.options.pids); sinon.stub(instance.process, "kill").throws({ code: "FOO" }); /*jshint immed:false */ (function () { instance.restart(); }).should.throwError(); instance.pidutil.getMasterPid.restore(); instance.process.kill.restore(); }); }); it("should send SIGUSR2 to master", signalMasterSuccess('restart', 'SIGUSR2')); }); describe("#shutdown()", function () { it("should send SIGTERM to master", signalMasterSuccess('shutdown', 'SIGTERM')); }); describe("#stop()", function () { it("should send SIGKILL to master", signalMasterSuccess('stop', 'SIGKILL')); }); function signalMasterSuccess(methodName, expectedSignal) { return function () { var instance = this.instance; var consoleError = sinon.stub(console, "error"); // silence var processKill = sinon.stub(instance.process, "kill"); sinon.stub(instance.pidutil, "getMasterPid") .yields(null, process.pid, instance.options.pids); sinon.stub(instance.pidutil, "removePidFileSync"); sinon.stub(instance.pidutil, "removeWorkerPidFiles").yields(); instance[methodName](); // match arguments processKill.calledOnce.should.be.ok; processKill.firstCall.args[0].should.equal(process.pid); processKill.firstCall.args[1].should.equal(expectedSignal); // remove stubs consoleError.restore(); processKill.restore(); instance.pidutil.getMasterPid.restore(); instance.pidutil.removePidFileSync.restore(); instance.pidutil.removeWorkerPidFiles.restore(); }; } }); describe("process event handlers:", function () { // stubbing console.log must be done inside tests :( beforeEach(createInstance); afterEach(cleanupInstance); describe("gracefulShutdown()", function () { it("should call cluster.disconnect", function () { var instance = this.instance; var consoleLog = sinon.stub(console, "log"); // silence sinon.stub(instance.cluster, "disconnect"); instance.gracefulShutdown(); instance.cluster.disconnect.calledOnce.should.be.ok; instance.cluster.disconnect.calledWith(sinon.match.func).should.be.ok; consoleLog.restore(); instance.cluster.disconnect.restore(); }); it("should hook process 'exit'", function () { var instance = this.instance; var consoleLog = sinon.stub(console, "log"); // silence sinon.stub(instance.cluster, "disconnect").yields(); sinon.stub(instance.process, "once"); instance.gracefulShutdown(); instance.process.once.calledOnce.should.be.ok; instance.process.once.calledWith('exit', sinon.match.func).should.be.ok; consoleLog.restore(); instance.cluster.disconnect.restore(); instance.process.once.restore(); }); it("should remove master pidfile on process exit", function () { var instance = this.instance; var consoleLog = sinon.stub(console, "log"); // verify later sinon.stub(instance.cluster, "disconnect").yields(); sinon.stub(instance.pidutil, "removePidFileSync"); var processOnce = sinon.stub(instance.process, "once"); processOnce.withArgs("exit", sinon.match.func).callsArg(1); // where the magic happens instance.gracefulShutdown(); consoleLog.calledTwice.should.be.ok; consoleLog.calledWith('combohandler master %d shutting down...', process.pid).should.be.ok; consoleLog.calledWith('combohandler master %d finished shutting down!', process.pid).should.be.ok; instance.pidutil.removePidFileSync.callCount.should.equal(1); instance.pidutil.removePidFileSync.calledWith(instance.options.pids, 'master'); consoleLog.restore(); instance.cluster.disconnect.restore(); instance.pidutil.removePidFileSync.restore(); processOnce.restore(); }); }); describe("restartWorkers()", function () { beforeEach(function () { this.instance.cluster.workers = { "1": { process: { pid: 1 } }, "2": { process: { pid: 2 } }, "3": { process: { pid: 3 } } }; }); afterEach(function () { this.instance.cluster.workers = {}; }); it("should send SIGUSR2 to all worker processes", function () { var instance = this.instance; var consoleLog = sinon.stub(console, "log"); // silence var processKill = sinon.stub(instance.process, "kill"); instance.restartWorkers(); processKill.callCount.should.equal(3); processKill.getCall(0).calledWith(1, "SIGUSR2"); processKill.getCall(1).calledWith(2, "SIGUSR2"); processKill.getCall(2).calledWith(3, "SIGUSR2"); processKill.restore(); consoleLog.restore(); }); }); }); describe("worker event", function () { beforeEach(createInstance); afterEach(cleanupInstance); function WorkerAPI(id) { this.id = id; this.process = { pid: 1e6 + id }; } WorkerAPI.prototype.send = function () {}; WorkerAPI.prototype.destroy = function () {}; describe("'fork'", function () { it("should set startupTimeout", function () { var instance = this.instance; var worker = { id: 1 }; instance._bindCluster(); instance.startupTimeout.should.be.empty; instance.cluster.emit('fork', worker); instance.startupTimeout.should.have.property("1"); clearTimeout(instance.startupTimeout["1"]); }); it("should log error if startupTimeout elapsed", function () { var instance = this.instance; var worker = { id: 1 }; var consoleError = sinon.stub(console, "error"); var clock = sinon.useFakeTimers("setTimeout"); instance._bindCluster(); instance.options.timeout = 10; instance.cluster.emit('fork', worker); clock.tick(20); consoleError.calledOnce.should.be.ok; consoleError.calledWith('Something is wrong with worker %d', 1).should.be.ok; consoleError.restore(); clock.restore(); }); }); describe("'online'", function () { it("should clear startupTimeout", function () { var instance = this.instance; var worker = sinon.mock(new WorkerAPI(1)); var consoleError = sinon.stub(console, "error"); // silence instance._bindCluster(); instance.startupTimeout["1"] = setTimeout(function() { should.fail(); }, 100); instance.cluster.emit('online', worker.object); instance.startupTimeout["1"].should.have.property('_onTimeout', null); consoleError.restore(); }); it("should send 'listen' command", function () { var instance = this.instance; var worker = sinon.mock(new WorkerAPI(1)); var consoleError = sinon.stub(console, "error"); // silence worker.expects("send").once().withArgs({ cmd: 'listen', data: instance.options }); instance._bindCluster(); instance.cluster.emit('fork', worker.object); instance.cluster.emit('online', worker.object); worker.verify(); consoleError.restore(); }); }); describe("'listening'", function () { it("should write worker pidfile", function () { var instance = this.instance; var worker = sinon.mock(new WorkerAPI(1)); var consoleError = sinon.stub(console, "error"); // silence var writePidFileSync = sinon.stub(instance.pidutil, "writePidFileSync"); instance._bindCluster(); instance.cluster.emit('listening', worker.object); worker.object.process.should.have.property('title', 'combohandler worker'); writePidFileSync.calledOnce.should.be.ok; writePidFileSync.calledWith( instance.options.pids, 'worker1', worker.object.process.pid ).should.be.ok; consoleError.restore(); writePidFileSync.restore(); }); }); describe("'disconnect'", function () { it("should set closingTimeout", function () { var instance = this.instance; var worker = { id: 1 }; var consoleError = sinon.stub(console, "error"); // silence instance._bindCluster(); instance.closingTimeout.should.be.empty; instance.cluster.emit('disconnect', worker); instance.closingTimeout.should.have.property("1"); clearTimeout(instance.startupTimeout["1"]); // timeouts aren't pushed onto the stack, they are assigned sparsely by id consoleError.restore(); }); it("should destroy() worker when closingTimeout expires", function (done) { var instance = this.instance; var worker = sinon.mock(new WorkerAPI(1)); var consoleError = sinon.stub(console, "error"); // silence instance.options.timeout = 10; worker.expects("destroy").once(); instance._bindCluster(); instance.cluster.emit('disconnect', worker.object); setTimeout(function () { worker.verify(); consoleError.restore(); done(); }, 25); }); }); describe("'exit'", function () { beforeEach(function () { this.consoleError = sinon.stub(console, "error"); // silence sinon.stub(this.instance.cluster, "fork"); this.instance._bindCluster(); }); afterEach(function () { this.consoleError.restore(); this.consoleError = null; this.instance.cluster.fork.restore(); }); it("should clear startup and closing timeouts", function () { var instance = this.instance; instance.startupTimeout[1] = setTimeout(should.fail, 100); instance.closingTimeout[1] = setTimeout(should.fail, 100); instance.cluster.emit('exit', { id: 1 }); instance.startupTimeout[1].should.have.property('_onTimeout', null); instance.closingTimeout[1].should.have.property('_onTimeout', null); }); describe("suicide", function () { it("should not spawn new worker", function () { var instance = this.instance; var removePidFile = sinon.stub(instance.pidutil, "removePidFileSync"); instance.cluster.emit('exit', { id: 1, suicide: true }); instance.cluster.fork.callCount.should.equal(0); removePidFile.restore(); }); it("should remove worker pidfile", function () { var instance = this.instance; var removePidFile = sinon.stub(instance.pidutil, "removePidFileSync"); instance.cluster.emit('exit', { id: 1, suicide: true }); removePidFile.callCount.should.equal(1); removePidFile.calledWith(instance.options.pids, 'worker1.pid'); removePidFile.restore(); }); }); describe("natural death", function () { it("should spawn new worker", function () { var instance = this.instance; instance.cluster.emit('exit', { id: 1 }); instance.cluster.fork.calledOnce.should.be.ok; }); }); describe("reloading", function () { it("should remove worker pidfile", function () { var instance = this.instance; var removePidFile = sinon.stub(instance.pidutil, "removePidFileSync"); instance.cluster.emit('exit', { id: 1 }, null, "SIGUSR2"); removePidFile.callCount.should.equal(1); removePidFile.calledWith(instance.options.pids, 'worker1.pid'); removePidFile.restore(); }); it("should not remove worker pidfile when worker receives a different signal", function () { var instance = this.instance; var removePidFile = sinon.stub(instance.pidutil, "removePidFileSync"); instance.cluster.emit('exit', { id: 1 }, null, "SIGKILL"); removePidFile.callCount.should.equal(0); removePidFile.restore(); }); }); describe("flameouts", function () { it("should not exit when flameouts threshhold unmet", function () { var instance = this.instance; instance.flameouts = 0; instance.cluster.emit('exit', { id: 1 }, 1); this.consoleError.calledWith('Worker %d exited with code %d', 1, 1).should.be.ok; }); it("should exit when flameouts threshhold exceeded", function (done) { var instance = this.instance; var consoleError = this.consoleError; var removePidFile = sinon.stub(instance.pidutil, "removePidFileSync"); var processExit = sinon.stub(instance.process, "exit", function (exitCode) { // verify that error messages were sent consoleError.calledTwice.should.be.ok; consoleError.calledWith("Too many errors during startup, bailing!"); removePidFile.callCount.should.equal(1); removePidFile.calledWith(instance.options.pids, 'master.pid'); // should not spawn another worker instance.cluster.fork.callCount.should.equal(0); exitCode.should.equal(1); removePidFile.restore(); processExit.restore(); done(); }); instance.flameouts = 20; instance.cluster.emit('exit', { id: 1 }, 1); }); }); }); }); describe("on 'listen'", function () { beforeEach(createInstance); afterEach(cleanupInstance); it("should fork workers", function (done) { var consoleLog = sinon.stub(console, "log"); // silence var instance = this.instance; instance.options.workers = 1; var setupMaster = sinon.stub(instance.cluster, "setupMaster"); var clusterFork = sinon.stub(instance.cluster, "fork"); instance.on('listen', function () { setTimeout(function () { clusterFork.calledOnce.should.be.ok; clusterFork.restore(); setupMaster.restore(); consoleLog.restore(); done(); }, 10); }); instance.listen(); }); }); // Test Utilities --------------------------------------------------------- function verifyCluster(spyCluster) { spyCluster.callCount.should.equal(5); spyCluster.getCall(0).calledWith('fork', sinon.match.func).should.be.ok; spyCluster.getCall(1).calledWith('online', sinon.match.func).should.be.ok; spyCluster.getCall(2).calledWith('listening', sinon.match.func).should.be.ok; spyCluster.getCall(3).calledWith('disconnect', sinon.match.func).should.be.ok; spyCluster.getCall(4).calledWith('exit', sinon.match.func).should.be.ok; return spyCluster; } function verifyProcess(spyProcess) { spyProcess.callCount.should.equal(3); spyProcess.getCall(0).calledWith('SIGINT', sinon.match.func).should.be.ok; spyProcess.getCall(1).calledWith('SIGTERM', sinon.match.func).should.be.ok; spyProcess.getCall(2).calledWith('SIGUSR2', sinon.match.func).should.be.ok; return spyProcess; } function makePidsDir(done) { mkdirp(PIDS_DIR, done); } function cleanPidsDir(done) { rimraf(PIDS_DIR, done); } function createInstance(done) { this.instance = new ComboMaster({ pids: PIDS_DIR }); makePidsDir(done); } function cleanupInstance(done) { this.instance.emit('cleanup'); this.instance = null; cleanPidsDir(done); } });