FooBarWidget-daemon_controller 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.rdoc +41 -0
- data/daemon_controller.gemspec +18 -0
- data/lib/daemon_controller.rb +525 -0
- data/spec/daemon_controller_spec.rb +300 -0
- data/spec/echo_server.rb +116 -0
- metadata +58 -0
data/README.rdoc
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
= Introduction
|
2
|
+
|
3
|
+
daemon_controller is a library for implementing daemon management capabilities.
|
4
|
+
|
5
|
+
Suppose that you have a Ruby on Rails application which uses the Sphinx search
|
6
|
+
server [1] for full-text searching capbilities. In order to search the index,
|
7
|
+
the search daemon (searchd) must be running. Furthermore, you're using the Riddle
|
8
|
+
library [2] for interfacing with the search daemon.
|
9
|
+
|
10
|
+
You can write this in your application:
|
11
|
+
|
12
|
+
require 'daemon_controller'
|
13
|
+
require 'riddle'
|
14
|
+
|
15
|
+
controller = DaemonController.new(
|
16
|
+
:identifier => 'Sphinx search daemon',
|
17
|
+
:start_command => 'searchd -c config/sphinx.conf',
|
18
|
+
:ping_command => proc { Riddle::Client.new('localhost', 1234) },
|
19
|
+
:pid_file => 'tmp/pids/sphinx.pid',
|
20
|
+
:log_file => 'log/sphinx.log'
|
21
|
+
)
|
22
|
+
client = controller.connect do
|
23
|
+
Riddle::Client.new('localhost', 1234)
|
24
|
+
end
|
25
|
+
client.query("some search query...")
|
26
|
+
|
27
|
+
controller.connect will start the Sphinx search daemon if it isn't already
|
28
|
+
started. Then, it will connect to the Sphinx search daemon by running the
|
29
|
+
given block.
|
30
|
+
|
31
|
+
Basically you just tell the library how to start the daemon, how to check
|
32
|
+
whether it's responding to connections, and which PID file and log file it
|
33
|
+
uses. daemon_controller will automatically take care of things like:
|
34
|
+
|
35
|
+
* concurrency control, e.g. to ensure that no two processes will try to start
|
36
|
+
the Sphinx search daemon at the same time.
|
37
|
+
* error handling: if 'searchd' failed to start, then its error message will
|
38
|
+
be propagated into the exception that will be thrown. This makes it much
|
39
|
+
easier to handle daemon startup errors in your application. This can also
|
40
|
+
allow the system administrator to see the error message directly in your
|
41
|
+
application, instead of having to consult the daemon's log file.
|
@@ -0,0 +1,18 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "daemon_controller"
|
3
|
+
s.version = "0.1.0"
|
4
|
+
s.date = "2008-08-21"
|
5
|
+
s.summary = "A library for implementing daemon management capabilities"
|
6
|
+
s.email = "hongli@phusion.nl"
|
7
|
+
s.homepage = "http://github.com/FooBarWidget/daemon_controller/tree/master"
|
8
|
+
s.description = "A library for implementing daemon management capabilities."
|
9
|
+
s.has_rdoc = false
|
10
|
+
s.authors = ["Hongli Lai"]
|
11
|
+
|
12
|
+
s.files = [
|
13
|
+
"README.rdoc", "LICENSE.txt", "daemon_controller.gemspec",
|
14
|
+
"lib/daemon_controller.rb",
|
15
|
+
"spec/daemon_controller_spec.rb",
|
16
|
+
"spec/echo_server.rb"
|
17
|
+
]
|
18
|
+
end
|
@@ -0,0 +1,525 @@
|
|
1
|
+
# Basic functionality for a single, local, external daemon:
|
2
|
+
# - starting daemon
|
3
|
+
# * must be concurrency-safe!
|
4
|
+
# * must be able to report startup errors!
|
5
|
+
# * returns when daemon is fully operational
|
6
|
+
# - stopping daemon
|
7
|
+
# * must be concurrency-safe!
|
8
|
+
# * returns when daemon has exited
|
9
|
+
# - querying the status of a daemon
|
10
|
+
# * querying the status of a daemon (i.e. whether it's running)
|
11
|
+
# - connect to a daemon, and start it if it isn't already running
|
12
|
+
# * must be a single atomic action
|
13
|
+
|
14
|
+
require 'tempfile'
|
15
|
+
require 'fcntl'
|
16
|
+
|
17
|
+
class DaemonController
|
18
|
+
class Error < StandardError
|
19
|
+
end
|
20
|
+
class TimeoutError < Error
|
21
|
+
end
|
22
|
+
class AlreadyStarted < Error
|
23
|
+
end
|
24
|
+
class StartError < Error
|
25
|
+
end
|
26
|
+
class StartTimeout < TimeoutError
|
27
|
+
end
|
28
|
+
class StopError < Error
|
29
|
+
end
|
30
|
+
class StopTimeout < TimeoutError
|
31
|
+
end
|
32
|
+
class ConnectError < Error
|
33
|
+
end
|
34
|
+
|
35
|
+
# Create a new DaemonController object.
|
36
|
+
#
|
37
|
+
# === Mandatory options
|
38
|
+
#
|
39
|
+
# [:identifier]
|
40
|
+
# A human-readable, unique name for this daemon, e.g. "Sphinx search server".
|
41
|
+
# This identifier will be used in some error messages. On some platforms, it will
|
42
|
+
# be used for concurrency control: on such platforms, no two DaemonController
|
43
|
+
# objects will operate on the same identifier on the same time.
|
44
|
+
#
|
45
|
+
# [:start_command]
|
46
|
+
# The command to start the daemon. This must be a a String, e.g.
|
47
|
+
# "mongrel_rails start -e production".
|
48
|
+
#
|
49
|
+
# [:ping_command]
|
50
|
+
# The ping command is used to check whether the daemon can be connected to.
|
51
|
+
# It is also used to ensure that #start only returns when the daemon can be
|
52
|
+
# connected to.
|
53
|
+
#
|
54
|
+
# The value may be a command string. This command must exit with an exit code of
|
55
|
+
# 0 if the daemon can be successfully connected to, or exit with a non-0 exit
|
56
|
+
# code on failure.
|
57
|
+
#
|
58
|
+
# The value may also be a Proc, which returns an expression that evaluates to
|
59
|
+
# true (indicating that the daemon can be connected to) or false (failure).
|
60
|
+
#
|
61
|
+
# [:pid_file]
|
62
|
+
# The PID file that the daemon will write to. Used to check whether the daemon
|
63
|
+
# is running.
|
64
|
+
#
|
65
|
+
# [:log_file]
|
66
|
+
# The log file that the daemon will write to. It will be consulted to see
|
67
|
+
# whether the daemon has printed any error messages during startup.
|
68
|
+
#
|
69
|
+
# === Optional options
|
70
|
+
# [:stop_command]
|
71
|
+
# A command to stop the daemon with, e.g. "/etc/rc.d/nginx stop". If no stop
|
72
|
+
# command is given (i.e. +nil+), then DaemonController will stop the daemon
|
73
|
+
# by killing the PID written in the PID file.
|
74
|
+
#
|
75
|
+
# The default value is +nil+.
|
76
|
+
#
|
77
|
+
# [:start_timeout]
|
78
|
+
# The maximum amount of time, in seconds, that #start may take to start
|
79
|
+
# the daemon. Since #start also waits until the daemon can be connected to,
|
80
|
+
# that wait time is counted as well. If the daemon does not start in time,
|
81
|
+
# then #start will raise an exception.
|
82
|
+
#
|
83
|
+
# The default value is 15.
|
84
|
+
#
|
85
|
+
# [:stop_timeout]
|
86
|
+
# The maximum amount of time, in seconds, that #stop may take to stop
|
87
|
+
# the daemon. Since #stop also waits until the daemon is no longer running,
|
88
|
+
# that wait time is counted as well. If the daemon does not stop in time,
|
89
|
+
# then #stop will raise an exception.
|
90
|
+
#
|
91
|
+
# The default value is 15.
|
92
|
+
#
|
93
|
+
# [:log_file_activity_timeout]
|
94
|
+
# Once a daemon has gone into the background, it will become difficult to
|
95
|
+
# know for certain whether it is still initializing or whether it has
|
96
|
+
# failed and exited, until it has written its PID file. It's 99.9% probable
|
97
|
+
# that the daemon has terminated with an if its start timeout has expired,
|
98
|
+
# not many system administrators want to wait 15 seconds (the default start
|
99
|
+
# timeout) to be notified of whether the daemon has terminated with an error.
|
100
|
+
#
|
101
|
+
# An alternative way to check whether the daemon has terminated with an error,
|
102
|
+
# is by checking whether its log file has been recently updated. If, after the
|
103
|
+
# daemon has started, the log file hasn't been updated for the amount of seconds
|
104
|
+
# given by the :log_file_activity_timeout option, then the daemon is assumed to
|
105
|
+
# have terminated with an error.
|
106
|
+
#
|
107
|
+
# The default value is 7.
|
108
|
+
def initialize(options)
|
109
|
+
[:identifier, :start_command, :ping_command, :pid_file, :log_file].each do |option|
|
110
|
+
if !options.has_key?(option)
|
111
|
+
raise ArgumentError, "The ':#{option}' option is mandatory."
|
112
|
+
end
|
113
|
+
end
|
114
|
+
@identifier = options[:identifier]
|
115
|
+
@start_command = options[:start_command]
|
116
|
+
@stop_command = options[:stop_command]
|
117
|
+
@ping_command = options[:ping_command]
|
118
|
+
@ping_interval = options[:ping_interval] || 0.1
|
119
|
+
@pid_file = options[:pid_file]
|
120
|
+
@log_file = options[:log_file]
|
121
|
+
@start_timeout = options[:start_timeout] || 15
|
122
|
+
@stop_timeout = options[:stop_timeout] || 15
|
123
|
+
@log_file_activity_timeout = options[:log_file_activity_timeout] || 7
|
124
|
+
@lock_file = determine_lock_file(@identifier, @pid_file)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Start the daemon and wait until it can be pinged.
|
128
|
+
#
|
129
|
+
# Raises:
|
130
|
+
# - AlreadyStarted - the daemon is already running.
|
131
|
+
# - StartError - the start command failed.
|
132
|
+
# - StartTimeout - the daemon did not start in time. This could also
|
133
|
+
# mean that the daemon failed after it has gone into the background.
|
134
|
+
def start
|
135
|
+
exclusive_lock do
|
136
|
+
start_without_locking
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Connect to the daemon by running the given block, which contains the
|
141
|
+
# connection logic. If the daemon isn't already running, then it will be
|
142
|
+
# started.
|
143
|
+
#
|
144
|
+
# The block must return nil or raise Errno::ECONNREFUSED, Errno::ENETUNREACH,
|
145
|
+
# or Errno::ETIMEDOUT to indicate that the daemon cannot be connected to.
|
146
|
+
# It must return non-nil if the daemon can be connected to.
|
147
|
+
# Upon successful connection, the return value of the block will
|
148
|
+
# be returned by #connect.
|
149
|
+
#
|
150
|
+
# Note that the block may be called multiple times.
|
151
|
+
#
|
152
|
+
# Raises:
|
153
|
+
# - StartError - an attempt to start the daemon was made, but the start
|
154
|
+
# command failed with an error.
|
155
|
+
# - StartTimeout - an attempt to start the daemon was made, but the daemon
|
156
|
+
# did not start in time, or it failed after it has gone into the background.
|
157
|
+
# - ConnectError - the daemon wasn't already running, but we couldn't connect
|
158
|
+
# to the daemon even after starting it.
|
159
|
+
def connect
|
160
|
+
connection = nil
|
161
|
+
shared_lock do
|
162
|
+
begin
|
163
|
+
connection = yield
|
164
|
+
rescue Errno::ECONNREFUSED, Errno::ENETUNREACH, Errno::ETIMEDOUT
|
165
|
+
connection = nil
|
166
|
+
end
|
167
|
+
end
|
168
|
+
if connection.nil?
|
169
|
+
exclusive_lock do
|
170
|
+
if !daemon_is_running?
|
171
|
+
start_without_locking
|
172
|
+
end
|
173
|
+
begin
|
174
|
+
connection = yield
|
175
|
+
rescue Errno::ECONNREFUSED, Errno::ENETUNREACH, Errno::ETIMEDOUT
|
176
|
+
connection = nil
|
177
|
+
end
|
178
|
+
if connection.nil?
|
179
|
+
# Daemon is running but we couldn't connect to it. Possible
|
180
|
+
# reasons:
|
181
|
+
# - The daemon froze.
|
182
|
+
# - Bizarre security restrictions.
|
183
|
+
# - There's a bug in the yielded code.
|
184
|
+
raise ConnectError, "Cannot connect to the daemon"
|
185
|
+
else
|
186
|
+
return connection
|
187
|
+
end
|
188
|
+
end
|
189
|
+
else
|
190
|
+
return connection
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# Stop the daemon and wait until it has exited.
|
195
|
+
#
|
196
|
+
# Raises:
|
197
|
+
# - StopError - the stop command failed.
|
198
|
+
# - StopTimeout - the daemon didn't stop in time.
|
199
|
+
def stop
|
200
|
+
exclusive_lock do
|
201
|
+
begin
|
202
|
+
Timeout.timeout(@stop_timeout) do
|
203
|
+
kill_daemon
|
204
|
+
wait_until do
|
205
|
+
!daemon_is_running?
|
206
|
+
end
|
207
|
+
end
|
208
|
+
rescue Timeout::Error
|
209
|
+
raise StopTimeout, "Daemon '#{@identifier}' did not exit in time"
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# Returns the daemon's PID, as reported by its PID file.
|
215
|
+
# This method doesn't check whether the daemon's actually running.
|
216
|
+
# Use #running? if you want to check whether it's actually running.
|
217
|
+
#
|
218
|
+
# Raises SystemCallError or IOError if something went wrong during
|
219
|
+
# reading of the PID file.
|
220
|
+
def pid
|
221
|
+
shared_lock do
|
222
|
+
return read_pid_file
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
# Checks whether the daemon is still running. This is done by reading
|
227
|
+
# the PID file and then checking whether there is a process with that
|
228
|
+
# PID.
|
229
|
+
#
|
230
|
+
# Raises SystemCallError or IOError if something went wrong during
|
231
|
+
# reading of the PID file.
|
232
|
+
def running?
|
233
|
+
shared_lock do
|
234
|
+
return daemon_is_running?
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
private
|
239
|
+
def exclusive_lock
|
240
|
+
File.open(@lock_file, 'w') do |f|
|
241
|
+
if Fcntl.const_defined? :F_SETFD
|
242
|
+
f.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
243
|
+
end
|
244
|
+
f.flock(File::LOCK_EX)
|
245
|
+
yield
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def shared_lock
|
250
|
+
File.open(@lock_file, 'w') do |f|
|
251
|
+
if Fcntl.const_defined? :F_SETFD
|
252
|
+
f.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
|
253
|
+
end
|
254
|
+
f.flock(File::LOCK_SH)
|
255
|
+
yield
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def start_without_locking
|
260
|
+
if daemon_is_running?
|
261
|
+
raise AlreadyStarted, "Daemon '#{@identifier}' is already started"
|
262
|
+
end
|
263
|
+
save_log_file_information
|
264
|
+
delete_pid_file
|
265
|
+
begin
|
266
|
+
started = false
|
267
|
+
Timeout.timeout(@start_timeout) do
|
268
|
+
done = false
|
269
|
+
spawn_daemon
|
270
|
+
record_activity
|
271
|
+
|
272
|
+
# We wait until the PID file is available and until
|
273
|
+
# the daemon responds to pings, but we wait no longer
|
274
|
+
# than @start_timeout seconds in total (including daemon
|
275
|
+
# spawn time).
|
276
|
+
# Furthermore, if the log file hasn't changed for
|
277
|
+
# @log_file_activity_timeout seconds, and the PID file
|
278
|
+
# still isn't available or the daemon still doesn't
|
279
|
+
# respond to pings, then assume that the daemon has
|
280
|
+
# terminated with an error.
|
281
|
+
wait_until do
|
282
|
+
if log_file_has_changed?
|
283
|
+
record_activity
|
284
|
+
elsif no_activity?(@log_file_activity_timeout)
|
285
|
+
raise Timeout::Error, "Daemon seems to have exited"
|
286
|
+
end
|
287
|
+
pid_file_available?
|
288
|
+
end
|
289
|
+
wait_until(@ping_interval) do
|
290
|
+
if log_file_has_changed?
|
291
|
+
record_activity
|
292
|
+
elsif no_activity?(@log_file_activity_timeout)
|
293
|
+
raise Timeout::Error, "Daemon seems to have exited"
|
294
|
+
end
|
295
|
+
run_ping_command || !daemon_is_running?
|
296
|
+
end
|
297
|
+
started = run_ping_command
|
298
|
+
end
|
299
|
+
result = started
|
300
|
+
rescue Timeout::Error
|
301
|
+
start_timed_out
|
302
|
+
if pid_file_available?
|
303
|
+
kill_daemon_with_signal
|
304
|
+
end
|
305
|
+
result = :timeout
|
306
|
+
end
|
307
|
+
if !result
|
308
|
+
raise StartError, differences_in_log_file
|
309
|
+
elsif result == :timeout
|
310
|
+
raise StartTimeout, differences_in_log_file
|
311
|
+
else
|
312
|
+
return true
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
def spawn_daemon
|
317
|
+
run_command(@start_command)
|
318
|
+
end
|
319
|
+
|
320
|
+
def kill_daemon
|
321
|
+
if @stop_command
|
322
|
+
begin
|
323
|
+
run_command(@stop_command)
|
324
|
+
rescue StartError => e
|
325
|
+
raise StopError, e.message
|
326
|
+
end
|
327
|
+
else
|
328
|
+
kill_daemon_with_signal
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
def kill_daemon_with_signal
|
333
|
+
Process.kill('SIGTERM', read_pid_file)
|
334
|
+
rescue Errno::ESRCH, Errno::ENOENT
|
335
|
+
end
|
336
|
+
|
337
|
+
def daemon_is_running?
|
338
|
+
begin
|
339
|
+
pid = read_pid_file
|
340
|
+
rescue Errno::ENOENT
|
341
|
+
# The PID file may not exist, or another thread/process
|
342
|
+
# executing #running? may have just deleted the PID file.
|
343
|
+
# So we catch this error.
|
344
|
+
pid = nil
|
345
|
+
end
|
346
|
+
if pid.nil?
|
347
|
+
return false
|
348
|
+
elsif check_pid(pid)
|
349
|
+
return true
|
350
|
+
else
|
351
|
+
delete_pid_file
|
352
|
+
return false
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def read_pid_file
|
357
|
+
return File.read(@pid_file).strip.to_i
|
358
|
+
end
|
359
|
+
|
360
|
+
def delete_pid_file
|
361
|
+
File.unlink(@pid_file)
|
362
|
+
rescue Errno::EPERM, Errno::EACCES, Errno::ENOENT # ignore
|
363
|
+
end
|
364
|
+
|
365
|
+
def check_pid(pid)
|
366
|
+
Process.kill(0, pid)
|
367
|
+
return true
|
368
|
+
rescue Errno::ESRCH
|
369
|
+
return false
|
370
|
+
rescue Errno::EPERM
|
371
|
+
# We didn't have permission to kill the process. Either the process
|
372
|
+
# is owned by someone else, or the system has draconian security
|
373
|
+
# settings and we aren't allowed to kill *any* process. Assume that
|
374
|
+
# the process is running.
|
375
|
+
return true
|
376
|
+
end
|
377
|
+
|
378
|
+
def wait_until(sleep_interval = 0.1)
|
379
|
+
while !yield
|
380
|
+
sleep(sleep_interval)
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
def wait_until_pid_file_is_available_or_log_file_has_changed
|
385
|
+
while !(pid_file_available? || log_file_has_changed?)
|
386
|
+
sleep 0.1
|
387
|
+
end
|
388
|
+
return pid_file_is_available?
|
389
|
+
end
|
390
|
+
|
391
|
+
def wait_until_daemon_responds_to_ping_or_has_exited_or_log_file_has_changed
|
392
|
+
while !(run_ping_command || !daemon_is_running? || log_file_has_changed?)
|
393
|
+
sleep(@ping_interval)
|
394
|
+
end
|
395
|
+
return run_ping_command
|
396
|
+
end
|
397
|
+
|
398
|
+
def record_activity
|
399
|
+
@last_activity_time = Time.now
|
400
|
+
end
|
401
|
+
|
402
|
+
# Check whether there has been no recorded activity in the past +seconds+ seconds.
|
403
|
+
def no_activity?(seconds)
|
404
|
+
return Time.now - @last_activity_time > seconds
|
405
|
+
end
|
406
|
+
|
407
|
+
def pid_file_available?
|
408
|
+
return File.exist?(@pid_file) && File.stat(@pid_file).size != 0
|
409
|
+
end
|
410
|
+
|
411
|
+
# This method does nothing and only serves as a hook for the unit test.
|
412
|
+
def start_timed_out
|
413
|
+
end
|
414
|
+
|
415
|
+
def save_log_file_information
|
416
|
+
@original_log_file_stat = File.stat(@log_file) rescue nil
|
417
|
+
@current_log_file_stat = @original_log_file_stat
|
418
|
+
end
|
419
|
+
|
420
|
+
def log_file_has_changed?
|
421
|
+
if @current_log_file_stat
|
422
|
+
stat = File.stat(@log_file) rescue nil
|
423
|
+
if stat
|
424
|
+
result = @current_log_file_stat.mtime != stat.mtime ||
|
425
|
+
@current_log_file_stat.size != stat.size
|
426
|
+
@current_log_file_stat = stat
|
427
|
+
return result
|
428
|
+
else
|
429
|
+
return true
|
430
|
+
end
|
431
|
+
else
|
432
|
+
return false
|
433
|
+
end
|
434
|
+
end
|
435
|
+
|
436
|
+
def differences_in_log_file
|
437
|
+
if @original_log_file_stat
|
438
|
+
File.open(@log_file, 'r') do |f|
|
439
|
+
f.seek(@original_log_file_stat.size, IO::SEEK_SET)
|
440
|
+
return f.read.strip
|
441
|
+
end
|
442
|
+
else
|
443
|
+
return nil
|
444
|
+
end
|
445
|
+
rescue Errno::ENOENT
|
446
|
+
return nil
|
447
|
+
end
|
448
|
+
|
449
|
+
def determine_lock_file(identifier, pid_file)
|
450
|
+
return File.expand_path(pid_file + ".lock")
|
451
|
+
end
|
452
|
+
|
453
|
+
def self.fork_supported?
|
454
|
+
return RUBY_PLATFORM != "java" && RUBY_PLATFORM !~ /win32/
|
455
|
+
end
|
456
|
+
|
457
|
+
def run_command(command)
|
458
|
+
# Create tempfile for storing the command's output.
|
459
|
+
tempfile = Tempfile.new('daemon-output')
|
460
|
+
tempfile_path = tempfile.path
|
461
|
+
File.chmod(0666, tempfile_path)
|
462
|
+
tempfile.close
|
463
|
+
|
464
|
+
if self.class.fork_supported?
|
465
|
+
pid = safe_fork do
|
466
|
+
STDIN.reopen("/dev/null", "r")
|
467
|
+
STDOUT.reopen(tempfile_path, "w")
|
468
|
+
STDERR.reopen(tempfile_path, "w")
|
469
|
+
exec(command)
|
470
|
+
end
|
471
|
+
begin
|
472
|
+
Process.waitpid(pid) rescue nil
|
473
|
+
rescue Timeout::Error
|
474
|
+
# If the daemon doesn't fork into the background
|
475
|
+
# in time, then kill it.
|
476
|
+
Process.kill('SIGTERM', pid) rescue nil
|
477
|
+
begin
|
478
|
+
Timeout.timeout(5) do
|
479
|
+
Process.waitpid(pid) rescue nil
|
480
|
+
end
|
481
|
+
rescue Timeout::Error
|
482
|
+
Process.kill('SIGKILL', pid)
|
483
|
+
Process.waitpid(pid) rescue nil
|
484
|
+
end
|
485
|
+
raise
|
486
|
+
end
|
487
|
+
if $?.exitstatus != 0
|
488
|
+
raise StartError, File.read(tempfile_path).strip
|
489
|
+
end
|
490
|
+
else
|
491
|
+
if !system("#{command} >\"#{tempfile_path}\" 2>\"#{tempfile_path}\"")
|
492
|
+
raise StartError, File.read(tempfile_path).strip
|
493
|
+
end
|
494
|
+
end
|
495
|
+
ensure
|
496
|
+
File.unlink(tempfile_path) rescue nil
|
497
|
+
end
|
498
|
+
|
499
|
+
def run_ping_command
|
500
|
+
if @ping_command.respond_to?(:call)
|
501
|
+
return @ping_command.call
|
502
|
+
else
|
503
|
+
return system(@ping_command)
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
def safe_fork
|
508
|
+
pid = fork
|
509
|
+
if pid.nil?
|
510
|
+
begin
|
511
|
+
yield
|
512
|
+
rescue Exception => e
|
513
|
+
message = "*** Exception #{e.class} " <<
|
514
|
+
"(#{e}) (process #{$$}):\n" <<
|
515
|
+
"\tfrom " << e.backtrace.join("\n\tfrom ")
|
516
|
+
STDERR.write(e)
|
517
|
+
STDERR.flush
|
518
|
+
ensure
|
519
|
+
exit!
|
520
|
+
end
|
521
|
+
else
|
522
|
+
return pid
|
523
|
+
end
|
524
|
+
end
|
525
|
+
end
|
@@ -0,0 +1,300 @@
|
|
1
|
+
$LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), "..", "lib"))
|
2
|
+
Dir.chdir(File.dirname(__FILE__))
|
3
|
+
require 'daemon_controller'
|
4
|
+
require 'benchmark'
|
5
|
+
require 'socket'
|
6
|
+
|
7
|
+
# A thread which doesn't execute its block until the
|
8
|
+
# 'go!' method has been called.
|
9
|
+
class WaitingThread < Thread
|
10
|
+
def initialize
|
11
|
+
super do
|
12
|
+
@mutex = Mutex.new
|
13
|
+
@cond = ConditionVariable.new
|
14
|
+
@go = false
|
15
|
+
@mutex.synchronize do
|
16
|
+
while !@go
|
17
|
+
@cond.wait(@mutex)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
yield
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def go!
|
25
|
+
@mutex.synchronize do
|
26
|
+
@go = true
|
27
|
+
@cond.broadcast
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
module TestHelpers
|
33
|
+
def new_controller(options = {})
|
34
|
+
start_command = './echo_server.rb -l echo_server.log -P echo_server.pid'
|
35
|
+
if options[:wait1]
|
36
|
+
start_command << " --wait1 #{options[:wait1]}"
|
37
|
+
end
|
38
|
+
if options[:wait2]
|
39
|
+
start_command << " --wait2 #{options[:wait2]}"
|
40
|
+
end
|
41
|
+
if options[:stop_time]
|
42
|
+
start_command << " --stop-time #{options[:stop_time]}"
|
43
|
+
end
|
44
|
+
if options[:crash_before_bind]
|
45
|
+
start_command << " --crash-before-bind"
|
46
|
+
end
|
47
|
+
new_options = {
|
48
|
+
:identifier => 'My Test Daemon',
|
49
|
+
:start_command => start_command,
|
50
|
+
:ping_command => proc do
|
51
|
+
begin
|
52
|
+
TCPSocket.new('localhost', 3230)
|
53
|
+
true
|
54
|
+
rescue SystemCallError
|
55
|
+
false
|
56
|
+
end
|
57
|
+
end,
|
58
|
+
:pid_file => 'echo_server.pid',
|
59
|
+
:log_file => 'echo_server.log',
|
60
|
+
:start_timeout => 3,
|
61
|
+
:stop_timeout => 3
|
62
|
+
}.merge(options)
|
63
|
+
@controller = DaemonController.new(new_options)
|
64
|
+
end
|
65
|
+
|
66
|
+
def write_file(filename, contents)
|
67
|
+
File.open(filename, 'w') do |f|
|
68
|
+
f.write(contents)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def exec_is_slow?
|
73
|
+
return RUBY_PLATFORM == "java"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
describe DaemonController, "#start" do
|
78
|
+
before :each do
|
79
|
+
new_controller
|
80
|
+
end
|
81
|
+
|
82
|
+
include TestHelpers
|
83
|
+
|
84
|
+
it "works" do
|
85
|
+
@controller.start
|
86
|
+
@controller.stop
|
87
|
+
end
|
88
|
+
|
89
|
+
it "raises AlreadyStarted if the daemon is already running" do
|
90
|
+
@controller.should_receive(:daemon_is_running?).and_return(true)
|
91
|
+
lambda { @controller.start }.should raise_error(DaemonController::AlreadyStarted)
|
92
|
+
end
|
93
|
+
|
94
|
+
it "deletes existing PID file before starting the daemon" do
|
95
|
+
write_file('echo_server.pid', '1234')
|
96
|
+
@controller.should_receive(:daemon_is_running?).and_return(false)
|
97
|
+
@controller.should_receive(:spawn_daemon)
|
98
|
+
@controller.should_receive(:pid_file_available?).and_return(true)
|
99
|
+
@controller.should_receive(:run_ping_command).at_least(:once).and_return(true)
|
100
|
+
@controller.start
|
101
|
+
File.exist?('echo_server.pid').should be_false
|
102
|
+
end
|
103
|
+
|
104
|
+
it "blocks until the daemon has written to its PID file" do
|
105
|
+
thread = WaitingThread.new do
|
106
|
+
sleep 0.15
|
107
|
+
write_file('echo_server.pid', '1234')
|
108
|
+
end
|
109
|
+
@controller.should_receive(:daemon_is_running?).and_return(false)
|
110
|
+
@controller.should_receive(:spawn_daemon).and_return do
|
111
|
+
thread.go!
|
112
|
+
end
|
113
|
+
@controller.should_receive(:run_ping_command).at_least(:once).and_return(true)
|
114
|
+
begin
|
115
|
+
result = Benchmark.measure do
|
116
|
+
@controller.start
|
117
|
+
end
|
118
|
+
(0.15 .. 0.30).should === result.real
|
119
|
+
ensure
|
120
|
+
thread.join
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
it "blocks until the daemon can be pinged" do
|
125
|
+
ping_ok = false
|
126
|
+
running = false
|
127
|
+
thread = WaitingThread.new do
|
128
|
+
sleep 0.15
|
129
|
+
ping_ok = true
|
130
|
+
end
|
131
|
+
@controller.should_receive(:daemon_is_running?).at_least(:once).and_return do
|
132
|
+
running
|
133
|
+
end
|
134
|
+
@controller.should_receive(:spawn_daemon).and_return do
|
135
|
+
thread.go!
|
136
|
+
running = true
|
137
|
+
end
|
138
|
+
@controller.should_receive(:pid_file_available?).and_return(true)
|
139
|
+
@controller.should_receive(:run_ping_command).at_least(:once).and_return do
|
140
|
+
ping_ok
|
141
|
+
end
|
142
|
+
begin
|
143
|
+
result = Benchmark.measure do
|
144
|
+
@controller.start
|
145
|
+
end
|
146
|
+
(0.15 .. 0.30).should === result.real
|
147
|
+
ensure
|
148
|
+
thread.join
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
it "raises StartTimeout if the daemon doesn't start in time" do
|
153
|
+
if exec_is_slow?
|
154
|
+
start_timeout = 4
|
155
|
+
min_start_timeout = 0
|
156
|
+
max_start_timeout = 6
|
157
|
+
else
|
158
|
+
start_timeout = 0.15
|
159
|
+
max_start_timeout = 0.30
|
160
|
+
end
|
161
|
+
new_controller(:start_command => 'sleep 2', :start_timeout => start_timeout)
|
162
|
+
start_time = Time.now
|
163
|
+
end_time = nil
|
164
|
+
@controller.should_receive(:start_timed_out).and_return do
|
165
|
+
end_time = Time.now
|
166
|
+
end
|
167
|
+
lambda { @controller.start }.should raise_error(DaemonController::StartTimeout)
|
168
|
+
(min_start_timeout .. max_start_timeout).should === end_time - start_time
|
169
|
+
end
|
170
|
+
|
171
|
+
it "kills the daemon with a signal if the daemon doesn't start in time and there's a PID file" do
|
172
|
+
new_controller(:wait2 => 3, :start_timeout => 1)
|
173
|
+
pid = nil
|
174
|
+
@controller.should_receive(:start_timed_out).and_return do
|
175
|
+
@controller.send(:wait_until) do
|
176
|
+
@controller.send(:pid_file_available?)
|
177
|
+
end
|
178
|
+
pid = @controller.send(:read_pid_file)
|
179
|
+
end
|
180
|
+
lambda { @controller.start }.should raise_error(DaemonController::StartTimeout)
|
181
|
+
end
|
182
|
+
|
183
|
+
if DaemonController.send(:fork_supported?)
|
184
|
+
it "kills the daemon if it doesn't start in time and hasn't " <<
|
185
|
+
"forked yet, on platforms where Ruby supports fork()" do
|
186
|
+
new_controller(:start_command => '(echo $$ > echo_server.pid && sleep 5)',
|
187
|
+
:start_timeout => 0.3)
|
188
|
+
lambda { @controller.start }.should raise_error(DaemonController::StartTimeout)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
it "raises an error if the daemon exits with an error before forking" do
|
193
|
+
new_controller(:start_command => 'false')
|
194
|
+
lambda { @controller.start }.should raise_error(DaemonController::Error)
|
195
|
+
end
|
196
|
+
|
197
|
+
it "raises an error if the daemon exits with an error after forking" do
|
198
|
+
new_controller(:crash_before_bind => true, :log_file_activity_timeout => 0.2)
|
199
|
+
lambda { @controller.start }.should raise_error(DaemonController::Error)
|
200
|
+
end
|
201
|
+
|
202
|
+
specify "the daemon's error output before forking is made available in the exception" do
|
203
|
+
new_controller(:start_command => '(echo hello world; false)')
|
204
|
+
begin
|
205
|
+
@controller.start
|
206
|
+
rescue DaemonController::Error => e
|
207
|
+
e.message.should == "hello world"
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
specify "the daemon's error output after forking is made available in the exception" do
|
212
|
+
new_controller(:crash_before_bind => true, :log_file_activity_timeout => 0.1)
|
213
|
+
begin
|
214
|
+
@controller.start
|
215
|
+
violated
|
216
|
+
rescue DaemonController::StartTimeout => e
|
217
|
+
e.message.should =~ /crashing, as instructed/
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
describe DaemonController, "#stop" do
|
223
|
+
include TestHelpers
|
224
|
+
|
225
|
+
before :each do
|
226
|
+
new_controller
|
227
|
+
end
|
228
|
+
|
229
|
+
it "raises no exception if the daemon is not running" do
|
230
|
+
@controller.stop
|
231
|
+
end
|
232
|
+
|
233
|
+
it "waits until the daemon is no longer running" do
|
234
|
+
new_controller(:stop_time => 0.3)
|
235
|
+
@controller.start
|
236
|
+
result = Benchmark.measure do
|
237
|
+
@controller.stop
|
238
|
+
end
|
239
|
+
@controller.running?.should be_false
|
240
|
+
(0.3 .. 0.5).should === result.real
|
241
|
+
end
|
242
|
+
|
243
|
+
it "raises StopTimeout if the daemon does not stop in time" do
|
244
|
+
new_controller(:stop_time => 0.3, :stop_timeout => 0.1)
|
245
|
+
@controller.start
|
246
|
+
begin
|
247
|
+
lambda { @controller.stop }.should raise_error(DaemonController::StopTimeout)
|
248
|
+
ensure
|
249
|
+
new_controller.stop
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
describe "if stop command was given" do
|
254
|
+
it "raises StopError if the stop command exits with an error" do
|
255
|
+
new_controller(:stop_command => '(echo hello world; false)')
|
256
|
+
begin
|
257
|
+
begin
|
258
|
+
@controller.stop
|
259
|
+
violated
|
260
|
+
rescue DaemonController::StopError => e
|
261
|
+
e.message.should == 'hello world'
|
262
|
+
end
|
263
|
+
ensure
|
264
|
+
new_controller.stop
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
it "makes the stop command's error message available in the exception" do
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
describe DaemonController, "#connect" do
|
274
|
+
include TestHelpers
|
275
|
+
|
276
|
+
before :each do
|
277
|
+
new_controller
|
278
|
+
end
|
279
|
+
|
280
|
+
it "starts the daemon if it isn't already running" do
|
281
|
+
socket = @controller.connect do
|
282
|
+
TCPSocket.new('localhost', 3230)
|
283
|
+
end
|
284
|
+
socket.close
|
285
|
+
@controller.stop
|
286
|
+
end
|
287
|
+
|
288
|
+
it "connects to the existing daemon if it's already running" do
|
289
|
+
@controller.start
|
290
|
+
begin
|
291
|
+
socket = @controller.connect do
|
292
|
+
TCPSocket.new('localhost', 3230)
|
293
|
+
end
|
294
|
+
socket.close
|
295
|
+
ensure
|
296
|
+
@controller.stop
|
297
|
+
end
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
data/spec/echo_server.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# A simple echo server, used by the unit test.
|
3
|
+
require 'socket'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
options = {
|
7
|
+
:port => 3230,
|
8
|
+
:chdir => "/",
|
9
|
+
:log_file => "/dev/null",
|
10
|
+
:wait1 => 0,
|
11
|
+
:wait2 => 0,
|
12
|
+
:stop_time => 0
|
13
|
+
}
|
14
|
+
parser = OptionParser.new do |opts|
|
15
|
+
opts.banner = "Usage: echo_server.rb [options]"
|
16
|
+
opts.separator ""
|
17
|
+
|
18
|
+
opts.separator "Options:"
|
19
|
+
opts.on("-p", "--port PORT", Integer, "Port to use. Default: 3230") do |value|
|
20
|
+
options[:port] = value
|
21
|
+
end
|
22
|
+
opts.on("-C", "--change-dir DIR", String, "Change working directory. Default: /") do |value|
|
23
|
+
options[:chdir] = value
|
24
|
+
end
|
25
|
+
opts.on("-l", "--log-file FILENAME", String, "Log file to use. Default: /dev/null") do |value|
|
26
|
+
options[:log_file] = value
|
27
|
+
end
|
28
|
+
opts.on("-P", "--pid-file FILENAME", String, "Pid file to use.") do |value|
|
29
|
+
options[:pid_file] = File.expand_path(value)
|
30
|
+
end
|
31
|
+
opts.on("--wait1 SECONDS", Float, "Wait a few seconds before writing pid file.") do |value|
|
32
|
+
options[:wait1] = value
|
33
|
+
end
|
34
|
+
opts.on("--wait2 SECONDS", Float, "Wait a few seconds before binding server socket.") do |value|
|
35
|
+
options[:wait2] = value
|
36
|
+
end
|
37
|
+
opts.on("--stop-time SECONDS", Float, "Wait a few seconds before exiting.") do |value|
|
38
|
+
options[:stop_time] = value
|
39
|
+
end
|
40
|
+
opts.on("--crash-before-bind", "Whether the daemon should crash before binding the server socket.") do
|
41
|
+
options[:crash_before_bind] = true
|
42
|
+
end
|
43
|
+
end
|
44
|
+
begin
|
45
|
+
parser.parse!
|
46
|
+
rescue OptionParser::ParseError => e
|
47
|
+
puts e
|
48
|
+
puts
|
49
|
+
puts "Please see '--help' for valid options."
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
|
53
|
+
if options[:pid_file]
|
54
|
+
if File.exist?(options[:pid_file])
|
55
|
+
STDERR.puts "*** ERROR: pid file #{options[:pid_file]} exists."
|
56
|
+
exit 1
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
pid = fork do
|
61
|
+
Process.setsid
|
62
|
+
fork do
|
63
|
+
STDIN.reopen("/dev/null", 'r')
|
64
|
+
STDOUT.reopen(options[:log_file], 'a')
|
65
|
+
STDERR.reopen(options[:log_file], 'a')
|
66
|
+
STDOUT.sync = true
|
67
|
+
STDERR.sync = true
|
68
|
+
Dir.chdir(options[:chdir])
|
69
|
+
File.umask(0)
|
70
|
+
|
71
|
+
if options[:pid_file]
|
72
|
+
sleep(options[:wait1])
|
73
|
+
File.open(options[:pid_file], 'w') do |f|
|
74
|
+
f.write(Process.pid)
|
75
|
+
end
|
76
|
+
at_exit do
|
77
|
+
File.unlink(options[:pid_file]) rescue nil
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
sleep(options[:wait2])
|
82
|
+
if options[:crash_before_bind]
|
83
|
+
puts "#{Time.now}: crashing, as instructed."
|
84
|
+
exit 2
|
85
|
+
end
|
86
|
+
|
87
|
+
server = TCPServer.new('localhost', options[:port])
|
88
|
+
begin
|
89
|
+
puts "*** #{Time.now}: echo server started"
|
90
|
+
while (client = server.accept)
|
91
|
+
puts "#{Time.now}: new client"
|
92
|
+
begin
|
93
|
+
while (line = client.readline)
|
94
|
+
puts "#{Time.now}: client sent: #{line.strip}"
|
95
|
+
client.puts(line)
|
96
|
+
end
|
97
|
+
rescue EOFError
|
98
|
+
ensure
|
99
|
+
puts "#{Time.now}: connection closed"
|
100
|
+
client.close rescue nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
rescue SignalException
|
104
|
+
exit 2
|
105
|
+
rescue => e
|
106
|
+
puts e.to_s
|
107
|
+
puts " " << e.backtrace.join("\n ")
|
108
|
+
exit 3
|
109
|
+
ensure
|
110
|
+
puts "*** #{Time.now}: echo server exiting..."
|
111
|
+
sleep(options[:stop_time])
|
112
|
+
puts "*** #{Time.now}: echo server exited"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
Process.waitpid(pid)
|
metadata
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: FooBarWidget-daemon_controller
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Hongli Lai
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-08-21 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description: A library for implementing daemon management capabilities.
|
17
|
+
email: hongli@phusion.nl
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files: []
|
23
|
+
|
24
|
+
files:
|
25
|
+
- README.rdoc
|
26
|
+
- LICENSE.txt
|
27
|
+
- daemon_controller.gemspec
|
28
|
+
- lib/daemon_controller.rb
|
29
|
+
- spec/daemon_controller_spec.rb
|
30
|
+
- spec/echo_server.rb
|
31
|
+
has_rdoc: false
|
32
|
+
homepage: http://github.com/FooBarWidget/daemon_controller/tree/master
|
33
|
+
post_install_message:
|
34
|
+
rdoc_options: []
|
35
|
+
|
36
|
+
require_paths:
|
37
|
+
- lib
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: "0"
|
43
|
+
version:
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: "0"
|
49
|
+
version:
|
50
|
+
requirements: []
|
51
|
+
|
52
|
+
rubyforge_project:
|
53
|
+
rubygems_version: 1.2.0
|
54
|
+
signing_key:
|
55
|
+
specification_version: 2
|
56
|
+
summary: A library for implementing daemon management capabilities
|
57
|
+
test_files: []
|
58
|
+
|