gross 1.7.2
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/CHANGELOG +410 -0
- data/README.md +102 -0
- data/Rakefile +25 -0
- data/bin/thin +6 -0
- data/example/adapter.rb +32 -0
- data/example/async_app.ru +126 -0
- data/example/async_chat.ru +247 -0
- data/example/async_tailer.ru +100 -0
- data/example/config.ru +22 -0
- data/example/monit_sockets +20 -0
- data/example/monit_unixsock +20 -0
- data/example/myapp.rb +1 -0
- data/example/ramaze.ru +12 -0
- data/example/thin.god +80 -0
- data/example/thin_solaris_smf.erb +36 -0
- data/example/thin_solaris_smf.readme.txt +150 -0
- data/example/vlad.rake +72 -0
- data/ext/thin_parser/common.rl +59 -0
- data/ext/thin_parser/ext_help.h +14 -0
- data/ext/thin_parser/extconf.rb +6 -0
- data/ext/thin_parser/parser.c +1447 -0
- data/ext/thin_parser/parser.h +49 -0
- data/ext/thin_parser/parser.rl +152 -0
- data/ext/thin_parser/thin.c +435 -0
- data/lib/rack/adapter/loader.rb +75 -0
- data/lib/rack/adapter/rails.rb +178 -0
- data/lib/thin.rb +45 -0
- data/lib/thin/backends/base.rb +167 -0
- data/lib/thin/backends/swiftiply_client.rb +56 -0
- data/lib/thin/backends/tcp_server.rb +34 -0
- data/lib/thin/backends/unix_server.rb +56 -0
- data/lib/thin/command.rb +53 -0
- data/lib/thin/connection.rb +215 -0
- data/lib/thin/controllers/cluster.rb +178 -0
- data/lib/thin/controllers/controller.rb +189 -0
- data/lib/thin/controllers/service.rb +76 -0
- data/lib/thin/controllers/service.sh.erb +39 -0
- data/lib/thin/daemonizing.rb +180 -0
- data/lib/thin/headers.rb +40 -0
- data/lib/thin/logging.rb +174 -0
- data/lib/thin/request.rb +162 -0
- data/lib/thin/response.rb +117 -0
- data/lib/thin/runner.rb +238 -0
- data/lib/thin/server.rb +290 -0
- data/lib/thin/stats.html.erb +216 -0
- data/lib/thin/stats.rb +52 -0
- data/lib/thin/statuses.rb +44 -0
- data/lib/thin/version.rb +32 -0
- metadata +156 -0
data/lib/thin/headers.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
module Thin
|
2
|
+
# Store HTTP header name-value pairs direcly to a string
|
3
|
+
# and allow duplicated entries on some names.
|
4
|
+
class Headers
|
5
|
+
HEADER_FORMAT = "%s: %s\r\n".freeze
|
6
|
+
ALLOWED_DUPLICATES = %w(set-cookie set-cookie2 warning www-authenticate).freeze
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@sent = {}
|
10
|
+
@out = []
|
11
|
+
end
|
12
|
+
|
13
|
+
# Add <tt>key: value</tt> pair to the headers.
|
14
|
+
# Ignore if already sent and no duplicates are allowed
|
15
|
+
# for this +key+.
|
16
|
+
def []=(key, value)
|
17
|
+
downcase_key = key.downcase
|
18
|
+
if !@sent.has_key?(downcase_key) || ALLOWED_DUPLICATES.include?(downcase_key)
|
19
|
+
@sent[downcase_key] = true
|
20
|
+
value = case value
|
21
|
+
when Time
|
22
|
+
value.httpdate
|
23
|
+
when NilClass
|
24
|
+
return
|
25
|
+
else
|
26
|
+
value.to_s
|
27
|
+
end
|
28
|
+
@out << HEADER_FORMAT % [key, value]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def has_key?(key)
|
33
|
+
@sent[key.downcase]
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_s
|
37
|
+
@out.join
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/thin/logging.rb
ADDED
@@ -0,0 +1,174 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module Thin
|
4
|
+
# To be included in classes to allow some basic logging
|
5
|
+
# that can be silenced (<tt>Logging.silent=</tt>) or made
|
6
|
+
# more verbose.
|
7
|
+
# <tt>Logging.trace=</tt>: log all raw request and response and
|
8
|
+
# messages logged with +trace+.
|
9
|
+
# <tt>Logging.silent=</tt>: silence all log all log messages
|
10
|
+
# altogether.
|
11
|
+
module Logging
|
12
|
+
# Simple formatter which only displays the message.
|
13
|
+
# Taken from ActiveSupport
|
14
|
+
class SimpleFormatter < Logger::Formatter
|
15
|
+
def call(severity, timestamp, progname, msg)
|
16
|
+
"#{String === msg ? msg : msg.inspect}\n"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
@trace_logger = nil
|
21
|
+
|
22
|
+
class << self
|
23
|
+
attr_reader :logger
|
24
|
+
attr_reader :trace_logger
|
25
|
+
|
26
|
+
def trace=(enabled)
|
27
|
+
if enabled
|
28
|
+
@trace_logger ||= Logger.new(STDOUT)
|
29
|
+
else
|
30
|
+
@trace_logger = nil
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def trace?
|
35
|
+
!@trace_logger.nil?
|
36
|
+
end
|
37
|
+
|
38
|
+
def silent=(shh)
|
39
|
+
if shh
|
40
|
+
@logger = nil
|
41
|
+
else
|
42
|
+
@logger ||= Logger.new(STDOUT)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def silent?
|
47
|
+
!@logger.nil?
|
48
|
+
end
|
49
|
+
|
50
|
+
def level
|
51
|
+
@logger ? @logger.level : nil # or 'silent'
|
52
|
+
end
|
53
|
+
|
54
|
+
def level=(value)
|
55
|
+
# If logging has been silenced, then re-enable logging
|
56
|
+
@logger = Logger.new(STDOUT) if @logger.nil?
|
57
|
+
@logger.level = value
|
58
|
+
end
|
59
|
+
|
60
|
+
# Allow user to specify a custom logger to use.
|
61
|
+
# This object must respond to:
|
62
|
+
# +level+, +level=+ and +debug+, +info+, +warn+, +error+, +fatal+
|
63
|
+
def logger=(custom_logger)
|
64
|
+
[ :level ,
|
65
|
+
:level= ,
|
66
|
+
:debug ,
|
67
|
+
:info ,
|
68
|
+
:warn ,
|
69
|
+
:error ,
|
70
|
+
:fatal ,
|
71
|
+
:unknown ,
|
72
|
+
].each do |method|
|
73
|
+
if not custom_logger.respond_to?(method)
|
74
|
+
raise ArgumentError, "logger must respond to #{method}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
@logger = custom_logger
|
79
|
+
end
|
80
|
+
|
81
|
+
def trace_logger=(custom_tracer)
|
82
|
+
[ :level ,
|
83
|
+
:level= ,
|
84
|
+
:debug ,
|
85
|
+
:info ,
|
86
|
+
:warn ,
|
87
|
+
:error ,
|
88
|
+
:fatal ,
|
89
|
+
:unknown ,
|
90
|
+
].each do |method|
|
91
|
+
if not custom_tracer.respond_to?(method)
|
92
|
+
raise ArgumentError, "trace logger must respond to #{method}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
@trace_logger = custom_tracer
|
97
|
+
end
|
98
|
+
|
99
|
+
def log_msg(msg, level=Logger::INFO)
|
100
|
+
return unless @logger
|
101
|
+
@logger.add(level, msg)
|
102
|
+
end
|
103
|
+
|
104
|
+
def trace_msg(msg)
|
105
|
+
return unless @trace_logger
|
106
|
+
@trace_logger.info(msg)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Provided for backwards compatibility.
|
110
|
+
# Callers should be using the +level+ (on the +Logging+ module
|
111
|
+
# or on the instance) to figure out what the log level is.
|
112
|
+
def debug?
|
113
|
+
self.level == Logger::DEBUG
|
114
|
+
end
|
115
|
+
def debug=(val)
|
116
|
+
self.level = (val ? Logger::DEBUG : Logger::INFO)
|
117
|
+
end
|
118
|
+
|
119
|
+
end # module methods
|
120
|
+
|
121
|
+
# Default logger to stdout.
|
122
|
+
self.logger = Logger.new(STDOUT)
|
123
|
+
self.logger.level = Logger::INFO
|
124
|
+
self.logger.formatter = Logging::SimpleFormatter.new
|
125
|
+
|
126
|
+
def silent
|
127
|
+
Logging.silent?
|
128
|
+
end
|
129
|
+
|
130
|
+
def silent=(value)
|
131
|
+
Logging.silent = value
|
132
|
+
end
|
133
|
+
|
134
|
+
# Log a message if tracing is activated
|
135
|
+
def trace(msg=nil)
|
136
|
+
Logging.trace_msg(msg) if msg
|
137
|
+
end
|
138
|
+
module_function :trace
|
139
|
+
public :trace
|
140
|
+
|
141
|
+
# Log a message at DEBUG level
|
142
|
+
def log_debug(msg=nil)
|
143
|
+
Logging.log_msg(msg || yield, Logger::DEBUG)
|
144
|
+
end
|
145
|
+
module_function :log_debug
|
146
|
+
public :log_debug
|
147
|
+
|
148
|
+
# Log a message at INFO level
|
149
|
+
def log_info(msg)
|
150
|
+
Logging.log_msg(msg || yield, Logger::INFO)
|
151
|
+
end
|
152
|
+
module_function :log_info
|
153
|
+
public :log_info
|
154
|
+
|
155
|
+
# Log a message at ERROR level (and maybe a backtrace)
|
156
|
+
def log_error(msg, e=nil)
|
157
|
+
log_msg = msg
|
158
|
+
if e
|
159
|
+
log_msg += ": #{e}\n\t" + e.backtrace.join("\n\t") + "\n"
|
160
|
+
end
|
161
|
+
Logging.log_msg(log_msg, Logger::ERROR)
|
162
|
+
end
|
163
|
+
module_function :log_error
|
164
|
+
public :log_error
|
165
|
+
|
166
|
+
# For backwards compatibility
|
167
|
+
def log msg
|
168
|
+
STDERR.puts('#log has been deprecated, please use the ' \
|
169
|
+
'log_level function instead (e.g. - log_info).')
|
170
|
+
log_info(msg)
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
174
|
+
end
|
data/lib/thin/request.rb
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
|
3
|
+
module Thin
|
4
|
+
# Raised when an incoming request is not valid
|
5
|
+
# and the server can not process it.
|
6
|
+
class InvalidRequest < IOError; end
|
7
|
+
|
8
|
+
# A request sent by the client to the server.
|
9
|
+
class Request
|
10
|
+
# Maximum request body size before it is moved out of memory
|
11
|
+
# and into a tempfile for reading.
|
12
|
+
MAX_BODY = 1024 * (80 + 32)
|
13
|
+
BODY_TMPFILE = 'thin-body'.freeze
|
14
|
+
MAX_HEADER = 1024 * (80 + 32)
|
15
|
+
|
16
|
+
INITIAL_BODY = String.new
|
17
|
+
# Force external_encoding of request's body to ASCII_8BIT
|
18
|
+
INITIAL_BODY.encode!(Encoding::ASCII_8BIT) if INITIAL_BODY.respond_to?(:encode!) && defined?(Encoding::ASCII_8BIT)
|
19
|
+
|
20
|
+
# Freeze some HTTP header names & values
|
21
|
+
SERVER_SOFTWARE = 'SERVER_SOFTWARE'.freeze
|
22
|
+
SERVER_NAME = 'SERVER_NAME'.freeze
|
23
|
+
REQUEST_METHOD = 'REQUEST_METHOD'.freeze
|
24
|
+
LOCALHOST = 'localhost'.freeze
|
25
|
+
HTTP_VERSION = 'HTTP_VERSION'.freeze
|
26
|
+
HTTP_1_0 = 'HTTP/1.0'.freeze
|
27
|
+
REMOTE_ADDR = 'REMOTE_ADDR'.freeze
|
28
|
+
CONTENT_LENGTH = 'CONTENT_LENGTH'.freeze
|
29
|
+
CONNECTION = 'HTTP_CONNECTION'.freeze
|
30
|
+
KEEP_ALIVE_REGEXP = /\bkeep-alive\b/i.freeze
|
31
|
+
CLOSE_REGEXP = /\bclose\b/i.freeze
|
32
|
+
HEAD = 'HEAD'.freeze
|
33
|
+
|
34
|
+
# Freeze some Rack header names
|
35
|
+
RACK_INPUT = 'rack.input'.freeze
|
36
|
+
RACK_VERSION = 'rack.version'.freeze
|
37
|
+
RACK_ERRORS = 'rack.errors'.freeze
|
38
|
+
RACK_MULTITHREAD = 'rack.multithread'.freeze
|
39
|
+
RACK_MULTIPROCESS = 'rack.multiprocess'.freeze
|
40
|
+
RACK_RUN_ONCE = 'rack.run_once'.freeze
|
41
|
+
ASYNC_CALLBACK = 'async.callback'.freeze
|
42
|
+
ASYNC_CLOSE = 'async.close'.freeze
|
43
|
+
|
44
|
+
# CGI-like request environment variables
|
45
|
+
attr_reader :env
|
46
|
+
|
47
|
+
# Unparsed data of the request
|
48
|
+
attr_reader :data
|
49
|
+
|
50
|
+
# Request body
|
51
|
+
attr_reader :body
|
52
|
+
|
53
|
+
def initialize
|
54
|
+
@parser = Thin::HttpParser.new
|
55
|
+
@data = String.new
|
56
|
+
@nparsed = 0
|
57
|
+
@body = StringIO.new(INITIAL_BODY.dup)
|
58
|
+
@env = {
|
59
|
+
SERVER_SOFTWARE => SERVER,
|
60
|
+
SERVER_NAME => LOCALHOST,
|
61
|
+
|
62
|
+
# Rack stuff
|
63
|
+
RACK_INPUT => @body,
|
64
|
+
|
65
|
+
RACK_VERSION => VERSION::RACK,
|
66
|
+
RACK_ERRORS => STDERR,
|
67
|
+
|
68
|
+
RACK_MULTITHREAD => false,
|
69
|
+
RACK_MULTIPROCESS => false,
|
70
|
+
RACK_RUN_ONCE => false
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
# Parse a chunk of data into the request environment
|
75
|
+
# Raises an +InvalidRequest+ if invalid.
|
76
|
+
# Returns +true+ if the parsing is complete.
|
77
|
+
def parse(data)
|
78
|
+
if @parser.finished? # Header finished, can only be some more body
|
79
|
+
@body << data
|
80
|
+
else # Parse more header using the super parser
|
81
|
+
@data << data
|
82
|
+
raise InvalidRequest, 'Header longer than allowed' if @data.size > MAX_HEADER
|
83
|
+
|
84
|
+
@nparsed = @parser.execute(@env, @data, @nparsed)
|
85
|
+
|
86
|
+
# Transfer to a tempfile if body is very big
|
87
|
+
move_body_to_tempfile if @parser.finished? && content_length > MAX_BODY
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
if finished? # Check if header and body are complete
|
92
|
+
@data = nil
|
93
|
+
@body.rewind
|
94
|
+
true # Request is fully parsed
|
95
|
+
else
|
96
|
+
false # Not finished, need more data
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# +true+ if headers and body are finished parsing
|
101
|
+
def finished?
|
102
|
+
@parser.finished? && @body.size >= content_length
|
103
|
+
end
|
104
|
+
|
105
|
+
# Expected size of the body
|
106
|
+
def content_length
|
107
|
+
@env[CONTENT_LENGTH].to_i
|
108
|
+
end
|
109
|
+
|
110
|
+
# Returns +true+ if the client expects the connection to be persistent.
|
111
|
+
def persistent?
|
112
|
+
# Clients and servers SHOULD NOT assume that a persistent connection
|
113
|
+
# is maintained for HTTP versions less than 1.1 unless it is explicitly
|
114
|
+
# signaled. (http://www.w3.org/Protocols/rfc2616/rfc2616-sec8.html)
|
115
|
+
if @env[HTTP_VERSION] == HTTP_1_0
|
116
|
+
@env[CONNECTION] =~ KEEP_ALIVE_REGEXP
|
117
|
+
|
118
|
+
# HTTP/1.1 client intends to maintain a persistent connection unless
|
119
|
+
# a Connection header including the connection-token "close" was sent
|
120
|
+
# in the request
|
121
|
+
else
|
122
|
+
@env[CONNECTION].nil? || @env[CONNECTION] !~ CLOSE_REGEXP
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def remote_address=(address)
|
127
|
+
@env[REMOTE_ADDR] = address
|
128
|
+
end
|
129
|
+
|
130
|
+
def threaded=(value)
|
131
|
+
@env[RACK_MULTITHREAD] = value
|
132
|
+
end
|
133
|
+
|
134
|
+
def async_callback=(callback)
|
135
|
+
@env[ASYNC_CALLBACK] = callback
|
136
|
+
@env[ASYNC_CLOSE] = EventMachine::DefaultDeferrable.new
|
137
|
+
end
|
138
|
+
|
139
|
+
def async_close
|
140
|
+
@async_close ||= @env[ASYNC_CLOSE]
|
141
|
+
end
|
142
|
+
|
143
|
+
def head?
|
144
|
+
@env[REQUEST_METHOD] == HEAD
|
145
|
+
end
|
146
|
+
|
147
|
+
# Close any resource used by the request
|
148
|
+
def close
|
149
|
+
@body.close! if @body.class == Tempfile
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
def move_body_to_tempfile
|
154
|
+
current_body = @body
|
155
|
+
current_body.rewind
|
156
|
+
@body = Tempfile.new(BODY_TMPFILE)
|
157
|
+
@body.binmode
|
158
|
+
@body << current_body.read
|
159
|
+
@env[RACK_INPUT] = @body
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
module Thin
|
2
|
+
# A response sent to the client.
|
3
|
+
class Response
|
4
|
+
CONNECTION = 'Connection'.freeze
|
5
|
+
CLOSE = 'close'.freeze
|
6
|
+
KEEP_ALIVE = 'keep-alive'.freeze
|
7
|
+
SERVER = 'Server'.freeze
|
8
|
+
CONTENT_LENGTH = 'Content-Length'.freeze
|
9
|
+
|
10
|
+
PERSISTENT_STATUSES = [100, 101].freeze
|
11
|
+
|
12
|
+
#Error Responses
|
13
|
+
ERROR = [500, {'Content-Type' => 'text/plain'}, ['Internal server error']].freeze
|
14
|
+
PERSISTENT_ERROR = [500, {'Content-Type' => 'text/plain', 'Connection' => 'keep-alive', 'Content-Length' => "21"}, ['Internal server error']].freeze
|
15
|
+
BAD_REQUEST = [400, {'Content-Type' => 'text/plain'}, ['Bad Request']].freeze
|
16
|
+
|
17
|
+
# Status code
|
18
|
+
attr_accessor :status
|
19
|
+
|
20
|
+
# Response body, must respond to +each+.
|
21
|
+
attr_accessor :body
|
22
|
+
|
23
|
+
# Headers key-value hash
|
24
|
+
attr_reader :headers
|
25
|
+
|
26
|
+
def initialize
|
27
|
+
@headers = Headers.new
|
28
|
+
@status = 200
|
29
|
+
@persistent = false
|
30
|
+
@skip_body = false
|
31
|
+
end
|
32
|
+
|
33
|
+
# String representation of the headers
|
34
|
+
# to be sent in the response.
|
35
|
+
def headers_output
|
36
|
+
# Set default headers
|
37
|
+
@headers[CONNECTION] = persistent? ? KEEP_ALIVE : CLOSE unless @headers.has_key?(CONNECTION)
|
38
|
+
@headers[SERVER] = Thin::NAME unless @headers.has_key?(SERVER)
|
39
|
+
|
40
|
+
@headers.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
# Top header of the response,
|
44
|
+
# containing the status code and response headers.
|
45
|
+
def head
|
46
|
+
"HTTP/1.1 #{@status} #{HTTP_STATUS_CODES[@status.to_i]}\r\n#{headers_output}\r\n"
|
47
|
+
end
|
48
|
+
|
49
|
+
if Thin.ruby_18?
|
50
|
+
|
51
|
+
# Ruby 1.8 implementation.
|
52
|
+
# Respects Rack specs.
|
53
|
+
#
|
54
|
+
# See http://rack.rubyforge.org/doc/files/SPEC.html
|
55
|
+
def headers=(key_value_pairs)
|
56
|
+
key_value_pairs.each do |k, vs|
|
57
|
+
vs.each { |v| @headers[k] = v.chomp } if vs
|
58
|
+
end if key_value_pairs
|
59
|
+
end
|
60
|
+
|
61
|
+
else
|
62
|
+
|
63
|
+
# Ruby 1.9 doesn't have a String#each anymore.
|
64
|
+
# Rack spec doesn't take care of that yet, for now we just use
|
65
|
+
# +each+ but fallback to +each_line+ on strings.
|
66
|
+
# I wish we could remove that condition.
|
67
|
+
# To be reviewed when a new Rack spec comes out.
|
68
|
+
def headers=(key_value_pairs)
|
69
|
+
key_value_pairs.each do |k, vs|
|
70
|
+
next unless vs
|
71
|
+
if vs.is_a?(String)
|
72
|
+
vs.each_line { |v| @headers[k] = v.chomp }
|
73
|
+
else
|
74
|
+
vs.each { |v| @headers[k] = v.chomp }
|
75
|
+
end
|
76
|
+
end if key_value_pairs
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
|
81
|
+
# Close any resource used by the response
|
82
|
+
def close
|
83
|
+
@body.close if @body.respond_to?(:close)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Yields each chunk of the response.
|
87
|
+
# To control the size of each chunk
|
88
|
+
# define your own +each+ method on +body+.
|
89
|
+
def each
|
90
|
+
yield head
|
91
|
+
|
92
|
+
unless @skip_body
|
93
|
+
if @body.is_a?(String)
|
94
|
+
yield @body
|
95
|
+
else
|
96
|
+
@body.each { |chunk| yield chunk }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Tell the client the connection should stay open
|
102
|
+
def persistent!
|
103
|
+
@persistent = true
|
104
|
+
end
|
105
|
+
|
106
|
+
# Persistent connection must be requested as keep-alive
|
107
|
+
# from the server and have a Content-Length, or the response
|
108
|
+
# status must require that the connection remain open.
|
109
|
+
def persistent?
|
110
|
+
(@persistent && @headers.has_key?(CONTENT_LENGTH)) || PERSISTENT_STATUSES.include?(@status)
|
111
|
+
end
|
112
|
+
|
113
|
+
def skip_body!
|
114
|
+
@skip_body = true
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|