tipi 0.43 → 0.45

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +1 -0
  3. data/.github/workflows/test.yml +1 -3
  4. data/CHANGELOG.md +12 -0
  5. data/Gemfile +3 -1
  6. data/Gemfile.lock +14 -7
  7. data/README.md +184 -8
  8. data/Rakefile +1 -7
  9. data/benchmarks/bm_http1_parser.rb +1 -1
  10. data/bin/benchmark +0 -0
  11. data/bin/h1pd +0 -0
  12. data/bm.png +0 -0
  13. data/df/agent.rb +1 -1
  14. data/df/sample_agent.rb +2 -2
  15. data/df/server_utils.rb +1 -1
  16. data/examples/hello.rb +5 -0
  17. data/examples/http_server.js +1 -1
  18. data/examples/http_server_graceful.rb +1 -1
  19. data/examples/https_server.rb +41 -18
  20. data/examples/rack_server_forked.rb +26 -0
  21. data/examples/rack_server_https_forked.rb +1 -1
  22. data/examples/websocket_demo.rb +1 -1
  23. data/lib/tipi/acme.rb +46 -39
  24. data/lib/tipi/cli.rb +79 -16
  25. data/lib/tipi/config_dsl.rb +13 -13
  26. data/lib/tipi/configuration.rb +2 -2
  27. data/lib/tipi/controller/bare_polyphony.rb +0 -0
  28. data/lib/tipi/controller/bare_stock.rb +10 -0
  29. data/lib/tipi/controller/stock_http1_adapter.rb +15 -0
  30. data/lib/tipi/controller/web_polyphony.rb +351 -0
  31. data/lib/tipi/controller/web_stock.rb +631 -0
  32. data/lib/tipi/controller.rb +12 -0
  33. data/lib/tipi/digital_fabric/agent.rb +3 -3
  34. data/lib/tipi/digital_fabric/agent_proxy.rb +11 -5
  35. data/lib/tipi/digital_fabric/executive.rb +1 -1
  36. data/lib/tipi/digital_fabric/protocol.rb +1 -1
  37. data/lib/tipi/digital_fabric/service.rb +8 -8
  38. data/lib/tipi/handler.rb +2 -2
  39. data/lib/tipi/http1_adapter.rb +32 -27
  40. data/lib/tipi/http2_adapter.rb +10 -10
  41. data/lib/tipi/http2_stream.rb +14 -14
  42. data/lib/tipi/rack_adapter.rb +2 -2
  43. data/lib/tipi/response_extensions.rb +1 -1
  44. data/lib/tipi/supervisor.rb +75 -0
  45. data/lib/tipi/version.rb +1 -1
  46. data/lib/tipi/websocket.rb +3 -3
  47. data/lib/tipi.rb +4 -83
  48. data/test/coverage.rb +2 -2
  49. data/test/test_http_server.rb +14 -14
  50. data/tipi.gemspec +3 -2
  51. metadata +30 -5
@@ -83,7 +83,7 @@ module DigitalFabric
83
83
  raise 'Invalid output from top (cpu)'
84
84
  end
85
85
  cpu_utilization = 100 - Regexp.last_match(1).to_i
86
-
86
+
87
87
  unless top =~ TOP_MEM_REGEXP && Regexp.last_match(1) =~ TOP_MEM_FREE_REGEXP
88
88
  raise 'Invalid output from top (mem)'
89
89
  end
@@ -141,7 +141,7 @@ module DigitalFabric
141
141
  def ws_data(id, data)
142
142
  [ WS_DATA, id, data ]
143
143
  end
144
-
144
+
145
145
  def ws_close(id)
146
146
  [ WS_CLOSE, id ]
147
147
  end
@@ -90,7 +90,7 @@ module DigitalFabric
90
90
  rescue Exception
91
91
  [nil, nil]
92
92
  end
93
-
93
+
94
94
  def get_stats
95
95
  calculate_stats
96
96
  end
@@ -123,14 +123,14 @@ module DigitalFabric
123
123
 
124
124
  puts format('slow request (%.1f): %p', latency, req.headers)
125
125
  end
