plum 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +84 -12
  3. data/circle.yml +27 -0
  4. data/examples/client/large.rb +20 -0
  5. data/examples/client/twitter.rb +51 -0
  6. data/examples/non_tls_server.rb +15 -9
  7. data/examples/static_server.rb +30 -23
  8. data/lib/plum.rb +9 -2
  9. data/lib/plum/client.rb +198 -0
  10. data/lib/plum/client/client_session.rb +91 -0
  11. data/lib/plum/client/connection.rb +19 -0
  12. data/lib/plum/client/legacy_client_session.rb +118 -0
  13. data/lib/plum/client/response.rb +100 -0
  14. data/lib/plum/client/upgrade_client_session.rb +46 -0
  15. data/lib/plum/connection.rb +58 -65
  16. data/lib/plum/connection_utils.rb +1 -1
  17. data/lib/plum/errors.rb +7 -3
  18. data/lib/plum/flow_control.rb +3 -3
  19. data/lib/plum/rack/listener.rb +3 -3
  20. data/lib/plum/rack/server.rb +1 -0
  21. data/lib/plum/rack/session.rb +5 -2
  22. data/lib/plum/server/connection.rb +42 -0
  23. data/lib/plum/{http_connection.rb → server/http_connection.rb} +7 -14
  24. data/lib/plum/{https_connection.rb → server/https_connection.rb} +2 -9
  25. data/lib/plum/stream.rb +54 -24
  26. data/lib/plum/stream_utils.rb +0 -12
  27. data/lib/plum/version.rb +1 -1
  28. data/plum.gemspec +2 -2
  29. data/test/plum/client/test_client.rb +152 -0
  30. data/test/plum/client/test_connection.rb +11 -0
  31. data/test/plum/client/test_legacy_client_session.rb +90 -0
  32. data/test/plum/client/test_response.rb +74 -0
  33. data/test/plum/client/test_upgrade_client_session.rb +45 -0
  34. data/test/plum/connection/test_handle_frame.rb +4 -1
  35. data/test/plum/{test_http_connection.rb → server/test_http_connection.rb} +4 -4
  36. data/test/plum/{test_https_connection.rb → server/test_https_connection.rb} +14 -8
  37. data/test/plum/test_connection.rb +9 -2
  38. data/test/plum/test_connection_utils.rb +9 -0
  39. data/test/plum/test_error.rb +1 -2
  40. data/test/plum/test_frame_factory.rb +37 -0
  41. data/test/plum/test_stream.rb +24 -4
  42. data/test/plum/test_stream_utils.rb +0 -1
  43. data/test/test_helper.rb +5 -2
  44. data/test/utils/assertions.rb +9 -9
  45. data/test/utils/client.rb +19 -0
  46. data/test/utils/server.rb +6 -6
  47. data/test/utils/string_socket.rb +15 -0
  48. metadata +36 -12
