unicorn 0.4.2 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +1 -0
- data/DESIGN +14 -10
- data/GNUmakefile +13 -3
- data/README +28 -21
- data/SIGNALS +11 -4
- data/TODO +0 -2
- data/ext/unicorn/http11/http11.c +17 -34
- data/lib/unicorn.rb +38 -37
- data/lib/unicorn/app/old_rails.rb +9 -3
- data/lib/unicorn/cgi_wrapper.rb +2 -4
- data/lib/unicorn/configurator.rb +29 -19
- data/lib/unicorn/const.rb +1 -1
- data/lib/unicorn/http_request.rb +0 -1
- data/lib/unicorn/socket.rb +17 -57
- data/local.mk.sample +1 -0
- data/test/exec/test_exec.rb +76 -6
- data/test/test_helper.rb +3 -2
- data/test/unit/test_configurator.rb +16 -1
- data/test/unit/test_http_parser.rb +1 -1
- data/test/unit/test_request.rb +53 -0
- data/test/unit/test_server.rb +38 -3
- data/test/unit/test_signals.rb +108 -0
- data/test/unit/test_socket_helper.rb +19 -7
- data/unicorn.gemspec +4 -4
- metadata +3 -2
@@ -13,9 +13,15 @@ class Unicorn::App::OldRails
|
|
13
13
|
|
14
14
|
def call(env)
|
15
15
|
cgi = Unicorn::CGIWrapper.new(env)
|
16
|
-
|
17
|
-
|
18
|
-
|
16
|
+
begin
|
17
|
+
Dispatcher.dispatch(cgi,
|
18
|
+
ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS,
|
19
|
+
cgi.body)
|
20
|
+
rescue Object => e
|
21
|
+
err = env['rack.errors']
|
22
|
+
out.write("#{e} #{e.message}\n")
|
23
|
+
e.backtrace.each { |line| out.write("#{line}\n") }
|
24
|
+
end
|
19
25
|
cgi.out # finalize the response
|
20
26
|
cgi.rack_response
|
21
27
|
end
|
data/lib/unicorn/cgi_wrapper.rb
CHANGED
@@ -71,11 +71,9 @@ class Unicorn::CGIWrapper < ::CGI
|
|
71
71
|
|
72
72
|
# Capitalized "Status:", with human-readable status code (e.g. "200 OK")
|
73
73
|
parseable_status = @head.delete(Status)
|
74
|
-
|
75
|
-
@status ||= parseable_status.split(/ /)[0].to_i rescue 404
|
76
|
-
end
|
74
|
+
@status ||= parseable_status.split(/ /)[0].to_i rescue 500
|
77
75
|
|
78
|
-
[ @status, @head, [ @body.string ] ]
|
76
|
+
[ @status || 500, @head, [ @body.string ] ]
|
79
77
|
end
|
80
78
|
|
81
79
|
# The header is typically called to send back the header. In our case we
|
data/lib/unicorn/configurator.rb
CHANGED
@@ -12,8 +12,8 @@ module Unicorn
|
|
12
12
|
# listen '0.0.0.0:9292'
|
13
13
|
# timeout 10
|
14
14
|
# pid "/tmp/my_app.pid"
|
15
|
-
# after_fork do |server,
|
16
|
-
# server.listen("127.0.0.1:#{9293 +
|
15
|
+
# after_fork do |server,worker|
|
16
|
+
# server.listen("127.0.0.1:#{9293 + worker.nr}") rescue nil
|
17
17
|
# end
|
18
18
|
class Configurator
|
19
19
|
# The default logger writes its output to $stderr
|
@@ -25,18 +25,18 @@ module Unicorn
|
|
25
25
|
:listeners => [],
|
26
26
|
:logger => DEFAULT_LOGGER,
|
27
27
|
:worker_processes => 1,
|
28
|
-
:after_fork => lambda { |server,
|
29
|
-
server.logger.info("worker=#{
|
28
|
+
:after_fork => lambda { |server, worker|
|
29
|
+
server.logger.info("worker=#{worker.nr} spawned pid=#{$$}")
|
30
30
|
|
31
31
|
# per-process listener ports for debugging/admin:
|
32
32
|
# "rescue nil" statement is needed because USR2 will
|
33
33
|
# cause the master process to reexecute itself and the
|
34
34
|
# per-worker ports can be taken, necessitating another
|
35
35
|
# HUP after QUIT-ing the original master:
|
36
|
-
# server.listen("127.0.0.1:#{8081 +
|
36
|
+
# server.listen("127.0.0.1:#{8081 + worker.nr}") rescue nil
|
37
37
|
},
|
38
|
-
:before_fork => lambda { |server,
|
39
|
-
server.logger.info("worker=#{
|
38
|
+
:before_fork => lambda { |server, worker|
|
39
|
+
server.logger.info("worker=#{worker.nr} spawning...")
|
40
40
|
},
|
41
41
|
:before_exec => lambda { |server|
|
42
42
|
server.logger.info("forked child re-executing...")
|
@@ -97,23 +97,37 @@ module Unicorn
|
|
97
97
|
# the worker after forking. The following is an example hook which adds
|
98
98
|
# a per-process listener to every worker:
|
99
99
|
#
|
100
|
-
# after_fork do |server,
|
100
|
+
# after_fork do |server,worker|
|
101
101
|
# # per-process listener ports for debugging/admin:
|
102
102
|
# # "rescue nil" statement is needed because USR2 will
|
103
103
|
# # cause the master process to reexecute itself and the
|
104
104
|
# # per-worker ports can be taken, necessitating another
|
105
105
|
# # HUP after QUIT-ing the original master:
|
106
|
-
# server.listen("127.0.0.1:#{9293 +
|
106
|
+
# server.listen("127.0.0.1:#{9293 + worker.nr}") rescue nil
|
107
|
+
#
|
108
|
+
# # drop permissions to "www-data" in the worker
|
109
|
+
# # generally there's no reason to start Unicorn as a priviledged user
|
110
|
+
# # as it is not recommended to expose Unicorn to public clients.
|
111
|
+
# uid, gid = Process.euid, Process.egid
|
112
|
+
# user, group = 'www-data', 'www-data'
|
113
|
+
# target_uid = Etc.getpwnam(user).uid
|
114
|
+
# target_gid = Etc.getgrnam(group).gid
|
115
|
+
# worker.tempfile.chown(target_uid, target_gid)
|
116
|
+
# if uid != target_uid || gid != target_gid
|
117
|
+
# Process.initgroups(user, target_gid)
|
118
|
+
# Process::GID.change_privilege(target_gid)
|
119
|
+
# Process::UID.change_privilege(target_uid)
|
120
|
+
# end
|
107
121
|
# end
|
108
|
-
def after_fork(&block)
|
109
|
-
set_hook(:after_fork, block)
|
122
|
+
def after_fork(*args, &block)
|
123
|
+
set_hook(:after_fork, block_given? ? block : args[0])
|
110
124
|
end
|
111
125
|
|
112
126
|
# sets before_fork got be a given Proc object. This Proc
|
113
127
|
# object will be called by the master process before forking
|
114
128
|
# each worker.
|
115
|
-
def before_fork(&block)
|
116
|
-
set_hook(:before_fork, block)
|
129
|
+
def before_fork(*args, &block)
|
130
|
+
set_hook(:before_fork, block_given? ? block : args[0])
|
117
131
|
end
|
118
132
|
|
119
133
|
# sets the before_exec hook to a given Proc object. This
|
@@ -122,8 +136,8 @@ module Unicorn
|
|
122
136
|
# for freeing certain OS resources that you do NOT wish to
|
123
137
|
# share with the reexeced child process.
|
124
138
|
# There is no corresponding after_exec hook (for obvious reasons).
|
125
|
-
def before_exec(&block)
|
126
|
-
set_hook(:before_exec, block, 1)
|
139
|
+
def before_exec(*args, &block)
|
140
|
+
set_hook(:before_exec, block_given? ? block : args[0], 1)
|
127
141
|
end
|
128
142
|
|
129
143
|
# sets the timeout of worker processes to +seconds+. Workers
|
@@ -192,10 +206,6 @@ module Unicorn
|
|
192
206
|
# specified.
|
193
207
|
#
|
194
208
|
# Defaults: operating system defaults
|
195
|
-
#
|
196
|
-
# Due to limitations of the operating system, options specified here
|
197
|
-
# cannot affect existing listener sockets in any way, sockets must be
|
198
|
-
# completely closed and rebound.
|
199
209
|
def listen(address, opt = { :backlog => 1024 })
|
200
210
|
address = expand_addr(address)
|
201
211
|
if String === address
|
data/lib/unicorn/const.rb
CHANGED
data/lib/unicorn/http_request.rb
CHANGED
data/lib/unicorn/socket.rb
CHANGED
@@ -1,29 +1,4 @@
|
|
1
1
|
require 'socket'
|
2
|
-
require 'io/nonblock'
|
3
|
-
|
4
|
-
# non-portable Socket code goes here:
|
5
|
-
class Socket
|
6
|
-
module Constants
|
7
|
-
# configure platform-specific options (only tested on Linux 2.6 so far)
|
8
|
-
case RUBY_PLATFORM
|
9
|
-
when /linux/
|
10
|
-
# from /usr/include/linux/tcp.h
|
11
|
-
TCP_DEFER_ACCEPT = 9 unless defined?(TCP_DEFER_ACCEPT)
|
12
|
-
TCP_CORK = 3 unless defined?(TCP_CORK)
|
13
|
-
when /freebsd(([1-4]\..{1,2})|5\.[0-4])/
|
14
|
-
when /freebsd/
|
15
|
-
# Use the HTTP accept filter if available.
|
16
|
-
# The struct made by pack() is defined in /usr/include/sys/socket.h
|
17
|
-
# as accept_filter_arg
|
18
|
-
unless `/sbin/sysctl -nq net.inet.accf.http`.empty?
|
19
|
-
unless defined?(SO_ACCEPTFILTER_HTTPREADY)
|
20
|
-
SO_ACCEPTFILTER_HTTPREADY = ['httpready',nil].pack('a16a240').freeze
|
21
|
-
end
|
22
|
-
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
26
|
-
end
|
27
2
|
|
28
3
|
class UNIXSocket
|
29
4
|
UNICORN_PEERADDR = '127.0.0.1'.freeze
|
@@ -42,19 +17,15 @@ module Unicorn
|
|
42
17
|
module SocketHelper
|
43
18
|
include Socket::Constants
|
44
19
|
|
45
|
-
def
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
sock.setsockopt(SOL_TCP, TCP_DEFER_ACCEPT, 1) rescue nil
|
53
|
-
end
|
54
|
-
if defined?(SO_ACCEPTFILTER_HTTPREADY)
|
55
|
-
sock.setsockopt(SOL_SOCKET, SO_ACCEPTFILTER,
|
56
|
-
SO_ACCEPTFILTER_HTTPREADY) rescue nil
|
20
|
+
def set_server_sockopt(sock, opt)
|
21
|
+
opt ||= {}
|
22
|
+
if opt[:rcvbuf] || opt[:sndbuf]
|
23
|
+
log_buffer_sizes(sock, "before: ")
|
24
|
+
sock.setsockopt(SOL_SOCKET, SO_RCVBUF, opt[:rcvbuf]) if opt[:rcvbuf]
|
25
|
+
sock.setsockopt(SOL_SOCKET, SO_SNDBUF, opt[:sndbuf]) if opt[:sndbuf]
|
26
|
+
log_buffer_sizes(sock, " after: ")
|
57
27
|
end
|
28
|
+
sock.listen(opt[:backlog] || 1024)
|
58
29
|
end
|
59
30
|
|
60
31
|
def log_buffer_sizes(sock, pfx = '')
|
@@ -70,7 +41,7 @@ module Unicorn
|
|
70
41
|
def bind_listen(address = '0.0.0.0:8080', opt = { :backlog => 1024 })
|
71
42
|
return address unless String === address
|
72
43
|
|
73
|
-
|
44
|
+
sock = if address[0..0] == "/"
|
74
45
|
if File.exist?(address)
|
75
46
|
if File.socket?(address)
|
76
47
|
if self.respond_to?(:logger)
|
@@ -82,29 +53,18 @@ module Unicorn
|
|
82
53
|
"socket=#{address} specified but it is not a socket!"
|
83
54
|
end
|
84
55
|
end
|
85
|
-
|
56
|
+
old_umask = File.umask(0)
|
57
|
+
begin
|
58
|
+
UNIXServer.new(address)
|
59
|
+
ensure
|
60
|
+
File.umask(old_umask)
|
61
|
+
end
|
86
62
|
elsif address =~ /^(\d+\.\d+\.\d+\.\d+):(\d+)$/
|
87
|
-
|
63
|
+
TCPServer.new($1, $2.to_i)
|
88
64
|
else
|
89
65
|
raise ArgumentError, "Don't know how to bind: #{address}"
|
90
66
|
end
|
91
|
-
|
92
|
-
sock = Socket.new(domain, SOCK_STREAM, 0)
|
93
|
-
sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) if defined?(SO_REUSEADDR)
|
94
|
-
begin
|
95
|
-
sock.bind(bind_addr)
|
96
|
-
rescue Errno::EADDRINUSE
|
97
|
-
sock.close rescue nil
|
98
|
-
return nil
|
99
|
-
end
|
100
|
-
if opt[:rcvbuf] || opt[:sndbuf]
|
101
|
-
log_buffer_sizes(sock, "before: ")
|
102
|
-
sock.setsockopt(SOL_SOCKET, SO_RCVBUF, opt[:rcvbuf]) if opt[:rcvbuf]
|
103
|
-
sock.setsockopt(SOL_SOCKET, SO_SNDBUF, opt[:sndbuf]) if opt[:sndbuf]
|
104
|
-
log_buffer_sizes(sock, " after: ")
|
105
|
-
end
|
106
|
-
sock.listen(opt[:backlog] || 1024)
|
107
|
-
set_server_sockopt(sock) if domain == AF_INET
|
67
|
+
set_server_sockopt(sock, opt)
|
108
68
|
sock
|
109
69
|
end
|
110
70
|
|
data/local.mk.sample
CHANGED
data/test/exec/test_exec.rb
CHANGED
@@ -39,8 +39,8 @@ end
|
|
39
39
|
worker_processes 4
|
40
40
|
timeout 30
|
41
41
|
logger Logger.new('#{COMMON_TMP.path}')
|
42
|
-
before_fork do |server,
|
43
|
-
server.logger.info "before_fork: worker=\#{
|
42
|
+
before_fork do |server, worker|
|
43
|
+
server.logger.info "before_fork: worker=\#{worker.nr}"
|
44
44
|
end
|
45
45
|
EOS
|
46
46
|
|
@@ -231,6 +231,38 @@ end
|
|
231
231
|
end
|
232
232
|
end
|
233
233
|
|
234
|
+
def test_unicorn_config_listener_swap
|
235
|
+
port_cli = unused_port
|
236
|
+
File.open("config.ru", "wb") { |fp| fp.syswrite(HI) }
|
237
|
+
ucfg = Tempfile.new('unicorn_test_config')
|
238
|
+
ucfg.syswrite("listen '#@addr:#@port'\n")
|
239
|
+
pid = xfork do
|
240
|
+
redirect_test_io do
|
241
|
+
exec($unicorn_bin, "-c#{ucfg.path}", "-l#@addr:#{port_cli}")
|
242
|
+
end
|
243
|
+
end
|
244
|
+
results = retry_hit(["http://#@addr:#{port_cli}/"])
|
245
|
+
assert_equal String, results[0].class
|
246
|
+
results = retry_hit(["http://#@addr:#@port/"])
|
247
|
+
assert_equal String, results[0].class
|
248
|
+
|
249
|
+
port2 = unused_port(@addr)
|
250
|
+
ucfg.sysseek(0)
|
251
|
+
ucfg.truncate(0)
|
252
|
+
ucfg.syswrite("listen '#@addr:#{port2}'\n")
|
253
|
+
Process.kill(:HUP, pid)
|
254
|
+
|
255
|
+
results = retry_hit(["http://#@addr:#{port2}/"])
|
256
|
+
assert_equal String, results[0].class
|
257
|
+
results = retry_hit(["http://#@addr:#{port_cli}/"])
|
258
|
+
assert_equal String, results[0].class
|
259
|
+
assert_nothing_raised do
|
260
|
+
reuse = TCPServer.new(@addr, @port)
|
261
|
+
reuse.close
|
262
|
+
end
|
263
|
+
assert_shutdown(pid)
|
264
|
+
end
|
265
|
+
|
234
266
|
def test_unicorn_config_listen_with_options
|
235
267
|
File.open("config.ru", "wb") { |fp| fp.syswrite(HI) }
|
236
268
|
ucfg = Tempfile.new('unicorn_test_config')
|
@@ -254,7 +286,7 @@ end
|
|
254
286
|
File.unlink(tmp.path)
|
255
287
|
ucfg = Tempfile.new('unicorn_test_config')
|
256
288
|
ucfg.syswrite("listen '#@addr:#@port'\n")
|
257
|
-
ucfg.syswrite("before_fork { |s,
|
289
|
+
ucfg.syswrite("before_fork { |s,w|\n")
|
258
290
|
ucfg.syswrite(" s.listen('#{tmp.path}', :backlog => 5, :sndbuf => 8192)\n")
|
259
291
|
ucfg.syswrite(" s.listen('#@addr:#{port2}', :rcvbuf => 8192)\n")
|
260
292
|
ucfg.syswrite("\n}\n")
|
@@ -407,6 +439,38 @@ end
|
|
407
439
|
reexec_basic_test(pid, pid_file)
|
408
440
|
end
|
409
441
|
|
442
|
+
def test_socket_unlinked_restore
|
443
|
+
results = nil
|
444
|
+
sock = Tempfile.new('unicorn_test_sock')
|
445
|
+
sock_path = sock.path
|
446
|
+
sock.close!
|
447
|
+
ucfg = Tempfile.new('unicorn_test_config')
|
448
|
+
ucfg.syswrite("listen \"#{sock_path}\"\n")
|
449
|
+
|
450
|
+
File.open("config.ru", "wb") { |fp| fp.syswrite(HI) }
|
451
|
+
pid = xfork { redirect_test_io { exec($unicorn_bin, "-c#{ucfg.path}") } }
|
452
|
+
wait_for_file(sock_path)
|
453
|
+
assert File.socket?(sock_path)
|
454
|
+
assert_nothing_raised do
|
455
|
+
sock = UNIXSocket.new(sock_path)
|
456
|
+
sock.syswrite("GET / HTTP/1.0\r\n\r\n")
|
457
|
+
results = sock.sysread(4096)
|
458
|
+
end
|
459
|
+
assert_equal String, results.class
|
460
|
+
assert_nothing_raised do
|
461
|
+
File.unlink(sock_path)
|
462
|
+
Process.kill(:HUP, pid)
|
463
|
+
end
|
464
|
+
wait_for_file(sock_path)
|
465
|
+
assert File.socket?(sock_path)
|
466
|
+
assert_nothing_raised do
|
467
|
+
sock = UNIXSocket.new(sock_path)
|
468
|
+
sock.syswrite("GET / HTTP/1.0\r\n\r\n")
|
469
|
+
results = sock.sysread(4096)
|
470
|
+
end
|
471
|
+
assert_equal String, results.class
|
472
|
+
end
|
473
|
+
|
410
474
|
def test_unicorn_config_file
|
411
475
|
pid_file = "#{@tmpdir}/test.pid"
|
412
476
|
sock = Tempfile.new('unicorn_test_sock')
|
@@ -442,7 +506,7 @@ end
|
|
442
506
|
assert_equal String, results.class
|
443
507
|
|
444
508
|
# try reloading the config
|
445
|
-
sock = Tempfile.new('
|
509
|
+
sock = Tempfile.new('new_test_sock')
|
446
510
|
new_sock_path = sock.path
|
447
511
|
@sockets << new_sock_path
|
448
512
|
sock.close!
|
@@ -452,6 +516,7 @@ end
|
|
452
516
|
|
453
517
|
assert_nothing_raised do
|
454
518
|
ucfg = File.open(ucfg.path, "wb")
|
519
|
+
ucfg.syswrite("listen \"#{sock_path}\"\n")
|
455
520
|
ucfg.syswrite("listen \"#{new_sock_path}\"\n")
|
456
521
|
ucfg.syswrite("pid \"#{pid_file}\"\n")
|
457
522
|
ucfg.syswrite("logger Logger.new('#{new_log.path}')\n")
|
@@ -522,6 +587,7 @@ end
|
|
522
587
|
end
|
523
588
|
|
524
589
|
wait_master_ready(log.path)
|
590
|
+
File.truncate(log.path, 0)
|
525
591
|
wait_for_file(pid_file)
|
526
592
|
orig_pid = pid = File.read(pid_file).to_i
|
527
593
|
orig_fds = `ls -l /proc/#{pid}/fd`.split(/\n/)
|
@@ -535,6 +601,8 @@ end
|
|
535
601
|
end
|
536
602
|
wait_for_death(pid)
|
537
603
|
|
604
|
+
wait_master_ready(log.path)
|
605
|
+
File.truncate(log.path, 0)
|
538
606
|
wait_for_file(pid_file)
|
539
607
|
pid = File.read(pid_file).to_i
|
540
608
|
assert_not_equal orig_pid, pid
|
@@ -542,7 +610,7 @@ end
|
|
542
610
|
assert $?.success?
|
543
611
|
|
544
612
|
# we could've inherited descriptors the first time around
|
545
|
-
assert expect_size >= curr_fds.size
|
613
|
+
assert expect_size >= curr_fds.size, curr_fds.inspect
|
546
614
|
expect_size = curr_fds.size
|
547
615
|
|
548
616
|
assert_nothing_raised do
|
@@ -552,11 +620,13 @@ end
|
|
552
620
|
end
|
553
621
|
wait_for_death(pid)
|
554
622
|
|
623
|
+
wait_master_ready(log.path)
|
624
|
+
File.truncate(log.path, 0)
|
555
625
|
wait_for_file(pid_file)
|
556
626
|
pid = File.read(pid_file).to_i
|
557
627
|
curr_fds = `ls -l /proc/#{pid}/fd`.split(/\n/)
|
558
628
|
assert $?.success?
|
559
|
-
assert_equal expect_size, curr_fds.size
|
629
|
+
assert_equal expect_size, curr_fds.size, curr_fds.inspect
|
560
630
|
|
561
631
|
Process.kill(:QUIT, pid)
|
562
632
|
wait_for_death(pid)
|
data/test/test_helper.rb
CHANGED
@@ -35,8 +35,9 @@ end
|
|
35
35
|
def redirect_test_io
|
36
36
|
orig_err = STDERR.dup
|
37
37
|
orig_out = STDOUT.dup
|
38
|
-
STDERR.reopen("test_stderr.#{$$}.log")
|
39
|
-
STDOUT.reopen("test_stdout.#{$$}.log")
|
38
|
+
STDERR.reopen("test_stderr.#{$$}.log", "a")
|
39
|
+
STDOUT.reopen("test_stdout.#{$$}.log", "a")
|
40
|
+
STDERR.sync = STDOUT.sync = true
|
40
41
|
|
41
42
|
at_exit do
|
42
43
|
File.unlink("test_stderr.#{$$}.log") rescue nil
|
@@ -4,7 +4,7 @@ require 'unicorn/configurator'
|
|
4
4
|
|
5
5
|
class TestConfigurator < Test::Unit::TestCase
|
6
6
|
|
7
|
-
def
|
7
|
+
def test_config_init
|
8
8
|
assert_nothing_raised { Unicorn::Configurator.new {} }
|
9
9
|
end
|
10
10
|
|
@@ -91,4 +91,19 @@ class TestConfigurator < Test::Unit::TestCase
|
|
91
91
|
end
|
92
92
|
end
|
93
93
|
|
94
|
+
def test_after_fork_proc
|
95
|
+
[ proc { |a,b| }, Proc.new { |a,b| }, lambda { |a,b| } ].each do |my_proc|
|
96
|
+
Unicorn::Configurator.new(:after_fork => my_proc).commit!(self)
|
97
|
+
assert_equal my_proc, @after_fork
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def test_after_fork_wrong_arity
|
102
|
+
[ proc { |a| }, Proc.new { }, lambda { |a,b,c| } ].each do |my_proc|
|
103
|
+
assert_raises(ArgumentError) do
|
104
|
+
Unicorn::Configurator.new(:after_fork => my_proc)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
94
109
|
end
|