httpx 0.2.1 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/httpx.rb +1 -0
- data/lib/httpx/altsvc.rb +76 -0
- data/lib/httpx/chainable.rb +4 -0
- data/lib/httpx/channel.rb +72 -20
- data/lib/httpx/channel/http1.rb +71 -52
- data/lib/httpx/channel/http2.rb +43 -5
- data/lib/httpx/client.rb +32 -1
- data/lib/httpx/connection.rb +21 -8
- data/lib/httpx/errors.rb +18 -1
- data/lib/httpx/extensions.rb +72 -41
- data/lib/httpx/parser/http1.rb +171 -0
- data/lib/httpx/plugins/multipart.rb +50 -0
- data/lib/httpx/plugins/proxy.rb +6 -1
- data/lib/httpx/request.rb +8 -3
- data/lib/httpx/response.rb +15 -6
- data/lib/httpx/selector.rb +1 -1
- data/lib/httpx/timeout.rb +37 -8
- data/lib/httpx/transcoder/chunker.rb +76 -3
- data/lib/httpx/transcoder/form.rb +7 -11
- data/lib/httpx/version.rb +1 -1
- metadata +7 -18
data/lib/httpx/channel/http2.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "io/wait"
|
3
4
|
require "http/2"
|
4
5
|
|
5
6
|
module HTTPX
|
@@ -7,6 +8,22 @@ module HTTPX
|
|
7
8
|
include Callbacks
|
8
9
|
include Loggable
|
9
10
|
|
11
|
+
if HTTP2::VERSION < "0.10.1"
|
12
|
+
module HTTP2Extensions
|
13
|
+
refine ::HTTP2::Client do
|
14
|
+
def receive(*)
|
15
|
+
send_connection_preface
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
def <<(*args)
|
20
|
+
receive(*args)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
using HTTP2Extensions
|
25
|
+
end
|
26
|
+
|
10
27
|
Error = Class.new(Error) do
|
11
28
|
def initialize(id, code)
|
12
29
|
super("stream #{id} closed with error: #{code}")
|
@@ -18,11 +35,12 @@ module HTTPX
|
|
18
35
|
def initialize(buffer, options)
|
19
36
|
@options = Options.new(options)
|
20
37
|
@max_concurrent_requests = @options.max_concurrent_requests
|
21
|
-
init_connection
|
22
38
|
@pending = []
|
23
39
|
@streams = {}
|
24
40
|
@drains = {}
|
25
41
|
@buffer = buffer
|
42
|
+
@handshake_completed = false
|
43
|
+
init_connection
|
26
44
|
end
|
27
45
|
|
28
46
|
def close
|
@@ -38,7 +56,8 @@ module HTTPX
|
|
38
56
|
end
|
39
57
|
|
40
58
|
def send(request, **)
|
41
|
-
if
|
59
|
+
if !@handshake_completed ||
|
60
|
+
@connection.active_stream_count >= @max_concurrent_requests
|
42
61
|
@pending << request
|
43
62
|
return
|
44
63
|
end
|
@@ -48,6 +67,7 @@ module HTTPX
|
|
48
67
|
@streams[request] = stream
|
49
68
|
end
|
50
69
|
handle(request, stream)
|
70
|
+
true
|
51
71
|
end
|
52
72
|
|
53
73
|
def reenqueue!
|
@@ -73,6 +93,12 @@ module HTTPX
|
|
73
93
|
|
74
94
|
private
|
75
95
|
|
96
|
+
def send_pending
|
97
|
+
while (request = @pending.shift)
|
98
|
+
break unless send(request)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
76
102
|
def headline_uri(request)
|
77
103
|
request.path
|
78
104
|
end
|
@@ -95,9 +121,16 @@ module HTTPX
|
|
95
121
|
@connection.on(:frame_sent, &method(:on_frame_sent))
|
96
122
|
@connection.on(:frame_received, &method(:on_frame_received))
|
97
123
|
@connection.on(:promise, &method(:on_promise))
|
98
|
-
@connection.on(:altsvc
|
124
|
+
@connection.on(:altsvc) { |frame| on_altsvc(frame[:origin], frame) }
|
99
125
|
@connection.on(:settings_ack, &method(:on_settings))
|
100
126
|
@connection.on(:goaway, &method(:on_close))
|
127
|
+
#
|
128
|
+
# Some servers initiate HTTP/2 negotiation right away, some don't.
|
129
|
+
# As such, we have to check the socket buffer. If there is something
|
130
|
+
# to read, the server initiated the negotiation. If not, we have to
|
131
|
+
# initiate it.
|
132
|
+
#
|
133
|
+
@connection.send_connection_preface
|
101
134
|
end
|
102
135
|
|
103
136
|
def handle_stream(stream, request)
|
@@ -105,7 +138,7 @@ module HTTPX
|
|
105
138
|
stream.on(:half_close) do
|
106
139
|
log(level: 2, label: "#{stream.id}: ") { "waiting for response..." }
|
107
140
|
end
|
108
|
-
|
141
|
+
stream.on(:altsvc, &method(:on_altsvc).curry[request.origin])
|
109
142
|
stream.on(:headers, &method(:on_stream_headers).curry[stream, request])
|
110
143
|
stream.on(:data, &method(:on_stream_data).curry[stream, request])
|
111
144
|
end
|
@@ -187,8 +220,10 @@ module HTTPX
|
|
187
220
|
end
|
188
221
|
|
189
222
|
def on_settings(*)
|
223
|
+
@handshake_completed = true
|
190
224
|
@max_concurrent_requests = [@max_concurrent_requests,
|
191
225
|
@connection.remote_settings[:settings_max_concurrent_streams]].min
|
226
|
+
send_pending
|
192
227
|
end
|
193
228
|
|
194
229
|
def on_close(_last_frame, error, _payload)
|
@@ -225,9 +260,12 @@ module HTTPX
|
|
225
260
|
end
|
226
261
|
end
|
227
262
|
|
228
|
-
def on_altsvc(frame)
|
263
|
+
def on_altsvc(origin, frame)
|
229
264
|
log(level: 2, label: "#{frame[:stream]}: ") { "altsvc frame was received" }
|
230
265
|
log(level: 2, label: "#{frame[:stream]}: ") { frame.inspect }
|
266
|
+
alt_origin = URI.parse("#{frame[:proto]}://#{frame[:host]}:#{frame[:port]}")
|
267
|
+
params = { "ma" => frame[:max_age] }
|
268
|
+
emit(:altsvc, origin, alt_origin, origin, params)
|
231
269
|
end
|
232
270
|
|
233
271
|
def on_promise(stream)
|
data/lib/httpx/client.rb
CHANGED
@@ -47,7 +47,6 @@ module HTTPX
|
|
47
47
|
def on_promise(_, stream)
|
48
48
|
log(level: 2, label: "#{stream.id}: ") { "refusing stream!" }
|
49
49
|
stream.refuse
|
50
|
-
# TODO: policy for handling promises
|
51
50
|
end
|
52
51
|
|
53
52
|
def fetch_response(request)
|
@@ -66,6 +65,9 @@ module HTTPX
|
|
66
65
|
other_channel = build_channel(uncoalesced_uri, options)
|
67
66
|
channel.unmerge(other_channel)
|
68
67
|
end
|
68
|
+
channel.on(:altsvc) do |alt_origin, origin, alt_params|
|
69
|
+
build_altsvc_channel(channel, alt_origin, origin, alt_params, options)
|
70
|
+
end
|
69
71
|
end
|
70
72
|
|
71
73
|
def build_channel(uri, options)
|
@@ -74,6 +76,35 @@ module HTTPX
|
|
74
76
|
channel
|
75
77
|
end
|
76
78
|
|
79
|
+
def build_altsvc_channel(existing_channel, alt_origin, origin, alt_params, options)
|
80
|
+
altsvc = AltSvc.cached_altsvc_set(origin, alt_params.merge("origin" => alt_origin))
|
81
|
+
|
82
|
+
# altsvc already exists, somehow it wasn't advertised, probably noop
|
83
|
+
return unless altsvc
|
84
|
+
|
85
|
+
channel = @connection.find_channel(alt_origin) || build_channel(alt_origin, options)
|
86
|
+
# advertised altsvc is the same origin being used, ignore
|
87
|
+
return if channel == existing_channel
|
88
|
+
|
89
|
+
log(level: 1) { "#{origin} alt-svc: #{alt_origin}" }
|
90
|
+
|
91
|
+
# get uninitialized requests
|
92
|
+
# incidentally, all requests will be re-routed to the first
|
93
|
+
# advertised alt-svc, which incidentally follows the spec.
|
94
|
+
existing_channel.purge_pending do |request, args|
|
95
|
+
is_idle = request.origin == origin &&
|
96
|
+
request.state == :idle &&
|
97
|
+
!request.headers.key?("alt-used")
|
98
|
+
if is_idle
|
99
|
+
log(level: 1) { "#{origin} alt-svc: sending #{request.uri} to #{alt_origin}" }
|
100
|
+
channel.send(request, args)
|
101
|
+
end
|
102
|
+
is_idle
|
103
|
+
end
|
104
|
+
rescue UnsupportedSchemeError
|
105
|
+
altsvc["noop"] = true
|
106
|
+
end
|
107
|
+
|
77
108
|
def __build_reqs(*args, **options)
|
78
109
|
requests = case args.size
|
79
110
|
when 1
|
data/lib/httpx/connection.rb
CHANGED
@@ -13,6 +13,7 @@ module HTTPX
|
|
13
13
|
resolver_type = Resolver.registry(resolver_type) if resolver_type.is_a?(Symbol)
|
14
14
|
@selector = Selector.new
|
15
15
|
@channels = []
|
16
|
+
@connected_channels = 0
|
16
17
|
@resolver = resolver_type.new(self, @options)
|
17
18
|
@resolver.on(:resolve, &method(:on_resolver_channel))
|
18
19
|
@resolver.on(:error, &method(:on_resolver_error))
|
@@ -32,8 +33,11 @@ module HTTPX
|
|
32
33
|
monitor.interests = channel.interests
|
33
34
|
end
|
34
35
|
end
|
35
|
-
rescue TimeoutError
|
36
|
-
|
36
|
+
rescue TimeoutError => timeout_error
|
37
|
+
@channels.each do |ch|
|
38
|
+
ch.handle_timeout_error(timeout_error)
|
39
|
+
end
|
40
|
+
rescue Errno::ECONNRESET,
|
37
41
|
Errno::ECONNABORTED,
|
38
42
|
Errno::EPIPE => ex
|
39
43
|
@channels.each do |ch|
|
@@ -50,6 +54,13 @@ module HTTPX
|
|
50
54
|
def build_channel(uri, **options)
|
51
55
|
channel = Channel.by(uri, @options.merge(options))
|
52
56
|
resolve_channel(channel)
|
57
|
+
channel.on(:open) do
|
58
|
+
@connected_channels += 1
|
59
|
+
@timeout.transition(:open) if @channels.size == @connected_channels
|
60
|
+
end
|
61
|
+
channel.on(:reset) do
|
62
|
+
@timeout.transition(:idle)
|
63
|
+
end
|
53
64
|
channel.once(:unreachable) do
|
54
65
|
@resolver.uncache(channel)
|
55
66
|
resolve_channel(channel)
|
@@ -107,6 +118,7 @@ module HTTPX
|
|
107
118
|
end
|
108
119
|
|
109
120
|
def register_channel(channel)
|
121
|
+
@timeout.transition(:idle)
|
110
122
|
monitor = @selector.register(channel, :w)
|
111
123
|
monitor.value = channel
|
112
124
|
channel.on(:close) do
|
@@ -117,12 +129,7 @@ module HTTPX
|
|
117
129
|
def unregister_channel(channel)
|
118
130
|
@channels.delete(channel)
|
119
131
|
@selector.deregister(channel)
|
120
|
-
|
121
|
-
|
122
|
-
def next_timeout
|
123
|
-
timeout = @timeout.timeout # force log time
|
124
|
-
return (@resolver.timeout || timeout) unless @resolver.closed?
|
125
|
-
timeout
|
132
|
+
@connected_channels -= 1
|
126
133
|
end
|
127
134
|
|
128
135
|
def coalesce_channels(ch1, ch2)
|
@@ -133,5 +140,11 @@ module HTTPX
|
|
133
140
|
register_channel(ch2)
|
134
141
|
end
|
135
142
|
end
|
143
|
+
|
144
|
+
def next_timeout
|
145
|
+
timeout = @timeout.timeout
|
146
|
+
return (@resolver.timeout || timeout) unless @resolver.closed?
|
147
|
+
timeout
|
148
|
+
end
|
136
149
|
end
|
137
150
|
end
|
data/lib/httpx/errors.rb
CHANGED
@@ -3,7 +3,24 @@
|
|
3
3
|
module HTTPX
|
4
4
|
Error = Class.new(StandardError)
|
5
5
|
|
6
|
-
|
6
|
+
UnsupportedSchemeError = Class.new(Error)
|
7
|
+
|
8
|
+
TimeoutError = Class.new(Error) do
|
9
|
+
attr_reader :timeout
|
10
|
+
|
11
|
+
def initialize(timeout, message)
|
12
|
+
@timeout = timeout
|
13
|
+
super(message)
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_connection_error
|
17
|
+
ex = ConnectTimeoutError.new(@timeout, message)
|
18
|
+
ex.set_backtrace(backtrace)
|
19
|
+
ex
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
ConnectTimeoutError = Class.new(TimeoutError)
|
7
24
|
|
8
25
|
ResolveError = Class.new(Error)
|
9
26
|
|
data/lib/httpx/extensions.rb
CHANGED
@@ -1,52 +1,83 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
#
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
module HTTPX
|
6
|
+
unless Method.method_defined?(:curry)
|
7
|
+
|
8
|
+
# Backport
|
9
|
+
#
|
10
|
+
# Ruby 2.1 and lower implement curry only for Procs.
|
11
|
+
#
|
12
|
+
# Why not using Refinements? Because they don't work for Method (tested with ruby 2.1.9).
|
13
13
|
#
|
14
|
-
|
15
|
-
|
14
|
+
module CurryMethods # :nodoc:
|
15
|
+
# Backport for the Method#curry method, which is part of ruby core since 2.2 .
|
16
|
+
#
|
17
|
+
def curry(*args)
|
18
|
+
to_proc.curry(*args)
|
19
|
+
end
|
16
20
|
end
|
21
|
+
Method.__send__(:include, CurryMethods)
|
17
22
|
end
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
frozen? ? dup : self
|
23
|
+
|
24
|
+
unless String.method_defined?(:+@)
|
25
|
+
# Backport for +"", to initialize unfrozen strings from the string literal.
|
26
|
+
#
|
27
|
+
module LiteralStringExtensions
|
28
|
+
def +@
|
29
|
+
frozen? ? dup : self
|
30
|
+
end
|
27
31
|
end
|
32
|
+
String.__send__(:include, LiteralStringExtensions)
|
28
33
|
end
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
self > 0
|
34
|
+
|
35
|
+
unless Numeric.method_defined?(:positive?)
|
36
|
+
# Ruby 2.3 Backport (Numeric#positive?)
|
37
|
+
#
|
38
|
+
module PosMethods
|
39
|
+
def positive?
|
40
|
+
self > 0
|
41
|
+
end
|
38
42
|
end
|
43
|
+
Numeric.__send__(:include, PosMethods)
|
39
44
|
end
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
45
|
+
|
46
|
+
unless Numeric.method_defined?(:negative?)
|
47
|
+
# Ruby 2.3 Backport (Numeric#negative?)
|
48
|
+
#
|
49
|
+
module NegMethods
|
50
|
+
def negative?
|
51
|
+
self < 0
|
52
|
+
end
|
53
|
+
end
|
54
|
+
Numeric.__send__(:include, NegMethods)
|
55
|
+
end
|
56
|
+
|
57
|
+
module URIExtensions
|
58
|
+
refine URI::Generic do
|
59
|
+
def authority
|
60
|
+
port_string = port == default_port ? nil : ":#{port}"
|
61
|
+
"#{host}#{port_string}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def origin
|
65
|
+
"#{scheme}://#{authority}"
|
66
|
+
end
|
67
|
+
|
68
|
+
def altsvc_match?(uri)
|
69
|
+
uri = URI.parse(uri)
|
70
|
+
self == uri || begin
|
71
|
+
case scheme
|
72
|
+
when 'h2'
|
73
|
+
uri.scheme == "https" &&
|
74
|
+
host == uri.host &&
|
75
|
+
(port || default_port) == (uri.port || uri.default_port)
|
76
|
+
else
|
77
|
+
false
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
49
81
|
end
|
50
82
|
end
|
51
|
-
|
52
|
-
end
|
83
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module HTTPX
|
4
|
+
module Parser
|
5
|
+
Error = Class.new(Error)
|
6
|
+
|
7
|
+
class HTTP1
|
8
|
+
VERSIONS = %w[1.0 1.1].freeze
|
9
|
+
|
10
|
+
attr_reader :status_code, :http_version, :headers
|
11
|
+
|
12
|
+
def initialize(observer, header_separator: ":")
|
13
|
+
@observer = observer
|
14
|
+
@state = :idle
|
15
|
+
@header_separator = header_separator
|
16
|
+
@buffer = "".b
|
17
|
+
@headers = {}
|
18
|
+
end
|
19
|
+
|
20
|
+
def <<(chunk)
|
21
|
+
@buffer << chunk
|
22
|
+
parse
|
23
|
+
end
|
24
|
+
|
25
|
+
def reset!
|
26
|
+
@state = :idle
|
27
|
+
@headers.clear
|
28
|
+
@content_length = nil
|
29
|
+
@_has_trailers = nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def upgrade?
|
33
|
+
@upgrade
|
34
|
+
end
|
35
|
+
|
36
|
+
def upgrade_data
|
37
|
+
@buffer
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def parse
|
43
|
+
state = @state
|
44
|
+
case @state
|
45
|
+
when :idle
|
46
|
+
parse_headline
|
47
|
+
when :headers
|
48
|
+
parse_headers
|
49
|
+
when :trailers
|
50
|
+
parse_headers
|
51
|
+
when :data
|
52
|
+
parse_data
|
53
|
+
end
|
54
|
+
parse if !@buffer.empty? && state != @state
|
55
|
+
end
|
56
|
+
|
57
|
+
def parse_headline
|
58
|
+
idx = @buffer.index("\n")
|
59
|
+
return unless idx
|
60
|
+
(m = %r{\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?}in.match(@buffer)) ||
|
61
|
+
raise(Error, "wrong head line format")
|
62
|
+
version, code, _ = m.captures
|
63
|
+
raise(Error, "unsupported HTTP version (HTTP/#{version})") unless VERSIONS.include?(version)
|
64
|
+
@http_version = version.split(".").map(&:to_i)
|
65
|
+
@status_code = code.to_i
|
66
|
+
raise(Error, "wrong status code (#{@status_code})") unless (100..599).cover?(@status_code)
|
67
|
+
@buffer.slice!(0, idx + 1)
|
68
|
+
nextstate(:headers)
|
69
|
+
end
|
70
|
+
|
71
|
+
def parse_headers
|
72
|
+
headers = @headers
|
73
|
+
while (idx = @buffer.index("\n"))
|
74
|
+
line = @buffer.slice!(0, idx + 1).sub(/\s+\z/, "")
|
75
|
+
if line.empty?
|
76
|
+
case @state
|
77
|
+
when :headers
|
78
|
+
prepare_data(headers)
|
79
|
+
@observer.on_headers(headers)
|
80
|
+
return unless @state == :headers
|
81
|
+
# state might have been reset
|
82
|
+
# in the :headers callback
|
83
|
+
nextstate(:data)
|
84
|
+
headers.clear
|
85
|
+
when :trailers
|
86
|
+
@observer.on_trailers(headers)
|
87
|
+
headers.clear
|
88
|
+
nextstate(:complete)
|
89
|
+
else
|
90
|
+
raise Error, "wrong header format"
|
91
|
+
end
|
92
|
+
return
|
93
|
+
end
|
94
|
+
separator_index = line.index(@header_separator)
|
95
|
+
raise Error, "wrong header format" unless separator_index
|
96
|
+
key = line[0..separator_index - 1]
|
97
|
+
raise Error, "wrong header format" if key.start_with?("\s", "\t")
|
98
|
+
key.strip!
|
99
|
+
value = line[separator_index + 1..-1]
|
100
|
+
value.strip!
|
101
|
+
raise Error, "wrong header format" if value.nil?
|
102
|
+
(headers[key.downcase] ||= []) << value
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def parse_data
|
107
|
+
if @buffer.respond_to?(:each)
|
108
|
+
@buffer.each do |chunk|
|
109
|
+
@observer.on_data(chunk)
|
110
|
+
end
|
111
|
+
elsif @content_length
|
112
|
+
data = @buffer.slice!(0, @content_length)
|
113
|
+
@content_length -= data.bytesize
|
114
|
+
@observer.on_data(data)
|
115
|
+
data.clear
|
116
|
+
else
|
117
|
+
@observer.on_data(@buffer)
|
118
|
+
@buffer.clear
|
119
|
+
end
|
120
|
+
return unless no_more_data?
|
121
|
+
@buffer = @buffer.to_s
|
122
|
+
if @_has_trailers
|
123
|
+
nextstate(:trailers)
|
124
|
+
else
|
125
|
+
nextstate(:complete)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def prepare_data(headers)
|
130
|
+
@upgrade = headers.key?("upgrade")
|
131
|
+
|
132
|
+
@_has_trailers = headers.key?("trailer")
|
133
|
+
|
134
|
+
if (tr_encodings = headers["transfer-encoding"])
|
135
|
+
tr_encodings.reverse_each do |tr_encoding|
|
136
|
+
tr_encoding.split(/ *, */).each do |encoding|
|
137
|
+
case encoding
|
138
|
+
when "chunked"
|
139
|
+
@buffer = Transcoder::Chunker::Decoder.new(@buffer, @_has_trailers)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
else
|
144
|
+
@content_length = headers["content-length"][0].to_i if headers.key?("content-length")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def no_more_data?
|
149
|
+
if @content_length
|
150
|
+
@content_length <= 0
|
151
|
+
elsif @buffer.respond_to?(:finished?)
|
152
|
+
@buffer.finished?
|
153
|
+
else
|
154
|
+
false
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def nextstate(state)
|
159
|
+
@state = state
|
160
|
+
case state
|
161
|
+
when :headers
|
162
|
+
@observer.on_start
|
163
|
+
when :complete
|
164
|
+
@observer.on_complete
|
165
|
+
reset!
|
166
|
+
nextstate(:idle) unless @buffer.empty?
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|