Skip to content

Commit

Permalink
feat: Add cross-platform child process module
Browse files Browse the repository at this point in the history
  • Loading branch information
honzajavorek committed Apr 3, 2017
1 parent 0e0f699 commit 65048d8
Show file tree
Hide file tree
Showing 15 changed files with 883 additions and 35 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
187 changes: 187 additions & 0 deletions 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
}
@@ -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]}!"
11 changes: 11 additions & 0 deletions 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)
5 changes: 0 additions & 5 deletions test/fixtures/scripts/endless-nosigterm.coffee

This file was deleted.

File renamed without changes.
27 changes: 27 additions & 0 deletions 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')
)
10 changes: 8 additions & 2 deletions 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)
13 changes: 13 additions & 0 deletions 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)
10 changes: 8 additions & 2 deletions 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)

0 comments on commit 65048d8

Please sign in to comment.