tp2 0.15 → 0.17

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: 7c0c245bd1ea541ba0cb2e6c89dabd928c11ecc0e9bba1fe27f0ae69b2ea67c8
4
- data.tar.gz: 4c0a8cd775703e03a2cce80f0727f62f39b3c6f171a2356586092ca97b510490
3
+ metadata.gz: 7f064819fba5d42c4376f3895ee0cce07745149e6b9ed06e864acb1ac3515d9e
4
+ data.tar.gz: 84767b1d2cd87c6006ba7202f48c2bf1cdc9345ddf258c8fcd716bcf2023c264
5
5
  SHA512:
6
- metadata.gz: 847c5fa38441c0af9ff06db44b4259edcf2990e8d017cd7a0c706e8ecd6f95a1439bd5c13ae6509c2f1a67fdbbe6002b5c9e9bba7332b0a3c83f8941546c68d1
7
- data.tar.gz: 7b41d103bc5235de576efaaa7c69d5309c837ce604c7d15b6c0e316459e5c5ade0a4dfb7073701edf42b0e11134cc1e179ac072d581aceb872453d2f0131c48e
6
+ metadata.gz: 72b97e276e4abb593566d761f44ac7bfa0a9af5d9ff5dcbece407412dd07eac84a9dfbe6d983c832010fb9a365be27053df471d2b9e76a2955b7332a33b06e69
7
+ data.tar.gz: 6a13102f1f2008b32aa34fec7da9b2cda496e19f5445525f398b6c4f0a863b15bb68bf94b457e56f0b3c6ace41d3caca4722158ea69fc638d6a828f39e047bbd
data/.gitignore CHANGED
@@ -54,3 +54,4 @@ Gemfile.lock
54
54
 
55
55
  # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
56
  # .rubocop-https?--*
57
+ *.json
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ # 0.17 2025-09-14
2
+
3
+ - Add support for server extensions: `Date`, `Server` headers
4
+ - Add `Request#set_response_headers` for injecting response headers
5
+ - Add support for env[:server_headers]
6
+
7
+ # 0.16 2025-09-11
8
+
9
+ - Remove support for HTTP/0.9, HTTP/1.0, use chunked transfer encoding
10
+ exclusively for HTTP responses
11
+ - Remove call to app.ready
12
+
1
13
  # 0.15 2025-08-30
2
14
 
3
15
  - Call app.ready after setting up, if method available