@@ -0,0 +1,91 @@
1
+ # -*- frozen-string-literal: true -*-
2
+ module Plum
3
+ # HTTP/2 client session.
4
+ class ClientSession
5
+ HTTP2_DEFAULT_SETTINGS = {
6
+ enable_push: 0, # TODO: api?
7
+ initial_window_size: 2 ** 30, # TODO
8
+ }
9
+
10
+ attr_reader :plum
11
+
12
+ def initialize(socket, config)
13
+ @socket = socket
14
+ @config = config
15
+ @http2_settings = HTTP2_DEFAULT_SETTINGS.merge(@config[:http2_settings])
16
+
17
+ @plum = setup_plum
18
+ @responses = Set.new
19
+ end
20
+
21
+ def succ
22
+ @plum << @socket.readpartial(16384)
23
+ rescue => e
24
+ fail(e)
25
+ end
26
+
27
+ def empty?
28
+ @responses.empty?
29
+ end
30
+
31
+ def close
32
+ @closed = true
33
+ @responses.each(&:_fail)
34
+ @responses.clear
35
+ @plum.close
36
+ end
37
+
38
+ def request(headers, body, options, &headers_cb)
39
+ headers = { ":method" => nil,
40
+ ":path" => nil,
41
+ ":authority" => @config[:hostname],
42
+ ":scheme" => @config[:scheme]
43
+ }.merge(headers)
44
+
45
+ response = Response.new
46
+ @responses << response
47
+ stream = @plum.open_stream
48
+ stream.send_headers(headers, end_stream: !body)
49
+ stream.send_data(body, end_stream: true) if body
50
+
51
+ stream.on(:headers) { |resp_headers_raw|
52
+ response._headers(resp_headers_raw)
53
+ headers_cb.call(response) if headers_cb
54
+ }
55
+ stream.on(:data) { |chunk|
56
+ response._chunk(chunk)
57
+ check_window(stream)
58
+ }
59
+ stream.on(:end_stream) {
60
+ response._finish
61
+ @responses.delete(response)
62
+ }
63
+ stream.on(:stream_error) { |ex|
64
+ response._fail
65
+ raise ex
66
+ }
67
+ response
68
+ end
69
+
70
+ private
71
+ def fail(exception)
72
+ close
73
+ raise exception
74
+ end
75
+
76
+ def setup_plum
77
+ plum = ClientConnection.new(@socket.method(:write), @http2_settings)
78
+ plum.on(:connection_error) { |ex|
79
+ fail(ex)
80
+ }
81
+ plum.window_update(@http2_settings[:initial_window_size])
82
+ plum
83
+ end
84
+
85
+ def check_window(stream)
86
+ ws = @http2_settings[:initial_window_size]
87
+ stream.window_update(ws) if stream.recv_remaining_window < (ws / 2)
88
+ @plum.window_update(ws) if @plum.recv_remaining_window < (ws / 2)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,19 @@
1
+ # -*- frozen-string-literal: true -*-
2
+ using Plum::BinaryString
3
+ module Plum
4
+ class ClientConnection < Connection
5
+ def initialize(writer, local_settings = {})
6
+ super(writer, local_settings)
7
+
8
+ writer.call(CLIENT_CONNECTION_PREFACE)
9
+ settings(local_settings)
10
+ @state = :waiting_settings
11
+ end
12
+
13
+ # Create a new stream for HTTP request.
14
+ def open_stream
15
+ next_id = @max_stream_id + (@max_stream_id.even? ? 1 : 2)
16
+ stream(next_id)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,118 @@
1
+ # -*- frozen-string-literal: true -*-
2
+ module Plum
3
+ # HTTP/1.x client session.
4
+ class LegacyClientSession
5
+ # Creates a new HTTP/1.1 client session
6
+ def initialize(socket, config)
7
+ require "http/parser"
8
+ @socket = socket
9
+ @config = config
10
+
11
+ @parser = setup_parser
12
+ @requests = []
13
+ @response = nil
14
+ @headers_callback = nil
15
+ end
16
+
17
+ def succ
18
+ @parser << @socket.readpartial(16384)
19
+ rescue => e # including HTTP::Parser::Error
20
+ fail(e)
21
+ end
22
+
23
+ def empty?
24
+ !@response
25
+ end
26
+
27
+ def close
28
+ @closed = true
29
+ @response._fail if @response
30
+ end
31
+
32
+ def request(headers, body, options, &headers_cb)
33
+ headers["host"] = headers[":authority"] || headers["host"] || @config[:hostname]
34
+ if body
35
+ if headers["content-length"] || headers["transfer-encoding"]
36
+ chunked = false
37
+ else
38
+ chunked = true
39
+ headers["transfer-encoding"] = "chunked"
40
+ end
41
+ end
42
+
43
+ response = Response.new
44
+ @requests << [response, headers, body, chunked, headers_cb]
45
+ consume_queue
46
+ response
47
+ end
48
+
49
+ private
50
+ def fail(exception)
51
+ close
52
+ raise exception
53
+ end
54
+
55
+ def consume_queue
56
+ return if @response || @requests.empty?
57
+
58
+ response, headers, body, chunked, cb = @requests.shift
59
+ @response = response
60
+ @headers_callback = cb
61
+
62
+ @socket << construct_request(headers)
63
+
64
+ if body
65
+ if chunked
66
+ read_object(body) { |chunk|
67
+ @socket << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n"
68
+ }
69
+ else
70
+ read_object(body) { |chunk| @socket << chunk }
71
+ end
72
+ end
73
+ end
74
+
75
+ def construct_request(headers)
76
+ out = String.new
77
+ out << "%s %s HTTP/1.1\r\n" % [headers[":method"], headers[":path"]]
78
+ headers.each { |key, value|
79
+ next if key.start_with?(":") # HTTP/2 psuedo headers
80
+ out << "%s: %s\r\n" % [key, value]
81
+ }
82
+ out << "\r\n"
83
+ end
84
+
85
+ def read_object(body)
86
+ if body.is_a?(String)
87
+ yield body
88
+ else # IO
89
+ until body.eof?
90
+ yield body.readpartial(1024)
91
+ end
92
+ end
93
+ end
94
+
95
+ def setup_parser
96
+ parser = HTTP::Parser.new
97
+ parser.on_headers_complete = proc {
98
+ resp_headers = parser.headers.map { |key, value| [key.downcase, value] }.to_h
99
+ @response._headers({ ":status" => parser.status_code.to_s }.merge(resp_headers))
100
+ @headers_callback.call(@response) if @headers_callback
101
+ }
102
+
103
+ parser.on_body = proc { |chunk|
104
+ @response._chunk(chunk)
105
+ }
106
+
107
+ parser.on_message_complete = proc { |env|
108
+ @response._finish
109
+ @response = nil
110
+ @headers_callback = nil
111
+ close unless parser.keep_alive?
112
+ consume_queue
113
+ }
114
+
115
+ parser
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,100 @@
1
+ # -*- frozen-string-literal: true -*-
2
+ module Plum
3
+ class Response
4
+ # The response headers
5
+ # @return [Hash<String, String>]
6
+ attr_reader :headers
7
+
8
+ # @api private
9
+ def initialize
10
+ @body = Queue.new
11
+ @finished = false
12
+ @failed = false
13
+ @body = []
14
+ end
15
+
16
+ # Returns the HTTP status code.
17
+ # @return [String] the HTTP status code
18
+ def status
19
+ @headers && @headers[":status"]
20
+ end
21
+
22
+ # Returns the header value that correspond to the header name.
23
+ # @param key [String] the header name
24
+ # @return [String] the header value
25
+ def [](key)
26
+ @headers[key.to_s.downcase]
27
+ end
28
+
29
+ # Returns whether the response is complete or not.
30
+ # @return [Boolean]
31
+ def finished?
32
+ @finished
33
+ end
34
+
35
+ # Returns whether the request has failed or not.
36
+ # @return [Boolean]
37
+ def failed?
38
+ @failed
39
+ end
40
+
41
+ # Set callback tha called when received a chunk of response body.
42
+ # @yield [chunk] A chunk of the response body.
43
+ def on_chunk(&block)
44
+ raise "Body already read" if @on_chunk
45
+ raise ArgumentError, "block must be given" unless block_given?
46
+ @on_chunk = block
47
+ unless @body.empty?
48
+ @body.each(&block)
49
+ @body.clear
50
+ end
51
+ end
52
+
53
+ # Set callback that will be called when the response finished.
54
+ def on_finish(&block)
55
+ raise ArgumentError, "block must be given" unless block_given?
56
+ if finished?
57
+ block.call
58
+ else
59
+ @on_finish = block
60
+ end
61
+ end
62
+
63
+ # Returns the complete response body. Use #each_body instead if the body can be very large.
64
+ # @return [String] the whole response body
65
+ def body
66
+ raise "Body already read" if @on_chunk
67
+ if finished?
68
+ @body.join
69
+ else
70
+ raise "Response body is not complete"
71
+ end
72
+ end
73
+
74
+ # @api private
75
+ def _headers(raw_headers)
76
+ # response headers should not have duplicates
77
+ @headers = raw_headers.to_h.freeze
78
+ end
79
+
80
+ # @api private
81
+ def _chunk(chunk)
82
+ if @on_chunk
83
+ @on_chunk.call(chunk)
84
+ else
85
+ @body << chunk
86
+ end
87
+ end
88
+
89
+ # @api private
90
+ def _finish
91
+ @finished = true
92
+ @on_finish.call if @on_finish
93
+ end
94
+
95
+ # @api private
96
+ def _fail
97
+ @failed = true
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,46 @@
1
+ # -*- frozen-string-literal: true -*-
2
+ module Plum
3
+ # Try upgrade to HTTP/2
4
+ class UpgradeClientSession
5
+ def initialize(socket, config)
6
+ prepare_session(socket, config)
7
+ end
8
+
9
+ def succ
10
+ @session.succ
11
+ end
12
+
13
+ def empty?
14
+ @session.empty?
15
+ end
16
+
17
+ def close
18
+ @session.close
19
+ end
20
+
21
+ def request(headers, body, options, &headers_cb)
22
+ @session.request(headers, body, options, &headers_cb)
23
+ end
24
+
25
+ private
26
+ def prepare_session(socket, config)
27
+ lcs = LegacyClientSession.new(socket, config)
28
+ opt_res = lcs.request({ ":method" => "OPTIONS",
29
+ ":path" => "*",
30
+ "User-Agent" => config[:user_agent],
31
+ "Connection" => "Upgrade, HTTP2-Settings",
32
+ "Upgrade" => "h2c",
33
+ "HTTP2-Settings" => "" }, nil, {})
34
+ lcs.succ until opt_res.finished?
35
+
36
+ if opt_res.status == "101"
37
+ lcs.close
38
+ @session = ClientSession.new(socket, config)
39
+ @session.plum.stream(1).set_state(:half_closed_local)
40
+ else
41
+ @session = lcs
42
+ end
43
+ end
44
+ end
45
+ end
46
+
@@ -23,23 +23,23 @@ module Plum
23
23
  attr_reader :state, :streams
