tp2 0.3 → 0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 766b9b6217cf2428d6ec31f2e87b6f1c3f51c8b4380f74621123272dc83f123d
4
- data.tar.gz: 82f32a760e314bc187ea3ccc84fed2f0def4e15ed89057c6b52cd985c9e1601c
3
+ metadata.gz: a9c8c52c6ec2e5db6393b54f0ccf96010c9c626984ea943075f1675a078e9eae
4
+ data.tar.gz: 80cf94801b882350e72d50b21e9e004c544d239e3a05e04e99db4543e88deb73
5
5
  SHA512:
6
- metadata.gz: 228ecf9a5bc7283a541ea609a53028e027587d332b1b527aea66f38084f5247a0248ce06564906fc8c31f552070986590c665d3ae157789606133cb5ac3f5e64
7
- data.tar.gz: f52498515bcd2d088b2d8fe8e1079e3a741e0ac0b0f6a0753b0e365d04e8868a07c15bb77fa395b89128766f18c12dcf7ca3d638ee5a5e093c8c2f1c76dd932a
6
+ metadata.gz: 48484025b54dc3a7ea959c795ed6575d62eae1165f97ed5171343616af84da1fce9cc2e818f7e3fb540dab146b6f0bd8e1a076fba6050e03cb78cfd479615449
7
+ data.tar.gz: a3a65e353b9a5f4c1d4d3af7a4a6977f77ed7c38a1c10eb85c4a4afbbc6cc4a4c8e244be482c66b9e1ce6c61b52ffd0755959e3dff545d87b310011598baec9a
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Version 0.5 2025-05-05
2
+
3
+ - Implement graceful shutdown
4
+
5
+ # Version 0.4 2025-05-02
6
+
7
+ - Add adapter and server tests
8
+ - Remove http/parser dependency, use regexs to parse HTTP requests
data/Gemfile.lock CHANGED
@@ -1,25 +1,23 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- tp2 (0.3)
5
- http_parser.rb (= 0.8.0)
4
+ tp2 (0.5)
6
5
  qeweney (= 0.21)
7
- uringmachine (= 0.5.1)
6
+ uringmachine (= 0.8)
8
7
 
9
8
  GEM
10
9
  remote: https://rubygems.org/
11
10
  specs:
12
11
  docile (1.4.1)
13
12
  escape_utils (1.3.0)
14
- ffi (1.17.1-aarch64-linux-gnu)
15
- ffi (1.17.1-aarch64-linux-musl)
16
- ffi (1.17.1-arm-linux-gnu)
17
- ffi (1.17.1-arm-linux-musl)
18
- ffi (1.17.1-arm64-darwin)
19
- ffi (1.17.1-x86_64-darwin)
20
- ffi (1.17.1-x86_64-linux-gnu)
21
- ffi (1.17.1-x86_64-linux-musl)
22
- http_parser.rb (0.8.0)
13
+ ffi (1.17.2-aarch64-linux-gnu)
14
+ ffi (1.17.2-aarch64-linux-musl)
15
+ ffi (1.17.2-arm-linux-gnu)
16
+ ffi (1.17.2-arm-linux-musl)
17
+ ffi (1.17.2-arm64-darwin)
18
+ ffi (1.17.2-x86_64-darwin)
19
+ ffi (1.17.2-x86_64-linux-gnu)
20
+ ffi (1.17.2-x86_64-linux-musl)
23
21
  listen (3.9.0)
24
22
  rb-fsevent (~> 0.10, >= 0.10.3)
25
23
  rb-inotify (~> 0.9, >= 0.9.10)
@@ -38,7 +36,7 @@ GEM
38
36
  simplecov_json_formatter (~> 0.1)
39
37
  simplecov-html (0.13.1)
40
38
  simplecov_json_formatter (0.1.4)
41
- uringmachine (0.5.1)
39
+ uringmachine (0.8)
42
40
  yard (0.9.37)
43
41
 
44
42
  PLATFORMS
