gorgon 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -1
- data/Gemfile.lock +9 -9
- data/README.md +33 -9
- data/bin/gorgon +9 -0
- data/gorgon.json.sample +29 -0
- data/gorgon_listener.json.sample +8 -0
- data/lib/gorgon/colors.rb +1 -0
- data/lib/gorgon/crash_reporter.rb +19 -0
- data/lib/gorgon/gem_command_handler.rb +46 -0
- data/lib/gorgon/gem_service.rb +77 -0
- data/lib/gorgon/job_state.rb +4 -4
- data/lib/gorgon/listener.rb +26 -21
- data/lib/gorgon/originator_protocol.rb +9 -8
- data/lib/gorgon/ping_service.rb +2 -2
- data/lib/gorgon/pipe_forker.rb +22 -0
- data/lib/gorgon/source_tree_syncer.rb +2 -1
- data/lib/gorgon/version.rb +1 -1
- data/lib/gorgon/worker.rb +64 -30
- data/lib/gorgon/worker_manager.rb +42 -12
- data/spec/crash_reporter_spec.rb +46 -0
- data/spec/gem_command_handler_spec.rb +84 -0
- data/spec/gem_service_spec.rb +74 -0
- data/spec/job_state_spec.rb +0 -7
- data/spec/listener_spec.rb +37 -19
- data/spec/originator_protocol_spec.rb +12 -9
- data/spec/ping_service_spec.rb +3 -3
- data/spec/pipe_forker_spec.rb +53 -0
- data/spec/worker_manager_spec.rb +34 -5
- data/spec/worker_spec.rb +63 -1
- metadata +12 -4
- data/lib/gorgon/pipe_manager.rb +0 -55
- data/lib/gorgon/worker_watcher.rb +0 -22
@@ -16,7 +16,7 @@ class OriginatorProtocol
|
|
16
16
|
end
|
17
17
|
|
18
18
|
def publish_files files
|
19
|
-
@file_queue = @channel.queue(UUIDTools::UUID.timestamp_create.to_s)
|
19
|
+
@file_queue = @channel.queue("file_queue_" + UUIDTools::UUID.timestamp_create.to_s)
|
20
20
|
|
21
21
|
files.each do |file|
|
22
22
|
@channel.default_exchange.publish(file, :routing_key => @file_queue.name)
|
@@ -30,9 +30,9 @@ class OriginatorProtocol
|
|
30
30
|
@channel.fanout("gorgon.jobs").publish(job_definition.to_json)
|
31
31
|
end
|
32
32
|
|
33
|
-
def
|
34
|
-
# TODO: we probably want to use a different exchange for
|
35
|
-
message = {:type =>
|
33
|
+
def send_message_to_listeners type, body={}
|
34
|
+
# TODO: we probably want to use a different exchange for this type of messages
|
35
|
+
message = {:type => type, :reply_exchange_name => @reply_exchange.name, :body => body}
|
36
36
|
@channel.fanout("gorgon.jobs").publish(Yajl::Encoder.encode(message))
|
37
37
|
end
|
38
38
|
|
@@ -49,21 +49,22 @@ class OriginatorProtocol
|
|
49
49
|
end
|
50
50
|
|
51
51
|
def disconnect
|
52
|
-
|
52
|
+
cleanup_queues_and_exchange
|
53
53
|
@connection.disconnect
|
54
54
|
end
|
55
55
|
|
56
56
|
private
|
57
57
|
|
58
58
|
def open_queues
|
59
|
-
@reply_queue = @channel.queue(UUIDTools::UUID.timestamp_create.to_s)
|
60
|
-
@reply_exchange = @channel.direct(UUIDTools::UUID.timestamp_create.to_s)
|
59
|
+
@reply_queue = @channel.queue("reply_queue_" + UUIDTools::UUID.timestamp_create.to_s)
|
60
|
+
@reply_exchange = @channel.direct("reply_exchange_" + UUIDTools::UUID.timestamp_create.to_s)
|
61
61
|
@reply_queue.bind(@reply_exchange)
|
62
62
|
end
|
63
63
|
|
64
|
-
def
|
64
|
+
def cleanup_queues_and_exchange
|
65
65
|
@reply_queue.delete if @reply_queue
|
66
66
|
@file_queue.delete if @file_queue
|
67
|
+
@reply_exchange.delete if @reply_exchange
|
67
68
|
end
|
68
69
|
|
69
70
|
def cancel_message
|
data/lib/gorgon/ping_service.rb
CHANGED
@@ -26,7 +26,7 @@ class PingService
|
|
26
26
|
@protocol.connect @configuration[:connection], :on_closed => proc {EM.stop}
|
27
27
|
|
28
28
|
@logger.log "Pinging Listeners..."
|
29
|
-
@protocol.
|
29
|
+
@protocol.send_message_to_listeners :ping
|
30
30
|
|
31
31
|
EM.add_timer(TIMEOUT) { disconnect }
|
32
32
|
|
@@ -53,7 +53,7 @@ class PingService
|
|
53
53
|
|
54
54
|
@listeners << payload
|
55
55
|
hostname = payload[:hostname].colorize(Colors::HOST)
|
56
|
-
puts "#{hostname} is running Listener version #{payload[:version]}"
|
56
|
+
puts "#{hostname} is running Listener version #{payload[:version]} and uses #{payload[:worker_slots]} workers"
|
57
57
|
end
|
58
58
|
|
59
59
|
def print_summary
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module PipeForker
|
2
|
+
def pipe_fork
|
3
|
+
stdin = Pipe.new(*IO.pipe)
|
4
|
+
pid = fork do
|
5
|
+
stdin.write.close
|
6
|
+
STDIN.reopen(stdin.read)
|
7
|
+
stdin.read.close
|
8
|
+
|
9
|
+
yield
|
10
|
+
|
11
|
+
exit
|
12
|
+
end
|
13
|
+
|
14
|
+
stdin.read.close
|
15
|
+
|
16
|
+
return pid, stdin.write
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
Pipe = Struct.new(:read, :write)
|
22
|
+
end
|
@@ -25,9 +25,10 @@ class SourceTreeSyncer
|
|
25
25
|
pid, stdin, stdout, stderr = Open4::popen4 @sys_command
|
26
26
|
stdin.close
|
27
27
|
|
28
|
+
ignore, status = Process.waitpid2 pid
|
29
|
+
|
28
30
|
@output, @errors = [stdout, stderr].map { |p| begin p.read ensure p.close end }
|
29
31
|
|
30
|
-
ignore, status = Process.waitpid2 pid
|
31
32
|
@exitstatus = status.exitstatus
|
32
33
|
end
|
33
34
|
|
data/lib/gorgon/version.rb
CHANGED
data/lib/gorgon/worker.rb
CHANGED
@@ -2,6 +2,8 @@ require "gorgon/configuration"
|
|
2
2
|
require "gorgon/amqp_service"
|
3
3
|
require 'gorgon/callback_handler'
|
4
4
|
require "gorgon/g_logger"
|
5
|
+
require 'gorgon/job_definition'
|
6
|
+
require "gorgon/testunit_runner"
|
5
7
|
|
6
8
|
require "uuidtools"
|
7
9
|
require "awesome_print"
|
@@ -9,7 +11,6 @@ require "socket"
|
|
9
11
|
|
10
12
|
module WorkUnit
|
11
13
|
def self.run_file filename
|
12
|
-
require "gorgon/testunit_runner"
|
13
14
|
start_t = Time.now
|
14
15
|
|
15
16
|
begin
|
@@ -31,29 +32,44 @@ end
|
|
31
32
|
class Worker
|
32
33
|
include GLogger
|
33
34
|
|
34
|
-
|
35
|
-
|
36
|
-
|
35
|
+
class << self
|
36
|
+
def build(worker_id, config)
|
37
|
+
redirect_output_to_files worker_id
|
37
38
|
|
38
|
-
|
39
|
-
|
39
|
+
payload = Yajl::Parser.new(:symbolize_keys => true).parse($stdin.read)
|
40
|
+
job_definition = JobDefinition.new(payload)
|
40
41
|
|
41
|
-
|
42
|
+
connection_config = config[:connection]
|
43
|
+
amqp = AmqpService.new connection_config
|
42
44
|
|
43
|
-
|
44
|
-
ENV["GORGON_WORKER_ID"] = worker_id
|
45
|
+
callback_handler = CallbackHandler.new(job_definition.callbacks)
|
45
46
|
|
46
|
-
|
47
|
-
:amqp => amqp,
|
48
|
-
:file_queue_name => job_definition.file_queue_name,
|
49
|
-
:reply_exchange_name => job_definition.reply_exchange_name,
|
50
|
-
:worker_id => worker_id,
|
51
|
-
:test_runner => WorkUnit,
|
52
|
-
:callback_handler => callback_handler,
|
53
|
-
:log_file => config[:log_file]
|
54
|
-
}
|
47
|
+
ENV["GORGON_WORKER_ID"] = worker_id.to_s
|
55
48
|
|
56
|
-
|
49
|
+
params = {
|
50
|
+
:amqp => amqp,
|
51
|
+
:file_queue_name => job_definition.file_queue_name,
|
52
|
+
:reply_exchange_name => job_definition.reply_exchange_name,
|
53
|
+
:worker_id => worker_id,
|
54
|
+
:test_runner => WorkUnit,
|
55
|
+
:callback_handler => callback_handler,
|
56
|
+
:log_file => config[:log_file]
|
57
|
+
}
|
58
|
+
|
59
|
+
new(params)
|
60
|
+
end
|
61
|
+
|
62
|
+
def output_file id, stream
|
63
|
+
"/tmp/gorgon-worker-#{id}.#{stream.to_s}"
|
64
|
+
end
|
65
|
+
|
66
|
+
def redirect_output_to_files worker_id
|
67
|
+
STDOUT.reopen(File.open(output_file(worker_id, :out), 'w'))
|
68
|
+
STDOUT.sync = true
|
69
|
+
|
70
|
+
STDERR.reopen(File.open(output_file(worker_id, :err), 'w'))
|
71
|
+
STDERR.sync = true
|
72
|
+
end
|
57
73
|
end
|
58
74
|
|
59
75
|
def initialize(params)
|
@@ -68,27 +84,35 @@ class Worker
|
|
68
84
|
end
|
69
85
|
|
70
86
|
def work
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
87
|
+
begin
|
88
|
+
log "Running before_start callback..."
|
89
|
+
register_trap_ints # do it before calling before_start callback!
|
90
|
+
@callback_handler.before_start
|
91
|
+
@cleaned = false
|
92
|
+
|
93
|
+
log "Running files ..."
|
94
|
+
@amqp.start_worker @file_queue_name, @reply_exchange_name do |queue, exchange|
|
95
|
+
while filename = queue.pop
|
96
|
+
exchange.publish make_start_message(filename)
|
97
|
+
log "Running '#{filename}'"
|
98
|
+
test_results = run_file(filename)
|
99
|
+
exchange.publish make_finish_message(filename, test_results)
|
100
|
+
end
|
81
101
|
end
|
102
|
+
rescue Exception => e
|
103
|
+
clean_up
|
104
|
+
raise e # So worker manager can catch it
|
82
105
|
end
|
83
|
-
ensure # this 'ensure' that we run after_complete even after an 'INT' signal
|
84
106
|
clean_up
|
85
107
|
end
|
86
108
|
|
87
109
|
private
|
88
110
|
|
89
111
|
def clean_up
|
112
|
+
return if @cleaned
|
90
113
|
log "Running after_complete callback"
|
91
114
|
@callback_handler.after_complete
|
115
|
+
@cleaned = true
|
92
116
|
end
|
93
117
|
|
94
118
|
def run_file(filename)
|
@@ -102,4 +126,14 @@ class Worker
|
|
102
126
|
def make_finish_message(filename, results)
|
103
127
|
{:action => :finish, :hostname => Socket.gethostname, :worker_id => @worker_id, :filename => filename}.merge(results)
|
104
128
|
end
|
129
|
+
|
130
|
+
def register_trap_ints
|
131
|
+
Signal.trap("INT") { ctrl_c }
|
132
|
+
Signal.trap("TERM") { ctrl_c }
|
133
|
+
end
|
134
|
+
|
135
|
+
def ctrl_c
|
136
|
+
clean_up
|
137
|
+
exit
|
138
|
+
end
|
105
139
|
end
|
@@ -1,22 +1,37 @@
|
|
1
1
|
require "gorgon/worker"
|
2
2
|
require "gorgon/g_logger"
|
3
3
|
require 'gorgon/callback_handler'
|
4
|
-
require 'gorgon/
|
4
|
+
require 'gorgon/pipe_forker'
|
5
5
|
require 'gorgon/job_definition'
|
6
|
+
require "gorgon/crash_reporter"
|
6
7
|
|
7
8
|
require 'eventmachine'
|
8
9
|
|
9
10
|
class WorkerManager
|
10
|
-
include
|
11
|
+
include PipeForker
|
11
12
|
include GLogger
|
13
|
+
include CrashReporter
|
14
|
+
|
15
|
+
STDOUT_FILE='/tmp/gorgon-worker-mgr.out'
|
16
|
+
STDERR_FILE='/tmp/gorgon-worker-mgr.err'
|
12
17
|
|
13
18
|
def self.build listener_config_file
|
14
19
|
@listener_config_file = listener_config_file
|
15
20
|
config = Configuration.load_configuration_from_file(listener_config_file)
|
16
21
|
|
22
|
+
redirect_output_to_files
|
23
|
+
|
17
24
|
new config
|
18
25
|
end
|
19
26
|
|
27
|
+
def self.redirect_output_to_files
|
28
|
+
STDOUT.reopen(File.open(STDOUT_FILE, 'w'))
|
29
|
+
STDOUT.sync = true
|
30
|
+
|
31
|
+
STDERR.reopen(File.open(STDERR_FILE, 'w'))
|
32
|
+
STDERR.sync = true
|
33
|
+
end
|
34
|
+
|
20
35
|
def initialize config
|
21
36
|
initialize_logger config[:log_file]
|
22
37
|
log "Worker Manager #{Gorgon::VERSION} initializing"
|
@@ -68,7 +83,13 @@ class WorkerManager
|
|
68
83
|
@available_worker_slots -= 1
|
69
84
|
ENV["GORGON_CONFIG_PATH"] = @listener_config_filename
|
70
85
|
|
71
|
-
|
86
|
+
worker_id = get_worker_id
|
87
|
+
log "Forking Worker #{worker_id}"
|
88
|
+
pid, stdin = pipe_fork do
|
89
|
+
worker = Worker.build(worker_id, @config)
|
90
|
+
worker.work
|
91
|
+
end
|
92
|
+
|
72
93
|
@worker_pids << pid
|
73
94
|
stdin.write(@job_definition.to_json)
|
74
95
|
stdin.close
|
@@ -82,17 +103,18 @@ class WorkerManager
|
|
82
103
|
|
83
104
|
worker_complete = proc do |status|
|
84
105
|
if status.exitstatus != 0
|
85
|
-
|
86
|
-
|
87
|
-
log_error "ERROR MSG: #{error_msg}"
|
106
|
+
exitstatus = status.exitstatus
|
107
|
+
log_error "Worker #{pid} crashed with exit status #{exitstatus}!"
|
88
108
|
|
89
109
|
# originator may have cancel job and exit, so only try to send message
|
90
110
|
begin
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
111
|
+
out_file = Worker.output_file(worker_id, :out)
|
112
|
+
err_file = Worker.output_file(worker_id, :err)
|
113
|
+
|
114
|
+
msg = report_crash @reply_exchange, :out_file => out_file,
|
115
|
+
:err_file => err_file, :footer_text => footer_text(err_file, out_file)
|
116
|
+
log_error "Process output:\n#{msg}"
|
117
|
+
|
96
118
|
# TODO: find a way to stop the whole system when a worker crashes or do something more clever
|
97
119
|
rescue Exception => e
|
98
120
|
log_error "Exception raised when trying to report crash to originator:"
|
@@ -105,6 +127,10 @@ class WorkerManager
|
|
105
127
|
EventMachine.defer(watcher, worker_complete)
|
106
128
|
end
|
107
129
|
|
130
|
+
def get_worker_id
|
131
|
+
@worker_id_count = @worker_id_count.nil? ? 1 : @worker_id_count + 1
|
132
|
+
end
|
133
|
+
|
108
134
|
def on_worker_complete
|
109
135
|
@available_worker_slots += 1
|
110
136
|
on_current_job_complete if current_job_complete?
|
@@ -125,7 +151,7 @@ class WorkerManager
|
|
125
151
|
@bunny.stop
|
126
152
|
end
|
127
153
|
|
128
|
-
CANCEL_TIMEOUT =
|
154
|
+
CANCEL_TIMEOUT = 20
|
129
155
|
def subscribe_to_originator_queue
|
130
156
|
|
131
157
|
originator_watcher = proc do
|
@@ -154,4 +180,8 @@ class WorkerManager
|
|
154
180
|
|
155
181
|
EventMachine.defer(originator_watcher, handle_message)
|
156
182
|
end
|
183
|
+
|
184
|
+
def footer_text err_file, out_file
|
185
|
+
"\n***** See #{err_file} and #{out_file} at '#{Socket.gethostname}' for more details *****\n"
|
186
|
+
end
|
157
187
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'gorgon/crash_reporter'
|
2
|
+
|
3
|
+
describe "CrashReporter" do
|
4
|
+
let(:exchange) { stub("Bunny Exchange", :publish => nil) }
|
5
|
+
let(:info) { {
|
6
|
+
:out_file => "stdout_file", :err_file => "stderr_file", :footer_text => "Text"
|
7
|
+
} }
|
8
|
+
|
9
|
+
let(:container_class) do
|
10
|
+
Class.new do
|
11
|
+
extend(CrashReporter)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "#report_crash" do
|
16
|
+
it "tails output files to get last few lines" do
|
17
|
+
container_class.should_receive(:'`').once.
|
18
|
+
with(/tail.*stdout_file/).and_return ""
|
19
|
+
container_class.should_receive(:'`').once.
|
20
|
+
with(/tail.*stderr_file/).and_return ""
|
21
|
+
container_class.report_crash exchange, info
|
22
|
+
end
|
23
|
+
|
24
|
+
it "calls send_crash_message" do
|
25
|
+
container_class.stub!(:'`').and_return "stdout text", "stderr text "
|
26
|
+
container_class.should_receive(:send_crash_message).with(exchange, "stdout text", "stderr text Text")
|
27
|
+
container_class.report_crash exchange, info
|
28
|
+
end
|
29
|
+
|
30
|
+
it "returns last lines of output from stderr message and footer text" do
|
31
|
+
container_class.stub!(:'`').and_return "stdout text", "stderr text "
|
32
|
+
container_class.stub!(:send_crash_message)
|
33
|
+
result = container_class.report_crash exchange, info
|
34
|
+
result.should == "stdout text\nstderr text Text"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "#send_crash_message" do
|
39
|
+
it "sends message with output and errors from syncer using reply_exchange " do
|
40
|
+
reply = {:type => :crash, :hostname => Socket.gethostname, :stdout => "some output",
|
41
|
+
:stderr => "some errors"}
|
42
|
+
exchange.should_receive(:publish).with(Yajl::Encoder.encode(reply))
|
43
|
+
container_class.send_crash_message exchange, "some output", "some errors"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'gorgon/gem_command_handler'
|
2
|
+
|
3
|
+
describe GemCommandHandler do
|
4
|
+
let(:exchange) { stub("Bunny Exchange", :publish => nil) }
|
5
|
+
let(:bunny) { stub("Bunny", :exchange => exchange, :stop => nil) }
|
6
|
+
|
7
|
+
let(:payload) {
|
8
|
+
{:type => :update, :reply_exchange_name => "name",
|
9
|
+
:body => {:gem_command => "cmd"}}
|
10
|
+
}
|
11
|
+
let(:stdin) { stub("IO object", :close => nil)}
|
12
|
+
let(:stdout) { stub("IO object", :read => "output", :close => nil)}
|
13
|
+
let(:stderr) { stub("IO object", :read => "errors", :close => nil)}
|
14
|
+
let(:status) { stub("Process Status", :exitstatus => 0)}
|
15
|
+
|
16
|
+
describe "#handle" do
|
17
|
+
before do
|
18
|
+
@handler = GemCommandHandler.new bunny
|
19
|
+
@running_response = {:type => :running_command, :hostname => Socket.gethostname}
|
20
|
+
stub_methods
|
21
|
+
end
|
22
|
+
|
23
|
+
it "publishes 'running_command' message" do
|
24
|
+
bunny.should_receive(:exchange).with("name", anything).and_return(exchange)
|
25
|
+
Yajl::Encoder.should_receive(:encode).with(@running_response).and_return :json_msg
|
26
|
+
exchange.should_receive(:publish).with(:json_msg)
|
27
|
+
@handler.handle payload, {}
|
28
|
+
end
|
29
|
+
|
30
|
+
it "runs 'gem <command> gorgon' using popen4" do
|
31
|
+
Open4.should_receive(:popen4).with("gem cmd gorgon").and_return([1, stdin, stdout, stderr])
|
32
|
+
@handler.handle payload, {}
|
33
|
+
end
|
34
|
+
|
35
|
+
it "uses binary gem from configuration if this was specified" do
|
36
|
+
Open4.should_receive(:popen4).with("path/to/gem cmd gorgon").and_return([1, stdin, stdout, stderr])
|
37
|
+
@handler.handle payload, {:bin_gem_path => "path/to/gem"}
|
38
|
+
end
|
39
|
+
|
40
|
+
it "waits for the command to finish" do
|
41
|
+
Open4.should_receive(:popen4).ordered
|
42
|
+
Process.should_receive(:waitpid2).with(1).ordered.and_return([nil, status])
|
43
|
+
@handler.handle payload, {}
|
44
|
+
end
|
45
|
+
|
46
|
+
it "closes stding and reads stdout and stderr from process" do
|
47
|
+
stdin.should_receive(:close)
|
48
|
+
stdout.should_receive(:read)
|
49
|
+
stderr.should_receive(:read)
|
50
|
+
@handler.handle payload, {}
|
51
|
+
end
|
52
|
+
|
53
|
+
it "sends 'command_completed' message when exitstatus is 0" do
|
54
|
+
response = {:type => :command_completed, :hostname => Socket.gethostname,
|
55
|
+
:command => "gem cmd gorgon", :stdout => "output", :stderr => "errors" }
|
56
|
+
Yajl::Encoder.should_receive(:encode).once.ordered.with(@running_response)
|
57
|
+
Yajl::Encoder.should_receive(:encode).once.ordered.with(response).and_return :json_msg
|
58
|
+
exchange.should_receive(:publish).with(:json_msg)
|
59
|
+
@handler.handle payload, {}
|
60
|
+
end
|
61
|
+
|
62
|
+
it "stops bunny and exit if exitstatus is 0" do
|
63
|
+
bunny.should_receive(:stop).once.ordered
|
64
|
+
@handler.should_receive(:exit).once.ordered
|
65
|
+
@handler.handle payload, {}
|
66
|
+
end
|
67
|
+
|
68
|
+
it "sends 'command_failed' message when exitstatus is not 0" do
|
69
|
+
status.should_receive(:exitstatus).and_return(99)
|
70
|
+
response = {:type => :command_failed, :hostname => Socket.gethostname,
|
71
|
+
:command => "gem cmd gorgon", :stdout => "output", :stderr => "errors" }
|
72
|
+
Yajl::Encoder.should_receive(:encode).with(response).and_return :json_msg
|
73
|
+
exchange.should_receive(:publish).with(:json_msg)
|
74
|
+
@handler.handle payload, {}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def stub_methods
|
79
|
+
Open4.stub!(:popen4).and_return([1, stdin, stdout, stderr])
|
80
|
+
Process.stub!(:waitpid2).and_return([nil, status])
|
81
|
+
Yajl::Encoder.stub!(:encode).and_return :json_msg
|
82
|
+
@handler.stub!(:exit)
|
83
|
+
end
|
84
|
+
end
|