hatetepe 0.4.1 → 0.5.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,32 @@
1
+ require "hatetepe"
2
+ require "awesome_print"
3
+
4
+ EM.synchrony do
5
+ # one server
6
+ app = proc do |env|
7
+ [200, {"Content-Type" => "text/plain"}, ["Hello, world!"]]
8
+ end
9
+ Hatetepe::Server.start :host => "127.0.0.1", :port => 3123, :app => app
10
+
11
+ # two clients
12
+ clients = 2.times.map { Hatetepe::Client.start :host => "127.0.0.1", :port => 3123 }
13
+
14
+ # six requests
15
+ requests = 6.times.map do |i|
16
+ # no extra headers, empty body
17
+ req = Hatetepe::Request.new(:get, "/", {}, [])
18
+
19
+ # response status between 100 and 399
20
+ req.callback {|res| puts "request ##{i} finished with #{res.status} response" }
21
+
22
+ # response status between 400 and 599 or connection failure
23
+ req.errback {|res| puts "request ##{i} finished with #{res ? res.status : 'no'} response" }
24
+
25
+ clients[i % clients.length] << req
26
+ req
27
+ end
28
+
29
+ # Client#stop waits until all of its requests have finished
30
+ clients.map &:stop
31
+ EM.stop
32
+ end
data/hatetepe.gemspec CHANGED
@@ -5,6 +5,7 @@ require "hatetepe/version"
5
5
  Gem::Specification.new do |s|
6
6
  s.name = "hatetepe"
7
7
  s.version = Hatetepe::VERSION
8
+ s.date = Date.today.to_s
8
9
  s.platform = Gem::Platform::RUBY
9
10
  s.authors = ["Lars Gierth"]
10
11
  s.email = ["lars.gierth@gmail.com"]
@@ -13,14 +14,12 @@ Gem::Specification.new do |s|
13
14
  #s.description = %q{TODO: write description}
14
15
 
15
16
  s.add_dependency "http_parser.rb", "~> 0.5.3"
16
- s.add_dependency "eventmachine"
17
- s.add_dependency "em-synchrony", "~> 1.0"
17
+ s.add_dependency "eventmachine", "~> 1.0.0.beta.4"
18
+ s.add_dependency "em-synchrony", "~> 1.0"
18
19
  s.add_dependency "rack"
19
- s.add_dependency "async-rack"
20
20
  s.add_dependency "thor"
21
21
 
22
22
  s.add_development_dependency "rspec"
23
- s.add_development_dependency "fakefs"
24
23
  s.add_development_dependency "yard"
25
24
  s.add_development_dependency "rdiscount"
26
25
 
@@ -135,6 +135,7 @@ module Hatetepe
135
135
  @state = :writing_body
136
136
  end
137
137
 
138
+ chunk = chunk.to_s
138
139
  if chunked?
139
140
  write "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
140
141
  else
data/lib/hatetepe/cli.rb CHANGED
@@ -23,11 +23,12 @@ module Hatetepe
23
23
  method_option :env, :aliases => "-e", :type => :string,
24
24
  :banner => "Boot the app in the specified environment (default: development)"
25
25
  method_option :timeout, :aliases => "-t", :type => :numeric,
26
- :banner => "Time out connections after the specified admount of seconds (default: 1)"
26
+ :banner => "Time out connections after the specified admount of seconds (default: see Hatetepe::Server::CONFIG_DEFAULTS)"
27
27
  def start
28
28
  require "hatetepe/server"
29
+ require "rack"
29
30
 
30
- ENV["RACK_ENV"] = expand_env(options[:env]) || ENV["RACK_ENV"] || "development"
31
+ ENV["RACK_ENV"] = options[:env] || ENV["RACK_ENV"] || "development"
31
32
  $stderr << "We're in #{ENV["RACK_ENV"]}\n"
32
33
  $stderr.flush
33
34
 
@@ -42,30 +43,20 @@ module Hatetepe
42
43
  trap("INT") { EM.stop }
43
44
  trap("TERM") { EM.stop }
44
45
 