data/README.md CHANGED
@@ -1,4 +1,10 @@
1
- # TP2
1
+ <p align="center"><img src="tp2-logo.png" /></p>
2
+
3
+ # TP2 - a io_uring-based app server for Ruby
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/tp2.svg)](http://rubygems.org/gems/tp2)
6
+ [![Tests](https://github.com/noteflakes/tp2/actions/workflows/test.yml/badge.svg)](https://github.com/noteflakes/tp2/actions/workflows/test.yml)
7
+ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/digital-fabric/tp2/blob/master/LICENSE)
2
8
 
3
9
  TP2 is an experimental HTTP server based on
4
10
  [UringMachine](https://github.com/digital-fabric/uringmachine) and [Qeweney](https://github.com/digital-fabric/qeweney).
data/TODO.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## Immediate
2
+
3
+ - [v] Remove support for HTTP/0.9, HTTP/1.0
4
+ - [v] Reply with 505 HTTP version not supported:
5
+
6
+ ```
7
+ < GET / HTTP/0.9
8
+
9
+ > HTTP/1.1 505
10
+ > Connection: close
11
+ ```
12
+
13
+ - [v] Use chunked transfer encoding exclusively
14
+ - [ ] Cache rendered headers
15
+ - [ ] Look at sketch in ~/Desktop/docs/
16
+
17
+ - [ ] Add options for:
18
+ - [ ] Date header
19
+ - [ ] Server header
20
+
21
+ ##
22
+
1
23
  - Add failing tests for request bombs (requests with lots of bytes)
2
24
  - Add test for `ProtocolError` handling
3
25
  - Add limits with tests for:
@@ -2,16 +2,23 @@
2
2
 
3
3
  require 'qeweney'
4
4
  require 'stringio'
5
+ require 'tp2/errors'
5
6
 
6
7
  module TP2
7
- class HTTP1Connection
8
- attr_reader :fd, :response_headers
9
-
10
- def initialize(machine, fd, opts, &app)
8
+ # Implements an HTTP/1.1 connection received by the TP2 server. This
9
+ # implementation rejects incoming HTTP/0.9 or HTTP/1.0 requests. The response
10
+ # body is sent exclusively using chunked transfer encoding. Request bodies are
11
+ # accepted using either fixed length (Content-Length header) or chunked
12
+ # transfer encoding.
13
+ class Connection
14
+ attr_reader :fd, :response_headers, :logger
15
+
16
+ def initialize(server, machine, fd, env, &app)
17
+ @server = server
11
18
  @machine = machine
12
19
  @fd = fd
13
- @opts = opts
14
- @logger = opts[:logger]
20
+ @env = env
21
+ @logger = env[:logger]
15
22
  @stream = UM::Stream.new(machine, fd)
16
23
  @app = app
17
24
 
@@ -37,37 +44,52 @@ module TP2
37
44
  @machine.close_async(@fd)
38
45
  end
39
46
 
40
- # Returns true if connection should persist
47
+ # Processes an incoming request by parsing the headers, creating a request
48
+ # object and handing it off to the app handler. Returns true if the
49
+ # connection should be persisted.
41
50
  def serve_request
42
51
  headers = parse_headers
43
52
  return false if !headers
44
53
 
45
54
  request = Qeweney::Request.new(headers, self)
55
+
46
56
  request.start_stamp = monotonic_clock
47
57
  @app.call(request)
48
58
  persist_connection?(headers)
49
- rescue ProtocolError => e
50
- @logger&.error(
51
- message: 'Protocol error, closing connection',
52
- error: e
53
- )
54
- false
55
- rescue SystemCallError => e
56
- @logger&.error(
57
- message: 'I/O error, closing connection',
58
- error: e
59
- )
60
- false
61
59
  rescue StandardError => e
62
- @logger&.error(
63
- message: 'Internal error while serving request, abandoning connection',
64
- error: e
65
- )
60
+ handle_error(request, e)
61
+ false
62
+ end
63
+
64
+ # Handles an error encountered while serving a request by logging the error
65
+ # and optionally sending an error response with the relevant HTTP status
66
+ # code. For I/O errors, no response is sent.
67
+ #
68
+ # @param request [Qeweney::Request] HTTP request
69
+ # @param err [Exception] error
70
+ # @return [void]
71
+ def handle_error(request, err)
72
+ case err
73
+ when SystemCallError
74
+ log_error(err, 'I/O error')
75
+ when ProtocolError
76
+ log_error(err, err.message)
77
+ respond(request, err.message, ':status' => err.http_status)
78
+ else
79
+ log_error(err, 'Internal error')
80
+ return if !request || @done
66
81
 
67
- if request && !@done
68
82
  respond(request, 'Internal server error', ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
69
83
  end
70
- false
84
+ end
85
+
86
+ # Logs the given err and given message.
87
+ #
88
+ # @param err [Exception] error
89
+ # @param message [String] error message
90
+ # @return [void]
91
+ def log_error(err, message)
92
+ @logger&.error(message: "#{message}, closing connection", error: err)
71
93
  end
72
94
 
73
95
  def get_body(req)
@@ -110,6 +132,16 @@ module TP2
110
132
 
111
133
  # response API
112
134
 
135
+ # Sets response headers before sending any response. This method is used to
136
+ # add headers such as Set-Cookie or cache control headers to a response
137
+ # before actually responding, specifically in middleware hooks.
138
+ #
139
+ # @param headers [Hash] response headers
140
+ # @return [void]
141
+ def set_response_headers(headers)
142
+ @response_headers ? @response_headers.merge!(headers) : @response_headers = headers
143
+ end
144
+
113
145
  SEND_FLAGS = UM::MSG_NOSIGNAL | UM::MSG_WAITALL
114
146
 
115
147
  # Sends response including headers and body. Waits for the request to complete
@@ -118,18 +150,19 @@ module TP2
118
150
  # @param body [String] response body
119
151
  # @param headers
120
152
  def respond(request, body, headers)
121
- formatted_headers = format_headers(headers, body, false)
122
- request.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
153
+ headers = @response_headers.merge(headers) if @response_headers
154
+
155
+ formatted_headers = format_headers(headers, body)
156
+ @response_headers = headers
157
+ request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
123
158
  if body
124
- buf = formatted_headers + body
159
+ buf = "#{formatted_headers}#{body.bytesize.to_s(16)}\r\n#{body}\r\n#{EMPTY_CHUNK}"
125
160
  @machine.send(@fd, buf, buf.bytesize, SEND_FLAGS)
126
- # handle_write(formatted_headers + body)
127
161
  else
128
162
  @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
129
163
  end
130
- @logger&.info(request: request, response_headers: headers)
164
+ @logger&.info(request: request, response_headers: headers) if request
131
165
  @done = true
132
- @response_headers = headers
133
166
  end
134
167
 
135
168
  # Sends response headers. If empty_response is truthy, the response status
@@ -137,10 +170,9 @@ module TP2
137
170
  # @param request [Qeweney::Request] HTTP request
138
171
  # @param headers [Hash] response headers
139
172
  # @param empty_response [boolean] whether a response body will be sent
140
- # @param chunked [boolean] whether to use chunked transfer encoding
141
173
  # @return [void]
142
- def send_headers(request, headers, empty_response: false, chunked: true)
143
- formatted_headers = format_headers(headers, !empty_response, http1_1?(request) && chunked)
174
+ def send_headers(request, headers, empty_response: false)
175
+ formatted_headers = format_headers(headers, !empty_response)
144
176
  request.tx_incr(formatted_headers.bytesize)
145
177
  @machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
146
178
  @response_headers = headers
@@ -182,28 +214,28 @@ module TP2
182
214
  @done = true
183
215
  end
184
216
 
185
- def respond_with_static_file(req, path, opts, cache_headers)
217
+ def respond_with_static_file(req, path, env, cache_headers)
186
218
  fd = @machine.open(path, UM::O_RDONLY)
187
- opts ||= {}
188
- if opts[:headers]
189
- opts[:headers].merge!(cache_headers)
219
+ env ||= {}
220
+ if env[:headers]
221
+ env[:headers].merge!(cache_headers)
190
222
  else
191
- opts[:headers] = cache_headers
223
+ env[:headers] = cache_headers
192
224
  end
193
225
 
194
- maxlen = opts[:max_len] || 65_536
226
+ maxlen = env[:max_len] || 65_536
195
227
  buf = String.new(capacity: maxlen)
196
228
  headers_sent = nil
197
229
  loop do
198
230
  res = @machine.read(fd, buf, maxlen, 0)
199
231
  if res < maxlen && !headers_sent
200
- return respond(req, buf, opts[:headers])
232
+ return respond(req, buf, env[:headers])
201
233
  elsif res == 0
202
234
  return finish(req)
203
235
  end
204
236
 
205
237
  if !headers_sent
206
- send_headers(req, opts[:headers])
238
+ send_headers(req, env[:headers])
207
239
  headers_sent = true
208
240
  end
209
241
  done = res < maxlen
@@ -211,7 +243,7 @@ module TP2
211
243
  return if done
212
244
  end
213
245
  ensure
214
- @machine.close(fd) if fd
246
+ @machine.close_async(fd) if fd
215
247
  end
216
248
 
217
249
  def close
@@ -224,20 +256,15 @@ module TP2
224
256
 
225
257
  private
226
258
 
227
- RE_REQUEST_LINE = %r{^([a-z]+)\s+([^\s]+)\s+(http/[0-9.]{1,3})}i
259
+ RE_REQUEST_LINE = /^([a-z]+)\s+([^\s]+)\s+http\/([019\.]{1,3})/i
228
260
  RE_HEADER_LINE = /^([a-z0-9-]+):\s+(.+)/i
229
261
  MAX_REQUEST_LINE_LEN = 1 << 14 # 16KB
230
262
  MAX_HEADER_LINE_LEN = 1 << 10 # 1KB
231
263
  MAX_CHUNK_SIZE_LEN = 16
232
264
 
233
- class ProtocolError < StandardError
234
- end
235
-
236
265
  def persist_connection?(headers)
237
266
  connection = headers['connection']&.downcase
238
- return connection != 'close' if headers[':protocol'] == 'http/1.1'
239
-
240
- connection && connection != 'close'
267
+ return connection != 'close'
241
268
  end
242
269
 
243
270
  def parse_headers
@@ -263,12 +290,15 @@ module TP2
263
290
  return nil if !line
264
291
 
265
292
  m = line.match(RE_REQUEST_LINE)
266
- raise ProtocolError, "Invalid request line: #{line[0..2047].inspect}" if !m
293
+ raise ProtocolError, 'Invalid request line' if !m
294
+
295
+ http_version = m[3]
296
+ raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
267
297
 
268
298
  {
269
299
  ':method' => m[1].downcase,
270
300
  ':path' => m[2],
271
- ':protocol' => m[3].downcase
301
+ ':protocol' => 'http/1.1'
272
302
  }
273
303
  end
274
304
 
@@ -305,21 +335,17 @@ module TP2
305
335
  buffer ? (buffer << chunk) : chunk
306
336
  end
307
337
 
308
- def http1_1?(request)
309
- request.headers[':protocol'] == 'http/1.1'
310
- end
311
-
312
338
  INTERNAL_HEADER_REGEXP = /^:/
313
339
 
314
340
  # Formats response headers into an array. If empty_response is true(thy),
315
341
  # the response status code will default to 204, otherwise to 200.
316
342
  # @param headers [Hash] response headers
317
343
  # @param body [boolean] whether a response body will be sent
318
- # @param chunked [boolean] whether to use chunked transfer encoding
319
344
  # @return [String] formatted response headers
320
- def format_headers(headers, body, chunked)
345
+ def format_headers(headers, body)
321
346
  status = headers[':status'] || (body ? Qeweney::Status::OK : Qeweney::Status::NO_CONTENT)
322
- lines = format_status_line(body, status, chunked)
347
+ lines = format_status_line(body, status)
348
+ lines << @env[:server_headers] if @env[:server_headers]
323
349
  headers.each do |k, v|
324
350
  next if k =~ INTERNAL_HEADER_REGEXP
325
351
 
@@ -329,11 +355,11 @@ module TP2
329
355
  lines
330
356
  end
331
357
 
332
- def format_status_line(body, status, chunked)
358
+ def format_status_line(body, status)
333
359
  if !body
334
360
  empty_status_line(status)
335
361
  else
336
- with_body_status_line(status, body, chunked)
362
+ with_body_status_line(status, body)
337
363
  end
338
364
  end
339
365
 
@@ -345,12 +371,8 @@ module TP2
345
371
  end
346
372
  end
347
373
 
348
- def with_body_status_line(status, body, chunked)
349
- if chunked
350
- +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
351
- else
352
- +"HTTP/1.1 #{status}\r\nContent-Length: #{body.bytesize}\r\n"
353
- end
374
+ def with_body_status_line(status, body)
375
+ +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
354
376
  end
355
377
 
356
378
  def collect_header_lines(lines, key, value)
data/lib/tp2/errors.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TP2
4
+ class ProtocolError < StandardError
5
+ def http_status
6
+ Qeweney::Status::BAD_REQUEST
7
+ end
8
+ end
9
+
10
+ class UnsupportedHTTPVersionError < ProtocolError
11
+ def http_status
12
+ Qeweney::Status::HTTP_VERSION_NOT_SUPPORTED
13
+ end
14
+ end
15
+ end
@@ -11,4 +11,8 @@ class Qeweney::Request
11
11
 
12
12
  adapter.respond_with_static_file(self, path, opts, cache_headers)
13
13
  end
14
+
15
+ def set_response_headers(headers)
16
+ adapter.set_response_headers(headers)
17
+ end
14
18
  end
data/lib/tp2/server.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'tp2/http1_connection'
3
+ require 'tp2/connection'
4
4
  require 'tp2/request_extensions'
5
5
  require 'tp2/rack_adapter'
6
6
 
@@ -9,42 +9,42 @@ module TP2
9
9
  PENDING_REQUESTS_GRACE_PERIOD = 0.1
10
10
  PENDING_REQUESTS_TIMEOUT_PERIOD = 5
11
11
 
12
- def self.rack_app(opts)
13
- raise 'Missing app location' if !opts[:app_location]
12
+ def self.rack_app(env)
13
+ raise 'Missing app location' if !env[:app_location]
14
14
 
15
- TP2::RackAdapter.load(opts[:app_location])
15
+ TP2::RackAdapter.load(env[:app_location])
16
16
  end
17
17
 
18
- def self.tp2_app(_machine, opts)
19
- if opts[:app_location]
20
- opts[:logger]&.info(message: 'Loading web app', location: opts[:app_location])
21
- require opts[:app_location]
18
+ def self.tp2_app(_machine, env)
19
+ if env[:app_location]
20
+ env[:logger]&.info(message: 'Loading web app', location: env[:app_location])
21
+ require env[:app_location]
22
22
 
23
- opts.merge!(TP2.config)
23
+ env.merge!(TP2.config)
24
24
  end
25
- opts[:app]
25
+ env[:app]
26
26
  end
27
27
 
28
- def self.static_app(opts); end
28
+ def self.static_app(env); end
29
29
 
30
- def initialize(machine, opts, &app)
30
+ def initialize(machine, env, &app)
31
31
  @machine = machine
32
- @opts = opts
33
- @app = app || app_from_opts
32
+ @env = env
33
+ @app = app || app_from_env
34
34
  @server_fds = []
35
35
  @accept_fibers = []
36
36
  end
37
37
 
38
- def app_from_opts
39
- case @opts[:app_type]
38
+ def app_from_env
39
+ case @env[:app_type]
40
40
  when nil, :tp2
41
- Server.tp2_app(@machine, @opts)
41
+ Server.tp2_app(@machine, @env)
42
42
  when :rack
43
- Server.rack_app(@opts)
43
+ Server.rack_app(@env)
44
44
  when :static
45
- Server.static_app(@opts)
45
+ Server.static_app(@env)
46
46
  else
47
- raise "Invalid app type #{@opts[:app_type].inspect}"
47
+ raise "Invalid app type #{@env[:app_type].inspect}"
48
48
  end
49
49
  end
50
50
 
@@ -65,14 +65,15 @@ module TP2
65
65
  @accept_fibers << @machine.spin { accept_incoming(fd) }
66
66
  end
67
67
  bind_string = bind_info.map { it.join(':') }.join(', ')
68
- @opts[:logger]&.info(message: "Listening on #{bind_string}")
68
+ @env[:logger]&.info(message: "Listening on #{bind_string}")
69
+ setup_server_extensions
69
70
 
70
71
  # map fibers
71
72
  @connection_fiber_map = {}
72
73
  end
73
74
 
74
75
  def get_bind_entries
75
- bind = @opts[:bind]
76
+ bind = @env[:bind]
76
77
  case bind
77
78
  when Array
78
79
  bind.map { bind_info(it) }
@@ -98,9 +99,33 @@ module TP2
98
99
  fd
99
100
  end
100
101
 
102
+ def setup_server_extensions
103
+ extensions = @env[:server_extensions]
104
+ return if !extensions
105
+
106
+ server_name = extensions[:name]
107
+ if extensions[:date]
108
+ @date_header_fiber = @machine.spin {
109
+ @machine.periodically(1) { update_server_headers(server_name) }
110
+ }
111
+ update_server_headers(server_name)
112
+ elsif server_name
113
+ @env[:server_headers] = "Server: #{server_name}\r\n"
114
+ end
115
+ end
116
+
117
+ def update_server_headers(server_name)
118
+ @env[:server_date] = Time.now
119
+ if server_name
120
+ @env[:server_headers] = "Server: #{server_name}\r\nDate: #{@env[:server_date].httpdate}\r\n"
121
+ else
122
+ @env[:server_headers] = "Date: #{Time.now.httpdate}\r\n"
123
+ end
124
+ end
125
+
101
126
  def accept_incoming(listen_fd)
102
127
  @machine.accept_each(listen_fd) do |fd|
103
- conn = HTTP1Connection.new(@machine, fd, @opts, &@app)
128
+ conn = Connection.new(self, @machine, fd, @env, &@app)
104
129
  f = @machine.spin(conn) do
105
130
  it.run
106
131
  ensure
@@ -121,7 +146,7 @@ module TP2
121
146
  end
122
147
 
123
148
  def graceful_shutdown
124
- @opts[:logger]&.info(message: 'Shutting down gracefully...')
149
+ @env[:logger]&.info(message: 'Shutting down gracefully...')
125
150
 
126
151
  # stop listening
127
152
  close_all_server_fds
data/lib/tp2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module TP2
2
- VERSION = '0.15'
2
+ VERSION = '0.17'
3
3
  end
data/lib/tp2.rb CHANGED
@@ -24,36 +24,35 @@ module TP2
24
24
  )
25
25
 
26
26
  class << self
27
- def run(opts = nil, &app)
27
+ def run(env = {}, &app)
28
28
  if @in_run
29
- @config = opts if opts
30
- @config[:app] = app if app
29
+ @env = env
30
+ @env[:app] = app if app
31
31
  return
32
32
  end
33
33
 
34
- opts ||= @config || {}
34
+ env ||= @env || {}
35
35
  begin
36
36
  @in_run = true
37
- machine = opts[:machine] || UM.new
38
- machine.puts(opts[:banner]) if opts[:banner]
37
+ machine = env[:machine] || UM.new
38
+ machine.puts(env[:banner]) if env[:banner]
39
39
 
40
- opts[:logger]&.info(message: "Running TP2 #{TP2::VERSION}, UringMachine #{UM::VERSION}, Ruby #{RUBY_VERSION}")
40
+ env[:logger]&.info(message: "Running TP2 #{TP2::VERSION}, UringMachine #{UM::VERSION}, Ruby #{RUBY_VERSION}")
41
41
 
42
- server = Server.new(machine, opts, &app)
42
+ server = Server.new(machine, env, &app)
43
43
 
44
44
  setup_signal_handling(machine, Fiber.current)
45
- app.ready if app.respond_to?(:ready)
46
45
  server.run
47
46
  ensure
48
47
  @in_run = false
49
48
  end
50
49
  end
51
50
 
52
- def config(opts = nil, &app)
53
- return @config if !opts && !app
51
+ def env(env = nil, &app)
52
+ return @env if !env && !app
54
53
 
55
- @config = opts || {}
56
- @config[:app] = app if app
54
+ @env = env || {}
55
+ @env[:app] = app if app
57
56
  end
58
57
 
59
58
  private
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/inline'
4
+
5
+ gemfile {
6
+ gem 'tp2', path: '..'
7
+ gem 'benchmark'
8
+ gem 'benchmark-ips'
9
+ gem 'vernier'
10
+ }
11
+
12
+ require 'vernier'
13
+ require 'securerandom'
14
+ require 'benchmark/ips'
15
+
16
+ class ConnectionTester
17
+ def make_socket_pair
18
+ port = SecureRandom.random_number(10000..40000)
19
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
20
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
21
+ @machine.bind(server_fd, '127.0.0.1', port)
22
+ @machine.listen(server_fd, UM::SOMAXCONN)
23
+
24
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
25
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
26
+
27
+ server_conn_fd = @machine.accept(server_fd)
28
+
29
+ @machine.close(server_fd)
30
+ [client_conn_fd, server_conn_fd]
31
+ end
32
+
33
+ def setup
34
+ @machine = UM.new
35
+ @c_fd, @s_fd = make_socket_pair
36
+ @reqs = []
37
+ @app = ->(req) { req.respond('foobar', 'Content-Type' => 'text/plain') }
38
+ @adapter = TP2::Connection.new(nil, @machine, @s_fd, {}, &@app)
39
+ end
40
+
41
+ def teardown
42
+ @machine.close(@c_fd) rescue nil
43
+ @machine.close(@s_fd) rescue nil
44
+ end
45
+
46
+ def write_http_request(msg, shutdown_wr = true)
47
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
48
+ @machine.shutdown(@c_fd, UM::SHUT_WR) if shutdown_wr
49
+ end
50
+
51
+ def write_client_side(msg)
52
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
53
+ end
54
+
55
+ def read_client_side(len = 65536)
56
+ buf = +''
57
+ res = @machine.recv(@c_fd, buf, len, 0)
58
+ res == 0 ? nil : buf
59
+ end
60
+
61
+ def run_request
62
+ write_http_request "GET / HTTP/1.1\r\n\r\n", false
63
+ @adapter.serve_request
64
+ read_client_side(len = 65536)
65
+ end
66
+ end
67
+
68
+ tester = ConnectionTester.new
69
+ tester.setup
70
+
71
+ # puts '*' * 40
72
+ # p tester.run_request
73
+ # p tester.run_request
74
+ # puts '*' * 40
75
+
76
+ # Vernier.profile(out: "profile.vernier.json") {
77
+ # 100000.times { tester.run_request }
78
+ # }
79
+
80
+ Benchmark.ips do |x|
81
+ x.report('request') { tester.run_request }
82
+ x.compare!(order: :baseline)
83
+ end
data/test/helper.rb CHANGED
@@ -36,6 +36,29 @@ module Kernel
36
36
  end
37
37
  end
38
38
 
39
+ class Qeweney::Request
40
+ def response_headers
41
+ adapter.headers
42
+ end
43
+
44
+ def response_status
45
+ adapter.status
46
+ end
47
+
48
+ def response_body
49
+ adapter.body
50
+ end
51
+
52
+ def response_json
53
+ raise if response_content_type != 'application/json'
54
+ JSON.parse(response_body, symbolize_names: true)
55
+ end
56
+
57
+ def response_content_type
58
+ response_headers['Content-Type']
59
+ end
60
+ end
61
+
39
62
  module Minitest::Assertions
40
63
  def assert_in_range exp_range, act
41
64
  msg = message(msg) { "Expected #{mu_pp(act)} to be in range #{mu_pp(exp_range)}" }
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative './helper'
4
+ require 'securerandom'
4
5
 
5
- class HTTP1ConnectionTest < Minitest::Test
6
+ class ConnectionTest < Minitest::Test
6
7
  def make_socket_pair
7
- port = 10000 + rand(30000)
8
+ port = SecureRandom.random_number(10000..40000)
8
9
  server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
9
10
  @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
10
11
  @machine.bind(server_fd, '127.0.0.1', port)
@@ -25,7 +26,8 @@ class HTTP1ConnectionTest < Minitest::Test
25
26
  @reqs = []
26
27
  @hook = nil
27
28
  @app = ->(req) { @hook&.call(req); @reqs << req }
28
- @adapter = TP2::HTTP1Connection.new(@machine, @s_fd, {}, &@app)
29
+ @env = {}
30
+ @adapter = TP2::Connection.new(nil, @machine, @s_fd, @env, &@app)
29
31
  end
30
32
 
31
33
  def teardown
@@ -48,8 +50,31 @@ class HTTP1ConnectionTest < Minitest::Test
48
50
  res == 0 ? nil : buf
49
51
  end
50
52
 
51
- def test_basic_request_parsing
53
+ def test_http_unsupported_versions
54
+ write_http_request "GET / HTTP/0.9\r\n\r\n"
55
+ @adapter.serve_request
56
+ response = read_client_side
57
+ assert_equal "HTTP/1.1 505\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHTTP version not supported\r\n0\r\n\r\n", response
58
+
59
+ setup
60
+
52
61
  write_http_request "GET / HTTP/1.0\r\n\r\n"
62
+ @adapter.serve_request
63
+ response = read_client_side
64
+ assert_equal "HTTP/1.1 505\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHTTP version not supported\r\n0\r\n\r\n", response
65
+
66
+ setup
67
+
68
+ @hook = ->(req) { req.respond('hi') }
69
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
70
+ @adapter.serve_request
71
+ @machine.close(@s_fd)
72
+ response = read_client_side
73
+ assert_equal "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nhi\r\n0\r\n\r\n", response
74
+ end
75
+
76
+ def test_basic_request_parsing
77
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
53
78
 
54
79
  @adapter.serve_request
55
80
  assert_equal 1, @reqs.size
@@ -58,7 +83,7 @@ class HTTP1ConnectionTest < Minitest::Test
58
83
  assert_equal({
59
84
  ':method' => 'get',
60
85
  ':path' => '/',
61
- ':protocol' => 'http/1.0'
86
+ ':protocol' => 'http/1.1'
62
87
  }, headers)
63
88
  end
64
89
 
@@ -196,34 +221,6 @@ class HTTP1ConnectionTest < Minitest::Test
196
221
  assert_equal '123456789abcdefghijklmnopqrstuv', body
197
222
  end
198
223
 
199
- def test_body_to_eof
200
- skip
201
-
202
- write_http_request <<~HTTP.crlf_lines
203
- POST /foo HTTP/1.1
204
- Server: foo.com
205
-
206
- barbaz
207
- HTTP
208
-
209
- @bodies = []
210
- @hook = ->(req) { @bodies << req.read }
211
-
212
- @adapter.run
213
- assert_equal 1, @reqs.size
214
-
215
- req0 = @reqs.shift
216
- headers = req0.headers
217
- assert_equal({
218
- ':method' => 'post',
219
- ':path' => '/foo',
220
- ':protocol' => 'http/1.1',
221
- 'server' => 'foo.com'
222
- }, headers)
223
- body = @bodies.shift
224
- assert_equal 'barbaz', body
225
- end
226
-
227
224
  def test_each_chunk
228
225
  write_http_request <<~HTTP.crlf_lines
229
226
  POST /foo HTTP/1.1
@@ -278,30 +275,12 @@ class HTTP1ConnectionTest < Minitest::Test
278
275
  assert_equal ['123456789abcdefghijklmnopqrstuv'], chunks
279
276
  end
280
277
 
281
- def test_that_server_uses_content_length_in_http_1_0
282
- @hook = ->(req) {
283
- req.respond('Hello, world!', {})
284
- }
285
-
286
- write_http_request "GET / HTTP/1.0\r\n\r\n"
287
- @adapter.run
288
- response = read_client_side
289
-
290
- expected = <<~HTTP.crlf_lines
291
- HTTP/1.1 200
292
- Content-Length: 13
293
-
294
- Hello, world!
295
- HTTP
296
- assert_equal(expected, response)
297
- end
298
-
299
278
  def test_204_status_on_empty_response
300
279
  @hook = ->(req) {
301
280
  req.respond(nil, {})
302
281
  }
303
282
 
304
- write_http_request "GET / HTTP/1.0\r\n\r\n"
283
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
305
284
  @adapter.run
306
285
  response = read_client_side
307
286
 
@@ -325,46 +304,29 @@ class HTTP1ConnectionTest < Minitest::Test
325
304
  @adapter.run
326
305
 
327
306
  response = read_client_side
328
- expected = <<~HTTP.crlf_lines.chomp
329
- HTTP/1.1 200
330
- Content-Length: 13
331
-
332
- Hello, world!
333
- HTTP
307
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n"
334
308
  assert_equal(expected, response)
335
309
  end
336
310
 
337
- def test_that_server_maintains_connection_when_using_keep_alives
311
+ def test_that_server_maintains_connection_if_no_connection_close_header
338
312
  @hook = ->(req) {
339
313
  req.respond('Hi', {})
340
314
  }
341
315
 
342
- write_http_request "GET / HTTP/1.0\r\nConnection: keep-alive\r\n\r\n", false
316
+ write_http_request "GET / HTTP/1.1\r\nConnection: close\r\n\r\n", false
343
317
  res = @adapter.serve_request
344
- assert_equal true, res
318
+ assert_equal false, res
345
319
 
346
320
  response = read_client_side
347
- assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
321
+ assert_equal("HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHi\r\n0\r\n\r\n", response)
348
322
 
349
323
  write_http_request "GET / HTTP/1.1\r\n\r\n", false
350
324
  res = @adapter.serve_request
351
325
  assert_equal true, res
352
326
 
353
327
  response = read_client_side
354
- expected = <<~HTTP.crlf_lines
355
- HTTP/1.1 200
356
- Content-Length: 2
357
-
358
- Hi
359
- HTTP
328
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHi\r\n0\r\n\r\n"
360
329
  assert_equal(expected, response)
361
-
362
- write_http_request "GET / HTTP/1.0\r\n\r\n"
363
- res = @adapter.serve_request
364
- assert_equal false, !!res
365
-
366
- response = read_client_side
367
- assert_equal("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\nHi", response)
368
330
  end
369
331
 
370
332
  def test_pipelining_client
@@ -381,15 +343,8 @@ class HTTP1ConnectionTest < Minitest::Test
381
343
  @adapter.run
382
344
  response = read_client_side
383
345
 
384
- expected = <<~HTTP.crlf_lines.chomp
385
- HTTP/1.1 200
386
- Content-Length: 13
387
-
388
- Hello, world!HTTP/1.1 200
389
- Content-Length: 14
390
-
391
- Hello, foobar!
392
- HTTP
346
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n" +
347
+ "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\ne\r\nHello, foobar!\r\n0\r\n\r\n"
393
348
  assert_equal(expected, response)
394
349
  end
395
350
 
@@ -538,7 +493,7 @@ class HTTP1ConnectionTest < Minitest::Test
538
493
 
539
494
  content = IO.read(__FILE__)
540
495
  file_size = content.bytesize
541
- expected = "HTTP/1.1 200\r\nContent-Length: 6\r\n\r\nfoobar"
496
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n0\r\n\r\n"
542
497
 
543
498
  assert_equal expected, response
544
499
  end
@@ -567,4 +522,64 @@ class HTTP1ConnectionTest < Minitest::Test
567
522
  expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n"
568
523
  assert_equal expected, response
569
524
  end
525
+
526
+ def test_connection_server_headers
527
+ @env[:server_headers] = "Server: TP2\r\n"
528
+
529
+ @hook = ->(req) do
530
+ req.respond('foo')
531
+ end
532
+
533
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
534
+ @adapter.serve_request
535
+ response = read_client_side(65536)
536
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: TP2\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
537
+ assert_equal expected, response
538
+
539
+ @env[:server_headers] = "Server: TP3\r\n"
540
+
541
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
542
+ @adapter.serve_request
543
+ response = read_client_side(65536)
544
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: TP3\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
545
+ assert_equal expected, response
546
+ end
547
+
548
+ def test_set_response_headers
549
+ @hook = ->(req) {
550
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
551
+ req.respond('foo')
552
+ }
553
+
554
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
555
+ @adapter.serve_request
556
+ response = read_client_side(65536)
557
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
558
+ assert_equal expected, response
559
+
560
+ @hook = ->(req) {
561
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
562
+ req.respond('foo', 'Content-Type' => 'text/plain')
563
+ }
564
+
565
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
566
+ @adapter.serve_request
567
+ response = read_client_side(65536)
568
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\nContent-Type: text/plain\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
569
+ assert_equal expected, response
570
+ end
571
+
572
+ def test_set_response_headers
573
+ @hook = ->(req) {
574
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
575
+ req.set_response_headers("Foo" => 'bar')
576
+ req.respond('foo', 'Content-Type' => 'text/plain')
577
+ }
578
+
579
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
580
+ @adapter.serve_request
581
+ response = read_client_side(65536)
582
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\nFoo: bar\r\nContent-Type: text/plain\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
583
+ assert_equal expected, response
584
+ end
570
585
  end
data/test/test_server.rb CHANGED
@@ -22,11 +22,11 @@ class ServerTest < Minitest::Test
22
22
  class STOP < StandardError
23
23
  end
24
24
 
25
- def setup
25
+ def setup(opts = {})
26
26
  @machine = UM.new
27
27
  @port = 10000 + rand(30000)
28
- @opts = { bind: "127.0.0.1:#{@port}" }
29
- @server = TP2::Server.new(@machine, @opts) { @app&.call(it) }
28
+ @env = { bind: "127.0.0.1:#{@port}" }.merge(opts)
29
+ @server = TP2::Server.new(@machine, @env) { @app&.call(it) }
30
30
  @f_server = @machine.spin { run_server }
31
31
 
32
32
  # let server spin and listen to incoming connections
@@ -65,14 +65,25 @@ class ServerTest < Minitest::Test
65
65
  res == 0 ? nil : buf
66
66
  end
67
67
 
68
- def test_basic_app_response
68
+ def test_http_1_0_response
69
69
  @app = ->(req) {
70
70
  req.respond('Hello, world!', {})
71
71
  }
72
72
 
73
73
  write_http_request "GET / HTTP/1.0\r\n\r\n"
74
74
  response = read_client_side
75
- expected = "HTTP/1.1 200\r\nContent-Length: 13\r\n\r\nHello, world!"
75
+ expected = "HTTP/1.1 505\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHTTP version not supported\r\n0\r\n\r\n"
76
+ assert_equal(expected, response)
77
+ end
78
+
79
+ def test_basic_app_response
80
+ @app = ->(req) {
81
+ req.respond('Hello, world!', {})
82
+ }
83
+
84
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
85
+ response = read_client_side
86
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n"
76
87
  assert_equal(expected, response)
77
88
  end
78
89
 
@@ -83,8 +94,10 @@ class ServerTest < Minitest::Test
83
94
 
84
95
  write_http_request "GET /foo HTTP/1.1\r\nServer: foo.com\r\n\r\nSCHmet /bar HTTP/1.1\r\n\r\n"
85
96
 
97
+ @machine.sleep(0.1)
86
98
  response = read_client_side
87
- 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"
99
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nmethod: get\r\n0\r\n\r\n" +
100
+ "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\ne\r\nmethod: schmet\r\n0\r\n\r\n"
88
101
  assert_equal(expected, response)
89
102
  end
90
103
 
@@ -104,7 +117,7 @@ class ServerTest < Minitest::Test
104
117
  @machine.snooze
105
118
 
106
119
  response = read_client_side
107
- expected = "HTTP/1.1 200\r\nContent-Length: 11\r\n\r\nTerminated!"
120
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nTerminated!\r\n0\r\n\r\n"
108
121
  assert_equal(expected, response)
109
122
  end
110
123
 
@@ -143,7 +156,7 @@ class ServerTest < Minitest::Test
143
156
  ':protocol' => 'http/1.1',
144
157
  'server' => 'foo.com',
145
158
  'content-length' => '3',
146
- ':tx' => 48,
159
+ ':tx' => 56,
147
160
  }, headers)
148
161
  body = @bodies.shift
149
162
  assert_equal 'abc', body
@@ -156,7 +169,7 @@ class ServerTest < Minitest::Test
156
169
  ':protocol' => 'http/1.1',
157
170
  'server' => 'bar.com',
158
171
  'content-length' => '6',
159
- ':tx' => 51,
172
+ ':tx' => 59,
160
173
  }, headers)