126
-
126
+
127
127
  def http_request(req, allow_df_upgrade = false)
128
128
  @current_request_count += 1
129
129
  @counters[:http_requests] += 1
130
130
  @counters[:connections] += 1 if req.headers[':first']
131
131
 
132
132
  return upgrade_request(req, allow_df_upgrade) if req.upgrade_protocol
133
-
133
+
134
134
  inject_request_headers(req)
135
135
  agent = find_agent(req)
136
136
  unless agent
@@ -159,7 +159,7 @@ module DigitalFabric
159
159
  req.headers['x-forwarded-for'] = conn.peeraddr(false)[2]
160
160
  req.headers['x-forwarded-proto'] ||= conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http'
161
161
  end
162
-
162
+
163
163
  def upgrade_request(req, allow_df_upgrade)
164
164
  case (protocol = req.upgrade_protocol)
165
165
  when 'df'
@@ -178,7 +178,7 @@ module DigitalFabric
178
178
  agent.http_upgrade(req, protocol)
179
179
  end
180
180
  end
181
-
181
+
182
182
  def df_upgrade(req)
183
183
  # we don't want to count connected agents
184
184
  @current_request_count -= 1
@@ -191,7 +191,7 @@ module DigitalFabric
191
191
  ensure
192
192
  @current_request_count += 1
193
193
  end
194
-
194
+
195
195
  def mount(route, agent)
196
196
  if route[:path]
197
197
  route[:path_regexp] = path_regexp(route[:path])
@@ -200,7 +200,7 @@ module DigitalFabric
200
200
  @agents[agent] = route
201
201
  @routing_changed = true
202
202
  end
203
-
203
+
204
204
  def unmount(agent)
205
205
  route = @agents[agent]
206
206
  return unless route
@@ -211,7 +211,7 @@ module DigitalFabric
211
211
  end
212
212
 
213
213
  INVALID_HOST = 'INVALID_HOST'
214
-
214
+
215
215
  def find_agent(req)
216
216
  compile_agent_routes if @routing_changed
217
217
 
data/lib/tipi/handler.rb CHANGED
@@ -20,14 +20,14 @@ module Tipi
20
20
  ensure
21
21
  socket.close
22
22
  end
23
-
23
+
24
24
  ALPN_PROTOCOLS = %w[h2 http/1.1].freeze
25
25
  H2_PROTOCOL = 'h2'
26
26
 
27
27
  def protocol_adapter(socket, opts)
28
28
  use_http2 = socket.respond_to?(:alpn_protocol) &&
29
29
  socket.alpn_protocol == H2_PROTOCOL
30
-
30
+
31
31
  klass = use_http2 ? HTTP2Adapter : HTTP1Adapter
32
32
  klass.new(socket, opts)
33
33
  end
@@ -17,24 +17,27 @@ module Tipi
17
17
  @first = true
18
18
  @parser = H1P::Parser.new(@conn)
19
19
  end
20
-
20
+
21
21
  def each(&block)
22
22
  while true
23
23
  headers = @parser.parse_headers
24
24
  break unless headers
25
-
25
+
26
26
  # handle_request returns true if connection is not persistent or was
27
27
  # upgraded
28
28
  break if handle_request(headers, &block)
29
29
  end
30
- rescue H1P::Error
30
+ rescue H1P::Error, ArgumentError
31
+ # an ArgumentError might be raised in the parser if an invalid input
32
+ # string is given as the HTTP method (String#upcase will raise on invalid HTTP string)
33
+ #
31
34
  # ignore
32
35
  rescue SystemCallError, IOError
33
36
  # ignore
34
37
  ensure
35
38
  finalize_client_loop
36
39
  end
37
-
40
+
38
41
  def handle_request(headers, &block)
39
42
  scheme = (proto = headers['x-forwarded-proto']) ?
40
43
  proto.downcase : scheme_from_connection
@@ -44,9 +47,9 @@ module Tipi
44
47
  headers[':first'] = true
45
48
  @first = nil
46
49
  end
47
-
50
+
48
51
  return true if upgrade_connection(headers, &block)