24
24
 
25
25
  def initialize(writer, local_settings = {})
26
+ @state = :open
26
27
  @writer = writer
27
28
  @local_settings = Hash.new {|hash, key| DEFAULT_SETTINGS[key] }.merge!(local_settings)
28
29
  @remote_settings = Hash.new {|hash, key| DEFAULT_SETTINGS[key] }
29
30
  @buffer = String.new
30
31
  @streams = {}
31
- @state = :negotiation
32
32
  @hpack_decoder = HPACK::Decoder.new(@local_settings[:header_table_size])
33
33
  @hpack_encoder = HPACK::Encoder.new(@remote_settings[:header_table_size])
34
34
  initialize_flow_control(send: @remote_settings[:initial_window_size],
35
35
  recv: @local_settings[:initial_window_size])
36
- @max_odd_stream_id = 0
37
- @max_even_stream_id = 0
36
+ @max_stream_id = 0
38
37
  end
39
- private :initialize
40
38
 
41
39
  # Emits :close event. Doesn't actually close socket.
42
40
  def close
41
+ return if @state == :closed
42
+ @state = :closed
43
43
  # TODO: server MAY wait streams
44
44
  callback(:close)
45
45
  end
@@ -47,80 +47,69 @@ module Plum
47
47
  # Receives the specified data and process.
