rainbows 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/.document +11 -0
- data/.gitignore +18 -0
- data/COPYING +339 -0
- data/DEPLOY +29 -0
- data/Documentation/.gitignore +5 -0
- data/Documentation/GNUmakefile +30 -0
- data/Documentation/rainbows.1.txt +159 -0
- data/FAQ +50 -0
- data/GIT-VERSION-GEN +41 -0
- data/GNUmakefile +156 -0
- data/LICENSE +55 -0
- data/README +122 -0
- data/Rakefile +103 -0
- data/SIGNALS +94 -0
- data/TODO +20 -0
- data/TUNING +31 -0
- data/bin/rainbows +166 -0
- data/lib/rainbows.rb +53 -0
- data/lib/rainbows/base.rb +69 -0
- data/lib/rainbows/const.rb +24 -0
- data/lib/rainbows/http_response.rb +35 -0
- data/lib/rainbows/http_server.rb +47 -0
- data/lib/rainbows/revactor.rb +158 -0
- data/lib/rainbows/revactor/tee_input.rb +44 -0
- data/lib/rainbows/thread_pool.rb +96 -0
- data/lib/rainbows/thread_spawn.rb +79 -0
- data/local.mk.sample +54 -0
- data/rainbows.gemspec +47 -0
- data/setup.rb +1586 -0
- data/t/.gitignore +4 -0
- data/t/GNUmakefile +64 -0
- data/t/bin/unused_listen +39 -0
- data/t/sha1.ru +17 -0
- data/t/t0000-basic.sh +18 -0
- data/t/t1000-thread-pool-basic.sh +53 -0
- data/t/t2000-thread-spawn-basic.sh +50 -0
- data/t/t3000-revactor-basic.sh +52 -0
- data/t/t3100-revactor-tee-input.sh +49 -0
- data/t/test-lib.sh +41 -0
- data/vs_Unicorn +48 -0
- metadata +135 -0
data/SIGNALS
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
== Signal handling
|
2
|
+
|
3
|
+
In general, signals need only be sent to the master process. However,
|
4
|
+
the signals Rainbows! uses internally to communicate with the worker
|
5
|
+
processes are documented here as well.
|
6
|
+
|
7
|
+
=== Master Process
|
8
|
+
|
9
|
+
* HUP - reload config file, app, and gracefully restart all workers
|
10
|
+
|
11
|
+
* INT/TERM - quick shutdown, kills all workers immediately
|
12
|
+
|
13
|
+
* QUIT - graceful shutdown, waits for workers to finish their
|
14
|
+
current request before finishing.
|
15
|
+
|
16
|
+
* USR1 - reopen all logs owned by the master and all workers
|
17
|
+
See Unicorn::Util.reopen_logs for what is considered a log.
|
18
|
+
|
19
|
+
* USR2 - reexecute the running binary. A separate QUIT
|
20
|
+
should be sent to the original process once the child is verified to
|
21
|
+
be up and running.
|
22
|
+
|
23
|
+
* WINCH - gracefully stops workers but keep the master running.
|
24
|
+
This will only work for daemonized processes.
|
25
|
+
|
26
|
+
* TTIN - increment the number of worker processes by one
|
27
|
+
|
28
|
+
* TTOU - decrement the number of worker processes by one
|
29
|
+
|
30
|
+
=== Worker Processes
|
31
|
+
|
32
|
+
Sending signals directly to the worker processes should not normally be
|
33
|
+
needed. If the master process is running, any exited worker will be
|
34
|
+
automatically respawned.
|
35
|
+
|
36
|
+
* INT/TERM - Quick shutdown, immediately exit.
|
37
|
+
Unless WINCH has been sent to the master (or the master is killed),
|
38
|
+
the master process will respawn a worker to replace this one.
|
39
|
+
|
40
|
+
* QUIT - Gracefully exit after finishing the current request.
|
41
|
+
Unless WINCH has been sent to the master (or the master is killed),
|
42
|
+
the master process will respawn a worker to replace this one.
|
43
|
+
|
44
|
+
* USR1 - Reopen all logs owned by the worker process.
|
45
|
+
See Unicorn::Util.reopen_logs for what is considered a log.
|
46
|
+
Log files are not reopened until it is done processing
|
47
|
+
the current request, so multiple log lines for one request
|
48
|
+
(as done by Rails) will not be split across multiple logs.
|
49
|
+
|
50
|
+
=== Procedure to replace a running rainbows executable
|
51
|
+
|
52
|
+
You may replace a running instance of unicorn with a new one without
|
53
|
+
losing any incoming connections. Doing so will reload all of your
|
54
|
+
application code, Unicorn config, Ruby executable, and all libraries.
|
55
|
+
The only things that will not change (due to OS limitations) are:
|
56
|
+
|
57
|
+
1. The path to the rainbows executable script. If you want to change to
|
58
|
+
a different installation of Ruby, you can modify the shebang
|
59
|
+
line to point to your alternative interpreter.
|
60
|
+
|
61
|
+
The procedure is exactly like that of nginx:
|
62
|
+
|
63
|
+
1. Send USR2 to the master process
|
64
|
+
|
65
|
+
2. Check your process manager or pid files to see if a new master spawned
|
66
|
+
successfully. If you're using a pid file, the old process will have
|
67
|
+
".oldbin" appended to its path. You should have two master instances
|
68
|
+
of rainbows running now, both of which will have workers servicing
|
69
|
+
requests. Your process tree should look something like this:
|
70
|
+
|
71
|
+
rainbows master (old)
|
72
|
+
\_ rainbows worker[0]
|
73
|
+
\_ rainbows worker[1]
|
74
|
+
\_ rainbows worker[2]
|
75
|
+
\_ rainbows worker[3]
|
76
|
+
\_ rainbows master
|
77
|
+
\_ rainbows worker[0]
|
78
|
+
\_ rainbows worker[1]
|
79
|
+
\_ rainbows worker[2]
|
80
|
+
\_ rainbows worker[3]
|
81
|
+
|
82
|
+
3. You can now send WINCH to the old master process so only the new workers
|
83
|
+
serve requests. If your rainbows process is bound to an
|
84
|
+
interactive terminal, you can skip this step. Step 5 will be more
|
85
|
+
difficult but you can also skip it if your process is not daemonized.
|
86
|
+
|
87
|
+
4. You should now ensure that everything is running correctly with the
|
88
|
+
new workers as the old workers die off.
|
89
|
+
|
90
|
+
5. If everything seems ok, then send QUIT to the old master. You're done!
|
91
|
+
|
92
|
+
If something is broken, then send HUP to the old master to reload
|
93
|
+
the config and restart its workers. Then send QUIT to the new master
|
94
|
+
process.
|
data/TODO
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
= TODO items for Rainbows!
|
2
|
+
|
3
|
+
We're lazy and pick the easy items to do first, then the ones people
|
4
|
+
care about.
|
5
|
+
|
6
|
+
* Rev (without Revactor) - since we already use Revactor we might as
|
7
|
+
well support this one so 1.8 users won't be left out. Doing TeeInput
|
8
|
+
is probably going to get ugly, though...
|
9
|
+
|
10
|
+
* EventMachine - much like Rev, but we haven't looked at this one much
|
11
|
+
(our benevolent dictator doesn't like C++). If we can figure out how
|
12
|
+
to do Rev without Revactor, then this should be pretty easy.
|
13
|
+
|
14
|
+
* Fiber support - Revactor already uses these with Ruby 1.9, also not
|
15
|
+
sure how TeeInput can be done with this.
|
16
|
+
|
17
|
+
* Omnibus - haven't looked into it, probably like Revactor with 1.8?
|
18
|
+
|
19
|
+
* Rubinius Actors - should be like Revactor and easily doable once
|
20
|
+
Rubinius gets more mature.
|
data/TUNING
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
= Tuning \Rainbows!
|
2
|
+
|
3
|
+
Most of the {tuning notes}[http://unicorn.bogomips.org/TUNING.html]
|
4
|
+
apply to \Rainbows! as well. \Rainbows! is not particularly optimized
|
5
|
+
at the moment and is designed for applications that spend large amounts
|
6
|
+
of the time waiting on network activity. Thus memory usage and memory
|
7
|
+
bandwidth for keeping connections open are often limiting factors as
|
8
|
+
well.
|
9
|
+
|
10
|
+
== \Rainbows! configuration
|
11
|
+
|
12
|
+
* Don't set +worker_connections+ too high. It is often better to start
|
13
|
+
denying requests and only serve the clients you can than to be
|
14
|
+
completely bogged down and be unusable for everybody.
|
15
|
+
|
16
|
+
* Increase +worker_processes+ if you have resources (RAM/DB connections)
|
17
|
+
available. Additional worker processes can better utilize SMP, are more
|
18
|
+
robust against crashes and are more likely to be fairly scheduled by
|
19
|
+
the kernel.
|
20
|
+
|
21
|
+
== nginx configuration
|
22
|
+
|
23
|
+
If you intend to use nginx as a reverse-proxy in front of \Rainbows! to
|
24
|
+
handle Comet applications, make sure you disable proxy response
|
25
|
+
buffering in nginx:
|
26
|
+
|
27
|
+
proxy_buffering off;
|
28
|
+
|
29
|
+
This can be disabled on a per-backend basis in nginx, so under no
|
30
|
+
circumstances should you disable response buffering to Unicorn
|
31
|
+
backends, only to \Rainbows! backends.
|
data/bin/rainbows
ADDED
@@ -0,0 +1,166 @@
|
|
1
|
+
#!/home/ew/bin/ruby
|
2
|
+
# -*- encoding: binary -*-
|
3
|
+
require 'unicorn/launcher'
|
4
|
+
require 'rainbows'
|
5
|
+
require 'optparse'
|
6
|
+
|
7
|
+
env = "development"
|
8
|
+
daemonize = false
|
9
|
+
listeners = []
|
10
|
+
options = { :listeners => listeners }
|
11
|
+
host, port = Unicorn::Const::DEFAULT_HOST, Unicorn::Const::DEFAULT_PORT
|
12
|
+
set_listener = false
|
13
|
+
|
14
|
+
opts = OptionParser.new("", 24, ' ') do |opts|
|
15
|
+
opts.banner = "Usage: #{File.basename($0)} " \
|
16
|
+
"[ruby options] [unicorn options] [rackup config file]"
|
17
|
+
|
18
|
+
opts.separator "Ruby options:"
|
19
|
+
|
20
|
+
lineno = 1
|
21
|
+
opts.on("-e", "--eval LINE", "evaluate a LINE of code") do |line|
|
22
|
+
eval line, TOPLEVEL_BINDING, "-e", lineno
|
23
|
+
lineno += 1
|
24
|
+
end
|
25
|
+
|
26
|
+
opts.on("-d", "--debug", "set debugging flags (set $DEBUG to true)") do
|
27
|
+
$DEBUG = true
|
28
|
+
end
|
29
|
+
|
30
|
+
opts.on("-w", "--warn", "turn warnings on for your script") do
|
31
|
+
$-w = true
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on("-I", "--include PATH",
|
35
|
+
"specify $LOAD_PATH (may be used more than once)") do |path|
|
36
|
+
$LOAD_PATH.unshift(*path.split(/:/))
|
37
|
+
end
|
38
|
+
|
39
|
+
opts.on("-r", "--require LIBRARY",
|
40
|
+
"require the library, before executing your script") do |library|
|
41
|
+
require library
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.separator "Unicorn options:"
|
45
|
+
|
46
|
+
# some of these switches exist for rackup command-line compatibility,
|
47
|
+
|
48
|
+
opts.on("-o", "--host HOST",
|
49
|
+
"listen on HOST (default: #{Unicorn::Const::DEFAULT_HOST})") do |h|
|
50
|
+
host = h
|
51
|
+
set_listener = true
|
52
|
+
end
|
53
|
+
|
54
|
+
opts.on("-p", "--port PORT",
|
55
|
+
"use PORT (default: #{Unicorn::Const::DEFAULT_PORT})") do |p|
|
56
|
+
port = p.to_i
|
57
|
+
set_listener = true
|
58
|
+
end
|
59
|
+
|
60
|
+
opts.on("-E", "--env ENVIRONMENT",
|
61
|
+
"use ENVIRONMENT for defaults (default: development)") do |e|
|
62
|
+
env = e
|
63
|
+
end
|
64
|
+
|
65
|
+
opts.on("-D", "--daemonize", "run daemonized in the background") do |d|
|
66
|
+
daemonize = d ? true : false
|
67
|
+
end
|
68
|
+
|
69
|
+
opts.on("-P", "--pid FILE", "DEPRECATED") do |f|
|
70
|
+
warn %q{Use of --pid/-P is strongly discouraged}
|
71
|
+
warn %q{Use the 'pid' directive in the Unicorn config file instead}
|
72
|
+
options[:pid] = File.expand_path(f)
|
73
|
+
end
|
74
|
+
|
75
|
+
opts.on("-s", "--server SERVER",
|
76
|
+
"this flag only exists for compatibility") do |s|
|
77
|
+
warn "-s/--server only exists for compatibility with rackup"
|
78
|
+
end
|
79
|
+
|
80
|
+
# Unicorn-specific stuff
|
81
|
+
opts.on("-l", "--listen {HOST:PORT|PATH}",
|
82
|
+
"listen on HOST:PORT or PATH",
|
83
|
+
"this may be specified multiple times",
|
84
|
+
"(default: #{Unicorn::Const::DEFAULT_LISTEN})") do |address|
|
85
|
+
listeners << address
|
86
|
+
end
|
87
|
+
|
88
|
+
opts.on("-c", "--config-file FILE", "Unicorn-specific config file") do |f|
|
89
|
+
options[:config_file] = File.expand_path(f)
|
90
|
+
end
|
91
|
+
|
92
|
+
# I'm avoiding Unicorn-specific config options on the command-line.
|
93
|
+
# IMNSHO, config options on the command-line are redundant given
|
94
|
+
# config files and make things unnecessarily complicated with multiple
|
95
|
+
# places to look for a config option.
|
96
|
+
|
97
|
+
opts.separator "Common options:"
|
98
|
+
|
99
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
100
|
+
puts opts.to_s.gsub(/^.*DEPRECATED.*$/s, '')
|
101
|
+
exit
|
102
|
+
end
|
103
|
+
|
104
|
+
opts.on_tail("-v", "--version", "Show version") do
|
105
|
+
puts "unicorn v#{Unicorn::Const::UNICORN_VERSION}"
|
106
|
+
exit
|
107
|
+
end
|
108
|
+
|
109
|
+
opts.parse! ARGV
|
110
|
+
end
|
111
|
+
|
112
|
+
config = ARGV[0] || "config.ru"
|
113
|
+
abort "configuration file #{config} not found" unless File.exist?(config)
|
114
|
+
|
115
|
+
if config =~ /\.ru$/
|
116
|
+
# parse embedded command-line options in config.ru comments
|
117
|
+
if File.open(config, "rb") { |fp| fp.sysread(fp.stat.size) } =~ /^#\\(.*)/
|
118
|
+
opts.parse! $1.split(/\s+/)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
require 'pp' if $DEBUG
|
123
|
+
|
124
|
+
app = lambda do ||
|
125
|
+
# require Rack as late as possible in case $LOAD_PATH is modified
|
126
|
+
# in config.ru or command-line
|
127
|
+
inner_app = case config
|
128
|
+
when /\.ru$/
|
129
|
+
raw = File.open(config, "rb") { |fp| fp.sysread(fp.stat.size) }
|
130
|
+
raw.sub!(/^__END__\n.*/, '')
|
131
|
+
eval("Rack::Builder.new {(#{raw}\n)}.to_app", nil, config)
|
132
|
+
else
|
133
|
+
require config
|
134
|
+
Object.const_get(File.basename(config, '.rb').capitalize)
|
135
|
+
end
|
136
|
+
pp({ :inner_app => inner_app }) if $DEBUG
|
137
|
+
case env
|
138
|
+
when "development"
|
139
|
+
Rack::Builder.new do
|
140
|
+
use Rack::CommonLogger, $stderr
|
141
|
+
use Rack::ShowExceptions
|
142
|
+
use Rack::Lint
|
143
|
+
run inner_app
|
144
|
+
end.to_app
|
145
|
+
when "deployment"
|
146
|
+
Rack::Builder.new do
|
147
|
+
use Rack::CommonLogger, $stderr
|
148
|
+
run inner_app
|
149
|
+
end.to_app
|
150
|
+
else
|
151
|
+
inner_app
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
listeners << "#{host}:#{port}" if set_listener
|
156
|
+
|
157
|
+
if $DEBUG
|
158
|
+
pp({
|
159
|
+
:unicorn_options => options,
|
160
|
+
:app => app,
|
161
|
+
:daemonize => daemonize,
|
162
|
+
})
|
163
|
+
end
|
164
|
+
|
165
|
+
Unicorn::Launcher.daemonize! if daemonize
|
166
|
+
Rainbows.run(app, options)
|
data/lib/rainbows.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
require 'unicorn'
|
3
|
+
|
4
|
+
module Rainbows
|
5
|
+
|
6
|
+
require 'rainbows/const'
|
7
|
+
require 'rainbows/http_server'
|
8
|
+
require 'rainbows/http_response'
|
9
|
+
require 'rainbows/base'
|
10
|
+
|
11
|
+
class << self
|
12
|
+
|
13
|
+
# runs the Rainbows! HttpServer with +app+ and +options+ and does
|
14
|
+
# not return until the server has exited.
|
15
|
+
def run(app, options = {})
|
16
|
+
HttpServer.new(app, options).start.join
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# configures Rainbows! with a given concurrency model to +use+ and
|
21
|
+
# a +worker_connections+ upper-bound. This method may be called
|
22
|
+
# inside a Unicorn/Rainbows configuration file:
|
23
|
+
#
|
24
|
+
# Rainbows! do
|
25
|
+
# use :Revactor # this may also be :ThreadSpawn or :ThreadPool
|
26
|
+
# worker_connections 128
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# See the documentation for the respective Revactor, ThreadSpawn,
|
30
|
+
# and ThreadPool classes for descriptions and recommendations for
|
31
|
+
# each of them.
|
32
|
+
def Rainbows!(&block)
|
33
|
+
block_given? or raise ArgumentError, "Rainbows! requires a block"
|
34
|
+
HttpServer.setup(block)
|
35
|
+
end
|
36
|
+
|
37
|
+
# maps models to default worker counts, default worker count numbers are
|
38
|
+
# pretty arbitrary and tuning them to your application and hardware is
|
39
|
+
# highly recommended
|
40
|
+
MODEL_WORKER_CONNECTIONS = {
|
41
|
+
:Base => 1, # this one can't change
|
42
|
+
:Revactor => 50,
|
43
|
+
:ThreadSpawn => 30,
|
44
|
+
:ThreadPool => 10,
|
45
|
+
}.each do |model, _|
|
46
|
+
u = model.to_s.gsub(/([a-z0-9])([A-Z0-9])/) { "#{$1}_#{$2.downcase!}" }
|
47
|
+
autoload model, "rainbows/#{u.downcase!}"
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
# inject the Rainbows! method into Unicorn::Configurator
|
53
|
+
Unicorn::Configurator.class_eval { include Rainbows }
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
|
3
|
+
module Rainbows
|
4
|
+
|
5
|
+
# base class for Rainbows concurrency models
|
6
|
+
module Base
|
7
|
+
|
8
|
+
include Unicorn
|
9
|
+
include Rainbows::Const
|
10
|
+
|
11
|
+
# write a response without caring if it went out or not for error
|
12
|
+
# messages.
|
13
|
+
# TODO: merge into Unicorn::HttpServer
|
14
|
+
def emergency_response(client, response_str)
|
15
|
+
client.write_nonblock(response_str) rescue nil
|
16
|
+
client.close rescue nil
|
17
|
+
end
|
18
|
+
|
19
|
+
# once a client is accepted, it is processed in its entirety here
|
20
|
+
# in 3 easy steps: read request, call app, write app response
|
21
|
+
def process_client(client)
|
22
|
+
buf = client.readpartial(CHUNK_SIZE)
|
23
|
+
hp = HttpParser.new
|
24
|
+
env = {}
|
25
|
+
remote_addr = TCPSocket === client ? client.peeraddr.last : LOCALHOST
|
26
|
+
|
27
|
+
begin
|
28
|
+
while ! hp.headers(env, buf)
|
29
|
+
buf << client.readpartial(CHUNK_SIZE)
|
30
|
+
end
|
31
|
+
|
32
|
+
env[RACK_INPUT] = 0 == hp.content_length ?
|
33
|
+
HttpRequest::NULL_IO :
|
34
|
+
Unicorn::TeeInput.new(client, env, hp, buf)
|
35
|
+
env[REMOTE_ADDR] = remote_addr
|
36
|
+
response = app.call(env.update(RACK_DEFAULTS))
|
37
|
+
|
38
|
+
if 100 == response.first.to_i
|
39
|
+
client.write(EXPECT_100_RESPONSE)
|
40
|
+
env.delete(HTTP_EXPECT)
|
41
|
+
response = app.call(env)
|
42
|
+
end
|
43
|
+
|
44
|
+
out = [ hp.keepalive? ? CONN_ALIVE : CONN_CLOSE ] if hp.headers?
|
45
|
+
HttpResponse.write(client, response, out)
|
46
|
+
end while hp.keepalive? and hp.reset.nil? and env.clear
|
47
|
+
client.close
|
48
|
+
# if we get any error, try to write something back to the client
|
49
|
+
# assuming we haven't closed the socket, but don't get hung up
|
50
|
+
# if the socket is already closed or broken. We'll always ensure
|
51
|
+
# the socket is closed at the end of this function
|
52
|
+
rescue EOFError,Errno::ECONNRESET,Errno::EPIPE,Errno::EINVAL,Errno::EBADF
|
53
|
+
emergency_response(client, ERROR_500_RESPONSE)
|
54
|
+
rescue HttpParserError # try to tell the client they're bad
|
55
|
+
buf.empty? or emergency_response(client, ERROR_400_RESPONSE)
|
56
|
+
rescue Object => e
|
57
|
+
emergency_response(client, ERROR_500_RESPONSE)
|
58
|
+
logger.error "Read error: #{e.inspect}"
|
59
|
+
logger.error e.backtrace.join("\n")
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.included(klass)
|
63
|
+
HttpServer.constants.each do |x|
|
64
|
+
klass.const_set(x, HttpServer.const_get(x))
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- encoding: binary -*-
|
2
|
+
|
3
|
+
module Rainbows
|
4
|
+
|
5
|
+
module Const
|
6
|
+
RAINBOWS_VERSION = '0.1.0'
|
7
|
+
|
8
|
+
include Unicorn::Const
|
9
|
+
|
10
|
+
RACK_DEFAULTS = ::Unicorn::HttpRequest::DEFAULTS.merge({
|
11
|
+
|
12
|
+
# we need to observe many of the rules for thread-safety even
|
13
|
+
# with Revactor or Rev, so we're considered multithread-ed even
|
14
|
+
# when we're not technically...
|
15
|
+
"rack.multithread" => true,
|
16
|
+
"SERVER_SOFTWARE" => "Rainbows! #{RAINBOWS_VERSION}",
|
17
|
+
})
|
18
|
+
|
19
|
+
CONN_CLOSE = "Connection: close\r\n"
|
20
|
+
CONN_ALIVE = "Connection: keep-alive\r\n"
|
21
|
+
LOCALHOST = "127.0.0.1"
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|