45
- host = options[:bind] || "127.0.0.1"
46
- port = options[:port] || 3000
46
+ host = options[:bind] || "127.0.0.1"
47
+ port = options[:port] || 3000
48
+ timeout = options[:timeout] || Hatetepe::Server::CONFIG_DEFAULTS[:timeout]
47
49
 
48
50
  $stderr << "Binding to #{host}:#{port}\n"
49
51
  $stderr.flush
52
+
50
53
  Server.start({
51
- :app => app,
52
- :errors => $stderr,
53
- :host => host,
54
- :port => port,
55
- :timeout => (options[:timeout] || 1)
54
+ app: app,
55
+ host: host,
56
+ port: port,
57
+ timeout: timeout
56
58
  })
57
59
  end
58
60
  end
59
-
60
- protected
61
-
62
- def expand_env(env)
63
- env &&= env.dup.downcase
64
- case env
65
- when /^dev(el(op)?)?$/ then "development"
66
- when /^test(ing)?$/ then "testing"
67
- else env
68
- end
69
- end
70
61
  end
71
62
  end
@@ -1,218 +1,247 @@
1
1
  require "em-synchrony"
2
2
  require "eventmachine"
3
- require "rack"
4
- require "uri"
5
3
 
6
4
  require "hatetepe/builder"
7
5
  require "hatetepe/connection"
8
- require "hatetepe/deferred_status_fix"
9
6
  require "hatetepe/parser"
10
7
  require "hatetepe/request"
11
8
  require "hatetepe/version"
12
9
 
13
- module Hatetepe
14
- class Client < Hatetepe::Connection; end
15
- end
10
+ module Hatetepe::Client
11
+ include Hatetepe::Connection
12
+
13
+ # @api private
14
+ Job = Struct.new(:fiber, :request, :sent, :response)
15
+
16
+ # The default configuration.
17
+ #
18
+ # @api public
19
+ CONFIG_DEFAULTS = {
20
+ :timeout => 5,
21
+ :connect_timeout => 5
22
+ }
16
23
 
17
- require "hatetepe/client/keep_alive"
18
- require "hatetepe/client/pipeline"
24
+ # The configuration for this Client instance.
25
+ #
26
+ # @api public
27
+ attr_reader :config
19
28
 
20
- class Hatetepe::Client
21
- attr_reader :app, :config
22
- attr_reader :parser, :builder
23
- attr_reader :requests, :pending_transmission, :pending_response
24
-
29
+ # The pipe of middleware and request transmission/response reception.
30
+ #
31
+ # @api private
32
+ attr_reader :app
33
+
34
+ # Initializes a new Client instance.
35
+ #
36
+ # @param [Hash] config
37
+ # Configuration values that overwrite the defaults.
38
+ #
39
+ # @api semipublic
25
40
  def initialize(config)
26
- @config = config
27
- @parser, @builder = Hatetepe::Parser.new, Hatetepe::Builder.new
28
-
29
- @requests = []
30
- @pending_transmission, @pending_response = {}, {}
31
-
32
- @app = Rack::Builder.new.tap do |b|
33
- b.use KeepAlive
34
- b.use Pipeline
35
- b.run method(:send_request)
36
- end.to_app
37
-
38
- super
41
+ @config = CONFIG_DEFAULTS.merge(config)
39
42
  end
40
-
43
+
44
+ # Initializes the parser, request queue, and middleware pipe.
45
+ #
46
+ # TODO: Use +Rack::Builder+ for building the app pipe.
47
+ #
48
+ # @see EM::Connection#post_init
49
+ #
50
+ # @api semipublic
41
51
  def post_init
42
- parser.on_response << method(:receive_response)
43
- # XXX check if the connection is still present
44
- builder.on_write << method(:send_data)
45
- #builder.on_write {|data| p "client >> #{data}" }
46
-
47
- self.processing_enabled = true
52
+ @builder, @parser = Hatetepe::Builder.new, Hatetepe::Parser.new
53
+ @builder.on_write << method(:send_data)
54
+ # @builder.on_write {|data| p "|--> #{data}" }
55
+ @parser.on_response << method(:receive_response)
56
+
57
+ @queue = []
58
+
59
+ @app = method(:send_request)
60
+
61
+ self.comm_inactivity_timeout = config[:timeout]
62
+ self.pending_connect_timeout = config[:connect_timeout]
48
63
  end