48
48
  # @param new_data [String] The data received from the peer.
49
49
  def receive(new_data)
50
+ return if @state == :closed
50
51
  return if new_data.empty?
51
52
  @buffer << new_data
52
-
53
- negotiate! if @state == :negotiation
54
-
55
- if @state != :negotiation
56
- while frame = Frame.parse!(@buffer)
57
- callback(:frame, frame)
58
- receive_frame(frame)
59
- end
60
- end
61
- rescue ConnectionError => e
53
+ consume_buffer
54
+ rescue RemoteConnectionError => e
62
55
  callback(:connection_error, e)
63
56
  goaway(e.http2_error_type)
64
57
  close
65
58
  end
66
59
  alias << receive
67
60
 
68
- # Reserves a new stream to server push.
69
- # @param args [Hash] The argument to pass to Stram.new.
70
- def reserve_stream(**args)
71
- next_id = @max_even_stream_id + 2
72
- stream = new_stream(next_id, state: :reserved_local, **args)
73
- stream
74
- end
75
-
76
- private
77
- def send_immediately(frame)
78
- callback(:send_frame, frame)
79
- @writer.call(frame.assemble)
80
- end
61
+ # Returns a Stream object with the specified ID.
62
+ # @param stream_id [Integer] the stream id
63
+ # @return [Stream] the stream
64
+ def stream(stream_id)
65
+ raise ArgumentError, "stream_id can't be 0" if stream_id == 0
81
66
 
82
- def negotiate!
83
- unless CLIENT_CONNECTION_PREFACE.start_with?(@buffer.byteslice(0, 24))
84
- raise ConnectionError.new(:protocol_error) # (MAY) send GOAWAY. sending.
67
+ stream = @streams[stream_id]
68
+ if stream
69
+ if stream.state == :idle && stream.id < @max_stream_id
70
+ stream.set_state(:closed_implicitly)
71
+ end
72
+ elsif stream_id > @max_stream_id
73
+ @max_stream_id = stream_id
74
+ stream = Stream.new(self, stream_id, state: :idle)
75
+ callback(:stream, stream)
76
+ @streams[stream_id] = stream
77
+ else
78
+ stream = Stream.new(self, stream_id, state: :closed_implicitly)
79
+ callback(:stream, stream)
85
80
  end
86
81
 
87
- if @buffer.bytesize >= 24
88
- @buffer.byteshift(24)
89
- @state = :waiting_settings
90
- settings(@local_settings)
91
- end
82
+ stream
92
83
  end
93
84
 
94
- def new_stream(stream_id, **args)
95
- if stream_id.even?
96
- @max_even_stream_id = stream_id
97
- else
98
- @max_odd_stream_id = stream_id
85
+ private
86
+ def consume_buffer
87
+ while frame = Frame.parse!(@buffer)
88
+ callback(:frame, frame)
89
+ receive_frame(frame)
99
90
  end
