plum 0.2.1 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/examples/client/twitter.rb +2 -1
- data/lib/plum/binary_string.rb +14 -0
- data/lib/plum/client/connection.rb +1 -1
- data/lib/plum/connection.rb +14 -7
- data/lib/plum/connection_utils.rb +1 -1
- data/lib/plum/errors.rb +8 -5
- data/lib/plum/frame_factory.rb +20 -12
- data/lib/plum/frame_utils.rb +21 -26
- data/lib/plum/hpack/constants.rb +2 -0
- data/lib/plum/hpack/context.rb +4 -4
- data/lib/plum/rack/cli.rb +17 -2
- data/lib/plum/rack/config.rb +2 -1
- data/lib/plum/rack/dsl.rb +10 -0
- data/lib/plum/rack/listener.rb +11 -5
- data/lib/plum/rack/server.rb +39 -11
- data/lib/plum/rack/session.rb +55 -31
- data/lib/plum/server/connection.rb +1 -1
- data/lib/plum/server/http_connection.rb +26 -24
- data/lib/plum/stream.rb +0 -1
- data/lib/plum/stream_utils.rb +8 -14
- data/lib/plum/version.rb +1 -1
- data/lib/rack/handler/plum.rb +2 -0
- data/test/plum/client/test_upgrade_client_session.rb +2 -2
- data/test/plum/test_binary_string.rb +6 -0
- data/test/plum/test_connection.rb +11 -0
- data/test/plum/test_connection_utils.rb +1 -1
- data/test/plum/test_flow_control.rb +6 -6
- data/test/plum/test_frame_factory.rb +3 -3
- data/test/plum/test_frame_utils.rb +7 -8
- data/test/plum/test_stream.rb +2 -2
- data/test/utils/server.rb +4 -5
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3671dbbd1de29adc6a254e5c95cef0a20cce6970
|
4
|
+
data.tar.gz: efd31623c389dbefb5d8d46de2f4f1233d72470e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8dd0b9146d5261854136d69c4bcbbe471f31daeb1785b3219779c5ad8daa76bfab82763a383e308b2c17f2fe6ec127a7c8b293ab59a33c22bb7123ecd9ccaab
|
7
|
+
data.tar.gz: 035f11f0270f7fb7c7c95cae95993edcf4e924d6f10643928a53f5bcbee8501b13f66b537eb2875607abf2b87f9c0fd3fd16de1241c525d91742505f2b6909f8
|
data/examples/client/twitter.rb
CHANGED
@@ -17,7 +17,7 @@ rest = Plum::Client.start("api.twitter.com", 443)
|
|
17
17
|
Plum::Client.start("userstream.twitter.com", 443) { |streaming|
|
18
18
|
streaming.get("/1.1/user.json",
|
19
19
|
headers: { "authorization" => SimpleOAuth::Header.new(:get, "https://userstream.twitter.com/1.1/user.json", {}, credentials).to_s,
|
20
|
-
"accept-encoding" => "
|
20
|
+
"accept-encoding" => "gzip, deflate" }) { |res|
|
21
21
|
if res.status != "200"
|
22
22
|
puts "failed userstream"
|
23
23
|
exit
|
@@ -25,6 +25,7 @@ Plum::Client.start("userstream.twitter.com", 443) { |streaming|
|
|
25
25
|
|
26
26
|
buf = String.new
|
27
27
|
res.on_chunk { |chunk| # when received DATA frame
|
28
|
+
next if chunk.empty?
|
28
29
|
buf << chunk
|
29
30
|
*msgs, buf = buf.split("\r\n", -1)
|
30
31
|
|
data/lib/plum/binary_string.rb
CHANGED
@@ -70,6 +70,20 @@ module Plum
|
|
70
70
|
# I want to write `enum_for(__method__, n)`!
|
71
71
|
end
|
72
72
|
end
|
73
|
+
|
74
|
+
# Splits this String into chunks.
|
75
|
+
# @param n [Integer] max chunk bytesize
|
76
|
+
# @return [Array<String>] the slices
|
77
|
+
def chunk(n)
|
78
|
+
res = []
|
79
|
+
pos = 0
|
80
|
+
lim = bytesize
|
81
|
+
while pos < lim
|
82
|
+
res << byteslice(pos, n)
|
83
|
+
pos += n
|
84
|
+
end
|
85
|
+
res
|
86
|
+
end
|
73
87
|
end
|
74
88
|
end
|
75
89
|
end
|
data/lib/plum/connection.rb
CHANGED
@@ -33,7 +33,7 @@ module Plum
|
|
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
|
-
@
|
36
|
+
@max_stream_ids = [0, -1] # [even, odd]
|
37
37
|
end
|
38
38
|
|
39
39
|
# Emits :close event. Doesn't actually close socket.
|
@@ -61,16 +61,16 @@ module Plum
|
|
61
61
|
# Returns a Stream object with the specified ID.
|
62
62
|
# @param stream_id [Integer] the stream id
|
63
63
|
# @return [Stream] the stream
|
64
|
-
def stream(stream_id)
|
64
|
+
def stream(stream_id, update_max_id = true)
|
65
65
|
raise ArgumentError, "stream_id can't be 0" if stream_id == 0
|
66
66
|
|
67
67
|
stream = @streams[stream_id]
|
68
68
|
if stream
|
69
|
-
if stream.state == :idle &&
|
69
|
+
if stream.state == :idle && stream_id < @max_stream_ids[stream_id % 2]
|
70
70
|
stream.set_state(:closed_implicitly)
|
71
71
|
end
|
72
|
-
elsif stream_id > @
|
73
|
-
@
|
72
|
+
elsif stream_id > @max_stream_ids[stream_id % 2]
|
73
|
+
@max_stream_ids[stream_id % 2] = stream_id if update_max_id
|
74
74
|
stream = Stream.new(self, stream_id, state: :idle)
|
75
75
|
callback(:stream, stream)
|
76
76
|
@streams[stream_id] = stream
|
@@ -92,7 +92,14 @@ module Plum
|
|
92
92
|
|
93
93
|
def send_immediately(frame)
|
94
94
|
callback(:send_frame, frame)
|
95
|
-
|
95
|
+
|
96
|
+
if frame.length <= @remote_settings[:max_frame_size]
|
97
|
+
@writer.call(frame.assemble)
|
98
|
+
else
|
99
|
+
frame.split(@remote_settings[:max_frame_size]) { |splitted|
|
100
|
+
@writer.call(splitted.assemble)
|
101
|
+
}
|
102
|
+
end
|
96
103
|
end
|
97
104
|
|
98
105
|
def validate_received_frame(frame)
|
@@ -124,7 +131,7 @@ module Plum
|
|
124
131
|
if frame.stream_id == 0
|
125
132
|
receive_control_frame(frame)
|
126
133
|
else
|
127
|
-
stream(frame.stream_id).receive_frame(frame)
|
134
|
+
stream(frame.stream_id, frame.type == :headers).receive_frame(frame)
|
128
135
|
end
|
129
136
|
end
|
130
137
|
|
@@ -20,7 +20,7 @@ module Plum
|
|
20
20
|
# Sends GOAWAY frame to the peer and closes the connection.
|
21
21
|
# @param error_type [Symbol] The error type to be contained in the GOAWAY frame.
|
22
22
|
def goaway(error_type = :no_error)
|
23
|
-
last_id = @
|
23
|
+
last_id = @max_stream_ids.max
|
24
24
|
send_immediately Frame.goaway(last_id, error_type)
|
25
25
|
end
|
26
26
|
|
data/lib/plum/errors.rb
CHANGED
@@ -30,6 +30,10 @@ module Plum
|
|
30
30
|
def http2_error_code
|
31
31
|
ERROR_CODES[@http2_error_type]
|
32
32
|
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
"#{@http2_error_type.to_s.upcase}: #{super}"
|
36
|
+
end
|
33
37
|
end
|
34
38
|
|
35
39
|
class RemoteHTTPError < HTTPError; end
|
@@ -40,12 +44,11 @@ module Plum
|
|
40
44
|
class LocalStreamError < LocalHTTPError; end
|
41
45
|
|
42
46
|
class LegacyHTTPError < Error
|
43
|
-
attr_reader :
|
47
|
+
attr_reader :buf
|
44
48
|
|
45
|
-
def initialize(
|
46
|
-
|
47
|
-
@
|
48
|
-
@parser = parser
|
49
|
+
def initialize(message, buf = nil)
|
50
|
+
super(message)
|
51
|
+
@buf = buf
|
49
52
|
end
|
50
53
|
end
|
51
54
|
|
data/lib/plum/frame_factory.rb
CHANGED
@@ -54,37 +54,45 @@ module Plum
|
|
54
54
|
# Creates a DATA frame.
|
55
55
|
# @param stream_id [Integer] The stream ID.
|
56
56
|
# @param payload [String] Payload.
|
57
|
-
# @param
|
58
|
-
def data(stream_id, payload,
|
57
|
+
# @param end_stream [Boolean] add END_STREAM flag
|
58
|
+
def data(stream_id, payload, end_stream: false)
|
59
59
|
payload = payload.b if payload && payload.encoding != Encoding::BINARY
|
60
|
-
|
60
|
+
fval = 0
|
61
|
+
fval += 1 if end_stream
|
62
|
+
Frame.new(type_value: 0, stream_id: stream_id, flags_value: fval, payload: payload)
|
61
63
|
end
|
62
64
|
|
63
65
|
# Creates a HEADERS frame.
|
64
66
|
# @param stream_id [Integer] The stream ID.
|
65
67
|
# @param encoded [String] Headers.
|
66
|
-
# @param
|
67
|
-
|
68
|
-
|
68
|
+
# @param end_stream [Boolean] add END_STREAM flag
|
69
|
+
# @param end_headers [Boolean] add END_HEADERS flag
|
70
|
+
def headers(stream_id, encoded, end_stream: false, end_headers: false)
|
71
|
+
fval = 0
|
72
|
+
fval += 1 if end_stream
|
73
|
+
fval += 4 if end_headers
|
74
|
+
Frame.new(type_value: 1, stream_id: stream_id, flags_value: fval, payload: encoded)
|
69
75
|
end
|
70
76
|
|
71
77
|
# Creates a PUSH_PROMISE frame.
|
72
78
|
# @param stream_id [Integer] The stream ID.
|
73
79
|
# @param new_id [Integer] The stream ID to create.
|
74
80
|
# @param encoded [String] Request headers.
|
75
|
-
# @param
|
76
|
-
def push_promise(stream_id, new_id, encoded,
|
81
|
+
# @param end_headers [Boolean] add END_HEADERS flag
|
82
|
+
def push_promise(stream_id, new_id, encoded, end_headers: false)
|
77
83
|
payload = String.new.push_uint32(new_id)
|
78
84
|
.push(encoded)
|
79
|
-
|
85
|
+
fval = 0
|
86
|
+
fval += 4 if end_headers
|
87
|
+
Frame.new(type: :push_promise, stream_id: stream_id, flags_value: fval, payload: payload)
|
80
88
|
end
|
81
89
|
|
82
90
|
# Creates a CONTINUATION frame.
|
83
91
|
# @param stream_id [Integer] The stream ID.
|
84
92
|
# @param payload [String] Payload.
|
85
|
-
# @param
|
86
|
-
def continuation(stream_id, payload,
|
87
|
-
Frame.new(type: :continuation, stream_id: stream_id,
|
93
|
+
# @param end_headers [Boolean] add END_HEADERS flag
|
94
|
+
def continuation(stream_id, payload, end_headers: false)
|
95
|
+
Frame.new(type: :continuation, stream_id: stream_id, flags_value: (end_headers && 4 || 0), payload: payload)
|
88
96
|
end
|
89
97
|
end
|
90
98
|
end
|
data/lib/plum/frame_utils.rb
CHANGED
@@ -3,33 +3,28 @@ using Plum::BinaryString
|
|
3
3
|
|
4
4
|
module Plum
|
5
5
|
module FrameUtils
|
6
|
-
# Splits
|
6
|
+
# Splits this frame into multiple frames not to exceed MAX_FRAME_SIZE.
|
7
7
|
# @param max [Integer] The maximum size of a frame payload.
|
8
|
-
# @
|
9
|
-
def
|
10
|
-
return
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
frames = fragments.map {|fragment| Frame.new(type: :continuation, flags: [], stream_id: self.stream_id, payload: fragment) }
|
29
|
-
frames.first.type_value = self.type_value
|
30
|
-
frames.first.flags = self.flags - [:end_headers]
|
31
|
-
frames.last.flags = self.flags & [:end_headers]
|
32
|
-
frames
|
8
|
+
# @yield [Frame] The splitted frames.
|
9
|
+
def split(max)
|
10
|
+
return yield self if @length <= max
|
11
|
+
first, *mid, last = @payload.chunk(max)
|
12
|
+
case type
|
13
|
+
when :data
|
14
|
+
yield Frame.new(type_value: 0, stream_id: @stream_id, payload: first, flags_value: @flags_value & ~1)
|
15
|
+
mid.each { |slice|
|
16
|
+
yield Frame.new(type_value: 0, stream_id: @stream_id, payload: slice, flags_value: 0)
|
17
|
+
}
|
18
|
+
yield Frame.new(type_value: 0, stream_id: @stream_id, payload: last, flags_value: @flags_value & 1)
|
19
|
+
when :headers, :push_promise
|
20
|
+
yield Frame.new(type_value: @type_value, stream_id: @stream_id, payload: first, flags_value: @flags_value & ~4)
|
21
|
+
mid.each { |slice|
|
22
|
+
yield Frame.new(type: :continuation, stream_id: @stream_id, payload: slice, flags_value: 0)
|
23
|
+
}
|
24
|
+
yield Frame.new(type: :continuation, stream_id: @stream_id, payload: last, flags_value: @flags_value & 4)
|
25
|
+
else
|
26
|
+
raise NotImplementedError.new("frame split of frame with type #{type} is not supported")
|
27
|
+
end
|
33
28
|
end
|
34
29
|
|
35
30
|
# Parses SETTINGS frame payload. Ignores unknown settings type (see RFC7540 6.5.2).
|
data/lib/plum/hpack/constants.rb
CHANGED
data/lib/plum/hpack/context.rb
CHANGED
@@ -26,10 +26,10 @@ module Plum
|
|
26
26
|
def fetch(index)
|
27
27
|
if index == 0
|
28
28
|
raise HPACKError.new("index can't be 0")
|
29
|
-
elsif index <=
|
30
|
-
|
29
|
+
elsif index <= STATIC_TABLE_SIZE
|
30
|
+
STATIC_TABLE[index - 1]
|
31
31
|
elsif index <= STATIC_TABLE.size + @dynamic_table.size
|
32
|
-
@dynamic_table[index -
|
32
|
+
@dynamic_table[index - STATIC_TABLE_SIZE - 1]
|
33
33
|
else
|
34
34
|
raise HPACKError.new("invalid index: #{index}")
|
35
35
|
end
|
@@ -43,7 +43,7 @@ module Plum
|
|
43
43
|
si = STATIC_TABLE.index &pr
|
44
44
|
return si + 1 if si
|
45
45
|
di = @dynamic_table.index &pr
|
46
|
-
return di +
|
46
|
+
return di + STATIC_TABLE_SIZE + 1 if di
|
47
47
|
end
|
48
48
|
|
49
49
|
def evict
|
data/lib/plum/rack/cli.rb
CHANGED
@@ -44,6 +44,13 @@ module Plum
|
|
44
44
|
ENV["RACK_ENV"] = @options[:env] if @options[:env]
|
45
45
|
config[:debug] = @options[:debug] unless @options[:debug].nil?
|
46
46
|
config[:server_push] = @options[:server_push] unless @options[:server_push].nil?
|
47
|
+
config[:threaded] = @options[:threaded] unless @options[:threaded].nil?
|
48
|
+
|
49
|
+
if @options[:fallback_legacy]
|
50
|
+
h, p = @options[:fallback_legacy].split(":")
|
51
|
+
config[:fallback_legacy_host] = h
|
52
|
+
config[:fallback_legacy_port] = p.to_i
|
53
|
+
end
|
47
54
|
|
48
55
|
if @options[:socket]
|
49
56
|
config[:listeners] << { listener: UNIXListener,
|
@@ -59,8 +66,8 @@ module Plum
|
|
59
66
|
config[:listeners] << { listener: TLSListener,
|
60
67
|
hostname: @options[:host] || "0.0.0.0",
|
61
68
|
port: @options[:port] || 8080,
|
62
|
-
certificate: @options[:cert]
|
63
|
-
certificate_key: @options[:cert] &&
|
69
|
+
certificate: @options[:cert],
|
70
|
+
certificate_key: @options[:cert] && @options[:key] }
|
64
71
|
end
|
65
72
|
end
|
66
73
|
|
@@ -113,6 +120,14 @@ module Plum
|
|
113
120
|
@options[:key] = arg
|
114
121
|
end
|
115
122
|
|
123
|
+
o.on "--threaded", "Call the Rack application in threads (experimental)" do
|
124
|
+
@options[:threaded] = true
|
125
|
+
end
|
126
|
+
|
127
|
+
o.on "--fallback-legacy HOST:PORT", "Fallbacks if the client doesn't support HTTP/2" do |arg|
|
128
|
+
@options[:fallback_legacy] = arg
|
129
|
+
end
|
130
|
+
|
116
131
|
o.on "-v", "--version", "Show version" do
|
117
132
|
puts "plum version #{::Plum::VERSION}"
|
118
133
|
exit(0)
|
data/lib/plum/rack/config.rb
CHANGED
data/lib/plum/rack/dsl.rb
CHANGED
@@ -39,6 +39,16 @@ module Plum
|
|
39
39
|
def server_push(bool)
|
40
40
|
@config[:server_push] = !!bool
|
41
41
|
end
|
42
|
+
|
43
|
+
def threaded(bool)
|
44
|
+
@config[:threaded] = !!bool
|
45
|
+
end
|
46
|
+
|
47
|
+
def fallback_legacy(str)
|
48
|
+
h, p = str.split(":")
|
49
|
+
@config[:fallback_legacy_host] = h
|
50
|
+
@config[:fallback_legacy_port] = p.to_i
|
51
|
+
end
|
42
52
|
end
|
43
53
|
end
|
44
54
|
end
|
data/lib/plum/rack/listener.rb
CHANGED
@@ -31,17 +31,22 @@ module Plum
|
|
31
31
|
|
32
32
|
class TLSListener < BaseListener
|
33
33
|
def initialize(lc)
|
34
|
-
|
35
|
-
|
36
|
-
|
34
|
+
if lc[:certificate] && lc[:certificate_key]
|
35
|
+
cert = File.read(lc[:certificate])
|
36
|
+
key = File.read(lc[:certificate_key])
|
37
|
+
else
|
38
|
+
STDERR.puts "WARNING: using dummy certificate"
|
37
39
|
cert, key = dummy_key
|
38
40
|
end
|
39
41
|
|
40
42
|
ctx = OpenSSL::SSL::SSLContext.new
|
41
43
|
ctx.ssl_version = :TLSv1_2
|
42
44
|
ctx.alpn_select_cb = -> protocols {
|
43
|
-
|
44
|
-
|
45
|
+
if protocols.include?("h2")
|
46
|
+
"h2"
|
47
|
+
else
|
48
|
+
protocols.first
|
49
|
+
end
|
45
50
|
}
|
46
51
|
ctx.tmp_ecdh_callback = -> (sock, ise, keyl) { OpenSSL::PKey::EC.new("prime256v1") }
|
47
52
|
ctx.cert = OpenSSL::X509::Certificate.new(cert)
|
@@ -56,6 +61,7 @@ module Plum
|
|
56
61
|
end
|
57
62
|
|
58
63
|
def plum(sock)
|
64
|
+
raise ::Plum::LegacyHTTPError.new("client doesn't offered h2 with ALPN", nil) unless sock.alpn_protocol == "h2"
|
59
65
|
::Plum::HTTPSServerConnection.new(sock)
|
60
66
|
end
|
61
67
|
|
data/lib/plum/rack/server.rb
CHANGED
@@ -47,31 +47,59 @@ module Plum
|
|
47
47
|
sock = svr.accept
|
48
48
|
Thread.new {
|
49
49
|
begin
|
50
|
-
|
51
|
-
|
50
|
+
begin
|
51
|
+
sock = sock.accept if sock.respond_to?(:accept)
|
52
|
+
plum = svr.plum(sock)
|
52
53
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
54
|
+
con = Session.new(app: @app,
|
55
|
+
plum: plum,
|
56
|
+
sock: sock,
|
57
|
+
logger: @logger,
|
58
|
+
config: @config,
|
59
|
+
remote_addr: sock.peeraddr.last)
|
60
|
+
con.run
|
61
|
+
rescue ::Plum::LegacyHTTPError => e
|
62
|
+
@logger.info "legacy HTTP client: #{e}"
|
63
|
+
handle_legacy(e, sock)
|
64
|
+
end
|
65
|
+
rescue Errno::ECONNRESET, Errno::EPROTO, Errno::EINVAL, EOFError => e # closed
|
62
66
|
rescue StandardError => e
|
63
67
|
log_exception(e)
|
68
|
+
ensure
|
64
69
|
sock.close if sock
|
65
70
|
end
|
66
71
|
}
|
67
72
|
rescue Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPROTO, Errno::EINVAL => e # closed
|
73
|
+
sock.close if sock
|
68
74
|
rescue StandardError => e
|
69
75
|
log_exception(e)
|
76
|
+
sock.close if sock
|
70
77
|
end
|
71
78
|
|
72
79
|
def log_exception(e)
|
73
80
|
@logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}")
|
74
81
|
end
|
82
|
+
|
83
|
+
def handle_legacy(e, sock)
|
84
|
+
if @config[:fallback_legacy_host]
|
85
|
+
@logger.info "legacy HTTP: fallbacking to: #{@config[:fallback_legacy_host]}:#{@config[:fallback_legacy_port]}"
|
86
|
+
upstream = TCPSocket.open(@config[:fallback_legacy_host], @config[:fallback_legacy_port])
|
87
|
+
upstream.write(e.buf) if e.buf
|
88
|
+
loop do
|
89
|
+
ret = IO.select([sock, upstream])
|
90
|
+
ret[0].each { |s|
|
91
|
+
a = s.readpartial(65536)
|
92
|
+
if s == upstream
|
93
|
+
sock.write(a)
|
94
|
+
else
|
95
|
+
upstream.write(a)
|
96
|
+
end
|
97
|
+
}
|
98
|
+
end
|
99
|
+
end
|
100
|
+
ensure
|
101
|
+
upstream.close if upstream
|
102
|
+
end
|
75
103
|
end
|
76
104
|
end
|
77
105
|
end
|
data/lib/plum/rack/session.rb
CHANGED
@@ -8,13 +8,14 @@ module Plum
|
|
8
8
|
class Session
|
9
9
|
attr_reader :app, :plum
|
10
10
|
|
11
|
-
def initialize(app:, plum:, sock:, logger:,
|
11
|
+
def initialize(app:, plum:, sock:, logger:, config:, remote_addr: "127.0.0.1")
|
12
12
|
@app = app
|
13
13
|
@plum = plum
|
14
14
|
@sock = sock
|
15
15
|
@logger = logger
|
16
|
-
@
|
16
|
+
@config = config
|
17
17
|
@remote_addr = remote_addr
|
18
|
+
@request_thread = {} # used if threaded
|
18
19
|
|
19
20
|
setup_plum
|
20
21
|
end
|
@@ -24,22 +25,33 @@ module Plum
|
|
24
25
|
end
|
25
26
|
|
26
27
|
def run
|
27
|
-
|
28
|
-
|
29
|
-
@plum << @sock.readpartial(16384)
|
30
|
-
end
|
31
|
-
rescue Errno::EPIPE, Errno::ECONNRESET => e
|
32
|
-
rescue StandardError => e
|
33
|
-
@logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}")
|
28
|
+
while !@sock.closed? && !@sock.eof?
|
29
|
+
@plum << @sock.readpartial(1024)
|
34
30
|
end
|
31
|
+
rescue Errno::EPIPE, Errno::ECONNRESET => e
|
32
|
+
rescue StandardError => e
|
33
|
+
@logger.error("#{e.class}: #{e.message}\n#{e.backtrace.map { |b| "\t#{b}" }.join("\n")}")
|
34
|
+
ensure
|
35
|
+
@request_thread.each { |stream, thread| thread.kill }
|
36
|
+
stop
|
35
37
|
end
|
36
38
|
|
37
39
|
private
|
38
40
|
def setup_plum
|
41
|
+
@plum.on(:frame) { |f| puts "recv:#{f.inspect}" }
|
42
|
+
@plum.on(:send_frame) { |f|
|
43
|
+
puts "send:#{f.inspect}" unless f.type == :data
|
44
|
+
}
|
39
45
|
@plum.on(:connection_error) { |ex| @logger.error(ex) }
|
40
46
|
|
41
47
|
# @plum.on(:stream) { |stream| @logger.debug("new stream: #{stream}") }
|
42
|
-
@plum.on(:stream_error) { |stream, ex|
|
48
|
+
@plum.on(:stream_error) { |stream, ex|
|
49
|
+
if [:cancel, :refused_stream].include?(ex.http2_error_type)
|
50
|
+
@logger.debug("stream was cancelled: #{stream}")
|
51
|
+
else
|
52
|
+
@logger.error(ex)
|
53
|
+
end
|
54
|
+
}
|
43
55
|
|
44
56
|
reqs = {}
|
45
57
|
@plum.on(:headers) { |stream, h|
|
@@ -48,25 +60,34 @@ module Plum
|
|
48
60
|
|
49
61
|
@plum.on(:data) { |stream, d|
|
50
62
|
reqs[stream][:data] << d # TODO: store to file?
|
63
|
+
check_window(stream)
|
51
64
|
}
|
52
65
|
|
53
66
|
@plum.on(:end_stream) { |stream|
|
54
|
-
|
67
|
+
if @config[:threaded]
|
68
|
+
@request_thread[stream] = Thread.new {
|
69
|
+
handle_request(stream, reqs[stream][:headers], reqs[stream][:data])
|
70
|
+
}
|
71
|
+
else
|
72
|
+
handle_request(stream, reqs[stream][:headers], reqs[stream][:data])
|
73
|
+
end
|
55
74
|
}
|
56
75
|
end
|
57
76
|
|
77
|
+
def check_window(stream)
|
78
|
+
ws = @plum.local_settings[:initial_window_size]
|
79
|
+
stream.window_update(ws) if stream.recv_remaining_window < (ws / 2)
|
80
|
+
@plum.window_update(ws) if @plum.recv_remaining_window < (ws / 2)
|
81
|
+
end
|
82
|
+
|
58
83
|
def send_body(stream, body)
|
59
84
|
begin
|
60
|
-
if body.
|
85
|
+
if body.respond_to?(:to_str)
|
61
86
|
stream.send_data(body, end_stream: true)
|
62
|
-
elsif body.respond_to?(:
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
stream.send_data(part, end_stream: last == i)
|
67
|
-
i += 1
|
68
|
-
}
|
69
|
-
stream.send_data(nil, end_stream: true) if i == 0
|
87
|
+
elsif body.respond_to?(:readpartial) && body.respond_to?(:eof?)
|
88
|
+
until body.eof?
|
89
|
+
stream.send_data(body.readpartial(65536), end_stream: body.eof?)
|
90
|
+
end
|
70
91
|
else
|
71
92
|
body.each { |part| stream.send_data(part, end_stream: false) }
|
72
93
|
stream.send_data(nil, end_stream: true)
|
@@ -77,9 +98,7 @@ module Plum
|
|
77
98
|
end
|
78
99
|
|
79
100
|
def extract_push(reqheaders, extheaders)
|
80
|
-
if @server_push &&
|
81
|
-
@plum.push_enabled? &&
|
82
|
-
pushs = extheaders["plum.serverpush"]
|
101
|
+
if @config[:server_push] && @plum.push_enabled? && pushs = extheaders["plum.serverpush"]
|
83
102
|
authority = reqheaders.find { |k, v| k == ":authority" }[1]
|
84
103
|
scheme = reqheaders.find { |k, v| k == ":scheme" }[1]
|
85
104
|
|
@@ -87,7 +106,7 @@ module Plum
|
|
87
106
|
method, path = push.split(" ", 2)
|
88
107
|
{
|
89
108
|
":authority" => authority,
|
90
|
-
":method" => method.
|
109
|
+
":method" => method.upcase,
|
91
110
|
":scheme" => scheme,
|
92
111
|
":path" => path
|
93
112
|
}
|
@@ -102,21 +121,26 @@ module Plum
|
|
102
121
|
r_status, r_rawheaders, r_body = @app.call(env)
|
103
122
|
r_headers, r_extheaders = extract_headers(r_status, r_rawheaders)
|
104
123
|
|
105
|
-
|
124
|
+
no_body = r_body.respond_to?(:empty?) && r_body.empty?
|
125
|
+
|
126
|
+
stream.send_headers(r_headers, end_stream: no_body)
|
106
127
|
|
107
128
|
push_sts = extract_push(headers, r_extheaders).map { |preq|
|
108
129
|
[stream.promise(preq), preq]
|
109
130
|
}
|
110
131
|
|
111
|
-
send_body(stream, r_body)
|
132
|
+
send_body(stream, r_body) unless no_body
|
112
133
|
|
113
134
|
push_sts.each { |st, preq|
|
114
|
-
penv = new_env(preq, "")
|
135
|
+
penv = new_env(preq, "".b)
|
115
136
|
p_status, p_h, p_body = @app.call(penv)
|
116
|
-
p_headers = extract_headers(p_status, p_h)
|
117
|
-
|
118
|
-
|
137
|
+
p_headers, _ = extract_headers(p_status, p_h)
|
138
|
+
pno_body = p_body.respond_to?(:empty?) && p_body.empty?
|
139
|
+
st.send_headers(p_headers, end_stream: pno_body)
|
140
|
+
send_body(st, p_body) unless pno_body
|
119
141
|
}
|
142
|
+
|
143
|
+
@request_thread.delete(stream)
|
120
144
|
end
|
121
145
|
|
122
146
|
def new_env(h, data)
|
@@ -130,6 +154,7 @@ module Plum
|
|
130
154
|
"rack.hijack?" => false,
|
131
155
|
"SCRIPT_NAME" => "",
|
132
156
|
"REMOTE_ADDR" => @remote_addr,
|
157
|
+
"HTTP_VERSION" => "HTTP/2.0", # Rack::CommonLogger uses
|
133
158
|
}
|
134
159
|
|
135
160
|
h.each { |k, v|
|
@@ -180,7 +205,6 @@ module Plum
|
|
180
205
|
if "set-cookie" == key
|
181
206
|
rbase[key] = v_.gsub("\n", "; ") # RFC 7540 8.1.2.5
|
182
207
|
elsif !INVALID_HEADERS.member?(key)
|
183
|
-
key.byteshift(2) if key.start_with?("x-")
|
184
208
|
rbase[key] = v_.tr("\n", ",") # RFC 7230 7
|
185
209
|
end
|
186
210
|
end
|
@@ -11,7 +11,7 @@ module Plum
|
|
11
11
|
# Reserves a new stream to server push.
|
12
12
|
# @param args [Hash] The argument to pass to Stram.new.
|
13
13
|
def reserve_stream(**args)
|
14
|
-
next_id = @
|
14
|
+
next_id = @max_stream_ids[0] + 2
|
15
15
|
stream = stream(next_id)
|
16
16
|
stream.set_state(:reserved_local)
|
17
17
|
stream.update_dependency(**args)
|
@@ -7,10 +7,9 @@ module Plum
|
|
7
7
|
|
8
8
|
def initialize(sock, local_settings = {})
|
9
9
|
require "http/parser"
|
10
|
-
@_headers = nil
|
11
|
-
@_body = String.new
|
12
|
-
@_http_parser = setup_parser
|
13
10
|
@sock = sock
|
11
|
+
@negobuf = String.new
|
12
|
+
@_http_parser = setup_parser
|
14
13
|
super(@sock.method(:write), local_settings)
|
15
14
|
end
|
16
15
|
|
@@ -23,40 +22,44 @@ module Plum
|
|
23
22
|
private
|
24
23
|
def negotiate!
|
25
24
|
super
|
26
|
-
rescue RemoteConnectionError
|
27
|
-
|
25
|
+
rescue RemoteConnectionError # Upgrade from HTTP/1.1 or legacy
|
26
|
+
@negobuf << @buffer
|
28
27
|
offset = @_http_parser << @buffer
|
29
28
|
@buffer.byteshift(offset)
|
30
29
|
end
|
31
30
|
|
32
31
|
def setup_parser
|
32
|
+
headers = nil
|
33
|
+
body = String.new
|
34
|
+
|
33
35
|
parser = HTTP::Parser.new
|
34
|
-
parser.on_headers_complete = proc {|_headers|
|
35
|
-
|
36
|
+
parser.on_headers_complete = proc { |_headers|
|
37
|
+
headers = _headers.map {|n, v| [n.downcase, v] }.to_h
|
36
38
|
}
|
37
|
-
parser.on_body = proc {|chunk|
|
38
|
-
parser.on_message_complete = proc {|env|
|
39
|
-
connection =
|
40
|
-
upgrade =
|
41
|
-
settings =
|
39
|
+
parser.on_body = proc { |chunk| body << chunk }
|
40
|
+
parser.on_message_complete = proc { |env|
|
41
|
+
connection = headers["connection"] || ""
|
42
|
+
upgrade = headers["upgrade"] || ""
|
43
|
+
settings = headers["http2-settings"]
|
42
44
|
|
43
45
|
if (connection.split(", ").sort == ["Upgrade", "HTTP2-Settings"].sort &&
|
44
46
|
upgrade.split(", ").include?("h2c") &&
|
45
47
|
settings != nil)
|
46
|
-
switch_protocol(settings)
|
48
|
+
switch_protocol(settings, parser, headers, body)
|
49
|
+
@negobuf = @_http_parser = nil
|
47
50
|
else
|
48
|
-
raise LegacyHTTPError.new(
|
51
|
+
raise LegacyHTTPError.new("request doesn't Upgrade", @negobuf)
|
49
52
|
end
|
50
53
|
}
|
51
54
|
|
52
55
|
parser
|
53
56
|
end
|
54
57
|
|
55
|
-
def switch_protocol(settings)
|
58
|
+
def switch_protocol(settings, parser, headers, data)
|
56
59
|
self.on(:negotiated) {
|
57
60
|
_frame = Frame.new(type: :settings, stream_id: 0, payload: Base64.urlsafe_decode64(settings))
|
58
61
|
receive_settings(_frame, send_ack: false) # HTTP2-Settings
|
59
|
-
process_first_request
|
62
|
+
process_first_request(parser, headers, data)
|
60
63
|
}
|
61
64
|
|
62
65
|
resp = "HTTP/1.1 101 Switching Protocols\r\n" +
|
@@ -68,18 +71,17 @@ module Plum
|
|
68
71
|
@sock.write(resp)
|
69
72
|
end
|
70
73
|
|
71
|
-
def process_first_request
|
74
|
+
def process_first_request(parser, headers, body)
|
72
75
|
encoder = HPACK::Encoder.new(0, indexing: false) # don't pollute connection's HPACK context
|
73
76
|
stream = stream(1)
|
74
77
|
max_frame_size = local_settings[:max_frame_size]
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
78
|
+
nheaders = headers.merge({ ":method" => parser.http_method,
|
79
|
+
":path" => parser.request_url,
|
80
|
+
":authority" => headers["host"] })
|
81
|
+
.reject {|n, v| ["connection", "http2-settings", "upgrade", "host"].include?(n) }
|
79
82
|
|
80
|
-
|
81
|
-
|
82
|
-
(headers_s + data_s).each {|frag| stream.receive_frame(frag) }
|
83
|
+
stream.receive_frame Frame.headers(1, encoder.encode(nheaders), end_headers: true)
|
84
|
+
stream.receive_frame Frame.data(1, body, end_stream: true)
|
83
85
|
end
|
84
86
|
end
|
85
87
|
end
|
data/lib/plum/stream.rb
CHANGED
data/lib/plum/stream_utils.rb
CHANGED
@@ -9,10 +9,8 @@ module Plum
|
|
9
9
|
def promise(headers)
|
10
10
|
stream = @connection.reserve_stream(weight: self.weight + 1, parent: self)
|
11
11
|
encoded = @connection.hpack_encoder.encode(headers)
|
12
|
-
|
13
|
-
|
14
|
-
send frame
|
15
|
-
end
|
12
|
+
frame = Frame.push_promise(id, stream.id, encoded, end_headers: true)
|
13
|
+
send frame
|
16
14
|
stream
|
17
15
|
end
|
18
16
|
|
@@ -22,10 +20,8 @@ module Plum
|
|
22
20
|
def send_headers(headers, end_stream:)
|
23
21
|
max = @connection.remote_settings[:max_frame_size]
|
24
22
|
encoded = @connection.hpack_encoder.encode(headers)
|
25
|
-
|
26
|
-
|
27
|
-
send frame
|
28
|
-
end
|
23
|
+
frame = Frame.headers(id, encoded, end_headers: true, end_stream: end_stream)
|
24
|
+
send frame
|
29
25
|
@state = :half_closed_local if end_stream
|
30
26
|
end
|
31
27
|
|
@@ -35,14 +31,12 @@ module Plum
|
|
35
31
|
def send_data(data, end_stream: true)
|
36
32
|
max = @connection.remote_settings[:max_frame_size]
|
37
33
|
if data.is_a?(IO)
|
38
|
-
|
39
|
-
|
34
|
+
until data.eof?
|
35
|
+
fragment = data.readpartial(max)
|
36
|
+
send Frame.data(id, fragment, end_stream: end_stream && data.eof?)
|
40
37
|
end
|
41
38
|
else
|
42
|
-
|
43
|
-
original.split_data(max).each do |frame|
|
44
|
-
send frame
|
45
|
-
end
|
39
|
+
send Frame.data(id, data, end_stream: end_stream)
|
46
40
|
end
|
47
41
|
@state = :half_closed_local if end_stream
|
48
42
|
end
|
data/lib/plum/version.rb
CHANGED
data/lib/rack/handler/plum.rb
CHANGED
@@ -24,8 +24,8 @@ class UpgradeClientSessionTest < Minitest::Test
|
|
24
24
|
sock.rio.string << Frame.settings().assemble
|
25
25
|
sock.rio.string << Frame.settings(:ack).assemble
|
26
26
|
res = session.request({ ":method" => "GET", ":path" => "/aa" }, "aa", {})
|
27
|
-
sock.rio.string << Frame.headers(3, HPACK::Encoder.new(3).encode(":status" => "200", "content-length" => "3"), :
|
28
|
-
sock.rio.string << Frame.data(3, "aaa", :
|
27
|
+
sock.rio.string << Frame.headers(3, HPACK::Encoder.new(3).encode(":status" => "200", "content-length" => "3"), end_headers: true).assemble
|
28
|
+
sock.rio.string << Frame.data(3, "aaa", end_stream: true).assemble
|
29
29
|
session.succ until res.finished?
|
30
30
|
assert(res.finished?)
|
31
31
|
assert_equal("aaa", res.body)
|
@@ -100,4 +100,15 @@ class ConnectionTest < Minitest::Test
|
|
100
100
|
}
|
101
101
|
}
|
102
102
|
end
|
103
|
+
|
104
|
+
def test_send_immediately_split
|
105
|
+
io = StringIO.new
|
106
|
+
con = Connection.new(io.method(:write))
|
107
|
+
fs = parse_frames(io) {
|
108
|
+
con.__send__(:send_immediately, Frame.new(type: :data, stream_id: 1, payload: "a"*16385))
|
109
|
+
}
|
110
|
+
assert_equal(2, fs.size)
|
111
|
+
assert_equal(16384, fs.first.length)
|
112
|
+
assert_equal(1, fs.last.length)
|
113
|
+
end
|
103
114
|
end
|
@@ -16,7 +16,7 @@ class ServerConnectionUtilsTest < Minitest::Test
|
|
16
16
|
|
17
17
|
def test_server_goaway
|
18
18
|
open_server_connection {|con|
|
19
|
-
con << Frame.headers(3, "", :
|
19
|
+
con << Frame.headers(3, "", end_stream: true, end_headers: true).assemble
|
20
20
|
con.goaway(:stream_closed)
|
21
21
|
|
22
22
|
last = sent_frames.last
|
@@ -135,17 +135,17 @@ class FlowControlTest < Minitest::Test
|
|
135
135
|
|
136
136
|
prepare.call {|con, stream|
|
137
137
|
con.window_update(500) # extend only connection
|
138
|
-
con << Frame.headers(stream.id, "", :
|
138
|
+
con << Frame.headers(stream.id, "", end_headers: true).assemble
|
139
139
|
assert_stream_error(:flow_control_error) {
|
140
|
-
con << Frame.data(stream.id, "\x00" * 30, :
|
140
|
+
con << Frame.data(stream.id, "\x00" * 30, end_stream: true).assemble
|
141
141
|
}
|
142
142
|
}
|
143
143
|
|
144
144
|
prepare.call {|con, stream|
|
145
145
|
stream.window_update(500) # extend only stream
|
146
|
-
con << Frame.headers(stream.id, "", :
|
146
|
+
con << Frame.headers(stream.id, "", end_headers: true).assemble
|
147
147
|
assert_connection_error(:flow_control_error) {
|
148
|
-
con << Frame.data(stream.id, "\x00" * 30, :
|
148
|
+
con << Frame.data(stream.id, "\x00" * 30, end_stream: true).assemble
|
149
149
|
}
|
150
150
|
}
|
151
151
|
end
|
@@ -155,8 +155,8 @@ class FlowControlTest < Minitest::Test
|
|
155
155
|
con = stream.connection
|
156
156
|
con.settings(initial_window_size: 24)
|
157
157
|
stream.window_update(1)
|
158
|
-
con << Frame.headers(stream.id, "", :
|
159
|
-
con << Frame.data(stream.id, "\x00" * 20, :
|
158
|
+
con << Frame.headers(stream.id, "", end_headers: true).assemble
|
159
|
+
con << Frame.data(stream.id, "\x00" * 20, end_stream: true).assemble
|
160
160
|
assert_equal(4, con.recv_remaining_window)
|
161
161
|
assert_equal(5, stream.recv_remaining_window)
|
162
162
|
con.settings(initial_window_size: 60)
|
@@ -55,7 +55,7 @@ class FrameFactoryTest < Minitest::Test
|
|
55
55
|
end
|
56
56
|
|
57
57
|
def test_continuation
|
58
|
-
frame = Frame.continuation(123, "abc", :
|
58
|
+
frame = Frame.continuation(123, "abc", end_headers: true)
|
59
59
|
assert_frame(frame,
|
60
60
|
type: :continuation,
|
61
61
|
stream_id: 123,
|
@@ -74,7 +74,7 @@ class FrameFactoryTest < Minitest::Test
|
|
74
74
|
end
|
75
75
|
|
76
76
|
def test_headers
|
77
|
-
frame = Frame.headers(123, "abc", :
|
77
|
+
frame = Frame.headers(123, "abc", end_stream: true)
|
78
78
|
assert_frame(frame,
|
79
79
|
type: :headers,
|
80
80
|
stream_id: 123,
|
@@ -83,7 +83,7 @@ class FrameFactoryTest < Minitest::Test
|
|
83
83
|
end
|
84
84
|
|
85
85
|
def test_push_promise
|
86
|
-
frame = Frame.push_promise(345, 2, "abc", :
|
86
|
+
frame = Frame.push_promise(345, 2, "abc", end_headers: true)
|
87
87
|
assert_frame(frame,
|
88
88
|
type: :push_promise,
|
89
89
|
stream_id: 345,
|
@@ -3,30 +3,29 @@ require "test_helper"
|
|
3
3
|
class FrameUtilsTest < Minitest::Test
|
4
4
|
def test_frame_enough_short
|
5
5
|
frame = Frame.new(type: :data, stream_id: 1, payload: "123")
|
6
|
-
ret = frame.
|
6
|
+
ret = frame.to_enum(:split, 3).to_a
|
7
7
|
assert_equal(1, ret.size)
|
8
8
|
assert_equal("123", ret.first.payload)
|
9
9
|
end
|
10
10
|
|
11
11
|
def test_frame_unknown
|
12
12
|
frame = Frame.new(type: :settings, stream_id: 1, payload: "123")
|
13
|
-
assert_raises { frame.
|
14
|
-
assert_raises { frame.split_headers(2) }
|
13
|
+
assert_raises(NotImplementedError) { frame.split(2) }
|
15
14
|
end
|
16
15
|
|
17
16
|
def test_frame_data
|
18
17
|
frame = Frame.new(type: :data, flags: [:end_stream], stream_id: 1, payload: "12345")
|
19
|
-
ret = frame.
|
20
|
-
assert_equal(
|
21
|
-
assert_equal("
|
18
|
+
ret = frame.to_enum(:split, 2).to_a
|
19
|
+
assert_equal(3, ret.size)
|
20
|
+
assert_equal("12", ret.first.payload)
|
22
21
|
assert_equal([], ret.first.flags)
|
23
|
-
assert_equal("
|
22
|
+
assert_equal("5", ret.last.payload)
|
24
23
|
assert_equal([:end_stream], ret.last.flags)
|
25
24
|
end
|
26
25
|
|
27
26
|
def test_frame_headers
|
28
27
|
frame = Frame.new(type: :headers, flags: [:priority, :end_stream, :end_headers], stream_id: 1, payload: "1234567")
|
29
|
-
ret = frame.
|
28
|
+
ret = frame.to_enum(:split, 3).to_a
|
30
29
|
assert_equal(3, ret.size)
|
31
30
|
assert_equal("123", ret[0].payload)
|
32
31
|
assert_equal([:end_stream, :priority], ret[0].flags)
|
data/test/plum/test_stream.rb
CHANGED
@@ -28,7 +28,7 @@ class StreamTest < Minitest::Test
|
|
28
28
|
}
|
29
29
|
|
30
30
|
assert_stream_error(:frame_size_error) {
|
31
|
-
con << Frame.headers(1, "", :
|
31
|
+
con << Frame.headers(1, "", end_headers: true).assemble
|
32
32
|
}
|
33
33
|
|
34
34
|
last = sent_frames.last
|
@@ -43,7 +43,7 @@ class StreamTest < Minitest::Test
|
|
43
43
|
stream = nil
|
44
44
|
con.on(:headers) { |s| stream = s }
|
45
45
|
|
46
|
-
con << Frame.headers(1, "", :
|
46
|
+
con << Frame.headers(1, "", end_headers: true).assemble
|
47
47
|
assert_raises(LocalStreamError) {
|
48
48
|
con << Frame.rst_stream(1, :frame_size_error).assemble
|
49
49
|
}
|
data/test/utils/server.rb
CHANGED
@@ -20,7 +20,7 @@ module ServerUtils
|
|
20
20
|
con = open_server_connection
|
21
21
|
end
|
22
22
|
|
23
|
-
@_stream = con.instance_eval { stream(
|
23
|
+
@_stream = con.instance_eval { stream(@max_stream_ids[1] + 2) }
|
24
24
|
@_stream.set_state(state)
|
25
25
|
@_stream.update_dependency(**kwargs)
|
26
26
|
if block_given?
|
@@ -39,8 +39,7 @@ module ServerUtils
|
|
39
39
|
frames
|
40
40
|
end
|
41
41
|
|
42
|
-
def
|
43
|
-
io = (con || @_con).sock
|
42
|
+
def parse_frames(io, &blk)
|
44
43
|
pos = io.string.bytesize
|
45
44
|
blk.call
|
46
45
|
resp = io.string.byteslice(pos, io.string.bytesize - pos).force_encoding(Encoding::BINARY)
|
@@ -51,8 +50,8 @@ module ServerUtils
|
|
51
50
|
frames
|
52
51
|
end
|
53
52
|
|
54
|
-
def
|
55
|
-
frames = capture_frames(
|
53
|
+
def parse_frame(io, &blk)
|
54
|
+
frames = capture_frames(io, &blk)
|
56
55
|
assert_equal(1, frames.size, "Supplied block sent no frames or more than 1 frame")
|
57
56
|
frames.first
|
58
57
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: plum
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- rhenium
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2015-11-
|
11
|
+
date: 2015-11-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|