hatetepe 0.4.1 → 0.5.0.pre

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.
@@ -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