49
-
64
+
65
+ # Feeds response data into the parser.
66
+ #
67
+ # @see EM::Connection#receive_data
68
+ #
69
+ # @param [String] data
70
+ # The received data that's gonna be fed into the parser.
71
+ #
72
+ # @api semipublic
50
73
  def receive_data(data)
51
- #p "client << #{data}"
52
- parser << data
53
- rescue => e
54
- close_connection
55
- raise e
56
- end
57
-
58
- def send_request(request)
59
- id = request.object_id
60
-
61
- request.headers.delete "X-Hatetepe-Single"
62
- builder.request request.to_a
63
- pending_transmission[id].succeed
64
-
65
- pending_response[id] = EM::DefaultDeferrable.new
66
- EM::Synchrony.sync pending_response[id]
67
- ensure
68
- pending_response.delete id
74
+ # p "|<-- #{data}"
75
+ @parser << data
69
76
  end
70
-
71
- def receive_response(response)
72
- requests.find {|req| !req.response }.tap do |req|
73
- req.response = response
74
- pending_response[req.object_id].succeed response
75
- end
77
+
78
+ # Aborts all outstanding requests.
79
+ #
80
+ # @see EM::Connection#unbind
81
+ #
82
+ # @api semipublic
83
+ def unbind(reason)
84
+ super
85
+ @queue.each {|job| job.fiber.resume(:kill) }
76
86
  end
77
-
78
- def <<(request)
79
- request.headers["Host"] ||= "#{config[:host]}:#{config[:port]}"
80
87
 
81
- request.connection = self
82
- unless processing_enabled?
83
- request.fail
84
- return
85
- end
86
-
87
- requests << request
88
-
88
+ # Sends a request and waits for the response without blocking.
89
+ #
90
+ # Transmission and reception are performed within a separate +Fiber+.
91
+ # +#succeed+ and +#fail+ will be called on the +request+ passing the
92
+ # response, depending on whether the response indicates success (100-399)
93
+ # or failure (400-599).
94
+ #
95
+ # The request will +#fail+ with a +nil+ response if the connection was
96
+ # closed for whatever reason.
97
+ #
98
+ # @api public
99
+ def <<(request)
89
100
  Fiber.new do
90
- begin
91
- pending_transmission[request.object_id] = EM::DefaultDeferrable.new
92
-
93
- app.call(request).tap do |response|
94
- request.response = response
95
- # XXX check for response.nil?
96
- status = (response && response.success?) ? :succeed : :fail
97
- requests.delete(request).send status, response
98
- end
99
- ensure
100
- pending_transmission.delete request.object_id
101
+ response = @app.call(request)
102
+
103
+ if !response || response.failure?
104
+ request.fail(response)
105
+ else
106
+ request.succeed(response)
101
107
  end
102
108
  end.resume
103
109
  end
104
-
105
- def request(verb, uri, headers = {}, body = nil, http_version = "1.1")
106
- headers["User-Agent"] ||= "hatetepe/#{Hatetepe::VERSION}"
107
-
108
- body = wrap_body(body)
109
- headers, body = encode_body(headers.dup, body)
110
-
111
- request = Hatetepe::Request.new(verb, uri, headers, body, http_version)
112
- self << request
113
-
114
- # XXX shouldn't this happen in ::request ?
115
- self.processing_enabled = false
116
-
117
- EM::Synchrony.sync request
118
-
119
- request.response.body.close_write if request.verb == "HEAD"
120
-
121
- request.response
110
+
111
+ # Builds a +Request+, sends it, and blocks while waiting for the response.
112
+ #
113
+ # @param [Symbol, String] verb
114
+ # The HTTP method verb, e.g. +:get+ or +"PUT"+.
115
+ # @param [String, URI] uri
116
+ # The request URI.
117
+ # @param [Hash] headers (optional)
118
+ # The request headers.
119
+ # @param [#each] body (optional)
120
+ # A request body object whose +#each+ method yields objects that respond
121
+ # to +#to_s+.
122
+ #
123
+ # @return [Hatetepe::Response, nil]
124
+ #
125
+ # @api public
126
+ def request(verb, uri, headers = {}, body = [])
127
+ request = Hatetepe::Request.new(verb, uri, headers, body)
128
+ self << request
129
+ EM::Synchrony.sync(request)
122
130
  end