161
174
  body = @bodies.shift
162
175
  assert_equal 'defghi', body
@@ -266,7 +279,7 @@ class ServerTest < Minitest::Test
266
279
  def test_logging
267
280
  skip
268
281
  reqs = []
269
- @opts[:logger] = TestLogger.new
282
+ @env[:logger] = TestLogger.new
270
283
  @app = ->(req) { reqs << req; req.respond('Hello, world!', {}) }
271
284
 
272
285
  write_http_request "GET / HTTP/1.0\r\n\r\n"
@@ -274,10 +287,68 @@ class ServerTest < Minitest::Test
274
287
  expected = "HTTP/1.1 200\r\nContent-Length: 13\r\n\r\nHello, world!"
275
288
  assert_equal(expected, response)
276
289
 
277
- entries = @opts[:logger].entries
290
+ entries = @env[:logger].entries
278
291
  assert_equal 1, entries.size
279
292
  assert_equal 1, reqs.size
280
293
 
281
294
  assert_equal reqs.first, entries.first[:request]
282
295
  end
296
+
297
+ def test_server_headers
298
+ @env[:server_headers] = "Server: Tipi\r\n"
299
+
300
+ @app = ->(req) {
301
+ req.respond('Hello, world!', {})
302
+ }
303
+
304
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
305
+ response = read_client_side
306
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: Tipi\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n"
307
+ assert_equal(expected, response)
308
+ end
309
+
310
+ def test_server_headers_date
311
+ setup({ server_extensions: { date: true } })
312
+ @machine.sleep(0.1)
313
+ assert_kind_of Time, @env[:server_date]
314
+
315
+ @app = ->(req) {
316
+ req.respond('foo', {})
317
+ }
318
+
319
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
320
+ response = read_client_side
321
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nDate: #{@env[:server_date].httpdate}\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
322
+ assert_equal(expected, response)
323
+ end
324
+
325
+ def test_server_headers_date_and_server_name
326
+ setup({ server_extensions: { date: true, name: 'Foo' } })
327
+ @machine.sleep(0.1)
328
+ assert_kind_of Time, @env[:server_date]
329
+
330
+ @app = ->(req) {
331
+ req.respond('foo', {})
332
+ }
333
+
334
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
335
+ response = read_client_side
336
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: Foo\r\nDate: #{@env[:server_date].httpdate}\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
337
+ assert_equal(expected, response)
338
+ end
339
+
340
+ def test_server_headers_server_name
341
+ setup({ server_extensions: { name: 'Bar' } })
342
+ @machine.sleep(0.1)
343
+ assert_nil @env[:server_date]
344
+
345
+ @app = ->(req) {
346
+ req.respond('foo', {})
347
+ }
348
+
349
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
350
+ response = read_client_side
351
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: Bar\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
352
+ assert_equal(expected, response)
353
+ end
283
354
  end