91
+ end
100
92
 
101
- stream = Stream.new(self, stream_id, **args)
102
- @streams[stream_id] = stream
103
- callback(:stream, stream)
104
- stream
93
+ def send_immediately(frame)
94
+ callback(:send_frame, frame)
95
+ @writer.call(frame.assemble)
105
96
  end
106
97
 
107
98
  def validate_received_frame(frame)
108
99
  if @state == :waiting_settings && frame.type != :settings
109
- raise ConnectionError.new(:protocol_error)
100
+ raise RemoteConnectionError.new(:protocol_error)
110
101
  end
111
102
 
112
103
  if @state == :waiting_continuation
113
104
  if frame.type != :continuation || frame.stream_id != @continuation_id
114
- raise ConnectionError.new(:protocol_error)
105
+ raise RemoteConnectionError.new(:protocol_error)
115
106
  end
116
-
117
107
  if frame.end_headers?
118
108
  @state = :open
119
- @continuation_id = nil
120
109
  end
121
110
  end
122
111
 
123
- if frame.type == :headers
112
+ if frame.type == :headers || frame.type == :push_promise
124
113
  if !frame.end_headers?
125
114
  @state = :waiting_continuation
126
115
  @continuation_id = frame.stream_id
@@ -135,20 +124,13 @@ module Plum
135
124
  if frame.stream_id == 0
136
125
  receive_control_frame(frame)
137
126
  else
138
- unless stream = @streams[frame.stream_id]
139
- if frame.stream_id.even? || @max_odd_stream_id >= frame.stream_id
140
- raise Plum::ConnectionError.new(:protocol_error)
141
- end
142
-
143
- stream = new_stream(frame.stream_id)
144
- end
145
- stream.receive_frame(frame)
127
+ stream(frame.stream_id).receive_frame(frame)
146
128
  end
147
129
  end
148
130
 
149
131
  def receive_control_frame(frame)
150
132
  if frame.length > @local_settings[:max_frame_size]
151
- raise ConnectionError.new(:frame_size_error)
133
+ raise RemoteConnectionError.new(:frame_size_error)
152
134
  end
153
135
 
154
136
  case frame.type
@@ -159,11 +141,9 @@ module Plum
159
141
  when :ping
160
142
  receive_ping(frame)
161
143
  when :goaway
162
- callback(:goaway, frame)
163
- goaway
164
- close
144
+ receive_goaway(frame)
165
145
  when :data, :headers, :priority, :rst_stream, :push_promise, :continuation
166
- raise Plum::ConnectionError.new(:protocol_error)
146
+ raise Plum::RemoteConnectionError.new(:protocol_error)
167
147
  else
168
148
  # MUST ignore unknown frame type.
169
149
  end
@@ -171,11 +151,11 @@ module Plum
171
151
 
172
152
  def receive_settings(frame, send_ack: true)
173
153
  if frame.ack?
174
- raise ConnectionError.new(:frame_size_error) if frame.length != 0
154
+ raise RemoteConnectionError.new(:frame_size_error) if frame.length != 0
175
155
  callback(:settings_ack)
176
156
  return
177
157
  else
178
- raise ConnectionError.new(:frame_size_error) if frame.length % 6 != 0
158
+ raise RemoteConnectionError.new(:frame_size_error) if frame.length % 6 != 0
179
159
  end
180
160
 
181
161
  old_remote_settings = @remote_settings.dup
@@ -198,7 +178,7 @@ module Plum
198
178
  end
199
179
 
200
180
  def receive_ping(frame)
201
- raise Plum::ConnectionError.new(:frame_size_error) if frame.length != 8
181
+ raise Plum::RemoteConnectionError.new(:frame_size_error) if frame.length != 8
202
182
 
203
183
  if frame.ack?
204
184
  callback(:ping_ack)
@@ -208,5 +188,18 @@ module Plum
208
188
  send_immediately Frame.ping(:ack, opaque_data)
209
189
  end
210
190
  end
191
+
192
+ def receive_goaway(frame)
193
+ callback(:goaway, frame)
194
+ goaway
195
+ close
196
+
197
+ last_id = frame.payload.uint32(0)
198
+ error_code = frame.payload.uint32(4)
199
+ message = frame.payload.byteslice(8, frame.length - 8)
200
+ if error_code > 0
201
+ raise LocalConnectionError.new(HTTPError::ERROR_CODES.key(error_code), message)
202
+ end
203
+ end
211
204
  end
212
205
  end