Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add cross-platform child process module
- Loading branch information
1 parent
0e0f699
commit 65048d8
Showing
15 changed files
with
883 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
14 changes: 9 additions & 5 deletions
14
...res/scripts/dummy-server-nosigterm.coffee → ...s/scripts/dummy-server-ignore-term.coffee
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]}!" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
require('./handle-windows-sigint')() | ||
|
||
|
||
ignore = -> | ||
console.log('ignoring termination') | ||
|
||
process.on('SIGTERM', ignore) | ||
process.on('SIGINT', ignore) | ||
|
||
|
||
setInterval(( -> ), 1000) |
This file was deleted.
Oops, something went wrong.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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') | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Oops, something went wrong.