123
-
131
+
132
+ # Gracefully stops the client.
133
+ #
134
+ # Waits for all requests to finish and then stops the client.
135
+ #
136
+ # @api public
124
137
  def stop
125
- unless requests.empty?
126
- last_response = EM::Synchrony.sync(requests.last)
127
- EM::Synchrony.sync last_response.body if last_response.body
128
- end
138
+ wait
139
+ stop!
140
+ end
141
+
142
+ # Immediately stops the client by closing the connection.
143
+ #
144
+ # This will lead to EventMachine's event loop calling {#unbind}, which fail
145
+ # all outstanding requests.
146
+ #
147
+ # @see #unbind
148
+ #
149
+ # @api public
150
+ def stop!
129
151
  close_connection
130
152
  end
131
-
132
- def unbind
133
- super
134
-
135
- EM.next_tick do
136
- requests.each do |req|
137
- # fail state triggers
138
- req.object_id.tap do |id|
139
- pending_transmission[id].fail if pending_transmission[id]
140
- pending_response[id].fail if pending_response[id]
141
- end
142
- # fail reponse body if the response has already been started
143
- if req.response
144
- req.response.body.tap {|b| b.close_write unless b.closed_write? }
145
- end
146
- # XXX FiberError: dead fiber called because req already succeeded
147
- # or failed, see github.com/eventmachine/eventmachine/issues/287
148
- req.fail req.response
149
- end
153
+
154
+ # Blocks until the last request has finished receiving its response.
155
+ #
156
+ # Returns immediately if there are no outstanding requests.
157
+ #
158
+ # @api public
159
+ def wait
160
+ if job = @queue.last
161
+ EM::Synchrony.sync(job.request)
162
+ EM::Synchrony.sync(job.response.body) if job.response
150
163
  end
151
164
  end
152
-
153
- def wrap_body(body)
154
- if body.respond_to? :each
155
- body
156
- elsif body.respond_to? :read
157
- [body.read]
158
- elsif body
159
- [body]
160
- else
161
- []
162
- end
165
+
166
+ # Starts a new Client.
167
+ #
168
+ # @param [Hash] config
169
+ # The +:host+ and +:port+ the Client should connect to.
170
+ #
171
+ # @return [Hatetepe::Client]
172
+ # The new Client instance.
173
+ #
174
+ # @api public
175
+ def self.start(config)
176
+ EM.connect(config[:host], config[:port], self, config)
163
177
  end