49
-
52
+
50
53
  request = Qeweney::Request.new(headers, self)
51
54
  if !@parser.complete?
52
55
  request.buffer_body_chunk(@parser.read_body_chunk(true))
@@ -63,14 +66,14 @@ module Tipi
63
66
  return connection && connection != 'close'
64
67
  end
65
68
  end
66
-
69
+
67
70
  def finalize_client_loop
68
71
  @parser = nil
69
72
  @splicing_pipe = nil
70
73
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
71
74
  @conn.close
72
75
  end
73
-
76
+
74
77
  # Reads a body chunk for the current request. Transfers control to the parse
75
78
  # loop, and resumes once the parse_loop has fired the on_body callback
76
79
  def get_body_chunk(request, buffered_only = false)
@@ -84,11 +87,11 @@ module Tipi
84
87
  def complete?(request)
85
88
  @parser.complete?
86
89
  end
87
-
90
+
88
91
  def protocol
89
92
  @protocol
90
93
  end
91
-
94
+
92
95
  # Upgrades the connection to a different protocol, if the 'Upgrade' header is
93
96
  # given. By default the only supported upgrade protocol is HTTP2. Additional
94
97
  # protocols, notably WebSocket, can be specified by passing a hash to the
@@ -114,28 +117,28 @@ module Tipi
114
117
  def upgrade_connection(headers, &block)
115
118
  upgrade_protocol = headers['upgrade']
116
119
  return nil unless upgrade_protocol
117
-
120
+
118
121
  upgrade_protocol = upgrade_protocol.downcase.to_sym
119
122
  upgrade_handler = @opts[:upgrade] && @opts[:upgrade][upgrade_protocol]
120
123
  return upgrade_with_handler(upgrade_handler, headers) if upgrade_handler
121
124
  return upgrade_to_http2(headers, &block) if upgrade_protocol == :h2c
122
-
125
+
123
126
  nil
124
127
  end
125
-
128
+
126
129
  def upgrade_with_handler(handler, headers)
127
130
  @parser = nil
128
131
  handler.(self, headers)
129
132
  true
130
133
  end
131
-
134
+
132
135
  def upgrade_to_http2(headers, &block)
133
136
  headers = http2_upgraded_headers(headers)
134
137
  body = @parser.read_body
135
138
  HTTP2Adapter.upgrade_each(@conn, @opts, headers, body, &block)
136
139
  true
137
140
  end
138
-
141
+
139
142
  # Returns headers for HTTP2 upgrade
140
143
  # @param headers [Hash] request headers
141
144
  # @return [Hash] headers for HTTP2 upgrade
@@ -153,10 +156,10 @@ module Tipi
153
156
  def scheme_from_connection
154
157
  @conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http'
155
158
  end
156
-
159
+
157
160
  # response API
158
161
 
159
- CRLF = "\r\n"
162
+ CRLF = "\r\n"
160
163
  CRLF_ZERO_CRLF_CRLF = "\r\n0\r\n\r\n"
161
164
 
162
165
  # Sends response including headers and body. Waits for the request to complete
@@ -174,17 +177,19 @@ module Tipi
174
177
  end
175
178
  end
176
179
 
180
+ CHUNK_LENGTH_PROC = ->(len) { "#{len.to_s(16)}\r\n" }
181
+
177
182
  def respond_from_io(request, io, headers, chunk_size = 2**14)
178
183
  formatted_headers = format_headers(headers, true, true)
179
184
  request.tx_incr(formatted_headers.bytesize)
180
-
185
+
181
186
  # assume chunked encoding
182
187
  Thread.current.backend.splice_chunks(
183
188
  io,
184
189
  @conn,
185
190
  formatted_headers,
186
191
  "0\r\n\r\n",
187
- ->(len) { "#{len.to_s(16)}\r\n" },
192
+ CHUNK_LENGTH_PROC,
188
193
  "\r\n",
189
194
  chunk_size
190
195
  )
@@ -206,7 +211,7 @@ module Tipi
206
211
  def http1_1?(request)
207
212
  request.headers[':protocol'] == 'http/1.1'
208
213
  end
