uma 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/lib/uma/http.rb ADDED
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uma
4
+ module HTTP
5
+ class Error < StandardError; end
6
+ class ParseError < Error; end
7
+ class ResponseError < Error; end
8
+
9
+ class RackIO
10
+ def initialize(env, stream)
11
+ @env = env
12
+ @stream = stream
13
+ @machine = stream.machine
14
+ @fd = stream.fd
15
+ end
16
+
17
+ def gets
18
+ @stream.get_line(nil, 0)
19
+ end
20
+
21
+ def read(len, buf = +'')
22
+ @stream.get_string(buf, -30)
23
+ end
24
+
25
+ def each(&)
26
+ if @env['HTTP_TRANSFER_ENCODING'] == 'chunked'
27
+ read_chunks(&)
28
+ elsif (v = @env['CONTENT_LENGTH'])
29
+ len = v.to_i
30
+ yield @stream.get_string(+'', len)
31
+ end
32
+ end
33
+
34
+ def read_chunks
35
+ buf = +''
36
+ while true
37
+ line = @stream.get_line(buf, 8)
38
+ len = line.to_i(16)
39
+ break if len == 0
40
+
41
+ chunk = @stream.get_string(nil, len)
42
+ yield chunk
43
+ @stream.skip(2)
44
+ end
45
+ end
46
+
47
+ def close
48
+ @machine.close(@fd)
49
+ end
50
+
51
+ def puts(str)
52
+ write("#{str}\n")
53
+ end
54
+
55
+ def write(*bufs)
56
+ @machine.writev(@fd, *bufs)
57
+ end
58
+ alias_method :<<, :write
59
+ end
60
+
61
+ class ChunkedIO
62
+ def initialize(machine, fd, stream)
63
+ @machine = machine
64
+ @fd = fd
65
+ @stream = stream
66
+ @first_write = true
67
+ end
68
+
69
+ def read(len, buf = +'')
70
+ stream.get_string(buf, len)
71
+ buf
72
+ end
73
+
74
+ def write(*chunks)
75
+ bufs = []
76
+ chunks.each do
77
+ bufs << (@first_write ? "#{it.bytesize.to_s(16)}\r\n" : "\r\n#{it.bytesize.to_s(16)}\r\n")
78
+ bufs << it
79
+ @first_write = false
80
+ end
81
+ @machine.sendv(@fd, *bufs)
82
+ end
83
+ alias_method :<<, :write
84
+
85
+ def flush
86
+ end
87
+
88
+ def close_read
89
+ @machine.shutdown(@fd, UM::SHUT_RD)
90
+ end
91
+
92
+ def close_write
93
+ @machine.shutdown(@fd, UM::SHUT_WR)
94
+ end
95
+
96
+ def close
97
+ return if @closed
98
+
99
+ @closed = true
100
+ chunked_ending = @first_write ? "0\r\n\r\n" : "\r\n0\r\n\r\n"
101
+ @machine.sendv(@fd, chunked_ending)
102
+ end
103
+
104
+ def closed?
105
+ @closed
106
+ end
107
+ end
108
+
109
+ extend self
110
+
111
+ def http_connection(machine, config, fd)
112
+ stream = UM::Stream.new(machine, fd)
113
+ while true
114
+ break if !process_request(machine, config, fd, stream)
115
+ end
116
+ end
117
+
118
+ def process_request(machine, config, fd, stream)
119
+ env = get_request_env(config, stream)
120
+ return false if !env
121
+
122
+ rack_response = config[:app].(env)
123
+ if !env['uma.hijacked?']
124
+ send_rack_response(machine, env, fd, rack_response)
125
+ should_process_next_request?(env)
126
+ else
127
+ false
128
+ end
129
+ rescue => e
130
+ if (h = config[:error_handler])
131
+ h.(e)
132
+ else
133
+ send_error_response(machine, fd, e)
134
+ end
135
+ end
136
+
137
+ def get_request_env(config, stream)
138
+ env = {
139
+ 'rack.url_scheme' => 'http',
140
+ 'SCRIPT_NAME' => '',
141
+ 'SERVER_NAME' => 'localhost',
142
+ 'rack.hijack?' => true,
143
+ 'rack.hijack' => -> {
144
+ env['uma.hijacked?'] = true
145
+ env['rack.hijack'] = env['rack.input'] || RackIO.new(env, stream)
146
+ },
147
+ 'rack.errors' => config[:error_stream]
148
+ }
149
+ buf = +''
150
+ ret = stream.get_line(buf, 4096)
151
+ return if !ret
152
+
153
+ parse_request_line(env, buf)
154
+ while true
155
+ ret = stream.get_line(buf, 4096)
156
+ raise ParseError, "Unexpected EOF" if !ret
157
+ break if buf.empty?
158
+
159
+ parse_header(env, buf)
160
+ end
161
+
162
+ if env['CONTENT_LENGTH'] || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
163
+ env['rack.input'] = RackIO.new(env, stream)
164
+ end
165
+ env
166
+ end
167
+
168
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s\?]+)(?:\?([^\s]+))?\s+(http\/[019\.]{1,3})/i
169
+
170
+ def parse_request_line(env, line)
171
+ m = line.match(RE_REQUEST_LINE)
172
+ raise ParseError, 'Invalid request line' if !m
173
+
174
+ env['REQUEST_METHOD'] = m[1].upcase
175
+ env['PATH_INFO'] = m[2]
176
+ env['QUERY_STRING'] = m[3] || ''
177
+ env['SERVER_PROTOCOL'] = m[4]
178
+ end
179
+
180
+ RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
181
+
182
+ def parse_header(env, line)
183
+ m = line.match(RE_HEADER_LINE)
184
+ raise ParseError, 'Invalid header' if !m
185
+
186
+ key = "HTTP_#{m[1].upcase.tr('-', '_')}"
187
+ value = m[2]
188
+ case key
189
+ when 'HTTP_CONTENT_TYPE'
190
+ env['CONTENT_TYPE'] = value
191
+ when 'HTTP_CONTENT_LENGTH'
192
+ env['CONTENT_LENGTH'] = value
193
+ when 'HTTP_HOST'
194
+ env['SERVER_NAME'] = value
195
+ env[key] = value
196
+ else
197
+ env[key] = value
198
+ end
199
+ end
200
+
201
+ CHUNK_END = "\r\n0\r\n\r\n"
202
+
203
+ def send_rack_response(machine, env, fd, response)
204
+ status, headers, body = response
205
+ hijack = headers['rack.hijack']
206
+
207
+ empty_body = body.nil? || body == '' || (body.is_a?(Array) && body.empty?)
208
+
209
+ if !hijack
210
+ chunked = nil
211
+ headers['content-length'] = '0' if empty_body
212
+ chunked = !headers['content-length']
213
+ headers['transfer-encoding'] = 'chunked' if chunked
214
+ end
215
+
216
+ buf_status = "HTTP/1.1 #{status}\r\n"
217
+ buf_headers = format_headers(headers)
218
+
219
+ if hijack
220
+ machine.sendv(fd, buf_status, buf_headers)
221
+ hijack.(env['rack.hijack'].())
222
+ elsif body && !empty_body
223
+ if chunked
224
+ send_body_chunked(machine, env, fd, buf_status, buf_headers, body)
225
+ else
226
+ if body.is_a?(Array)
227
+ machine.sendv(fd, buf_status, buf_headers, *body)
228
+ else
229
+ machine.sendv(fd, buf_status, buf_headers, body)
230
+ end
231
+ end
232
+ else
233
+ machine.sendv(fd, buf_status, buf_headers)
234
+ end
235
+ end
236
+
237
+ def send_body_chunked(machine, env, fd, buf_status, buf_headers, body)
238
+ case body
239
+ when String
240
+ buf_chunk_header = "#{body.bytesize.to_s(16)}\r\n"
241
+ machine.sendv(
242
+ fd,
243
+ buf_status,
244
+ buf_headers,
245
+ buf_chunk_header,
246
+ body,
247
+ CHUNK_END
248
+ )
249
+ when Array
250
+ bufs = [buf_status, buf_headers]
251
+ first = true
252
+ body.each {
253
+ bufs << (first ? "#{it.bytesize.to_s(16)}\r\n" : "\r\n#{it.bytesize.to_s(16)}\r\n")
254
+ first = false
255
+ bufs << it
256
+ }
257
+ bufs << CHUNK_END
258
+ machine.sendv(fd, *bufs)
259
+ else
260
+ if body.respond_to?(:each)
261
+ first = true
262
+ body.each do
263
+ chunk_header = first ? "#{it.bytesize.to_s(16)}\r\n" : "\r\n#{it.bytesize.to_s(16)}\r\n"
264
+ if first
265
+ machine.sendv(fd, buf_status, buf_headers, chunk_header, it)
266
+ first = false
267
+ else
268
+ machine.sendv(fd, chunk_header, it)
269
+ end
270
+ end
271
+ machine.sendv(fd, first ? "0\r\n\r\n" : "\r\n0\r\n\r\n")
272
+ elsif body.respond_to?(:call)
273
+ machine.sendv(fd, buf_status, buf_headers)
274
+ chunked_stream = ChunkedIO.new(machine, fd, env['uma.stream'])
275
+ body.call(chunked_stream)
276
+ else
277
+ raise ResponseError, "Invalid response body: #{body.inspect}"
278
+ end
279
+
280
+ chunked_stream.close if chunked_stream
281
+ body.close if body.respond_to?(:close)
282
+ end
283
+ end
284
+
285
+ def format_headers(headers)
286
+ buf = +''
287
+ headers.each do |k, v|
288
+ next if k =~ /^rack\./
289
+
290
+ case v
291
+ when String
292
+ buf << "#{k}: #{v}\r\n"
293
+ when Array
294
+ v.each { buf << "#{k}: #{it}\r\n" }
295
+ else
296
+ raise ResponseError, "Invalid header value #{v.inspect}"
297
+ end
298
+ end
299
+ buf << "\r\n"
300
+ buf
301
+ end
302
+
303
+ def should_process_next_request?(env)
304
+ # TODO: look at env
305
+ true
306
+ end
307
+ end
308
+ end
data/lib/uma/server.rb ADDED
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uringmachine/fiber_scheduler'
4
+
5
+ module Uma
6
+
7
+ class Server
8
+ def initialize(env)
9
+ @env = env
10
+ end
11
+
12
+ def start
13
+ @machine = UM.new
14
+ @stop_queue = UM::Queue.new
15
+
16
+ @config = ServerControl.server_config(@env)
17
+ @threads = @config[:thread_count].times.map {
18
+ ServerControl.start_worker_thread(@config, @stop_queue)
19
+ }
20
+
21
+ ServerControl.await_process_termination(@machine)
22
+
23
+ stop
24
+ end
25
+
26
+ def stop
27
+ return if !@threads
28
+
29
+ @config[:thread_count].times { @machine.push(@stop_queue, :stop) }
30
+ @threads.each(&:join)
31
+ @threads = nil
32
+ end
33
+ end
34
+
35
+ module ServerControl
36
+ extend self
37
+
38
+ def server_config(env)
39
+ {
40
+ thread_count: 2,
41
+ bind_entries: env[:bind] ? bind_entries(env[:bind]) : [],
42
+ connection_proc: env[:connection_proc],
43
+ error_stream: env[:error_stream]
44
+ }
45
+ end
46
+
47
+ def bind_entries(bind_value)
48
+ case bind_value
49
+ when Array
50
+ bind_value.map { parse_bind_spec(it) }
51
+ when String
52
+ [parse_bind_spec(bind_value)]
53
+ else
54
+ raise ArgumentError, "invalid bind value"
55
+ end
56
+ end
57
+
58
+ BIND_SPEC_RE = /^(.+)\:(\d+)$/.freeze
59
+
60
+ def parse_bind_spec(spec)
61
+ if (m = spec.match(BIND_SPEC_RE))
62
+ [m[1], m[2].to_i]
63
+ else
64
+ raise ArgumentError, "Invalid bind spec"
65
+ end
66
+ end
67
+
68
+ def start_worker_thread(config, stop_queue)
69
+ Thread.new do
70
+ machine = UM.new
71
+ scheduler = UM::FiberScheduler.new(machine)
72
+ Fiber.set_scheduler(scheduler)
73
+
74
+ worker_thread(machine, config, stop_queue)
75
+ end
76
+ end
77
+
78
+ def worker_thread(machine, config, stop_queue)
79
+ connection_fibers = Set.new
80
+ accept_fibers = start_acceptors(machine, config, connection_fibers)
81
+
82
+ machine.shift(stop_queue)
83
+
84
+ worker_thread_graceful_stop(machine, accept_fibers, connection_fibers)
85
+ end
86
+
87
+ # @return [Set<Fiber>] a set of accept fibers
88
+ def start_acceptors(machine, config, connection_fibers)
89
+ set = Set.new
90
+ return set if !config[:bind_entries] || config[:bind_entries].empty?
91
+
92
+ config[:bind_entries].each do
93
+ host, port = it
94
+ set << machine.spin do
95
+ fd = prepare_listening_socket(machine, host, port)
96
+ machine.accept_each(fd) { |fd|
97
+ start_connection(machine, config, connection_fibers, fd)
98
+ }
99
+ rescue UM::Terminate
100
+ ensure
101
+ machine.close(fd)
102
+ end
103
+ end
104
+ set
105
+ end
106
+
107
+ def prepare_listening_socket(machine, host, port)
108
+ fd = machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
109
+ machine.setsockopt(fd, UM::SOL_SOCKET, UM::SO_REUSEPORT, true)
110
+ machine.bind(fd, host, port)
111
+ machine.listen(fd, UM::SOMAXCONN)
112
+ fd
113
+ end
114
+
115
+ # @return [Fiber]
116
+ def start_connection(machine, config, connection_fibers, fd)
117
+ f = machine.spin do
118
+ connection_fibers << f
119
+ config[:connection_proc]&.(machine, fd)
120
+ ensure
121
+ machine.close(fd) rescue nil
122
+ connection_fibers.delete(f)
123
+ end
124
+ end
125
+
126
+ def worker_thread_graceful_stop(machine, accept_fibers, connection_fibers)
127
+ # stop accepting connections
128
+ machine.terminate(accept_fibers)
129
+ machine.await_fibers(accept_fibers)
130
+
131
+ machine.terminate(connection_fibers)
132
+
133
+ # graceful stop with a timeout of 10 seconds
134
+ machine.timeout(10, UM::Terminate) do
135
+ machine.await_fibers(connection_fibers)
136
+ rescue UM::Terminate
137
+ alive = connection_fibers.reject(&:done?)
138
+ machine.terminate(alive)
139
+ machine.await_fibers(alive)
140
+ end
141
+ end
142
+
143
+ def await_process_termination(machine)
144
+ sig_queue = UM::Queue.new
145
+
146
+ old_term_handler = trap('SIGTERM') {
147
+ machine.push(sig_queue, :term)
148
+ trap('SIGTERM', old_term_handler)
149
+ }
150
+ old_int_handler = trap('SIGINT') {
151
+ machine.push(sig_queue, :int)
152
+ trap('SIGINT', old_int_handler)
153
+ }
154
+
155
+ machine.shift(sig_queue)
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Uma
4
+ VERSION = '0.1.0'
5
+ end
data/lib/uma.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uringmachine'
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ def t
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ run ->(env) { [200, {}, 'Hello from config.ru'] }
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roda"
4
+ require "irb"
5
+
6
+ # Demo app copied from Roda website:
7
+ # https://roda.jeremyevans.net/
8
+ class App < Roda
9
+ route do |r|
10
+ # GET / request
11
+ r.root do
12
+ r.redirect "/hello"
13
+ end
14
+
15
+ # /hello branch
16
+ r.on "hello" do
17
+ # Set variable for all routes in /hello branch
18
+ @greeting = 'Hello'
19
+
20
+ # GET /hello/world request
21
+ r.get "world" do
22
+ "#{@greeting} world!"
23
+ end
24
+
25
+ # /hello request
26
+ r.is do
27
+ # GET /hello request
28
+ r.get do
29
+ "#{@greeting}!"
30
+ end
31
+
32
+ # POST /hello request
33
+ r.post do
34
+ r.redirect
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ run App.freeze.app
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ run ->(env) { [200, {}, 'simple'] }
data/test/helper.rb ADDED
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require_relative './coverage' if ENV['COVERAGE']
5
+ require 'uma'
6
+ require 'minitest/autorun'
7
+ require 'securerandom'
8
+
9
+ STDOUT.sync = true
10
+ STDERR.sync = true
11
+
12
+ module ::Kernel
13
+ def debug(**h)
14
+ k, v = h.first
15
+ h.delete(k)
16
+
17
+ rest = h.inject(+'') { |s, (k, v)| s << " #{k}: #{v.inspect}\n" }
18
+ STDOUT.orig_write("#{k}=>#{v} #{caller[0]}\n#{rest}")
19
+ end
20
+
21
+ def trace(*args)
22
+ STDOUT.orig_write(format_trace(args))
23
+ end
24
+
25
+ def format_trace(args)
26
+ if args.first.is_a?(String)
27
+ if args.size > 1
28
+ format("%s: %p\n", args.shift, args)
29
+ else
30
+ format("%s\n", args.first)
31
+ end
32
+ else
33
+ format("%p\n", args.size == 1 ? args.first : args)
34
+ end
35
+ end
36
+
37
+ def monotonic_clock
38
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
39
+ end
40
+ end
41
+
42
+ module Minitest::Assertions
43
+ # def setup
44
+ # sleep 0.0001
45
+ # end
46
+
47
+ def assert_in_range exp_range, act
48
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
49
+ assert exp_range.include?(act), msg
50
+ end
51
+ end
52
+
53
+ class UMBaseTest < Minitest::Test
54
+ # pull in UM constants
55
+ UM.constants.each do |c|
56
+ v = UM.const_get(c)
57
+ const_set(c, v) if v.is_a?(Integer)
58
+ end
59
+
60
+ attr_accessor :machine
61
+
62
+ def setup
63
+ @machine = UM.new
64
+ @machine.test_mode = true
65
+ end
66
+
67
+ def teardown
68
+ return if !@machine
69
+
70
+ if ENV['UM_METRICS']
71
+ pp machine.metrics
72
+ end
73
+
74
+ pending_fibers = @machine.pending_fibers
75
+ raise "leaked fibers: #{pending_fibers}" if pending_fibers.size > 0
76
+
77
+ GC.start
78
+ end
79
+
80
+ def scheduler_calls_tally
81
+ @scheduler.calls.map { it[:sym] }.tally
82
+ end
83
+
84
+ @@port ||= 10001 + SecureRandom.rand(50000)
85
+
86
+ def random_port
87
+ @@port_assign_mutex ||= Mutex.new
88
+ @@port_assign_mutex.synchronize do
89
+ @@port += 1
90
+ end
91
+ end
92
+
93
+ def make_tmp_file_tree(dir, spec)
94
+ FileUtils.mkdir(dir) rescue nil
95
+ spec.each do |k, v|
96
+ fn = File.join(dir, k.to_s)
97
+ case v
98
+ when String
99
+ IO.write(fn, v)
100
+ when Hash
101
+ FileUtils.mkdir(fn) rescue nil
102
+ make_tmp_file_tree(fn, v)
103
+ end
104
+ end
105
+ dir
106
+ end
107
+ end
data/test/run.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob("#{__dir__}/test_*.rb").each do |path|
4
+ require(path)
5
+ end
data/test/test_app.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'uma/app'
5
+
6
+ class AppTest < UMBaseTest
7
+ App = Uma::App
8
+
9
+ def test_app
10
+ fn = File.join(__dir__, 'apps/simple.ru')
11
+ app = App.new(fn)
12
+
13
+ assert_kind_of App, app
14
+ assert_equal [200, {}, 'simple'], app.to_proc.({})
15
+ end
16
+
17
+ def test_app_bad_filename
18
+ fn = File.join(__dir__, 'apps/simple2.ru')
19
+ assert_raises(Uma::Error) { App.new(fn) }
20
+ end
21
+
22
+ def test_app_invalid_syntax
23
+ fn = File.join(__dir__, 'apps/bad_syntax.ru')
24
+ assert_raises(Uma::Error) { App.new(fn) }
25
+ end
26
+ end