164
-
165
- def encode_body(headers, body)
166
- multipart, urlencoded = false, false
167
- if Hash === body
168
- query = lambda do |value|
169
- case value
170
- when Array
171
- value.each &query
172
- when Hash
173
- value.values.each &query
174
- when Rack::Multipart::UploadedFile
175
- multipart = true
176
- end
177
- end
178
- body.values.each &query
179
- urlencoded = !multipart
180
- end
181
-
182
- body = if multipart
183
- boundary = Rack::Multipart::MULTIPART_BOUNDARY
184
- headers["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
185
- [Rack::Multipart.build_multipart(body)]
186
- elsif urlencoded
187
- headers["Content-Type"] = "application/x-www-form-urlencoded"
188
- [Rack::Utils.build_nested_query(body)]
189
- else
190
- body
191
- end
192
-
193
- [headers, body]
178
+
179
+ # @api public
180
+ def self.request(verb, uri, headers = {}, body = [])
194
181
  end
195
-
196
- class << self
197
- def start(config)
198
- EM.connect config[:host], config[:port], self, config
182
+
183
+ # Feeds the request into the builder and blocks while waiting for the
184
+ # response to arrive.
185
+ #
186
+ # Supports the request bit of HTTP pipelining by waiting until the previous
187
+ # request has been sent.
188
+ #
189
+ # @param [Hatetepe::Request] request
190
+ # The request that's gonna be sent.
191
+ #
192
+ # @return [Hatetepe::Response, nil]
193
+ # The received response or +nil+ if the connection has been closed before
194
+ # receiving a response.
195
+ #
196
+ # @api private
197
+ def send_request(request)
198
+ previous = @queue.last
199
+ current = Job.new(Fiber.current, request, false)
200
+ @queue << current
201
+
202
+ # wait for the previous request to be sent
203
+ while previous && !previous.sent
204
+ return if Fiber.yield == :kill
199
205
  end
200
-
201
- def request(verb, uri, headers = {}, body = nil)
202
- uri = URI(uri)
203
- client = start(:host => uri.host, :port => uri.port)
204
-
205
- headers["X-Hatetepe-Single"] = true
206
- client.request(verb, uri.request_uri, headers, body).tap do |*|
207
- client.stop
208
- end
206
+
207
+ # send the request
208
+ self.comm_inactivity_timeout = 0
209
+ @builder.request(request.to_a)
210
+ current.sent = true
211
+ self.comm_inactivity_timeout = config[:timeout]
212
+
213
+ # wait for the response
214
+ while !current.response
215
+ return if Fiber.yield == :kill
209
216
  end
217
+
218
+ # clean up and return response
219
+ @queue.delete(current)
220
+ current.response
210
221
  end
211
-
212
- [self, self.singleton_class].each do |cls|
213
- [:get, :head, :post, :put, :delete,
214
- :options, :trace, :connect].each do |verb|
215
- cls.send(:define_method, verb) {|uri, *args| request verb, uri, *args }
222
+
223
+ # Relates an incoming response to the corresponding request.
224
+ #
225
+ # Supports the response bit of HTTP pipelining by relating responses to
226
+ # requests in the order the requests were sent.
227
+ #
228
+ # TODO: raise a more meaningful error.
229
+ #
230
+ # @param [Hatetepe::Response] response
231
+ # The incoming response
232
+ #
233
+ # @raise [RuntimeError]
234
+ # There is no request that's waiting for a response.
235
+ #
236
+ # @api private
237
+ def receive_response(response)
238
+ query = proc {|j| j.response.nil? }
239
+
240
+ if job = @queue.find(&query)
241
+ job.response = response
242
+ job.fiber.resume
243
+ else
244
+ raise "Received response but didn't expect one: #{response.status}"
216
245
  end
217
246
  end
218
247
  end
@@ -1,7 +1,5 @@
1
- require "eventmachine"
2
-
3
1
  module Hatetepe
4
- class Connection < EM::Connection
2
+ module Connection
5
3
  attr_accessor :processing_enabled
6
4
  alias_method :processing_enabled?, :processing_enabled
7
5
 
@@ -16,9 +14,17 @@ module Hatetepe
16
14
  def sockaddr
17
15
  @sockaddr ||= Socket.unpack_sockaddr_in(get_peername) rescue nil
18
16
  end
17
+
18
+ def connection_completed
19
+ @connected = true
20
+ end
21
+
22
+ def connected?
23
+ defined?(@connected) && @connected
24
+ end
19
25
 
20
26
  def closed?
21
- !!@closed_by
27
+ !!defined?(@closed_by)
22
28
  end
23
29
 
24
30
  def closed_by_remote?
@@ -26,17 +32,30 @@ module Hatetepe
26
32
  end
27
33
 
28
34
  def closed_by_self?
29
- closed? && !closed_by_remote?
35
+ @closed_by == :self
30
36
  end
31
-
32
- def close_connection(after_writing = false)
33
- @closed_by = :self
34
- super after_writing
37
+
38
+ def closed_by_timeout?
39
+ connected? && @closed_by == :timeout
40
+ end
41
+
42
+ def closed_by_connect_timeout?
43
+ !connected? && @closed_by == :timeout
35
44
  end
36
45
 
37
- # TODO how to detect closed-by-timeout?
38
- def unbind
39
- @closed_by = :remote unless closed?
46
+ def close_connection(after_writing = false)
47
+ @closed_by = :self unless closed?
48
+ super
49
+ end
50
+
51
+ def unbind(reason)
52
+ unless closed?
53
+ @closed_by = if reason == Errno::ETIMEDOUT
54
+ :timeout
55
+ else
56
+ :remote
57
+ end
58
+ end
40
59
  end
41
60
  end
42
61
  end