209
-
214
+
210
215
  # Sends a response body chunk. If no headers were sent, default headers are
211
216
  # sent using #send_headers. if the done option is true(thy), an empty chunk
212
217
  # will be sent to signal response completion to the client.
@@ -223,7 +228,7 @@ module Tipi
223
228
  request.tx_incr(data.bytesize)
224
229
  @conn.write(data)
225
230
  end
226
-
231
+
227
232
  def send_chunk_from_io(request, io, r, w, chunk_size)
228
233
  len = w.splice(io, chunk_size)
229
234
  if len > 0
@@ -245,12 +250,12 @@ module Tipi
245
250
  request.tx_incr(5)
246
251
  @conn << "0\r\n\r\n"
247
252
  end
248
-
253
+
249
254
  def close
250
255
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
251
256
  @conn.close
252
257
  end
253
-
258
+
254
259
  private
255
260
 
256
261
  INTERNAL_HEADER_REGEXP = /^:/.freeze
@@ -267,13 +272,13 @@ module Tipi
267
272
  lines = format_status_line(body, status, chunked)
268
273
  headers.each do |k, v|
269
274
  next if k =~ INTERNAL_HEADER_REGEXP
270
-
275
+
271
276
  collect_header_lines(lines, k, v)
272
277
  end
273
278
  lines << CRLF
274
279
  lines
275
280
  end
276
-
281
+
277
282
  def format_status_line(body, status, chunked)
278
283
  if !body
279
284
  empty_status_line(status)
@@ -281,7 +286,7 @@ module Tipi
281
286
  with_body_status_line(status, body, chunked)
282
287
  end
283
288
  end
284
-
289
+
285
290
  def empty_status_line(status)
286
291
  if status == 204
287
292
  +"HTTP/1.1 #{status}\r\n"
@@ -289,7 +294,7 @@ module Tipi
289
294
  +"HTTP/1.1 #{status}\r\nContent-Length: 0\r\n"
290
295
  end
291
296
  end
292
-
297
+
293
298
  def with_body_status_line(status, body, chunked)
294
299
  if chunked
295
300
  +"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
@@ -21,7 +21,7 @@ module Tipi
21
21
  adapter = new(socket, opts, headers, body)
22
22
  adapter.each(&block)
23
23
  end
24
-
24
+
25
25
  def initialize(conn, opts, upgrade_headers = nil, upgrade_body = nil)
26
26
  @conn = conn
27
27
  @opts = opts
@@ -36,7 +36,7 @@ module Tipi
36
36
  @interface.on(:frame, &method(:send_frame))
37
37
  @streams = {}
38
38
  end
39
-
39
+
40
40
  def send_frame(data)
41
41
  if @transfer_count_request
42
42
  @transfer_count_request.tx_incr(data.bytesize)
@@ -47,14 +47,14 @@ module Tipi
47
47
  rescue Exception => e
48
48
  @connection_fiber.transfer e
49
49
  end
50
-
50
+
51
51
  UPGRADE_MESSAGE = <<~HTTP.gsub("\n", "\r\n")
52
52
  HTTP/1.1 101 Switching Protocols
53
53
  Connection: Upgrade
54
54
  Upgrade: h2c
55
-
55
+
56
56
  HTTP
57
-
57
+
58
58
  def upgrade
59
59
  @conn << UPGRADE_MESSAGE
60
60
  @tx += UPGRADE_MESSAGE.bytesize
@@ -63,7 +63,7 @@ module Tipi
63
63
  ensure
64
64
  @upgrade_headers = nil
65
65
  end
66
-
66
+
67
67
  # Iterates over incoming requests
68
68
  def each(&block)
69
69
  @interface.on(:stream) { |stream| start_stream(stream, &block) }
@@ -84,26 +84,26 @@ module Tipi
84
84
  @rx = 0
85
85
  count
86
86
  end
87
-
87
+
88
88
  def get_tx_count
89
89
  count = @tx
90
90
  @tx = 0
91
91
  count
92
92
  end
93
-
93
+
94
94
  def start_stream(stream, &block)
95
95
  stream = HTTP2StreamHandler.new(self, stream, @conn, @first, &block)