data/tp2-logo.png ADDED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tp2
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.15'
4
+ version: '0.17'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -71,16 +71,19 @@ files:
71
71
  - examples/rack.ru
72
72
  - examples/simple.rb
73
73
  - lib/tp2.rb
74
- - lib/tp2/http1_connection.rb
74
+ - lib/tp2/connection.rb
75
+ - lib/tp2/errors.rb
75
76
  - lib/tp2/logger.rb
76
77
  - lib/tp2/rack_adapter.rb
77
78
  - lib/tp2/request_extensions.rb
78
79
  - lib/tp2/server.rb
79
80
  - lib/tp2/version.rb
81
+ - test/bm_connection.rb
80
82
  - test/helper.rb
81
83
  - test/run.rb
82
- - test/test_http1_connection.rb
84
+ - test/test_connection.rb
83
85
  - test/test_server.rb
86
+ - tp2-logo.png
84
87
  - tp2.gemspec
85
88
  homepage: https://github.com/noteflakes/tp2
86
89
  licenses:
@@ -107,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
110
  - !ruby/object:Gem::Version
108
111
  version: '0'
109
112
  requirements: []
110
- rubygems_version: 3.6.9
113
+ rubygems_version: 3.7.0.dev
111
114
  specification_version: 4
112
115
  summary: Experimental HTTP/1 server for UringMachine
113
116
  test_files: []