dante 0.1.5 → 0.3.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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +30 -0
- data/.github/workflows/release.yml +210 -0
- data/CHANGELOG.md +96 -0
- data/Gemfile +1 -1
- data/LICENSE +2 -2
- data/README.md +23 -3
- data/Rakefile +15 -7
- data/dante.gemspec +4 -5
- data/lib/dante/runner.rb +186 -45
- data/lib/dante/version.rb +1 -1
- data/test/dante_test.rb +3 -3
- data/test/runner_test.rb +683 -57
- data/test/test_helper.rb +142 -12
- data/test/user_switch_test.rb +194 -0
- metadata +52 -85
data/test/runner_test.rb
CHANGED
|
@@ -1,130 +1,755 @@
|
|
|
1
1
|
require File.expand_path('../test_helper', __FILE__)
|
|
2
|
+
require 'socket'
|
|
2
3
|
|
|
3
4
|
describe "dante runner" do
|
|
5
|
+
|
|
6
|
+
describe "verify options fails" do
|
|
7
|
+
it "should bubble up exception" do
|
|
8
|
+
runner = Dante::Runner.new('test-process', {key1:"val2"}) {
|
|
9
|
+
raise Exception.new("should not get here!!!")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
runner.verify_options_hook = lambda { |opts|
|
|
13
|
+
raise Exception.new("Look for this exception") if(opts[:key1] != "val1")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
err = assert_raises(Exception) {
|
|
17
|
+
runner.execute
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
assert_equal(err.message, "Look for this exception")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
4
24
|
describe "with no daemonize" do
|
|
5
25
|
before do
|
|
6
|
-
@process = TestingProcess.new('a')
|
|
26
|
+
@process = TestingProcess.new('a', @tmpdir)
|
|
7
27
|
@runner = Dante::Runner.new('test-process') { @process.run_a! }
|
|
8
28
|
@stdout = capture_stdout { @runner.execute }
|
|
9
29
|
end
|
|
10
30
|
|
|
11
31
|
it "prints correct stdout" do
|
|
12
|
-
assert_match
|
|
32
|
+
assert_match(/Starting test-process/, @stdout)
|
|
13
33
|
end
|
|
14
34
|
|
|
15
35
|
it "starts successfully when executed" do
|
|
16
36
|
@output = File.read(@process.tmp_path)
|
|
17
|
-
assert_match
|
|
37
|
+
assert_match(/Started/, @output)
|
|
18
38
|
end
|
|
19
39
|
end # no daemonize
|
|
20
40
|
|
|
21
41
|
describe "with daemonize flag" do
|
|
22
42
|
before do
|
|
23
|
-
@process = TestingProcess.new('b')
|
|
24
|
-
@
|
|
43
|
+
@process = TestingProcess.new('b', @tmpdir)
|
|
44
|
+
@pid_path = tmp_path('dante.pid')
|
|
45
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :port => 8080, :log_path => tmp_path('dante-daemon.log') }
|
|
25
46
|
@runner = Dante::Runner.new('test-process-2', @run_options) { |opts|
|
|
26
47
|
@process.run_b!(opts[:port]) }
|
|
27
48
|
@stdout = capture_stdout { @runner.execute }
|
|
28
|
-
|
|
49
|
+
wait_for_output(@process.tmp_path)
|
|
29
50
|
end
|
|
30
51
|
|
|
31
52
|
it "can properly handles aborts and starts / stops on INT" do
|
|
32
|
-
refute_equal 0, @pid =
|
|
53
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
33
54
|
Process.kill "INT", @pid
|
|
34
|
-
|
|
55
|
+
wait_for_exit(@pid)
|
|
35
56
|
@output = File.read(@process.tmp_path)
|
|
36
57
|
|
|
37
|
-
assert_match
|
|
38
|
-
assert_match
|
|
39
|
-
assert_match
|
|
58
|
+
assert_match(/Started on 8080!!/, @output)
|
|
59
|
+
assert_match(/Interrupt!!/, @output)
|
|
60
|
+
assert_match(/Closing!!/, @output)
|
|
40
61
|
end
|
|
41
62
|
|
|
42
63
|
it "can properly handles aborts and starts / stops on TERM" do
|
|
43
|
-
refute_equal 0, @pid =
|
|
64
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
44
65
|
Process.kill "TERM", @pid
|
|
45
|
-
|
|
66
|
+
wait_for_exit(@pid)
|
|
46
67
|
@output = File.read(@process.tmp_path)
|
|
47
68
|
|
|
48
|
-
assert_match
|
|
49
|
-
assert_match
|
|
50
|
-
refute_match
|
|
69
|
+
assert_match(/Started on 8080!!/, @output)
|
|
70
|
+
assert_match(/Closing!!/, @output)
|
|
71
|
+
refute_match(/Interrupt!!/, @output)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "can be stopped with #stop, which removes the pid file" do
|
|
75
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
76
|
+
@stop_output = capture_stdout { @runner.stop }
|
|
77
|
+
|
|
78
|
+
assert_match(/Daemonized process killed after term/, @stop_output)
|
|
79
|
+
refute File.exist?(@pid_path), "pid file left behind after stop"
|
|
80
|
+
assert_match(/Closing!!/, File.read(@process.tmp_path))
|
|
81
|
+
refute_match(/Interrupt!!/, File.read(@process.tmp_path)) # stopped with TERM, not INT
|
|
51
82
|
end
|
|
52
83
|
end # daemonize
|
|
53
84
|
|
|
85
|
+
describe "daemonize with block on sigterm" do
|
|
86
|
+
before do
|
|
87
|
+
@logfile = tmp_path('dante-logging.log')
|
|
88
|
+
@pid_path = tmp_path('dante.pid')
|
|
89
|
+
@process = TestingProcess.new('f1', @tmpdir)
|
|
90
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :log_path => @logfile }
|
|
91
|
+
@runner = Dante::Runner.new('test-process-f1', @run_options) { |opts|
|
|
92
|
+
@process.run_d! }
|
|
93
|
+
@stdout = capture_stdout { @runner.execute }
|
|
94
|
+
wait_for_output(@logfile, /The rescuer/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "cannot be stopped without force flag" do
|
|
98
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
99
|
+
@stop_output = capture_stdout { @runner.stop }
|
|
100
|
+
@output = File.read(@logfile)
|
|
101
|
+
|
|
102
|
+
assert_match(/Trying to stop test-process/, @output)
|
|
103
|
+
assert_match(/Failed to kill daemonized process/, @stop_output)
|
|
104
|
+
refute_match(/Daemonized process killed after kill/, @stop_output)
|
|
105
|
+
assert File.exist?(@pid_path), "pid file removed although the daemon is still running"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "daemonize with block on sigkill" do
|
|
110
|
+
before do
|
|
111
|
+
@logfile = tmp_path('dante-logging.log')
|
|
112
|
+
@pid_path = tmp_path('dante.pid')
|
|
113
|
+
@process = TestingProcess.new('f2', @tmpdir)
|
|
114
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :log_path => @logfile, force: true }
|
|
115
|
+
@runner = Dante::Runner.new('test-process-f2', @run_options) { |opts|
|
|
116
|
+
@process.run_d! }
|
|
117
|
+
@stdout = capture_stdout { @runner.execute }
|
|
118
|
+
wait_for_output(@logfile, /The rescuer/)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "can be stopped without force flag" do
|
|
122
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
123
|
+
@stop_output = capture_stdout { @runner.stop }
|
|
124
|
+
@output = File.read(@logfile)
|
|
125
|
+
|
|
126
|
+
assert_match(/Trying to stop test-process/, @output)
|
|
127
|
+
assert_match(/Failed to kill daemonized process/, @stop_output)
|
|
128
|
+
assert_match(/Daemonized process killed after kill/, @stop_output)
|
|
129
|
+
refute File.exist?(@pid_path), "pid file left behind after a forced stop"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
54
133
|
describe "with daemonize flag and log file specified" do
|
|
55
134
|
before do
|
|
56
|
-
@logfile = '
|
|
57
|
-
|
|
58
|
-
@process = TestingProcess.new('c')
|
|
59
|
-
@run_options = { :daemonize => true, :pid_path =>
|
|
135
|
+
@logfile = tmp_path('dante-logging.log')
|
|
136
|
+
@pid_path = tmp_path('dante.pid')
|
|
137
|
+
@process = TestingProcess.new('c', @tmpdir)
|
|
138
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :port => 8081, :log_path => @logfile }
|
|
60
139
|
@runner = Dante::Runner.new('test-process-2', @run_options) { |opts|
|
|
61
140
|
@process.run_c!(opts[:port]) }
|
|
62
|
-
@runner.execute
|
|
63
|
-
|
|
141
|
+
@stdout = capture_stdout { @runner.execute }
|
|
142
|
+
wait_for_output(@logfile, /Started on 8081!!/)
|
|
64
143
|
end
|
|
65
144
|
|
|
66
145
|
it "can properly handles log to file and aborts on INT" do
|
|
67
|
-
refute_equal 0, @pid =
|
|
146
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
68
147
|
Process.kill "INT", @pid
|
|
69
|
-
|
|
148
|
+
wait_for_exit(@pid)
|
|
70
149
|
@output = File.read(@logfile)
|
|
71
150
|
|
|
72
|
-
assert_match
|
|
73
|
-
assert_match
|
|
74
|
-
assert_match
|
|
151
|
+
assert_match(/Started on 8081!!/, @output)
|
|
152
|
+
assert_match(/Interrupt!!/, @output)
|
|
153
|
+
assert_match(/Closing!!/, @output)
|
|
75
154
|
end
|
|
76
155
|
end
|
|
77
156
|
|
|
78
157
|
describe "with daemonize flag and logging flag enabled, and debug flag disabled" do
|
|
79
158
|
before do
|
|
80
|
-
@logfile = '
|
|
81
|
-
|
|
82
|
-
@process = TestingProcess.new('c')
|
|
83
|
-
@run_options = { :daemonize => true, :pid_path =>
|
|
159
|
+
@logfile = tmp_path('dante-logging.log')
|
|
160
|
+
@pid_path = tmp_path('dante.pid')
|
|
161
|
+
@process = TestingProcess.new('c', @tmpdir)
|
|
162
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :port => 8081, :log_path => @logfile, :debug => false }
|
|
84
163
|
@runner = Dante::Runner.new('test-process-2', @run_options) { |opts|
|
|
85
164
|
@process.run_c!(opts[:port]) }
|
|
86
|
-
@runner.execute
|
|
87
|
-
|
|
165
|
+
@stdout = capture_stdout { @runner.execute }
|
|
166
|
+
wait_for_output(@logfile, /Started on 8081!!/)
|
|
88
167
|
end
|
|
89
168
|
|
|
90
169
|
it "can properly handles log to file and aborts on INT" do
|
|
91
|
-
refute_equal 0, @pid =
|
|
170
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
92
171
|
Process.kill "INT", @pid
|
|
93
|
-
|
|
172
|
+
wait_for_exit(@pid)
|
|
94
173
|
@output = File.read(@logfile)
|
|
95
174
|
|
|
96
|
-
assert_match
|
|
97
|
-
assert_match
|
|
98
|
-
assert_match
|
|
175
|
+
assert_match(/Started on 8081!!/, @output)
|
|
176
|
+
assert_match(/Interrupt received/, @output)
|
|
177
|
+
assert_match(/Closing!!/, @output)
|
|
99
178
|
end
|
|
100
179
|
end
|
|
101
180
|
|
|
102
181
|
|
|
103
182
|
describe "with execute accepting block" do
|
|
104
183
|
before do
|
|
105
|
-
@process = TestingProcess.new('b')
|
|
106
|
-
@
|
|
184
|
+
@process = TestingProcess.new('b', @tmpdir)
|
|
185
|
+
@pid_path = tmp_path('dante.pid')
|
|
186
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :port => 8080, :log_path => tmp_path('dante-daemon.log') }
|
|
107
187
|
@runner = Dante::Runner.new('test-process-2', @run_options)
|
|
108
188
|
@stdout = capture_stdout { @runner.execute { |opts| @process.run_b!(opts[:port]) } }
|
|
109
|
-
|
|
189
|
+
wait_for_output(@process.tmp_path)
|
|
110
190
|
end
|
|
111
191
|
|
|
112
192
|
it "can properly handles aborts and starts / stops on INT" do
|
|
113
|
-
refute_equal 0, @pid =
|
|
193
|
+
refute_equal 0, @pid = daemon_pid(@pid_path)
|
|
114
194
|
Process.kill "INT", @pid
|
|
115
|
-
|
|
195
|
+
wait_for_exit(@pid)
|
|
116
196
|
@output = File.read(@process.tmp_path)
|
|
117
197
|
|
|
118
|
-
assert_match
|
|
119
|
-
assert_match
|
|
120
|
-
assert_match
|
|
198
|
+
assert_match(/Started on 8080!!/, @output)
|
|
199
|
+
assert_match(/Interrupt!!/, @output)
|
|
200
|
+
assert_match(/Closing!!/, @output)
|
|
121
201
|
end
|
|
122
202
|
end # execute with block
|
|
123
203
|
|
|
204
|
+
describe "with daemonize flag, stopped while starting up" do
|
|
205
|
+
before do
|
|
206
|
+
# Holds the daemon at its "Starting" message, after its pid file is written and
|
|
207
|
+
# before its startup block runs, and shows that it got there by creating @starting.
|
|
208
|
+
# (It holds before writing the message, so the TERM below never lands mid-write.)
|
|
209
|
+
starting = @starting = tmp_path('starting')
|
|
210
|
+
slow_runner = Class.new(Dante::Runner) do
|
|
211
|
+
define_method(:log) do |message|
|
|
212
|
+
if message.start_with?("Starting")
|
|
213
|
+
File.write(starting, "")
|
|
214
|
+
sleep 10
|
|
215
|
+
end
|
|
216
|
+
super(message)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
@logfile = tmp_path('dante-starting.log')
|
|
220
|
+
@pid_path = tmp_path('dante.pid')
|
|
221
|
+
@runner = slow_runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => @logfile) { sleep 100 }
|
|
222
|
+
@stdout = capture_stdout { @runner.execute }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "stops through its TERM handler once it has reported starting" do
|
|
226
|
+
assert_match(/Daemon has started successfully/, @stdout)
|
|
227
|
+
@pid = daemon_pid(@pid_path)
|
|
228
|
+
wait_for_output(@starting)
|
|
229
|
+
Process.kill "TERM", @pid
|
|
230
|
+
wait_for_exit(@pid)
|
|
231
|
+
|
|
232
|
+
assert_match(/Trying to stop test-process/, File.read(@logfile))
|
|
233
|
+
end
|
|
234
|
+
end # stopped while starting up
|
|
235
|
+
|
|
236
|
+
describe "with daemonize flag and a umask" do
|
|
237
|
+
before do
|
|
238
|
+
@original_umask = File.umask(0027)
|
|
239
|
+
@logfile = tmp_path('dante-umask.log')
|
|
240
|
+
@pid_path = tmp_path('dante.pid')
|
|
241
|
+
@created = tmp_path('created-by-daemon')
|
|
242
|
+
created = @created
|
|
243
|
+
@runner = Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => @logfile) {
|
|
244
|
+
File.write(created, "x")
|
|
245
|
+
sleep 100 }
|
|
246
|
+
@stdout = capture_stdout { @runner.execute }
|
|
247
|
+
wait_for_output(@created)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
after do
|
|
251
|
+
File.umask(@original_umask)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
it "creates files with the umask of the process that started it" do
|
|
255
|
+
assert_equal "640", "%o" % (File.stat(@created).mode & 0777)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "still gives the log file mode 0644" do
|
|
259
|
+
assert_equal 0644, File.stat(@logfile).mode & 0777
|
|
260
|
+
end
|
|
261
|
+
end # umask
|
|
262
|
+
|
|
263
|
+
describe "with the standard streams connected to a socket" do
|
|
264
|
+
# Runs the block in a forked child whose stdin, stdout and stderr are one end of a
|
|
265
|
+
# socket pair, as under Spring or Zeus. Returns the child's exit status and the
|
|
266
|
+
# error it raised, if any. The error goes through a file rather than a pipe, which
|
|
267
|
+
# a daemon started by the block would hold open.
|
|
268
|
+
def run_with_socket_streams
|
|
269
|
+
error_path = tmp_path('child-error')
|
|
270
|
+
pid = fork do
|
|
271
|
+
begin
|
|
272
|
+
socket, _peer = UNIXSocket.pair
|
|
273
|
+
[STDIN, STDOUT, STDERR].each { |io| io.reopen(socket) }
|
|
274
|
+
$stdin, $stdout, $stderr = STDIN, STDOUT, STDERR
|
|
275
|
+
yield
|
|
276
|
+
rescue Exception => e
|
|
277
|
+
File.write(error_path, "#{e.class}: #{e.message}")
|
|
278
|
+
exit!(1)
|
|
279
|
+
end
|
|
280
|
+
exit!(0)
|
|
281
|
+
end
|
|
282
|
+
Process.wait(pid)
|
|
283
|
+
[$?.exitstatus, File.exist?(error_path) ? File.read(error_path) : ""]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def null_device?(io)
|
|
287
|
+
io.stat.chardev? && io.stat.rdev == File.stat(File::NULL).rdev
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it "appends to the log file when run in the foreground" do
|
|
291
|
+
@logfile = tmp_path('dante-socket.log')
|
|
292
|
+
File.write(@logfile, "earlier line\n")
|
|
293
|
+
status, error = run_with_socket_streams do
|
|
294
|
+
Dante::Runner.new('test-process', :log_path => @logfile) { puts "Started" }.execute
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
assert_equal [0, ""], [status, error]
|
|
298
|
+
assert_equal "earlier line\nStarted\n", File.read(@logfile)
|
|
299
|
+
assert_equal 0644, File.stat(@logfile).mode & 0777
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it "logs to the log file when daemonized" do
|
|
303
|
+
@logfile = tmp_path('dante-socket.log')
|
|
304
|
+
@pid_path = tmp_path('dante.pid')
|
|
305
|
+
@process = TestingProcess.new('c', @tmpdir)
|
|
306
|
+
status, error = run_with_socket_streams do
|
|
307
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => @logfile) {
|
|
308
|
+
@process.run_c!(8082) }.execute
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
assert_equal [0, ""], [status, error]
|
|
312
|
+
wait_for_output(@logfile, /Started on 8082!!/)
|
|
313
|
+
assert_equal 0644, File.stat(@logfile).mode & 0777
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it "redirects all three streams to /dev/null without a log file" do
|
|
317
|
+
status, error = run_with_socket_streams do
|
|
318
|
+
Dante::Runner.new('test-process', :log_path => false).send(:redirect_output!)
|
|
319
|
+
streams = [STDIN, STDOUT, STDERR]
|
|
320
|
+
raise "not redirected to /dev/null: #{streams.reject { |io| null_device?(io) }.map(&:fileno)}" unless streams.all? { |io| null_device?(io) }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
assert_equal [0, ""], [status, error]
|
|
324
|
+
end
|
|
325
|
+
end # socket streams
|
|
326
|
+
|
|
327
|
+
describe "with the pid file naming a process of another user" do
|
|
328
|
+
before do
|
|
329
|
+
@pid_path = tmp_path('dante.pid')
|
|
330
|
+
if Process.euid == 0
|
|
331
|
+
# A process of root's; the blocks below run as nobody.
|
|
332
|
+
@foreign_pid = @sleeper = spawn('sleep', '60')
|
|
333
|
+
else
|
|
334
|
+
@foreign_pid = 1
|
|
335
|
+
begin
|
|
336
|
+
Process.kill(0, @foreign_pid)
|
|
337
|
+
skip "pid #{@foreign_pid} can be signalled, so there is no process of another user to name"
|
|
338
|
+
rescue Errno::EPERM
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
File.write(@pid_path, "#{@foreign_pid}\n")
|
|
342
|
+
File.chmod(0755, @tmpdir)
|
|
343
|
+
File.chmod(0644, @pid_path)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
after do
|
|
347
|
+
FileUtils.rm_f(@pid_path) if @pid_path
|
|
348
|
+
if @sleeper
|
|
349
|
+
Process.kill('KILL', @sleeper)
|
|
350
|
+
Process.wait(@sleeper)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Runs the block in a forked child that may not signal @foreign_pid (as nobody, when
|
|
355
|
+
# the tests run as root). Returns what the block returned or raised, and what it
|
|
356
|
+
# printed.
|
|
357
|
+
def run_without_permission_to_signal
|
|
358
|
+
out_path = tmp_path('out')
|
|
359
|
+
reader, writer = IO.pipe
|
|
360
|
+
pid = fork do
|
|
361
|
+
reader.close
|
|
362
|
+
$stdout = File.open(out_path, 'w')
|
|
363
|
+
$stdout.sync = true
|
|
364
|
+
result = begin
|
|
365
|
+
if Process.euid == 0
|
|
366
|
+
nobody = Etc.getpwnam('nobody')
|
|
367
|
+
Process.groups = [nobody.gid]
|
|
368
|
+
Process::GID.change_privilege(nobody.gid)
|
|
369
|
+
Process::UID.change_privilege(nobody.uid)
|
|
370
|
+
end
|
|
371
|
+
raise "still root" if Process.euid == 0
|
|
372
|
+
[:returned, yield]
|
|
373
|
+
rescue Exception => e
|
|
374
|
+
[:raised, "#{e.class}: #{e.message}"]
|
|
375
|
+
end
|
|
376
|
+
writer.write(Marshal.dump(result))
|
|
377
|
+
exit!(0)
|
|
378
|
+
end
|
|
379
|
+
writer.close
|
|
380
|
+
Process.wait(pid)
|
|
381
|
+
[Marshal.load(reader.read), File.read(out_path)]
|
|
382
|
+
ensure
|
|
383
|
+
reader.close if reader && !reader.closed?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it "reports the daemon as running" do
|
|
387
|
+
result, _output = run_without_permission_to_signal do
|
|
388
|
+
Dante::Runner.new('test-process', :pid_path => @pid_path).daemon_running?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
assert_equal [:returned, true], result
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
it "does not start a second daemon" do
|
|
395
|
+
result, output = run_without_permission_to_signal do
|
|
396
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => tmp_path('dante.log')) {
|
|
397
|
+
raise "should not get here" }.execute
|
|
398
|
+
nil
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
assert_equal [:returned, nil], result
|
|
402
|
+
assert_equal "Process is already started\n", output
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
it "reports that it could not stop it with -k, and keeps the pid file" do
|
|
406
|
+
result, output = run_without_permission_to_signal do
|
|
407
|
+
ARGV.replace(['-k', '-P', @pid_path])
|
|
408
|
+
Dante::Runner.new('test-process').execute
|
|
409
|
+
nil
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
assert_equal [:returned, nil], result
|
|
413
|
+
assert_match(/Failed to stop! : Operation not permitted/, output)
|
|
414
|
+
assert_match(/Failed to kill daemonized process/, output)
|
|
415
|
+
assert_equal "#{@foreign_pid}\n", File.read(@pid_path)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it "stops waiting at once when it cannot signal it, even with :force" do
|
|
419
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
420
|
+
result, output = run_without_permission_to_signal do
|
|
421
|
+
Dante::Runner.new('test-process', :pid_path => @pid_path, :force => true).stop
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
425
|
+
|
|
426
|
+
assert_equal [:returned, nil], result
|
|
427
|
+
assert_match(/Failed to kill daemonized process/, output)
|
|
428
|
+
refute_match(/Sent SIG/, output)
|
|
429
|
+
assert_operator elapsed, :<, Dante::Runner::MAX_START_TRIES / 2.0, "waited for a daemon it could not signal"
|
|
430
|
+
assert_equal "#{@foreign_pid}\n", File.read(@pid_path)
|
|
431
|
+
end
|
|
432
|
+
end # another user's process
|
|
433
|
+
|
|
434
|
+
describe "with an empty pid file, as left by a crash between creating and writing it" do
|
|
435
|
+
before do
|
|
436
|
+
@pid_path = tmp_path('dante.pid')
|
|
437
|
+
File.write(@pid_path, "")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it "does not count as a running daemon, nor does a pid file naming no process" do
|
|
441
|
+
["", "0\n", "-1\n"].each do |content|
|
|
442
|
+
File.write(@pid_path, content)
|
|
443
|
+
assert_equal false, Dante::Runner.new('test-process', :pid_path => @pid_path).daemon_running?, "pid file #{content.inspect}"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
it "does not count a number too large for a pid as a daemon, running or to stop" do
|
|
448
|
+
["2147483648\n", "99999999999999999999\n"].each do |content|
|
|
449
|
+
File.write(@pid_path, content)
|
|
450
|
+
runner = Dante::Runner.new('test-process', :pid_path => @pid_path)
|
|
451
|
+
assert_equal false, runner.daemon_running?, "pid file #{content.inspect}"
|
|
452
|
+
assert_equal "No test-process processes are running\n", capture_stdout { runner.stop }, "pid file #{content.inspect}"
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
it "starts a daemon in its place" do
|
|
457
|
+
started = tmp_path('started')
|
|
458
|
+
result = nil
|
|
459
|
+
output = capture_stdout {
|
|
460
|
+
result = Dante::Runner.new('test-process', :daemonize => true, :pid_path => @pid_path, :log_path => tmp_path('dante.log')) {
|
|
461
|
+
File.write(started, ""); sleep 100 }.execute }
|
|
462
|
+
|
|
463
|
+
assert_equal true, result
|
|
464
|
+
assert_match(/Daemon has started successfully/, output)
|
|
465
|
+
wait_for_output(started)
|
|
466
|
+
assert process_alive?(daemon_pid(@pid_path))
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
it "reports that no daemon is running when asked to stop, and signals nothing" do
|
|
470
|
+
# The stop runs in a child leading a process group of its own, which is all that a
|
|
471
|
+
# signal to "pid 0" (the caller's process group) could reach.
|
|
472
|
+
out_path = tmp_path('out')
|
|
473
|
+
pid = fork do
|
|
474
|
+
Process.setpgid(0, 0)
|
|
475
|
+
$stdout = File.open(out_path, 'w')
|
|
476
|
+
$stdout.sync = true
|
|
477
|
+
p Dante::Runner.new('test-process', :pid_path => @pid_path).stop
|
|
478
|
+
exit!(0)
|
|
479
|
+
end
|
|
480
|
+
Process.wait(pid)
|
|
481
|
+
|
|
482
|
+
assert_equal "No test-process processes are running\nfalse\n", File.read(out_path)
|
|
483
|
+
assert_equal 0, $?.exitstatus, "the stop ended with #{$?.inspect}"
|
|
484
|
+
assert_equal "", File.read(@pid_path)
|
|
485
|
+
end
|
|
486
|
+
end # empty pid file
|
|
487
|
+
|
|
488
|
+
describe "with daemonize flag, controlling a running daemon" do
|
|
489
|
+
before do
|
|
490
|
+
@pid_path = tmp_path('dante.pid')
|
|
491
|
+
@logfile = tmp_path('dante.log')
|
|
492
|
+
@first_started = tmp_path('first-started')
|
|
493
|
+
@run_options = { :daemonize => true, :pid_path => @pid_path, :log_path => @logfile }
|
|
494
|
+
first_started = @first_started
|
|
495
|
+
@start_result = nil
|
|
496
|
+
@stdout = capture_stdout {
|
|
497
|
+
@start_result = Dante::Runner.new('test-process', @run_options) { File.write(first_started, ""); sleep 100 }.execute }
|
|
498
|
+
wait_for_output(@first_started)
|
|
499
|
+
@pid = daemon_pid(@pid_path)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
it "runs the daemon in a session of its own, which it does not lead" do
|
|
503
|
+
assert_equal true, @start_result
|
|
504
|
+
refute_equal Process.getsid, Process.getsid(@pid)
|
|
505
|
+
refute_equal @pid, Process.getsid(@pid)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
it "does not start a second daemon on the same pid file" do
|
|
509
|
+
second_started = tmp_path('second-started')
|
|
510
|
+
result = :not_returned
|
|
511
|
+
output = capture_stdout {
|
|
512
|
+
result = Dante::Runner.new('test-process', @run_options) { File.write(second_started, "") }.execute }
|
|
513
|
+
|
|
514
|
+
assert_equal ["Process is already started\n"], output.lines.grep_v(/: warning: /) # Ruby 2.7 -w warns on stderr
|
|
515
|
+
assert_nil result
|
|
516
|
+
assert_equal "#{@pid}\n", File.read(@pid_path)
|
|
517
|
+
assert process_alive?(@pid)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it "stops the daemon and starts a new one with :restart" do
|
|
521
|
+
second_started = tmp_path('second-started')
|
|
522
|
+
result = nil
|
|
523
|
+
output = capture_stdout {
|
|
524
|
+
result = Dante::Runner.new('test-process', @run_options.merge(:restart => true)) {
|
|
525
|
+
File.write(second_started, ""); sleep 100 }.execute }
|
|
526
|
+
|
|
527
|
+
assert_equal true, result
|
|
528
|
+
assert_match(/Daemonized process killed after term.*Daemon has started successfully/m, output)
|
|
529
|
+
refute process_alive?(@pid), "the first daemon is still running"
|
|
530
|
+
wait_for_output(second_started)
|
|
531
|
+
new_pid = daemon_pid(@pid_path)
|
|
532
|
+
refute_equal @pid, new_pid
|
|
533
|
+
assert process_alive?(new_pid)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
it "stops the daemon with -k and removes its pid file" do
|
|
537
|
+
ARGV.replace(['-k', '-P', @pid_path])
|
|
538
|
+
output = capture_stdout { Dante::Runner.new('test-process').execute }
|
|
539
|
+
|
|
540
|
+
assert_match(/Sent SIGTERM to PID: #{@pid}/, output)
|
|
541
|
+
assert_match(/Daemonized process killed after term/, output)
|
|
542
|
+
refute process_alive?(@pid), "daemon still running after -k"
|
|
543
|
+
refute File.exist?(@pid_path), "pid file left behind after -k"
|
|
544
|
+
end
|
|
545
|
+
end # controlling a running daemon
|
|
546
|
+
|
|
547
|
+
describe "with daemonize flag and no log path" do
|
|
548
|
+
before do
|
|
549
|
+
# Records the log path the daemon would redirect its output to, then sends the
|
|
550
|
+
# output to a file of this test's instead of writing to /var/log.
|
|
551
|
+
recorded = @recorded = tmp_path('recorded-log-path')
|
|
552
|
+
logfile = tmp_path('dante.log')
|
|
553
|
+
recording_runner = Class.new(Dante::Runner) do
|
|
554
|
+
define_method(:redirect_output!) do
|
|
555
|
+
File.write("#{recorded}.tmp", options[:log_path].inspect)
|
|
556
|
+
File.rename("#{recorded}.tmp", recorded)
|
|
557
|
+
options[:log_path] = logfile
|
|
558
|
+
super()
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
@pid_path = tmp_path('dante.pid')
|
|
562
|
+
@stdout = capture_stdout {
|
|
563
|
+
recording_runner.new('test-process', :daemonize => true, :pid_path => @pid_path) { sleep 100 }.execute }
|
|
564
|
+
daemon_pid(@pid_path)
|
|
565
|
+
wait_for_output(@recorded)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
it "logs to /var/log/<name>.log" do
|
|
569
|
+
assert_equal '"/var/log/test-process.log"', File.read(@recorded)
|
|
570
|
+
end
|
|
571
|
+
end # default log path
|
|
572
|
+
|
|
573
|
+
describe "with daemonize flag, when the daemon dies before recording its pid" do
|
|
574
|
+
it "reports that it could not be started" do
|
|
575
|
+
# The daemon fails to create the pid file's directory, which is a device.
|
|
576
|
+
pid_path = File.join(File::NULL, 'dante.pid')
|
|
577
|
+
result = nil
|
|
578
|
+
output = capture_stdout {
|
|
579
|
+
result = Dante::Runner.new('test-process', :daemonize => true, :pid_path => pid_path,
|
|
580
|
+
:log_path => tmp_path('dante.log')) { raise "should not get here" }.execute }
|
|
581
|
+
|
|
582
|
+
assert_equal false, result
|
|
583
|
+
assert_match(/Daemonized process couldn't be started/, output)
|
|
584
|
+
refute_match(/started successfully/, output)
|
|
585
|
+
end
|
|
586
|
+
end # dies at once
|
|
587
|
+
|
|
588
|
+
describe "with daemonize flag, when the daemon dies right after recording its pid" do
|
|
589
|
+
it "reports that it could not be started" do
|
|
590
|
+
pid_path = tmp_path('dante.pid')
|
|
591
|
+
# After the check made before starting, checks for the daemon only once its pid is
|
|
592
|
+
# recorded, so the check falls in the moment between the daemon starting up and dying.
|
|
593
|
+
checking_runner = Class.new(Dante::Runner) do
|
|
594
|
+
define_method(:daemon_running?) do
|
|
595
|
+
if @checked_before_starting
|
|
596
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
|
|
597
|
+
sleep 0.01 until (File.exist?(pid_path) && File.read(pid_path).to_i > 0) ||
|
|
598
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
599
|
+
end
|
|
600
|
+
@checked_before_starting = true
|
|
601
|
+
super()
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
result = nil
|
|
605
|
+
output = capture_stdout {
|
|
606
|
+
result = checking_runner.new('test-process', :daemonize => true, :pid_path => pid_path,
|
|
607
|
+
:log_path => tmp_path('dante.log')) { sleep 0.2; raise "boom" }.execute }
|
|
608
|
+
|
|
609
|
+
assert_equal false, result
|
|
610
|
+
assert_match(/Daemonized process couldn't be started/, output)
|
|
611
|
+
refute_match(/started successfully/, output)
|
|
612
|
+
assert_match(/Starting test-process/, File.read(tmp_path('dante.log'))) # it did start, then died
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
it "removes the pid file the daemon left" do
|
|
616
|
+
pid_path = tmp_path('dante.pid')
|
|
617
|
+
result = nil
|
|
618
|
+
capture_stdout {
|
|
619
|
+
result = Dante::Runner.new('test-process', :daemonize => true, :pid_path => pid_path,
|
|
620
|
+
:log_path => tmp_path('dante.log')) { raise "boom" }.execute }
|
|
621
|
+
|
|
622
|
+
assert_equal false, result
|
|
623
|
+
assert_match(/Starting test-process/, File.read(tmp_path('dante.log'))) # it recorded its pid and started
|
|
624
|
+
refute File.exist?(pid_path), "the dead daemon's pid file was left behind"
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
it "keeps the pid file when it names another process" do
|
|
628
|
+
pid_path = tmp_path('dante.pid')
|
|
629
|
+
other = spawn('sleep', '30')
|
|
630
|
+
# The daemon records another process's pid, as if another start had replaced the
|
|
631
|
+
# pid file after this daemon wrote it.
|
|
632
|
+
other_runner = Class.new(Dante::Runner) do
|
|
633
|
+
define_method(:store_pid) { |_pid| super(other) }
|
|
634
|
+
end
|
|
635
|
+
result = nil
|
|
636
|
+
capture_stdout {
|
|
637
|
+
result = other_runner.new('test-process', :daemonize => true, :pid_path => pid_path,
|
|
638
|
+
:log_path => tmp_path('dante.log')) { raise "boom" }.execute }
|
|
639
|
+
|
|
640
|
+
assert_equal false, result
|
|
641
|
+
assert_equal "#{other}\n", File.read(pid_path)
|
|
642
|
+
ensure
|
|
643
|
+
if other
|
|
644
|
+
Process.kill('KILL', other)
|
|
645
|
+
Process.wait(other)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
end # dies after recording its pid
|
|
649
|
+
|
|
650
|
+
describe "with daemonize flag, interrupted while the daemon starts up" do
|
|
651
|
+
it "leaves no zombie behind" do
|
|
652
|
+
pid_path = tmp_path('dante.pid')
|
|
653
|
+
leader_path = tmp_path('leader')
|
|
654
|
+
leader = nil
|
|
655
|
+
# The daemon records its parent, the session leader, which then waits out the
|
|
656
|
+
# grace period while the caller waits for it; that wait is interrupted.
|
|
657
|
+
interrupter = Thread.new(Thread.current) do |caller|
|
|
658
|
+
leader = wait_until { File.exist?(leader_path) && (n = File.read(leader_path).to_i) > 0 && n }
|
|
659
|
+
caller.raise(Interrupt) if leader
|
|
660
|
+
end
|
|
661
|
+
capture_stdout {
|
|
662
|
+
assert_raises(Interrupt) {
|
|
663
|
+
Dante::Runner.new('test-process', :daemonize => true, :pid_path => pid_path, :log_path => tmp_path('dante.log')) {
|
|
664
|
+
File.write("#{leader_path}.tmp", Process.ppid.to_s)
|
|
665
|
+
File.rename("#{leader_path}.tmp", leader_path)
|
|
666
|
+
sleep 100 }.execute } }
|
|
667
|
+
interrupter.join
|
|
668
|
+
assert leader, "the daemon never recorded its parent"
|
|
669
|
+
|
|
670
|
+
# A zombie still answers signal 0; a reaped process does not.
|
|
671
|
+
gone = wait_until(10) do
|
|
672
|
+
begin
|
|
673
|
+
Process.kill(0, leader)
|
|
674
|
+
false
|
|
675
|
+
rescue Errno::ESRCH
|
|
676
|
+
true
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
assert gone, "the session leader #{leader} was left as a zombie"
|
|
680
|
+
ensure
|
|
681
|
+
begin
|
|
682
|
+
Process.waitpid(leader) if leader
|
|
683
|
+
rescue Errno::ECHILD
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
end # interrupted while starting
|
|
687
|
+
|
|
688
|
+
describe "with daemonize flag, in a program of its own" do
|
|
689
|
+
# Runs script with dante loaded in a separate Ruby process, passing it args, and
|
|
690
|
+
# returns what it printed, without Ruby 2.7's -w warnings. A process of its own,
|
|
691
|
+
# because the test harness keeps forked processes from running at_exit handlers.
|
|
692
|
+
# It runs outside Bundler's environment: it needs only dante, and bundler/setup
|
|
693
|
+
# would print git's errors where the tree is not a git checkout.
|
|
694
|
+
def run_program(script, *args)
|
|
695
|
+
lib = File.expand_path('../../lib', __FILE__)
|
|
696
|
+
run = lambda { IO.popen([RbConfig.ruby, '-I', lib, '-rdante', '-e', script, *args], :err => [:child, :out], &:read) }
|
|
697
|
+
output = defined?(Bundler) ? Bundler.with_unbundled_env(&run) : run.call
|
|
698
|
+
output.lines.grep_v(/: warning: /).join
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
before do
|
|
702
|
+
@pid_path = tmp_path('dante.pid')
|
|
703
|
+
@logfile = tmp_path('dante.log')
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
it "reports a running daemon as started whatever the program's at_exit handlers do" do
|
|
707
|
+
output = run_program(<<-RUBY, @pid_path, @logfile)
|
|
708
|
+
main = Process.pid
|
|
709
|
+
at_exit { exit 1 if Process.pid != main }
|
|
710
|
+
p Dante::Runner.new('test-process', :daemonize => true, :pid_path => ARGV[0], :log_path => ARGV[1]) { sleep 30 }.execute
|
|
711
|
+
RUBY
|
|
712
|
+
|
|
713
|
+
assert_equal "Daemon has started successfully\ntrue\n", output
|
|
714
|
+
pid = daemon_pid(@pid_path)
|
|
715
|
+
Process.kill('TERM', pid)
|
|
716
|
+
wait_for_exit(pid)
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
it "reports whether the daemon started when the program ignores SIGCHLD" do
|
|
720
|
+
script = <<-RUBY
|
|
721
|
+
trap('CHLD', 'IGNORE')
|
|
722
|
+
p Dante::Runner.new('test-process', :daemonize => true, :pid_path => ARGV[0], :log_path => ARGV[1]) {
|
|
723
|
+
raise "boom" if ARGV[2] == 'crash'
|
|
724
|
+
sleep 30 }.execute
|
|
725
|
+
RUBY
|
|
726
|
+
|
|
727
|
+
assert_equal "Daemon has started successfully\ntrue\n", run_program(script, @pid_path, @logfile, 'run')
|
|
728
|
+
pid = daemon_pid(@pid_path)
|
|
729
|
+
Process.kill('TERM', pid)
|
|
730
|
+
wait_for_exit(pid)
|
|
731
|
+
|
|
732
|
+
crash_pid_path = tmp_path('crash.pid')
|
|
733
|
+
assert_equal "Daemonized process couldn't be started\nfalse\n", run_program(script, crash_pid_path, @logfile, 'crash')
|
|
734
|
+
refute File.exist?(crash_pid_path), "the dead daemon's pid file was left behind"
|
|
735
|
+
end
|
|
736
|
+
end # program of its own
|
|
737
|
+
|
|
738
|
+
describe "with the -p option" do
|
|
739
|
+
it "passes the port to the startup block as an Integer" do
|
|
740
|
+
ARGV.replace(['-p', '1234'])
|
|
741
|
+
port = nil
|
|
742
|
+
capture_stdout { Dante::Runner.new('test-process').execute { |opts| port = opts[:port] } }
|
|
743
|
+
|
|
744
|
+
assert_equal 1234, port
|
|
745
|
+
assert_kind_of Integer, port
|
|
746
|
+
end
|
|
747
|
+
end # -p
|
|
748
|
+
|
|
124
749
|
describe "with parsing options" do
|
|
125
750
|
before do
|
|
126
|
-
|
|
127
|
-
@process = TestingProcess.new('a')
|
|
751
|
+
ARGV.replace(['-t test_text'])
|
|
752
|
+
@process = TestingProcess.new('a', @tmpdir)
|
|
128
753
|
@runner = Dante::Runner.new('test-process')
|
|
129
754
|
@runner.with_options do |opts|
|
|
130
755
|
opts.on("-t", "--test TEST", String, "Test this thing") { |test| options[:test] = test }
|
|
@@ -133,32 +758,33 @@ describe "dante runner" do
|
|
|
133
758
|
end
|
|
134
759
|
|
|
135
760
|
it "prints correct stdout" do
|
|
136
|
-
assert_match
|
|
761
|
+
assert_match(/Starting test-process/, @stdout)
|
|
137
762
|
end
|
|
138
763
|
|
|
139
764
|
it "prints correct data" do
|
|
140
765
|
@output = File.read(@process.tmp_path)
|
|
141
|
-
assert_match
|
|
766
|
+
assert_match(/test_text/, @output)
|
|
142
767
|
end
|
|
143
768
|
|
|
144
769
|
it "starts successfully when executed" do
|
|
145
770
|
@output = File.read(@process.tmp_path)
|
|
146
|
-
assert_match
|
|
771
|
+
assert_match(/Started/, @output)
|
|
147
772
|
end
|
|
148
773
|
end # options parsing
|
|
149
774
|
|
|
150
775
|
describe "with help command" do
|
|
151
776
|
before do
|
|
152
|
-
|
|
153
|
-
@process = TestingProcess.new('a')
|
|
777
|
+
ARGV.replace(['--help'])
|
|
778
|
+
@process = TestingProcess.new('a', @tmpdir)
|
|
154
779
|
@runner = Dante::Runner.new('test-process')
|
|
155
780
|
@runner.description = "Test process banana"
|
|
156
|
-
@
|
|
157
|
-
|
|
781
|
+
@stdout = capture_stdout {
|
|
782
|
+
assert_raises(SystemExit) { @runner.execute { @process.run_a! } }
|
|
783
|
+
}
|
|
158
784
|
end
|
|
159
785
|
|
|
160
786
|
it "prints correct stdout" do
|
|
161
|
-
assert_match
|
|
787
|
+
assert_match(/test-process - Test process banana/, @stdout)
|
|
162
788
|
end
|
|
163
789
|
end # help
|
|
164
|
-
end
|
|
790
|
+
end
|