96
96
  @first = nil if @first
97
97
  @streams[stream] = true
98
98
  end
99
-
99
+
100
100
  def finalize_client_loop
101
101
  @interface = nil
102
102
  @streams.each_key(&:stop)
103
103
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
104
104
  @conn.close
105
105
  end
106
-
106
+
107
107
  def close
108
108
  @conn.shutdown if @conn.respond_to?(:shutdown) rescue nil
109
109
  @conn.close
@@ -8,7 +8,7 @@ module Tipi
8
8
  class HTTP2StreamHandler
9
9
  attr_accessor :__next__
10
10
  attr_reader :conn
11
-
11
+
12
12
  def initialize(adapter, stream, conn, first, &block)
13
13
  @adapter = adapter
14
14
  @stream = stream
@@ -32,7 +32,7 @@ module Tipi
32
32
  stream.on(:data, &method(:on_data))
33
33
  stream.on(:half_close, &method(:on_half_close))
34
34
  end
35
-
35
+
36
36
  def run(&block)
37
37
  request = receive
38
38
  error = nil
@@ -45,7 +45,7 @@ module Tipi
45
45
  ensure
46
46
  @connection_fiber.schedule error
47
47
  end
48
-
48
+
49
49
  def on_headers(headers)
50
50
  @request = Qeweney::Request.new(headers.to_h, self)
51
51
  @request.rx_incr(@adapter.get_rx_count)
@@ -59,7 +59,7 @@ module Tipi
59
59
 
60
60
  def on_data(data)
61
61
  data = data.to_s # chunks might be wrapped in a HTTP2::Buffer
62
-
62
+
63
63
  (@buffered_chunks ||= []) << data
64
64
  @get_body_chunk_fiber&.schedule
65
65
  end
@@ -68,7 +68,7 @@ module Tipi
68
68
  @get_body_chunk_fiber&.schedule
69
69
  @complete = true
70
70
  end
71
-
71
+
72
72
  def protocol
73
73
  'h2'
74
74
  end
@@ -84,7 +84,7 @@ module Tipi
84
84
  @buffered_chunks ||= []
85
85
  return @buffered_chunks.shift unless @buffered_chunks.empty?
86
86
  return nil if @complete
87
-
87
+
88
88
  begin
89
89
  @get_body_chunk_fiber = Fiber.current
90
90
  suspend
@@ -112,7 +112,7 @@ module Tipi
112
112
  def complete?(request)
113
113
  @complete
114
114
  end
115
-
115
+
116
116
  # response API
117
117
  def respond(request, chunk, headers)
118
118
  headers[':status'] ||= Qeweney::Status::OK
@@ -149,10 +149,10 @@ module Tipi
149
149
  end
150
150
  end
151
151
  end
152
-
152
+
153
153
  def send_headers(request, headers, empty_response: false)
154
154
  return if @headers_sent
155
-
155
+
156
156
  headers[':status'] ||= (empty_response ? Qeweney::Status::NO_CONTENT : Qeweney::Status::OK).to_s
157
157
  with_transfer_count(request) do
158
158
  @stream.headers(transform_headers(headers), end_stream: false)
@@ -161,10 +161,10 @@ module Tipi
161
161
  rescue HTTP2::Error::StreamClosed
162
162
  # ignore
163
163
  end
164
-
164
+
165
165
  def send_chunk(request, chunk, done: false)
166
166
  send_headers({}, false) unless @headers_sent
167
-
167
+
168
168
  if chunk
169
169
  with_transfer_count(request) do
170
170
  @stream.data(chunk, end_stream: done)
@@ -175,7 +175,7 @@ module Tipi
175
175
  rescue HTTP2::Error::StreamClosed
176
176
  # ignore
177
177
  end
178
-
178
+
179
179
  def finish(request)
180
180
  if @headers_sent
181
181
  @stream.close
@@ -188,10 +188,10 @@ module Tipi
188
188
  rescue HTTP2::Error::StreamClosed
189
189
  # ignore
190
190
  end
191
-
191
+
192
192
  def stop
193
193
  return if @complete
