plum 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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