data/TODO.md CHANGED
@@ -1 +1,14 @@
1
- - Write some tests.
1
+ - Add failing tests for request bombs (requests with lots of bytes)
2
+ - Add limits for:
3
+ - total request size (pre body)
4
+ - line size
5
+ - request method size
6
+ - request path size
7
+ - header size
8
+ - limits expressed inside regexps done as follows:
9
+
10
+ ```ruby
11
+ MAX_HEADER_KEY_BYTES = 256
12
+
13
+ HEADER_RE = /^\s([^\s^\:]{1, #{MAX_HEADER_KEY_BYTES}}).../
14
+ ```
data/examples/simple.rb CHANGED
@@ -14,13 +14,21 @@ app = ->(req) {
14
14
 
15
15
  machine = UM.new
16
16
  server = TP2::Server.new(machine, '0.0.0.0', 1234, &app)
17
+ puts "Listening on port 1234..."
17
18
 
18
- machine.spin { server.run }
19
+ server_fiber = machine.spin { server.run }
19
20
 
20
- main = Fiber.current
21
- trap('SIGINT') { machine.schedule(main, nil) }
21
+ sig_queue = UM::Queue.new
22
+ trap('SIGINT') { machine.push(sig_queue, :SIGINT) }
22
23
 
23
24
  puts "Running... (pid: #{Process.pid})"
24
25
  STDOUT.flush
25
- machine.yield # wait for termination
26
+
27
+ # wait for signal
28
+ sig = machine.shift(sig_queue)
29
+
30
+ puts "Got signal (#{sig}), shutting down gracefully..."
31
+ machine.schedule(server_fiber, UM::Terminate.new)
32
+ machine.join(server_fiber)
33
+
26
34
  puts
@@ -1,69 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'http/parser'
4
3
  require 'qeweney'
4
+ require 'stringio'
5
5
 
6
6
  module TP2
7
- # Encapsulates a HTTP/1 connection
8
- class HTTP1Connection # rubocop:disable Metrics/ClassLength
9
- SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
7
+ class HTTP1Adapter
8
+ attr_reader :fd
10
9
 
11
- def initialize(machine, fd, bgid, &app) # rubocop:disable Naming/MethodParameterName
10
+ def initialize(machine, fd, &app)
12
11
  @machine = machine
13
12
  @fd = fd
14
- @bgid = bgid
15
- @parser = Http::Parser.new(self)
13
+ @buffer = String.new('', capacity: 4096)
14
+ @sio = StringIO.new(@buffer)
16
15
  @app = app
17
16
  end
18
17
 
19
- def on_headers_complete(headers) # rubocop:disable Metrics/MethodLength
20
- headers = normalize_headers(headers)
21
- headers[':path'] = @parser.request_url
22
- headers[':method'] = @parser.http_method.downcase
23
- headers[':scheme'] = get_scheme_from_headers(headers)
24
- headers[':protocol'] = "http/#{@parser.http_major}.#{@parser.http_minor}"
25
- @request = Qeweney::Request.new(headers, self)
26
- end
27
-
28
- def get_scheme_from_headers(headers)
29
- if (proto = headers['x-forwarded-proto'])
30
- proto.downcase
31
- else
32
- 'http'
18
+ def run
19
+ while true
20
+ persist = serve_request
21
+ break if !persist
33
22
  end
23
+ rescue UM::Terminate
24
+ # do nothing
25
+ rescue => e
26
+ puts '!' * 40
27
+ p e
28
+ puts e.backtrace.join("\n")
29
+ exit!
30
+ ensure
31
+ @machine.close(@fd)
34
32
  end
35
33
 
36
- def normalize_headers(headers)
37
- headers.each_with_object({}) do |(k, v), h|
38
- k = k.downcase
39
- hk = h[k]
40
- if hk
41
- hk = h[k] = [hk] unless hk.is_a?(Array)
42
- v.is_a?(Array) ? hk.concat(v) : hk << v
43
- else
44
- h[k] = v
45
- end
46
- end
47
- end
34
+ def serve_request
35
+ headers = parse_headers
36
+ return false if !headers
48
37
 
49
- def on_body(chunk)
50
- @request.buffer_body_chunk(chunk)
38
+ request = Qeweney::Request.new(headers, self)
39
+ @app.call(request)
40
+ persist_connection?(headers)
51
41
  end
52
42
 
53
- def on_message_complete
54
- @done = @request.headers[':protocol'] != 'http/1.1'
55
- @app.call(@request)
43
+ def get_body(req)
44
+ headers = req.headers
45
+ content_length = headers['content-length']
46
+ return read(content_length.to_i) if content_length
47
+
48
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
49
+ return get_body_chunked_encoding(headers) if chunked_encoding
50
+
51
+ # if content-length is not specified, we read to EOF, up to max 1MB size
52
+ read(1 << 20, nil, false)
56
53
  end
57
54
 
58
- def run
59
- @machine.recv_each(@fd, @bgid, 0) do |buf|
60
- @parser << buf
61
- break if @done
55
+ def get_body_chunk(req, buffered_only = false)
56
+ headers = req.headers
57
+ content_length = headers['content-length']
58
+ if content_length
59
+ return nil if headers[':body-done-reading']
60
+
61
+ chunk = read(content_length.to_i)
62
+ headers[':body-done-reading'] = true
63
+ return chunk
62
64
  end
63
- rescue SystemCallError, HTTP::Parser::Error
64
- # ignore error, just close the port silently
65
- ensure
66
- @machine.close(@fd)
65
+
66
+ chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
67
+ return read_chunk(headers, nil) if chunked_encoding
68
+
69
+ return nil if headers[':body-done-reading']
70
+
71
+ # if content-length is not specified, we read to EOF, up to max 1MB size
72
+ chunk = read(1 << 20, nil, false)
73
+ headers[':body-done-reading'] = true
74
+ chunk
75
+ end
76
+
77
+ def complete?(req)
78
+ req.headers[':body-done-reading']
67
79
  end
68
80
 
69
81
  # response API
@@ -72,6 +84,8 @@ module TP2
72
84
  ZERO_CRLF_CRLF = "0\r\n\r\n"
73
85
  CRLF_ZERO_CRLF_CRLF = "\r\n0\r\n\r\n"
74
86
 
87
+ SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
88
+
75
89
  # Sends response including headers and body. Waits for the request to complete
76
90
  # if not yet completed. The body is sent using chunked transfer encoding.
77
91
  # @param request [Qeweney::Request] HTTP request
@@ -128,6 +142,117 @@ module TP2
128
142
  @machine.send(@fd, ZERO_CRLF_CRLF, ZERO_CRLF_CRLF.bytesize, SEND_FLAGS)
129
143
  end
130
144
 
145
+ private
146
+
147
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+(http\/[0-9\.]{1,3})/i
148
+ RE_HEADER_LINE = /^([a-z0-9\-]+)\:\s+(.+)/i
149
+
150
+ class ProtocolError < StandardError
151
+ end
152
+
153
+ def persist_connection?(headers)
154
+ connection = headers['connection']&.downcase
155
+ if headers[':protocol'] == 'http/1.1'
156
+ return connection != 'close'
157
+ else
158
+ return connection && connection != 'close'
159
+ end
160
+ end
161
+
162
+ def parse_headers
163
+ headers = get_request_line
164
+ return nil if !headers
165
+
166
+ while true
167
+ line = get_line
168
+ break if line.nil? || line.empty?
169
+
170
+ m = line.match(RE_HEADER_LINE)
171
+ raise ProtocolError, 'Invalid header' if !m
172
+
173
+ headers[m[1].downcase] = m[2]
174
+ end
175
+
176
+ headers
177
+ end
178
+
179
+ def get_line
180
+ while true
181
+ line = @sio.gets(chomp: true)
182
+ return line if line
183
+
184
+ res = @machine.read(@fd, @buffer, 65536, -1)
185
+ return nil if res == 0
186
+ end
187
+ end
188
+
189
+ def get_request_line
190
+ line = get_line
191
+ return nil if !line
192
+
193
+ m = line.match(RE_REQUEST_LINE)
194
+ raise ProtocolError, 'Invalid request line' if !m
195
+
196
+ {
197
+ ':method' => m[1].downcase,
198
+ ':path' => m[2],
199
+ ':protocol' => m[3].downcase
200
+ }
201
+ end
202
+
203
+ def get_body_chunked_encoding(headers)
204
+ buf = String.new(capacity: 65536)
205
+ while read_chunk(headers, buf)
206
+ end
207
+
208
+ buf
209
+ end
210
+
211
+ def read(len, buf = nil, raise_on_eof = true)
212
+ from_sio = @sio.read(len)
213
+ if from_sio
214
+ left = len - from_sio&.bytesize
215
+ if buf
216
+ buf << from_sio
217
+ else
218
+ buf = +from_sio
219
+ end
220
+ else
221
+ left = len
222
+ buf ||= +''
223
+ end
224
+
225
+ while left > 0
226
+ res = @machine.read(@fd, buf, left, -1)
227
+ if res == 0
228
+ raise ProtocolError, "Incomplete body" if raise_on_eof
229
+
230
+ return buf
231
+ end
232
+
233
+
234
+ left -= res
235
+ end
236
+ buf
237
+ end
238
+
239
+ def read_chunk(headers, buf)
240
+ chunk_size = get_line
241
+ return nil if !chunk_size
242
+
243
+ chunk_size = chunk_size.to_i(16)
244
+ if chunk_size == 0
245
+ headers[':body-done-reading'] = true
246
+ get_line
247
+ return nil
248
+ end
249
+
250
+ chunk = read(chunk_size, buf)
251
+ get_line
252
+
253
+ chunk
254
+ end
255
+
131
256
  def http1_1?(request)
132
257
  request.headers[':protocol'] == 'http/1.1'
133
258
  end
data/lib/tp2/server.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tp2/http'
3
+ require 'tp2/http1_adapter'
4
4
  require 'tp2/request_extensions'
5
5
 
6
6
  module TP2
7
7
  class Server
8
+ PENDING_REQUESTS_GRACE_PERIOD = 0.1
9
+ PENDING_REQUESTS_TIMEOUT_PERIOD = 5
10
+
8
11
  def initialize(machine, hostname, port, &app)
9
12
  @machine = machine
10
13
  @hostname = hostname
@@ -12,22 +15,61 @@ module TP2
12
15
  @app = app
13
16
  end
14
17
 
18
+ def run
19
+ setup
20
+ accept_incoming
21
+ rescue UM::Terminate
22
+ graceful_shutdown
23
+ end
24
+
25
+ private
26
+
15
27
  def setup
16
28
  @server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
17
29
  @machine.setsockopt(@server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
18
30
  @machine.bind(@server_fd, @hostname, @port)
19
31
  @machine.listen(@server_fd, UM::SOMAXCONN)
20
32
 
21
- @bgid = @machine.setup_buffer_ring(4096, 1024)
22
- puts "Listening on #{@hostname}:#{@port}"
33
+ # map fibers
34
+ @fiber_map = {}
35
+
36
+ # puts "Listening on #{@hostname}:#{@port}"
23
37
  end
24
38
 
25
- def run
26
- setup
39
+ def accept_incoming
27
40
  @machine.accept_each(@server_fd) do |fd|
28
- conn = HTTP1Connection.new(@machine, fd, @bgid, &@app)
29
- @machine.spin(conn) { it.run }
41
+ conn = HTTP1Adapter.new(@machine, fd, &@app)
42
+ f = @machine.spin(conn) do
43
+ it.run
44
+ ensure
45
+ @fiber_map.delete(f)
46
+ end
47
+ @fiber_map[f] = true
48
+ end
49
+ end
50
+
51
+ def graceful_shutdown
52
+ # stop listening
53
+ @machine.close(@server_fd)
54
+
55
+ return if @fiber_map.empty?
56
+
57
+ # sleep for a bit, let requests finish
58
+ @machine.sleep(PENDING_REQUESTS_GRACE_PERIOD)
59
+ return if @fiber_map.empty?
60
+
61
+ # terminate pending fibers
62
+ pending = @fiber_map.keys
63
+ signal = UM::Terminate.new
64
+ pending.each { @machine.schedule(it, signal) }
65
+
66
+ @machine.timeout(PENDING_REQUESTS_TIMEOUT_PERIOD, UM::Terminate) do
67
+ @machine.join(*@fiber_map.keys)
68
+ rescue UM::Terminate
69
+ # timeout on waiting for adapters to finish running, do nothing
30
70
  end
71
+ ensure
72
+ @machine.close(@server_fd)
31
73
  end
32
74
  end
33
75
  end
data/lib/tp2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module TP2
2
- VERSION = '0.3'
2
+ VERSION = '0.5'
3
3
  end
data/test/helper.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'minitest/autorun'
5
+ require 'tp2'
6
+
7
+ require_relative './coverage' if ENV['COVERAGE']
8
+
9
+ module Kernel
10
+ def capture_exception
11
+ yield
12
+ rescue Exception => e
13
+ e
14
+ end
15
+
16
+ def trace(*args)
17
+ STDOUT.orig_write(format_trace(args))
18
+ end
19
+
20
+ def format_trace(args)
21
+ if args.first.is_a?(String)
22
+ if args.size > 1
23
+ format("%s: %p\n", args.shift, args)
24
+ else
25
+ format("%s\n", args.first)
26
+ end
27
+ else
28
+ format("%p\n", args.size == 1 ? args.first : args)
29
+ end
30
+ end
31
+ end
32
+
33
+ module Minitest::Assertions
34
+ def assert_in_range exp_range, act
35
+ msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
36
+ assert exp_range.include?(act), msg
37
+ end
38
+ 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
@@ -0,0 +1,532 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+
5
+ class String
6
+ def crlf_lines
7
+ chomp.gsub("\n", "\r\n").chomp
8
+ end
9
+ end
10
+
11
+ class HTTP1AdapterTest < Minitest::Test
12
+ def make_socket_pair
13
+ port = 10000 + rand(30000)
14
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
15
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
16
+ @machine.bind(server_fd, '127.0.0.1', port)
17
+ @machine.listen(server_fd, UM::SOMAXCONN)
18
+
19
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
20
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
21
+
22
+ server_conn_fd = @machine.accept(server_fd)
23
+
24
+ @machine.close(server_fd)
25
+ [client_conn_fd, server_conn_fd]
26
+ end
27
+
28
+ def setup
29
+ @machine = UM.new
30
+ @c_fd, @s_fd = make_socket_pair
31
+ @reqs = []
32
+ @hook = nil
33
+ @app = ->(req) { @hook&.call(req); @reqs << req }
34
+ @adapter = TP2::HTTP1Adapter.new(@machine, @s_fd, &@app)
35
+ end
36
+
37
+ def teardown
38
+ @machine.close(@c_fd) rescue nil
39
+ @machine.close(@s_fd) rescue nil
40
+ end
41
+
42
+ def write_http_request(msg, shutdown_wr = true)
43
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
44
+ @machine.shutdown(@c_fd, UM::SHUT_WR) if shutdown_wr
45
+ end
46
+
47
+ def write_client_side(msg)
48
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
49
+ end
50
+
51
+ def read_client_side(len = 65536)
52
+ buf = +''
53
+ res = @machine.recv(@c_fd, buf, len, 0)
54
+ res == 0 ? nil : buf
55
+ end
56
+
57
+ def test_basic_request_parsing
58
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
59
+
60
+ @adapter.serve_request
61
+ assert_equal 1, @reqs.size
62
+ req = @reqs.shift
63
+ headers = req.headers
64
+ assert_equal({
65
+ ':method' => 'get',
66
+ ':path' => '/',
67
+ ':protocol' => 'http/1.0'
68
+ }, headers)
69
+ end
70
+
71
+ def test_pipelined_requests
72
+ write_http_request <<~HTTP.crlf_lines
73
+ GET /foo HTTP/1.1
74
+ Server: foo.com
75
+
76
+ SCHMET /bar HTTP/1.1
77
+
78
+ HTTP
79
+
80
+ @adapter.run
81
+ assert_equal 2, @reqs.size
82
+ req0 = @reqs.shift
83
+ headers = req0.headers
84
+ assert_equal({
85
+ ':method' => 'get',
86
+ ':path' => '/foo',
87
+ ':protocol' => 'http/1.1',
88
+ 'server' => 'foo.com'
89
+ }, headers)
90
+
91
+ req1 = @reqs.shift
92
+ headers = req1.headers
93
+ assert_equal({
94
+ ':method' => 'schmet',
95
+ ':path' => '/bar',
96
+ ':protocol' => 'http/1.1'
97
+ }, headers)
98
+ end
99
+
100
+ def test_pipelined_requests_with_body
101
+ write_http_request <<~HTTP.crlf_lines
102
+ POST /foo HTTP/1.1
103
+ Server: foo.com
104
+ Content-Length: 3
105
+
106
+ abcSCHMOST /bar HTTP/1.1
107
+ Server: bar.com
108
+ Content-Length: 6
109
+
110
+ defghi
111
+ HTTP
112
+
113
+ @bodies = []
114
+ @hook = ->(req) { @bodies << req.read }
115
+
116
+ @adapter.run
117
+ assert_equal 2, @reqs.size
118
+
119
+ req0 = @reqs.shift
120
+ headers = req0.headers
121
+ assert_equal({
122
+ ':method' => 'post',
123
+ ':path' => '/foo',
124
+ ':protocol' => 'http/1.1',
125
+ 'server' => 'foo.com',
126
+ 'content-length' => '3'
127
+ }, headers)
128
+ body = @bodies.shift
129
+ assert_equal 'abc', body
130
+
131
+ req1 = @reqs.shift
132
+ headers = req1.headers
133
+ assert_equal({
134
+ ':method' => 'schmost',
135
+ ':path' => '/bar',
136
+ ':protocol' => 'http/1.1',
137
+ 'server' => 'bar.com',
138
+ 'content-length' => '6'
139
+ }, headers)
140
+ body = @bodies.shift
141
+ assert_equal 'defghi', body
142
+ end
143
+
144
+ def test_pipelined_requests_with_body_chunked
145
+ write_http_request <<~HTTP.crlf_lines
146
+ POST /foo HTTP/1.1
147
+ Server: foo.com
148
+ Transfer-Encoding: chunked
149
+
150
+ 3
151
+ abc
152
+ 2
153
+ de
154
+ 0
155
+
156
+ SCHMOST /bar HTTP/1.1
157
+ Server: bar.com
158
+ Transfer-Encoding: chunked
159
+
160
+ 1f
161
+ 123456789abcdefghijklmnopqrstuv
162
+ 0
163
+
164
+ HTTP
165
+
166
+ @bodies = []
167
+ @hook = ->(req) { @bodies << req.read }
168
+
169
+ @adapter.run
170
+ assert_equal 2, @reqs.size
171
+
172
+ req0 = @reqs.shift
173
+ headers = req0.headers
174
+ assert_equal({
175
+ ':method' => 'post',
176
+ ':path' => '/foo',
177
+ ':protocol' => 'http/1.1',
178
+ 'server' => 'foo.com',
179
+ 'transfer-encoding' => 'chunked',
180
+ ':body-done-reading' => true
181
+ }, headers)
182
+ body = @bodies.shift
183
+ assert_equal 'abcde', body
184
+
185
+ req1 = @reqs.shift
186
+ headers = req1.headers
187
+ assert_equal({
188
+ ':method' => 'schmost',
189
+ ':path' => '/bar',
190
+ ':protocol' => 'http/1.1',
191
+ 'server' => 'bar.com',
192
+ 'transfer-encoding' => 'chunked',
193
+ ':body-done-reading' => true
194
+ }, headers)
195
+ body = @bodies.shift
196
+ assert_equal '123456789abcdefghijklmnopqrstuv', body
197
+ end
198
+
199
+ def test_body_to_eof
200
+ write_http_request <<~HTTP.crlf_lines
201
+ POST /foo HTTP/1.1
202
+ Server: foo.com
203
+
204
+ barbaz
205
+ HTTP
206
+
207
+ @bodies = []
208
+ @hook = ->(req) { @bodies << req.read }
209
+
210
+ @adapter.run
211
+ assert_equal 1, @reqs.size
212
+
213
+ req0 = @reqs.shift
214
+ headers = req0.headers
215
+ assert_equal({
216
+ ':method' => 'post',
217
+ ':path' => '/foo',
218
+ ':protocol' => 'http/1.1',
219
+ 'server' => 'foo.com'
220
+ }, headers)
221
+ body = @bodies.shift
222
+ assert_equal 'barbaz', body
223
+ end
224
+
225
+ def test_each_chunk
226
+ write_http_request <<~HTTP.crlf_lines
227
+ POST /foo HTTP/1.1
228
+ Server: foo.com
229
+ Transfer-Encoding: chunked
230
+
231
+ 3
232
+ abc
233
+ 2
234
+ de
235
+ 0
236
+
237
+ SCHMOST /bar HTTP/1.1
238
+ Server: bar.com
239
+ Content-Length: 31
240
+
241
+ 123456789abcdefghijklmnopqrstuv
242
+ HTTP
243
+
244
+ chunks = []
245
+ @hook = ->(req) { req.each_chunk { chunks << it } }
246
+
247
+ @adapter.serve_request
248
+ assert_equal 1, @reqs.size
249
+
250
+ req0 = @reqs.shift
251
+ headers = req0.headers
252
+ assert_equal({
253
+ ':method' => 'post',
254
+ ':path' => '/foo',
255
+ ':protocol' => 'http/1.1',
256
+ 'server' => 'foo.com',
257
+ 'transfer-encoding' => 'chunked',
258
+ ':body-done-reading' => true
259
+ }, headers)
260
+ assert_equal ['abc', 'de'], chunks
261
+
262
+ chunks.clear
263
+ @adapter.serve_request
264
+ assert_equal 1, @reqs.size
265
+
266
+ req1 = @reqs.shift
267
+ headers = req1.headers
268
+ assert_equal({
269
+ ':method' => 'schmost',
270
+ ':path' => '/bar',
271
+ ':protocol' => 'http/1.1',
272
+ 'server' => 'bar.com',
273
+ 'content-length' => '31',
274
+ ':body-done-reading' => true
275
+ }, headers)
276
+ assert_equal ['123456789abcdefghijklmnopqrstuv'], chunks
277
+ end
278
+
279
+ def test_that_server_uses_content_length_in_http_1_0
280
+ @hook = ->(req) {
281
+ req.respond('Hello, world!', {})
282
+ }
283
+
284
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
285
+ @adapter.run
286
+ response = read_client_side
287
+
288
+ expected = <<~HTTP.crlf_lines
289
+ HTTP/1.1 200
290
+ Content-Length: 13
291
+
292
+ Hello, world!
293
+ HTTP
294
+ assert_equal(expected, response)
295
+ end
296
+
297
+ def test_204_status_on_empty_response
298
+ @hook = ->(req) {
299
+ req.respond(nil, {})
300
+ }
301
+
302
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
303
+ @adapter.run
304
+ response = read_client_side
305
+
306
+ expected = <<~HTTP.crlf_lines
307
+ HTTP/1.1 204
308
+
309
+
310
+
311
+ HTTP
312
+ assert_equal(expected, response)
313
+
314
+ end
315
+
316
+ def test_that_server_uses_chunked_encoding_in_http_1_1
317
+ @hook = ->(req) {
318
+ req.respond('Hello, world!')
319
+ }
320
+
321
+ # using HTTP 1.0, server should close connection after responding
322
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
323
+ @adapter.run
324
+
325
+ response = read_client_side
326
+ expected = <<~HTTP.crlf_lines.chomp
327
+ HTTP/1.1 200
328
+ Content-Length: 13
329
+
330
+ Hello, world!
331
+ HTTP
332
+ assert_equal(expected, response)
333
+ end
334
+
335
+ def test_that_server_maintains_connection_when_using_keep_alives
336
+ @hook = ->(req) {
337
+ req.respond('Hi', {})
338
+ }
339
+
340
+ write_http_request "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n", false
341
+ res = @adapter.serve_request
342
+ assert_equal true, res
343
+
344
+ response = read_client_side
345
+ assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
346
+
347
+ write_http_request "GET / HTTP/1.1\r\n\r\n", false
348
+ res = @adapter.serve_request
349
+ assert_equal true, res
350
+
351
+ response = read_client_side
352
+ expected = <<~HTTP.crlf_lines
353
+ HTTP/1.1 200
354
+ Content-Length: 2
355
+
356
+ Hi
357
+ HTTP
358
+ assert_equal(expected, response)
359
+
360
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
361
+ res = @adapter.serve_request
362
+ assert_equal false, !!res
363
+
364
+ response = read_client_side
365
+ assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
366
+ end
367
+
368
+ def test_pipelining_client
369
+ @hook = ->(req) {
370
+ if req.headers['foo'] == 'bar'
371
+ req.respond('Hello, foobar!', {})
372
+ else
373
+ req.respond('Hello, world!', {})
374
+ end
375
+
376
+ }
377
+
378
+ write_http_request "GET / HTTP/1.1\r\n\r\nGET / HTTP/1.1\r\nFoo: bar\r\n\r\n"
379
+ @adapter.run
380
+ response = read_client_side
381
+
382
+ expected = <<~HTTP.crlf_lines.chomp
383
+ HTTP/1.1 200
384
+ Content-Length: 13
385
+
386
+ Hello, world!HTTP/1.1 200
387
+ Content-Length: 14
388
+
389
+ Hello, foobar!
390
+ HTTP
391
+ assert_equal(expected, response)
392
+ end
393
+
394
+ def test_body_chunks
395
+ chunks = []
396
+ request = nil
397
+
398
+ @hook = ->(req) {
399
+ request = req
400
+ req.send_headers
401
+ req.each_chunk do |c|
402
+ chunks << c
403
+ req << c.upcase
404
+ end
405
+ req.finish
406
+ }
407
+
408
+ msg = "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n"
409
+ write_http_request msg, false
410
+ @machine.spin { @adapter.serve_request rescue nil }
411
+ @machine.sleep(0.01)
412
+
413
+ assert request
414
+ assert_equal %w[foobar], chunks
415
+ assert !request.complete?
416
+
417
+ write_http_request "6\r\nbazbud\r\n", false
418
+ @machine.sleep(0.01)
419
+ assert_equal %w[foobar bazbud], chunks
420
+ assert !request.complete?
421
+
422
+ write_http_request "0\r\n\r\n"
423
+ @machine.sleep(0.01)
424
+ assert_equal %w[foobar bazbud], chunks
425
+ assert request.complete?
426
+
427
+ @machine.sleep(0.01)
428
+ response = read_client_side
429
+
430
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nFOOBAR\r\n6\r\nBAZBUD\r\n0\r\n\r\n"
431
+ assert_equal(expected, response)
432
+ end
433
+
434
+ def test_upgrade
435
+ done = nil
436
+
437
+ @hook = ->(req) do
438
+ return if req.upgrade_protocol != 'echo'
439
+
440
+ req.upgrade(:echo)
441
+ @machine.sleep(0.01)
442
+ buf = +''
443
+ while true
444
+ res = @machine.recv(@s_fd, buf, 4096, 0)
445
+ break if res == 0
446
+
447
+ res = @machine.send(@s_fd, buf, res, 0)
448
+ end
449
+ done = true
450
+ rescue Exception > e
451
+ p e
452
+ p e.backtrace.join("\n")
453
+ end
454
+
455
+ opts = {
456
+ upgrade: {
457
+ echo: lambda do |adapter, _headers|
458
+ conn = adapter.conn
459
+ conn << <<~HTTP.crlf_lines
460
+ HTTP/1.1 101 Switching Protocols
461
+ Upgrade: echo
462
+ Connection: Upgrade
463
+
464
+ HTTP
465
+
466
+ conn.read_loop { |data| conn << data }
467
+ done = true
468
+ end
469
+ }
470
+ }
471
+
472
+ msg = "GET / HTTP/1.1\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
473
+ write_http_request(msg, false)
474
+ @machine.spin { @adapter.serve_request rescue nil }
475
+ @machine.sleep(0.01)
476
+
477
+ response = read_client_side
478
+ expected = "HTTP/1.1 101\r\nContent-Length: 0\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
479
+ assert_equal(expected, response)
480
+
481
+ assert !done
482
+
483
+ write_client_side 'foo'
484
+ assert_equal 'foo', read_client_side
485
+
486
+ write_client_side 'bar'
487
+ assert_equal 'bar', read_client_side
488
+
489
+ @machine.close(@c_fd)
490
+ assert !done
491
+
492
+ @machine.sleep(0.01)
493
+ assert done
494
+ end
495
+
496
+ def test_big_download
497
+ chunk_size = 1000
498
+ chunk_count = 1000
499
+ chunk = '*' * chunk_size
500
+
501
+ @hook = ->(req) do
502
+ req.send_headers
503
+ chunk_count.times do |i|
504
+ req << chunk
505
+ @machine.snooze
506
+ end
507
+ req.finish
508
+ @machine.close(@s_fd)
509
+ rescue Exception => e
510
+ p e
511
+ p e.backtrace.join("\n")
512
+ end
513
+
514
+ response = +''
515
+ count = 0
516
+
517
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
518
+ @machine.spin { @adapter.serve_request }
519
+
520
+ while (data = read_client_side(chunk_size))
521
+ response << data
522
+ count += 1
523
+ @machine.snooze
524
+ end
525
+
526
+ chunks = "#{chunk_size.to_s(16)}\r\n#{'*' * chunk_size}\r\n" * chunk_count
527
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n#{chunks}0\r\n\r\n"
528
+
529
+ assert_equal expected, response
530
+ assert count >= chunk_count
531
+ end
532
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+
5
+ class String
6
+ end
7
+
8
+ class ServerTest < Minitest::Test
9
+ def make_socket_pair
10
+ port = 10000 + rand(30000)
11
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
12
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
13
+ @machine.bind(server_fd, '127.0.0.1', port)
14
+ @machine.listen(server_fd, UM::SOMAXCONN)
15
+
16
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
17
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
18
+
19
+ server_conn_fd = @machine.accept(server_fd)
20
+
21
+ @machine.close(server_fd)
22
+ [client_conn_fd, server_conn_fd]
23
+ end
24
+
25
+ class STOP < StandardError
26
+ end
27
+
28
+ def setup
29
+ @machine = UM.new
30
+ @port = 10000 + rand(30000)
31
+ @server = TP2::Server.new(@machine, '127.0.0.1', @port) { @app&.call(it) }
32
+ @f_server = @machine.spin do
33
+ @server.run
34
+ rescue STOP
35
+ ensure
36
+ @server_done = true
37
+ end
38
+
39
+ @machine.sleep(0.01)
40
+ @client_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
41
+ @machine.connect(@client_fd, '127.0.0.1', @port)
42
+ end
43
+
44
+ def teardown
45
+ @machine.close(@client_fd) rescue nil
46
+ @machine.schedule(@f_server, STOP.new)
47
+ @machine.snooze until @server_done
48
+ end
49
+
50
+ def write_http_request(msg, shutdown_wr = true)
51
+ @machine.send(@client_fd, msg, msg.bytesize, UM::MSG_WAITALL)
52
+ @machine.shutdown(@client_fd, UM::SHUT_WR) if shutdown_wr
53
+ end
54
+
55
+ def write_client_side(msg)
56
+ @machine.send(@client_fd, msg, msg.bytesize, UM::MSG_WAITALL)
57
+ end
58
+
59
+ def read_client_side(len = 65536)
60
+ buf = +''
61
+ res = @machine.recv(@client_fd, buf, len, 0)
62
+ res == 0 ? nil : buf
63
+ end
64
+
65
+ def test_basic_app_response
66
+ @app = ->(req) {
67
+ req.respond('Hello, world!', {})
68
+ }
69
+
70
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
71
+ response = read_client_side
72
+ expected = "HTTP/1.1 200\r\nContent-Length: 13\r\n\r\nHello, world!"
73
+ assert_equal(expected, response)
74
+ end
75
+
76
+ def test_pipelined_requests
77
+ @app = ->(req) {
78
+ req.respond("method: #{req.method}")
79
+ }
80
+
81
+ write_http_request "GET /foo HTTP/1.1\r\nServer: foo.com\r\n\r\nSCHMET /bar HTTP/1.1\r\n\r\n"
82
+
83
+ response = read_client_side
84
+ expected = "HTTP/1.1 200\r\nContent-Length: 11\r\n\r\nmethod: getHTTP/1.1 200\r\nContent-Length: 14\r\n\r\nmethod: schmet"
85
+ assert_equal(expected, response)
86
+ end
87
+
88
+ def test_graceful_shutdown
89
+ @app = ->(req) do
90
+ @machine.sleep(1)
91
+ req.respond('Hello, world!', {})
92
+ rescue UM::Terminate
93
+ req.respond('Terminated!', {})
94
+ raise
95
+ end
96
+
97
+ write_http_request "GET /foo HTTP/1.1\r\nServer: foo.com\r\n\r\nSCHMET /bar HTTP/1.1\r\n\r\n"
98
+
99
+ @machine.sleep(0.01)
100
+ @machine.schedule(@f_server, UM::Terminate.new)
101
+ @machine.snooze
102
+
103
+ response = read_client_side
104
+ expected = "HTTP/1.1 200\r\nContent-Length: 11\r\n\r\nTerminated!"
105
+ assert_equal(expected, response)
106
+ end
107
+
108
+ def test_pipelined_requests_with_body
109
+ skip
110
+
111
+ write_http_request <<~HTTP.crlf_lines
112
+ POST /foo HTTP/1.1
113
+ Server: foo.com
114
+ Content-Length: 3
115
+
116
+ abcSCHMOST /bar HTTP/1.1
117
+ Server: bar.com
118
+ Content-Length: 6
119
+
120
+ defghi
121
+ HTTP
122
+
123
+ @bodies = []
124
+ @hook = ->(req) { @bodies << req.read }
125
+
126
+ @adapter.run
127
+ assert_equal 2, @reqs.size
128
+
129
+ req0 = @reqs.shift
130
+ headers = req0.headers
131
+ assert_equal({
132
+ ':method' => 'post',
133
+ ':path' => '/foo',
134
+ ':protocol' => 'http/1.1',
135
+ 'server' => 'foo.com',
136
+ 'content-length' => '3'
137
+ }, headers)
138
+ body = @bodies.shift
139
+ assert_equal 'abc', body
140
+
141
+ req1 = @reqs.shift
142
+ headers = req1.headers
143
+ assert_equal({
144
+ ':method' => 'schmost',
145
+ ':path' => '/bar',
146
+ ':protocol' => 'http/1.1',
147
+ 'server' => 'bar.com',
148
+ 'content-length' => '6'
149
+ }, headers)
150
+ body = @bodies.shift
151
+ assert_equal 'defghi', body
152
+ end
153
+
154
+ def test_pipelined_requests_with_body_chunked
155
+ skip
156
+
157
+ write_http_request <<~HTTP.crlf_lines
158
+ POST /foo HTTP/1.1
159
+ Server: foo.com
160
+ Transfer-Encoding: chunked
161
+
162
+ 3
163
+ abc
164
+ 2
165
+ de
166
+ 0
167
+
168
+ SCHMOST /bar HTTP/1.1
169
+ Server: bar.com
170
+ Transfer-Encoding: chunked
171
+
172
+ 1f
173
+ 123456789abcdefghijklmnopqrstuv
174
+ 0
175
+
176
+ HTTP
177
+
178
+ @bodies = []
179
+ @hook = ->(req) { @bodies << req.read }
180
+
181
+ @adapter.run
182
+ assert_equal 2, @reqs.size
183
+
184
+ req0 = @reqs.shift
185
+ headers = req0.headers
186
+ assert_equal({
187
+ ':method' => 'post',
188
+ ':path' => '/foo',
189
+ ':protocol' => 'http/1.1',
190
+ 'server' => 'foo.com',
191
+ 'transfer-encoding' => 'chunked',
192
+ ':body-done-reading' => true
193
+ }, headers)
194
+ body = @bodies.shift
195
+ assert_equal 'abcde', body
196
+
197
+ req1 = @reqs.shift
198
+ headers = req1.headers
199
+ assert_equal({
200
+ ':method' => 'schmost',
201
+ ':path' => '/bar',
202
+ ':protocol' => 'http/1.1',
203
+ 'server' => 'bar.com',
204
+ 'transfer-encoding' => 'chunked',
205
+ ':body-done-reading' => true
206
+ }, headers)
207
+ body = @bodies.shift
208
+ assert_equal '123456789abcdefghijklmnopqrstuv', body
209
+ end
210
+
211
+ def test_body_to_eof
212
+ skip
213
+
214
+ write_http_request <<~HTTP.crlf_lines
215
+ POST /foo HTTP/1.1
216
+ Server: foo.com
217
+
218
+ barbaz
219
+ HTTP
220
+
221
+ @bodies = []
222
+ @hook = ->(req) { @bodies << req.read }
223
+
224
+ @adapter.run
225
+ assert_equal 1, @reqs.size
226
+
227
+ req0 = @reqs.shift
228
+ headers = req0.headers
229
+ assert_equal({
230
+ ':method' => 'post',
231
+ ':path' => '/foo',
232
+ ':protocol' => 'http/1.1',
233
+ 'server' => 'foo.com'
234
+ }, headers)
235
+ body = @bodies.shift
236
+ assert_equal 'barbaz', body
237
+ end
238
+ end
data/tp2.gemspec CHANGED
@@ -19,7 +19,6 @@ Gem::Specification.new do |s|
19
19
  s.require_paths = ['lib']
20
20
  s.required_ruby_version = '>= 3.4'
21
21
 
22
- s.add_dependency 'http_parser.rb', '0.8.0'
23
- s.add_dependency 'uringmachine', '0.5.1'
22
+ s.add_dependency 'uringmachine', '0.8'
24
23
  s.add_dependency 'qeweney', '0.21'
25
24
  end
metadata CHANGED
@@ -1,42 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tp2
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.3'
4
+ version: '0.5'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: http_parser.rb
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - '='
17
- - !ruby/object:Gem::Version
18
- version: 0.8.0
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - '='
24
- - !ruby/object:Gem::Version
25
- version: 0.8.0
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: uringmachine
28
14
  requirement: !ruby/object:Gem::Requirement
29
15
  requirements:
30
16
  - - '='
31
17
  - !ruby/object:Gem::Version
32
- version: 0.5.1
18
+ version: '0.8'
33
19
  type: :runtime
34
20
  prerelease: false
35
21
  version_requirements: !ruby/object:Gem::Requirement
36
22
  requirements:
37
23
  - - '='
38
24
  - !ruby/object:Gem::Version
39
- version: 0.5.1
25
+ version: '0.8'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: qeweney
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -58,6 +44,7 @@ extra_rdoc_files:
58
44
  - README.md
59
45
  files:
60
46
  - ".gitignore"
47
+ - CHANGELOG.md
61
48
  - Gemfile
62
49
  - Gemfile.lock
63
50
  - README.md
@@ -65,10 +52,14 @@ files:
65
52
  - TODO.md
66
53
  - examples/simple.rb
67
54
  - lib/tp2.rb
68
- - lib/tp2/http.rb
55
+ - lib/tp2/http1_adapter.rb
69
56
  - lib/tp2/request_extensions.rb
70
57
  - lib/tp2/server.rb
71
58
  - lib/tp2/version.rb
59
+ - test/helper.rb
60
+ - test/run.rb
61
+ - test/test_http1_adapter.rb
62
+ - test/test_server.rb
72
63
  - tp2.gemspec
73
64
  homepage: https://github.com/noteflakes/tp2
74
65
  licenses:
@@ -95,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
86
  - !ruby/object:Gem::Version
96
87
  version: '0'
97
88
  requirements: []
98
- rubygems_version: 3.6.2
89
+ rubygems_version: 3.6.8
99
90
  specification_version: 4
100
91
  summary: Experimental HTTP/1 server for UringMachine
101
92
  test_files: []