194
-
194
+
195
195
  @stream.close
196
196
  @stream_fiber.schedule(Polyphony::MoveOn.new)
197
197
  end
@@ -3,12 +3,12 @@
3
3
  require 'rack'
4
4
 
5
5
  module Tipi
6
- module RackAdapter
6
+ module RackAdapter
7
7
  class << self
8
8
  def run(app)
9
9
  ->(req) { respond(req, app.(env(req))) }
10
10
  end
11
-
11
+
12
12
  def load(path)
13
13
  src = IO.read(path)
14
14
  instance_eval(src, path, 1)
@@ -9,7 +9,7 @@ module Tipi
9
9
  def serve_io(io, opts)
10
10
  if !opts[:stat] || opts[:stat].size >= SPLICE_CHUNKS_SIZE_THRESHOLD
11
11
  @adapter.respond_from_io(self, io, opts[:headers], opts[:chunk_size] || 2**14)
12
- else
12
+ else
13
13
  respond(io.read, opts[:headers] || {})
14
14
  end
15
15
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'polyphony'
4
+ require 'json'
5
+
6
+ module Tipi
7
+ module Supervisor
8
+ class << self
9
+ def run(opts)
10
+ puts "Start supervisor pid: #{Process.pid}"
11
+ @opts = opts
12
+ @controller_watcher = start_controller_watcher
13
+ supervise_loop
14
+ end
15
+
16
+ def start_controller_watcher
17
+ spin do
18
+ cmd = controller_cmd
19
+ puts "Starting controller..."
20
+ pid = Kernel.spawn(*cmd)
21
+ @controller_pid = pid
22
+ puts "Controller pid: #{pid}"
23
+ _pid, status = Polyphony.backend_waitpid(pid)
24
+ puts "Controller has terminated with status: #{status.inspect}"
25
+ terminated = true
26
+ ensure
27
+ if pid && !terminated
28
+ puts "Terminate controller #{pid.inspect}"
29
+ Polyphony::Process.kill_process(pid)
30
+ end
31
+ Fiber.current.parent << pid
32
+ end
33
+ end
34
+
35
+ def controller_cmd
36
+ [
37
+ 'ruby',
38
+ File.join(__dir__, 'controller.rb'),
39
+ @opts.to_json
40
+ ]
41
+ end
42
+
43
+ def supervise_loop
44
+ this_fiber = Fiber.current
45
+ trap('SIGUSR2') { this_fiber << :replace_controller }
46
+ loop do
47
+ case (msg = receive)
48
+ when :replace_controller
49
+ replace_controller
50
+ when Integer
51
+ pid = msg
52
+ if pid == @controller_pid
53
+ puts 'Detected dead controller. Restarting...'
54
+ exit!
55
+ @controller_watcher.restart
56
+ end
57
+ else
58
+ raise "Invalid message received: #{msg.inspect}"
59
+ end
60
+ end
61
+ end
62
+
63
+ def replace_controller
64
+ puts "Replacing controller"
65
+ old_watcher = @controller_watcher
66
+ @controller_watcher = start_controller_watcher
67
+
68
+ # TODO: we'll want to get some kind of signal from the new controller once it's ready
69
+ sleep 1
70
+
71
+ old_watcher.terminate(true)
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/tipi/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tipi
4
- VERSION = '0.43'
4
+ VERSION = '0.45'
5
5
  end
@@ -20,12 +20,12 @@ module Tipi
20
20
  @version = headers['sec-websocket-version'].to_i
21
21
  @reader = ::WebSocket::Frame::Incoming::Server.new(version: @version)
22
22
  end
23
-
23
+
24
24
  def recv
25
25
  if (msg = @reader.next)
26
26
  return msg.to_s
27
27
  end
28
-
28
+
29
29
  @conn.recv_loop do |data|
30
30
  @reader << data
31
31
  if (msg = @reader.next)
@@ -48,7 +48,7 @@ module Tipi
48
48
  end
49
49
  end
50
50
  end
51
-
51
+
52
52
  OutgoingFrame = ::WebSocket::Frame::Outgoing::Server
53
53
 
54
54
  def send(data)