the-shepherd
Version:
Control a herd of wild processes.
223 lines (199 loc) • 6.76 kB
text/coffeescript
$ = require 'bling'
Os = require "os"
Shell = require 'shelljs'
Handlebars = require "handlebars"
Process = require './process'
Helpers = require './helpers'
Http = require './http'
Opts = require './opts'
log = $.logger "[child]"
verbose = ->
try if Opts.verbose then log.apply null, arguments
catch err then log "verbose error:", err.stack ? err
class Child
constructor: (opts, index) ->
$.extend @,
opts: opts
index: index
process: null
started: $.extend $.Promise(),
attempts: 0
timeout: null
@log = $.logger @toString()
@log.verbose = =>
try if Opts.verbose then @log.apply null, arguments
catch err then @log "verbose error:", err.stack ? err
start: ->
try return @started
finally
fail = (msg) => @started.reject msg
if ++@started.attempts > @opts.restart.maxAttempts
fail "too many attempts"
else
clearTimeout @started.timeout
@started.timeout = setTimeout (=> @started.attempts = 0), @opts.restart.maxInterval
log "shell >" , cmd = "env #{@env()} bash -c 'cd #{@opts.cd} && #{@opts.command}'"
@process = Shell.exec cmd, { silent: true, async: true }, $.identity
@process.on "exit", (err, signal) => @onExit err, signal
on_data = (prefix = "") => (data) =>
for line in String(data).split /\n/ when line.length
@log prefix + line
@process.stdout.on "data", on_data ""
@process.stderr.on "data", on_data "(stderr) "
unless @process.pid then fail "no pid"
# IMPORTANT NOTE: does not resolve @started on it's own,
# a sub-class like Server or Worker is expected to @started.resolve()
stop: (signal) ->
try return p = $.Promise()
finally
@started.attempts = Infinity
@expectedExit = true
if @process
try Process.killTree(@process.pid, signal).then p.resolve, p.reject
catch err
log "Error calling killTree:", err.stack ? err
else p.resolve()
restart: ->
try return p = $.Promise()
finally unless @process?
log "Starting fresh child (no existing process)"
@start().then p.resolve, p.reject
else
restart = =>
try
log "Restarting child..."
@process = null
@started.reset()
@started.attempts = 0
@start().then p.resolve, p.reject
catch err
log "restart error:", err.stack ? err
p.reject err
log "Killing existing process", @process.pid
@expectedExit = true
Process.killTree(@process.pid, "SIGTERM").wait @opts.restart.gracePeriod, (err) ->
try
if err is "timeout"
log "Child failed to die within #{@opts.restart.gracePeriod}ms, using SIGKILL"
Process.killTree(@process.pid, "SIGKILL")
.then restart, p.reject
else if err then p.reject err
else restart()
catch err
log "restart error during kill tree:", err.stack ? err
p.reject err
onExit: (code, signal) ->
try
signal = if $.is 'number', code then code - 128 else Process.getSignalNumber signal
@log "Child exited (signal=#{signal})", if @expectedExit then "(expected)" else ""
@restart() unless @expectedExit
@expectedExit = false
catch err
@log "child.onExit error:", err.stack ? err
toString: toString = ->
try return "#{@constructor.name}[#{@index}]"
catch err then log "toString error:", err.stack ? err
inspect: toString
env: ->
try return ("#{key}=\"#{val}\"" for key,val of @opts.env when val?).join " "
catch err then log "env error:", err.stack ? err
Child.defaults = (opts) ->
opts = $.extend Object.create(null), {
cd: "."
command: "node index.js"
count: -1
env: {}
}, opts
opts.count = parseInt opts.count, 10
while opts.count < 0
opts.count += Os.cpus().length
opts.count or= 1
# control what happens at (re)start time
opts.restart = $.extend Object.create(null), {
maxAttempts: 5, # failing five times fast is fatal
maxInterval: 10000, # in what interval is "fast"?
gracePeriod: 3000, # how long to wait for a forcibly killed process to die
timeout: 10000, # how long to wait for a newly launched process to start listening on it's port
}, opts.restart
# defaults for the git configuration
opts.git = $.extend Object.create(null), {
enabled: false
cd: "."
remote: "origin"
branch: "master"
command: "git pull {{remote}} {{branch}} || git merge --abort"
}, opts.git
opts.git.command = Handlebars.compile(opts.git.command)
opts.git.command.inspect = (level) ->
return '"' + opts.git.command({ remote: "{{remote}}", branch: "{{branch}}" }) + '"'
return opts
class Worker extends Child
Http.get "/workers", (req, res) ->
res.pass """[#{
("[#{worker.process?.pid ? "DEAD"}, #{worker.port}]" for worker in workers).join ",\n"
}]"""
Http.get "/workers/restart", (req, res) ->
for worker in workers
worker.restart()
res.redirect 302, "/workers?restarting"
workers = []
constructor: (opts, index) ->
Child.apply @, [
opts = Worker.defaults(opts),
index
]
workers.push @
@log = $.logger @toString()
start: ->
super()
@started.resolve()
Worker.defaults = Child.defaults
class Server extends Child
Http.get "/servers", (req, res) ->
ret = "["
for port,v of servers
ret += ("[#{s.process?.pid ? "DEAD"}, #{s.port}]" for s in v).join ",\n"
res.pass ret + "]"
Http.get "/servers/restart", (req, res) ->
$.valuesOf(servers).flatten().select('restart').call()
res.redirect 302, "/servers?restarting"
# a map of base port to all Server instances based on that port
servers = {}
constructor: (opts, index) ->
Child.apply @, [
opts = Server.defaults(opts),
index
]
@port = opts.port + index
@log = $.logger "(#{@opts.cd}):#{@port}"
(servers[opts.port] ?= []).push @
# wrap the default start function
start: ->
try return @started
finally
# find any process that is listening on our port
Process.clearCache().findOne({ ports: @port }).then (owner) =>
if owner? # if the port is being listened on
@log "Killing previous owner of", @port, "PID:", owner.pid
Process.killTree(owner, "SIGKILL").then =>
@start()
else # port is available, so really start
super() # do the base Child start
unless @process then @started.reject("no process")
else
verbose "Waiting for port", @port, "to be owned by", @process.pid
Helpers.portIsOwned(@process.pid, @port, @opts.restart.timeout)
.then (=>
verbose "Port #{@port} is successfully owned."
@started.resolve()
), @started.reject
env: -> super() + "#{@opts.portVariable}=\"#{@port}\""
Server.defaults = (opts) ->
opts = $.extend {
port: 8001
portVariable: "PORT"
poolName: "shepherd_pool"
}, Child.defaults opts
opts.port = parseInt opts.port, 10
opts
$.extend module.exports, { Server, Worker }