diff --git a/package.json b/package.json index c30aa7042..7be4b494b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "optimist": "^0.6.1", "pitboss-ng": "^0.3.2", "proxyquire": "^1.7.10", + "ps-node": "^0.1.4", "request": "^2.79.0", "spawn-args": "^0.2.0", "sync-exec": "^0.6.2", @@ -67,7 +68,6 @@ "mocha": "^3.0.0", "mocha-lcov-reporter": "^1.3.0", "nock": "^9.0.4", - "ps-node": "^0.1.4", "semantic-release": "^6.3.2", "sinon": "^1.17.4" }, diff --git a/src/child-process.coffee b/src/child-process.coffee new file mode 100644 index 000000000..2345ec3fb --- /dev/null +++ b/src/child-process.coffee @@ -0,0 +1,187 @@ +crossSpawn = require('cross-spawn') + + +IS_WINDOWS = process.platform is 'win32' +ASCII_CTRL_C = 3 + +TERMINATION_DEFAULT_TIMEOUT_MS = 1000 +TERMINATION_FIRST_CHECK_TIMEOUT_MS = 1 +TERMINATION_MIN_RETRY_TIMEOUT_MS = 100 +TERMINATION_PREFERRED_RETRY_COUNT = 3 + + +# Signals the child process to forcefully terminate +signalKill = (childProcess, callback) -> + childProcess.emit('signalKill') + if IS_WINDOWS + taskkill = spawn('taskkill', ['/F', '/T', '/PID', childProcess.pid]) + taskkill.on('close', (statusCode) -> + if statusCode + err = new Error("Unable to forcefully terminate process #{childProcess.pid}") + return callback(err) + callback() + ) + else + childProcess.kill('SIGKILL') + process.nextTick(callback) + + +# Signals the child process to gracefully terminate +signalTerm = (childProcess, callback) -> + childProcess.emit('signalTerm') + if IS_WINDOWS + # On Windows, there is no such way as SIGTERM or SIGINT. The closest + # thing is to interrupt the process with Ctrl+C. Under the hood, that + # generates '\u0003' character on stdin of the process and if + # the process listens on stdin, it's able to catch this as 'SIGINT'. + # + # However, that only works if user does it manually. There is no + # way to do it programmatically, at least not in Node.js (and even + # for C/C++, all the solutions are dirty hacks). Even if you send + # the very same character to stdin of the process, it's not + # recognized (the rl.on('SIGINT') event won't get triggered) + # for some reason. + # + # The only thing Dredd is left with is a convention. So when Dredd + # wants to gracefully signal to the child process it should terminate, + # it sends the '\u0003' to stdin of the child. It's up to the child + # to implement reading from stdin in such way it works both for + # programmatic and manual Ctrl+C. + childProcess.stdin.write(String.fromCharCode(ASCII_CTRL_C)) + else + childProcess.kill('SIGTERM') + process.nextTick(callback) + + +# Gracefully terminates a child process +# +# Sends a signal to the process as a heads up it should terminate. +# Then checks multiple times whether the process terminated. Retries +# sending the signal. In case it's not able to terminate the process +# within given timeout, it returns an error. +# +# If provided with the 'force' option, instead of returning an error, +# it kills the process unconditionally. +# +# Available options: +# - timeout (number) - Time period in ms for which the termination +#. attempts will be done +# - force (boolean) - Kills the process forcefully after the timeout +terminate = (childProcess, options = {}, callback) -> + [callback, options] = [options, {}] if typeof options is 'function' + force = options.force or false + + # By default, the period between retries is calculated from the total + # timeout, but there's minimum meaningful value for the period. + # + # If the timeout is zero or less then the minimum meaningful period + # for waiting between retries, there will be just one termination + # attempt. + timeout = if options.timeout? then options.timeout else TERMINATION_DEFAULT_TIMEOUT_MS + retryTimeout = if timeout > 0 then timeout / TERMINATION_PREFERRED_RETRY_COUNT else 0 + retryTimeout = Math.max(retryTimeout, TERMINATION_MIN_RETRY_TIMEOUT_MS) + + terminated = false + onClose = -> + terminated = true + childProcess.removeListener('close', onClose) + childProcess.on('close', onClose) + + start = Date.now() + t = undefined + + # A function representing one check, whether the process already + # ended or not. It is repeatedly called until the timeout has passed. + check = -> + if terminated + # Successfully terminated + clearTimeout(t) + callback() + else + if (Date.now() - start) < timeout + # Still not terminated, try again + signalTerm(childProcess, (err) -> + return callback(err) if err + t = setTimeout(check, retryTimeout) + ) + else + # Still not terminated and the timeout has passed, either + # kill the process (force) or provide an error + clearTimeout(t) + if force + signalKill(childProcess, callback) + else + callback(new Error("Unable to gracefully terminate process #{childProcess.pid}")) + + # Fire the first termination attempt and check the result + signalTerm(childProcess, (err) -> + return callback(err) if err + t = setTimeout(check, TERMINATION_FIRST_CHECK_TIMEOUT_MS) + ) + + +spawn = (args...) -> + childProcess = crossSpawn.spawn.apply(null, args) + + childProcess.terminated = false + killedIntentionally = false + terminatedIntentionally = false + + childProcess.on('signalKill', -> killedIntentionally = true) + childProcess.on('signalTerm', -> terminatedIntentionally = true) + + childProcess.signalKill = -> + signalKill(childProcess, (err) -> + childProcess.emit('error', err) if err + ) + + childProcess.signalTerm = -> + signalTerm(childProcess, (err) -> + childProcess.emit('error', err) if err + ) + + childProcess.terminate = (options) -> + terminate(childProcess, options, (err) -> + childProcess.emit('error', err) if err + ) + + childProcess.on('close', (statusCode, signal) -> + childProcess.terminated = true + childProcess.killedIntentionally = killedIntentionally + childProcess.terminatedIntentionally = terminatedIntentionally + + # Crash detection. Emits a 'crash' event in case the process + # unintentionally terminated with non-zero status code. + # The 'crash' event's signature: + # + # - statusCode (number, nullable) - The non-zero status code + # - killed (boolean) - Whether the process was killed or not + # + # How to distinguish a process was killed? + # + # UNIX: + # - statusCode is null or 137 or... https://github.com/apiaryio/dredd/issues/735 + # - signal is 'SIGKILL' + # + # Windows: + # - statusCode is usually 1 + # - signal isn't set (Windows do not have signals) + # + # Yes, you got it - on Windows there's no way to distinguish + # a process was forcefully killed... + if not killedIntentionally and not terminatedIntentionally + if signal is 'SIGKILL' + childProcess.emit('crash', null, true) + else if statusCode isnt 0 + childProcess.emit('crash', statusCode, false) + ) + + return childProcess + + +module.exports = { + signalKill + signalTerm + terminate + spawn +} diff --git a/test/fixtures/scripts/dummy-server-nosigterm.coffee b/test/fixtures/scripts/dummy-server-ignore-term.coffee similarity index 63% rename from test/fixtures/scripts/dummy-server-nosigterm.coffee rename to test/fixtures/scripts/dummy-server-ignore-term.coffee index 93922300d..c98476a3e 100644 --- a/test/fixtures/scripts/dummy-server-nosigterm.coffee +++ b/test/fixtures/scripts/dummy-server-ignore-term.coffee @@ -1,18 +1,22 @@ +express = require('express') + +require('./handle-windows-sigint')() -express = require 'express' -app = express() +ignore = -> + console.log('ignoring termination') -process.on 'SIGTERM', -> - console.log 'ignoring sigterm' +process.on('SIGTERM', ignore) +process.on('SIGINT', ignore) +app = express() + app.get '/machines', (req, res) -> res.json [{type: 'bulldozer', name: 'willy'}] app.get '/machines/:name', (req, res) -> res.json {type: 'bulldozer', name: req.params.name} - app.listen process.argv[2], -> console.log "Dummy server listening on port #{process.argv[2]}!" diff --git a/test/fixtures/scripts/endless-ignore-term.coffee b/test/fixtures/scripts/endless-ignore-term.coffee new file mode 100644 index 000000000..a97726323 --- /dev/null +++ b/test/fixtures/scripts/endless-ignore-term.coffee @@ -0,0 +1,11 @@ +require('./handle-windows-sigint')() + + +ignore = -> + console.log('ignoring termination') + +process.on('SIGTERM', ignore) +process.on('SIGINT', ignore) + + +setInterval(( -> ), 1000) diff --git a/test/fixtures/scripts/endless-nosigterm.coffee b/test/fixtures/scripts/endless-nosigterm.coffee deleted file mode 100644 index ab0de75f3..000000000 --- a/test/fixtures/scripts/endless-nosigterm.coffee +++ /dev/null @@ -1,5 +0,0 @@ -process.on('SIGTERM', -> - process.stdout.write('ignoring sigterm\n') -) - -setInterval(( -> ), 1000) diff --git a/test/fixtures/scripts/noop.coffee b/test/fixtures/scripts/exit-0.coffee similarity index 100% rename from test/fixtures/scripts/noop.coffee rename to test/fixtures/scripts/exit-0.coffee diff --git a/test/fixtures/scripts/handle-windows-sigint.coffee b/test/fixtures/scripts/handle-windows-sigint.coffee new file mode 100644 index 000000000..4643d909a --- /dev/null +++ b/test/fixtures/scripts/handle-windows-sigint.coffee @@ -0,0 +1,27 @@ +readline = require('readline') + + +ASCII_CTRL_C = 3 + + +# To learn about why this is needed and how it works, see +# the 'src/child-process.coffee' file, function 'signalTerm'. +module.exports = -> + # Handling programmatic interruption (Dredd sends '\u0003' + # to stdin) + process.stdin.on('data', (chunk) -> + for char in chunk.toString() + if char.charCodeAt(0) is ASCII_CTRL_C + process.emit('SIGINT') + break + ) + + # Handling manual interruption (user sends '\u0003' to stdin by + # manually pressing Ctrl+C) + rl = readline.createInterface( + input: process.stdin + output: process.stdout + ) + rl.on('SIGINT', -> + process.emit('SIGINT') + ) diff --git a/test/fixtures/scripts/stderr.coffee b/test/fixtures/scripts/stderr.coffee index 067fa576a..13acec922 100644 --- a/test/fixtures/scripts/stderr.coffee +++ b/test/fixtures/scripts/stderr.coffee @@ -1,7 +1,13 @@ -process.on('SIGTERM', -> +require('./handle-windows-sigint')() + + +exit = -> process.stdout.write('exiting\n') process.exit(0) -) + +process.on('SIGTERM', exit) +process.on('SIGINT', exit) + process.stderr.write('error output text\n') setInterval(( -> ), 100) diff --git a/test/fixtures/scripts/stdout-exit-3.coffee b/test/fixtures/scripts/stdout-exit-3.coffee new file mode 100644 index 000000000..21cfb69fc --- /dev/null +++ b/test/fixtures/scripts/stdout-exit-3.coffee @@ -0,0 +1,13 @@ +require('./handle-windows-sigint')() + + +exit = -> + process.stdout.write('exiting\n') + process.exit(3) + +process.on('SIGTERM', exit) +process.on('SIGINT', exit) + + +process.stdout.write('standard output text\n') +setInterval(( -> ), 100) diff --git a/test/fixtures/scripts/stdout.coffee b/test/fixtures/scripts/stdout.coffee index 336e0dccf..67f0af242 100644 --- a/test/fixtures/scripts/stdout.coffee +++ b/test/fixtures/scripts/stdout.coffee @@ -1,7 +1,13 @@ -process.on('SIGTERM', -> +require('./handle-windows-sigint')() + + +exit = -> process.stdout.write('exiting\n') process.exit(0) -) + +process.on('SIGTERM', exit) +process.on('SIGINT', exit) + process.stdout.write('standard output text\n') setInterval(( -> ), 100) diff --git a/test/integration/child-process-test.coffee b/test/integration/child-process-test.coffee new file mode 100644 index 000000000..7838185fc --- /dev/null +++ b/test/integration/child-process-test.coffee @@ -0,0 +1,586 @@ +{assert} = require('chai') +sinon = require('sinon') + +helpers = require('./helpers') +{spawn, signalTerm, signalKill} = require('../../src/child-process') + +COFFEE_BIN = 'node_modules/.bin/coffee' +WAIT_AFTER_COMMAND_SPAWNED_MS = 500 +WAIT_AFTER_COMMAND_TERMINATED_MS = 1500 + + +runChildProcess = (command, fn, callback) -> + onCrash = sinon.spy() + + processInfo = + pid: undefined + stdout: '' + stderr: '' + terminated: false + statusCode: undefined + signal: undefined + onCrash: onCrash + + childProcess = spawn(COFFEE_BIN, [command]) + + childProcess.stdout.on('data', (data) -> processInfo.stdout += data.toString()) + childProcess.stderr.on('data', (data) -> processInfo.stderr += data.toString()) + + onClose = (statusCode, signal) -> + processInfo.terminated = true + processInfo.statusCode = statusCode + processInfo.signal = signal + childProcess.on('close', onClose) + + onError = (err) -> + processInfo.error = err + childProcess.on('error', onError) + + childProcess.on('crash', onCrash) + + setTimeout( -> + fn(childProcess) + + setTimeout( -> + childProcess.removeListener('close', onClose) + childProcess.removeListener('error', onError) + childProcess.removeListener('crash', onCrash) + + processInfo.childProcess = childProcess + callback(null, processInfo) + , WAIT_AFTER_COMMAND_TERMINATED_MS) + , WAIT_AFTER_COMMAND_SPAWNED_MS) + + +describe('Babysitting Child Processes', -> + describe('when forcefully killed by childProcess.signalKill()', -> + describe('process with support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + childProcess.signalKill() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('does not log a message about being gracefully terminated', -> + assert.notInclude(processInfo.stdout, 'exiting') + ) + it('gets terminated', -> + assert.isTrue(processInfo.terminated) + ) + if process.platform is 'win32' + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + else + it('gets killed', -> + assert.equal(processInfo.signal, 'SIGKILL') + ) + it('returns no status code', -> + assert.isNull(processInfo.statusCode) + ) + it('does not emit an error', -> + assert.isUndefined(processInfo.error) + ) + ) + + describe('process without support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/endless-ignore-term.coffee', (childProcess) -> + childProcess.signalKill() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('does not log a message about ignoring graceful termination', -> + assert.notInclude(processInfo.stdout, 'ignoring') + ) + it('gets terminated', -> + assert.isTrue(processInfo.terminated) + ) + if process.platform is 'win32' + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + else + it('gets killed', -> + assert.equal(processInfo.signal, 'SIGKILL') + ) + it('returns no status code', -> + assert.isNull(processInfo.statusCode) + ) + it('does not emit an error', -> + assert.isUndefined(processInfo.error) + ) + ) + ) + + ['signalTerm', 'terminate'].forEach((functionName) -> + describe("when gracefully terminated by childProcess.#{functionName}()", -> + describe('process with support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + childProcess[functionName]() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('logs a message about being gracefully terminated', -> + assert.include(processInfo.stdout, 'exiting') + ) + it('gets terminated', -> + assert.isTrue(processInfo.terminated) + ) + if process.platform isnt 'win32' # Windows does not have signals + it('does not get terminated directly by the signal', -> + assert.isNull(processInfo.signal) + ) + it('returns zero status code', -> + assert.equal(processInfo.statusCode, 0) + ) + it('does not emit an error', -> + assert.isUndefined(processInfo.error) + ) + ) + + describe('process without support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/endless-ignore-term.coffee', (childProcess) -> + childProcess.terminate() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('logs a message about ignoring the graceful termination attempt', -> + assert.include(processInfo.stdout, 'ignoring') + ) + it('does not get terminated', -> + assert.isFalse(processInfo.terminated) + ) + it('has undefined status code', -> + assert.isUndefined(processInfo.statusCode) + ) + it('emits an error', -> + assert.instanceOf(processInfo.error, Error) + ) + it('the error has a message about unsuccessful termination', -> + assert.equal( + processInfo.error.message, + "Unable to gracefully terminate process #{processInfo.childProcess.pid}" + ) + ) + ) + ) + ) + + describe('when gracefully terminated by childProcess.terminate({\'force\': true})', -> + describe('process with support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + childProcess.terminate({force: true}) + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('logs a message about being gracefully terminated', -> + assert.include(processInfo.stdout, 'exiting') + ) + it('gets terminated', -> + assert.isTrue(processInfo.terminated) + ) + if process.platform isnt 'win32' # Windows does not have signals + it('does not get terminated directly by the signal', -> + assert.isNull(processInfo.signal) + ) + it('returns zero status code', -> + assert.equal(processInfo.statusCode, 0) + ) + it('does not emit an error', -> + assert.isUndefined(processInfo.error) + ) + ) + + describe('process without support for graceful termination', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/endless-ignore-term.coffee', (childProcess) -> + childProcess.terminate({force: true}) + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('logs a message about ignoring the graceful termination attempt', -> + assert.include(processInfo.stdout, 'ignoring') + ) + it('gets terminated', -> + assert.isTrue(processInfo.terminated) + ) + if process.platform is 'win32' + # Windows does not have signals and when a process gets + # forcefully terminated, it has a non-zero status code. + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + else + it('gets killed', -> + assert.equal(processInfo.signal, 'SIGKILL') + ) + it('returns no status code', -> + assert.isNull(processInfo.statusCode) + ) + it('does not emit an error', -> + assert.isUndefined(processInfo.error) + ) + ) + ) + + describe('when child process terminates', -> + describe('normally with zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/exit-0.coffee', (childProcess) -> + ; # do nothing + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns zero status code', -> + assert.equal(processInfo.statusCode, 0) + ) + it('does not emit the \'crash\' event', -> + assert.isFalse(processInfo.onCrash.called) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('normally with non-zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/exit-3.coffee', (childProcess) -> + ; # do nothing + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + it('does emit the \'crash\' event', -> + assert.isTrue(processInfo.onCrash.called) + ) + it('the \'crash\' event is provided with non-zero status code', -> + assert.isAbove(processInfo.onCrash.getCall(0).args[0], 0) + ) + it('the \'crash\' event is not provided with killed flag', -> + assert.isFalse(processInfo.onCrash.getCall(0).args[1]) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('intentionally gracefully with zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + childProcess.signalTerm() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns zero status code', -> + assert.equal(processInfo.statusCode, 0) + ) + it('does not emit the \'crash\' event', -> + assert.isFalse(processInfo.onCrash.called) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is flagged as intentionally terminated', -> + assert.isTrue(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('intentionally gracefully with non-zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout-exit-3.coffee', (childProcess) -> + childProcess.signalTerm() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + it('does not emit the \'crash\' event', -> + assert.isFalse(processInfo.onCrash.called) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is flagged as intentionally terminated', -> + assert.isTrue(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('intentionally forcefully', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + childProcess.signalKill() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + if process.platform is 'win32' + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + else + it('gets killed', -> + assert.equal(processInfo.signal, 'SIGKILL') + ) + it('returns no status code', -> + assert.isNull(processInfo.statusCode) + ) + it('does not emit the \'crash\' event', -> + assert.isFalse(processInfo.onCrash.called) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is flagged as intentionally killed', -> + assert.isTrue(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('gracefully with zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + # simulate that the process was terminated externally + emit = sinon.stub(childProcess, 'emit') + signalTerm(childProcess, -> ) + emit.restore() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns zero status code', -> + assert.equal(processInfo.statusCode, 0) + ) + it('does not emit the \'crash\' event', -> + assert.isFalse(processInfo.onCrash.called) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('gracefully with non-zero status code', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout-exit-3.coffee', (childProcess) -> + # simulate that the process was terminated externally + emit = sinon.stub(childProcess, 'emit') + signalTerm(childProcess, -> ) + emit.restore() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + it('does emit the \'crash\' event', -> + assert.isTrue(processInfo.onCrash.called) + ) + it('the \'crash\' event is provided with non-zero status code', -> + assert.isAbove(processInfo.onCrash.getCall(0).args[0], 0) + ) + it('the \'crash\' event is not provided with killed flag', -> + assert.isFalse(processInfo.onCrash.getCall(0).args[1]) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + + describe('forcefully', -> + processInfo = undefined + + beforeEach((done) -> + runChildProcess('test/fixtures/scripts/stdout.coffee', (childProcess) -> + # simulate that the process was killed externally + emit = sinon.stub(childProcess, 'emit') + signalKill(childProcess, -> ) + emit.restore() + , (err, info) -> + processInfo = info + done(err) + ) + ) + afterEach((done) -> + helpers.kill(processInfo.childProcess.pid, done) + ) + + if process.platform is 'win32' + it('returns non-zero status code', -> + assert.isAbove(processInfo.statusCode, 0) + ) + else + it('gets killed', -> + assert.equal(processInfo.signal, 'SIGKILL') + ) + it('returns no status code', -> + assert.isNull(processInfo.statusCode) + ) + it('does emit the \'crash\' event', -> + assert.isTrue(processInfo.onCrash.called) + ) + if process.platform is 'win32' + it('the \'crash\' event is provided with non-zero status code', -> + assert.isAbove(processInfo.onCrash.getCall(0).args[0], 0) + ) + it('the \'crash\' event is not provided with killed flag (cannot be detected on Windows)', -> + assert.isFalse(processInfo.onCrash.getCall(0).args[1]) + ) + else + it('the \'crash\' event is provided with no status code', -> + assert.isNull(processInfo.onCrash.getCall(0).args[0]) + ) + it('the \'crash\' event is provided with killed flag', -> + assert.isTrue(processInfo.onCrash.getCall(0).args[1]) + ) + it('is flagged as terminated', -> + assert.isTrue(processInfo.childProcess.terminated) + ) + it('is not flagged as intentionally killed', -> + assert.isFalse(processInfo.childProcess.killedIntentionally) + ) + it('is not flagged as intentionally terminated', -> + assert.isFalse(processInfo.childProcess.terminatedIntentionally) + ) + ) + ) +) diff --git a/test/integration/cli/cli-test.coffee b/test/integration/cli/cli-test.coffee index db3c6b6f4..8c7400337 100644 --- a/test/integration/cli/cli-test.coffee +++ b/test/integration/cli/cli-test.coffee @@ -112,7 +112,7 @@ describe 'CLI', -> assert.include runtimeInfo.dredd.stderr, 'not found' it 'should term or kill the server', (done) -> - isProcessRunning('endless-nosigterm', (err, isRunning) -> + isProcessRunning('endless-ignore-term', (err, isRunning) -> assert.isFalse isRunning unless err done(err) ) @@ -150,7 +150,7 @@ describe 'CLI', -> assert.include runtimeInfo.dredd.stderr, 'exited' itNotWindows 'should term or kill the server', (done) -> - isProcessRunning('endless-nosigterm', (err, isRunning) -> + isProcessRunning('endless-ignore-term', (err, isRunning) -> assert.isFalse isRunning unless err done(err) ) @@ -169,7 +169,7 @@ describe 'CLI', -> args = [ './test/fixtures/single-get.apib' "http://127.0.0.1:#{DEFAULT_SERVER_PORT}" - "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-nosigterm.coffee" + "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-ignore-term.coffee" '--server-wait=0' "--language=#{COFFEE_BIN} ./test/fixtures/scripts/kill-self.coffee" '--hookfiles=./test/fixtures/scripts/emptyfile' @@ -189,7 +189,7 @@ describe 'CLI', -> assert.include runtimeInfo.dredd.stderr, 'killed' itNotWindows 'should term or kill the server', (done) -> - isProcessRunning('endless-nosigterm', (err, isRunning) -> + isProcessRunning('endless-ignore-term', (err, isRunning) -> assert.isFalse isRunning unless err done(err) ) @@ -203,7 +203,7 @@ describe 'CLI', -> before (done) -> app = createServer() app.get '/machines', (req, res) -> - killAll('endless-nosigterm.+[^=]foo/bar/hooks', (err) -> + killAll('endless-ignore-term.+[^=]foo/bar/hooks', (err) -> done err if err res.json([{type: 'bulldozer', name: 'willy'}]) ) @@ -216,9 +216,9 @@ describe 'CLI', -> args = [ './test/fixtures/single-get.apib' "http://127.0.0.1:#{DEFAULT_SERVER_PORT}" - "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-nosigterm.coffee" + "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-ignore-term.coffee" '--server-wait=0' - "--language=#{COFFEE_BIN} ./test/fixtures/scripts/endless-nosigterm.coffee" + "--language=#{COFFEE_BIN} ./test/fixtures/scripts/endless-ignore-term.coffee" '--hookfiles=foo/bar/hooks' ] hookHandler.listen DEFAULT_HOOK_HANDLER_PORT, -> @@ -238,7 +238,7 @@ describe 'CLI', -> assert.include runtimeInfo.dredd.stderr, 'killed' itNotWindows 'should term or kill the server', (done) -> - isProcessRunning('endless-nosigterm', (err, isRunning) -> + isProcessRunning('endless-ignore-term', (err, isRunning) -> assert.isFalse isRunning unless err done(err) ) @@ -263,9 +263,9 @@ describe 'CLI', -> args = [ './test/fixtures/single-get.apib' "http://127.0.0.1:#{DEFAULT_SERVER_PORT}" - "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-nosigterm.coffee" + "--server=#{COFFEE_BIN} ./test/fixtures/scripts/endless-ignore-term.coffee" '--server-wait=0' - "--language=#{COFFEE_BIN} ./test/fixtures/scripts/endless-nosigterm.coffee" + "--language=#{COFFEE_BIN} ./test/fixtures/scripts/endless-ignore-term.coffee" '--hookfiles=./test/fixtures/scripts/emptyfile' ] hookHandler.listen DEFAULT_HOOK_HANDLER_PORT, -> diff --git a/test/integration/cli/server-process-cli-test.coffee b/test/integration/cli/server-process-cli-test.coffee index 24fe0e877..82d7470b0 100644 --- a/test/integration/cli/server-process-cli-test.coffee +++ b/test/integration/cli/server-process-cli-test.coffee @@ -159,7 +159,7 @@ describe 'CLI - Server Process', -> args = [ './test/fixtures/single-get.apib' "http://127.0.0.1:#{DEFAULT_SERVER_PORT}" - "--server=#{COFFEE_BIN} test/fixtures/scripts/dummy-server-nosigterm.coffee #{DEFAULT_SERVER_PORT}" + "--server=#{COFFEE_BIN} test/fixtures/scripts/dummy-server-ignore-term.coffee #{DEFAULT_SERVER_PORT}" '--server-wait=1' ] @@ -172,8 +172,8 @@ describe 'CLI - Server Process', -> assert.include dreddCommandInfo.stdout, 'Starting backend server process with command' it 'should inform about sending SIGTERM', -> assert.include dreddCommandInfo.stdout, 'Gracefully terminating backend server process' - it 'should redirect server\'s message about ignoring SIGTERM', -> - assert.include dreddCommandInfo.stdout, 'ignoring sigterm' + it 'should redirect server\'s message about ignoring termination', -> + assert.include dreddCommandInfo.stdout, 'ignoring termination' it 'should inform about sending SIGKILL', -> assert.include dreddCommandInfo.stdout, 'Killing backend server process' it 'the server should not be running', (done) -> diff --git a/test/integration/helpers.coffee b/test/integration/helpers.coffee index c40d1f71a..750a44471 100644 --- a/test/integration/helpers.coffee +++ b/test/integration/helpers.coffee @@ -164,12 +164,24 @@ isProcessRunning = (pattern, callback) -> ) +kill = (pid, callback) -> + if process.platform is 'win32' + taskkill = spawn('taskkill', ['/F', '/T', '/PID', pid]) + taskkill.on('close', -> callback()) + else + try + process.kill(pid, 'SIGKILL') + catch + # do nothing + process.nextTick(callback) + + killAll = (pattern, callback) -> ps.lookup({arguments: pattern}, (err, processList) -> return callback(err) if err or not processList.length async.each(processList, (processListItem, next) -> - ps.kill(processListItem.pid, {signal: 9}, next) # 9 is SIGKILL + kill(processListItem.pid, next) , callback) ) @@ -182,5 +194,6 @@ module.exports = { runDreddCommand runDreddCommandWithServer isProcessRunning + kill killAll } diff --git a/test/unit/hooks-worker-client-test.coffee b/test/unit/hooks-worker-client-test.coffee index e58c405d3..43a8c92ce 100644 --- a/test/unit/hooks-worker-client-test.coffee +++ b/test/unit/hooks-worker-client-test.coffee @@ -20,7 +20,7 @@ measureExecutionDurationMs = (fn) -> COFFEE_BIN = 'node_modules/.bin/coffee' MIN_COMMAND_EXECUTION_DURATION_MS = 2 * measureExecutionDurationMs( -> - crossSpawnStub.sync(COFFEE_BIN, ['test/fixtures/scripts/noop.coffee']) + crossSpawnStub.sync(COFFEE_BIN, ['test/fixtures/scripts/exit-0.coffee']) ) PORT = 61321 @@ -107,7 +107,7 @@ describe 'Hooks worker client', -> it 'should not set the error on worker if process gets intentionally killed by Dredd ' + 'because it can be killed after all hooks execution if SIGTERM isn\'t handled', (done) -> - runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/endless-nosigterm.coffee" + runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/endless-ignore-term.coffee" loadWorkerClient (workerError) -> return done workerError if workerError @@ -591,7 +591,7 @@ describe 'Hooks worker client', -> it 'should connect to the server', (done) -> - runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/noop.coffee" + runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/exit-0.coffee" loadWorkerClient (err) -> assert.isUndefined err @@ -614,7 +614,7 @@ describe 'Hooks worker client', -> if eventType.indexOf("All") > -1 beforeEach (done) -> receivedData = "" - runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/noop.coffee" + runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/exit-0.coffee" sentData = clone [transaction] loadWorkerClient (err) -> assert.isUndefined err @@ -624,7 +624,7 @@ describe 'Hooks worker client', -> else beforeEach (done) -> receivedData = "" - runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/noop.coffee" + runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/exit-0.coffee" sentData = clone transaction loadWorkerClient (err) -> assert.isUndefined err @@ -695,7 +695,7 @@ describe 'Hooks worker client', -> beforeEach((done) -> # Dummy placeholder for a real hook handler - runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/noop.coffee" + runner.hooks.configuration.options.language = "#{COFFEE_BIN} test/fixtures/scripts/exit-0.coffee" # Mock hook handler implementation, which ocuppies expected port instead # of a real hook handler.