spider-gazelle 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 3081d50c47f8dd30b20deca6f8ff4ab4f6b96b04
4
- data.tar.gz: 6d33aab9f1fe72354d919b59d6232918cd452c2b
3
+ metadata.gz: f6835ff8fc79a090958589cc6c64474d27446b98
4
+ data.tar.gz: be7c36e4a3a219ee14ee0c35d4099f534b2fa9ee
5
5
  SHA512:
6
- metadata.gz: fe5cc932cb5a8616da336f5806ff8c46ae7fba0743f42e688d9e9c6a3e9371c05cf1a01081e3f077283a9b8c35b4c93263e75fb6875a7039ef030aaf4314704e
7
- data.tar.gz: 1a1ef9210f7edd32884f5e7dba6f7257ff8c4c33b2ee18ab83a2f209e48aa5bc13cf0a455b61316cd7d78702cf0f9b2d89cb41f57f5d9247711b390cdc662f79
6
+ metadata.gz: 52c7dabc77dbe369030abff53cf86b1c120601de0fc88a03f5e84ce888c7513c99bbe7c2281597d525cb80eac92137e3ebb78e18fd2b0298f2a0b1f0ee47454d
7
+ data.tar.gz: 4b97ce2d2131da24f716d438c0d82c1e76ed7c94c3b5acdf9263c9175a0b7c8152737e7113ead316116c4fcfae7752ddb2b9548aef6ece7a6336ce22bc06d4d9
data/bin/sg CHANGED
@@ -6,23 +6,25 @@ require 'optparse'
6
6
 
7
7
 
8
8
  options = {
9
- Host: "0.0.0.0",
10
- Port: 3000,
9
+ host: "0.0.0.0",
10
+ port: 3000,
11
+ tls: false,
11
12
  environment: ENV['RACK_ENV'] || 'development',
12
- rackup: "#{Dir.pwd}/config.ru"
13
+ rackup: "#{Dir.pwd}/config.ru",
14
+ quiet: false
13
15
  }
14
16
 
15
17
  parser = OptionParser.new do |opts|
16
18
  opts.on "-p", "--port PORT", Integer, "Define what port TCP port to bind to (default: 3000)" do |arg|
17
- options[:Port] = arg
19
+ options[:port] = arg
18
20
  end
19
21
 
20
22
  opts.on "-a", "--address HOST", "bind to HOST address (default: 0.0.0.0)" do |arg|
21
- options[:Host] = arg
23
+ options[:host] = arg
22
24
  end
23
25
 
24
- opts.on "-q", "--quiet", "Quiet down the output" do
25
- options[:Quiet] = true
26
+ opts.on "-q", "--quiet", "quiet down the output" do
27
+ options[:quiet] = true
26
28
  end
27
29
 
28
30
  opts.on "-e", "--environment ENVIRONMENT", "The environment to run the Rack app on (default: development)" do |arg|
@@ -32,6 +34,14 @@ parser = OptionParser.new do |opts|
32
34
  opts.on "-r", "--rackup FILE", "Load Rack config from this file (default: config.ru)" do |arg|
33
35
  options[:rackup] = arg
34
36
  end
37
+
38
+ opts.on "-l", "--logfile FILE", "Location of the servers log file (default: logs/server.log)" do |arg|
39
+ options[:rackup] = arg
40
+ end
41
+
42
+ opts.on "-m", "--mode MODE", "Either thread, process or no_ipc (default: thread)" do |arg|
43
+ ENV['SG_MODE'] = arg
44
+ end
35
45
  end
36
46
 
37
47
  parser.banner = "sg <options> <rackup file>"
@@ -50,15 +60,34 @@ unless File.exists?(options[:rackup])
50
60
  abort "No rackup found at #{options[:rackup]}"
51
61
  end
52
62
 
53
- app, rack_options = Rack::Builder.parse_file options[:rackup]
54
- server = ::SpiderGazelle::Spider.new(app, options)
55
-
56
- puts "Look out! Here comes Spider-Gazelle #{::SpiderGazelle::VERSION}!"
57
- puts "* Environment: #{ENV['RACK_ENV']}"
58
- puts "* Listening on tcp://#{options[:Host]}:#{options[:Port]}"
63
+ ENV['RACK_ENV'] = options[:environment].to_s
64
+ # Force process mode on Windows (pipes + sockets not working at the moment)
65
+ ENV['SG_MODE'] = 'no_ipc' if ::FFI::Platform.windows?
66
+
67
+ ::Libuv::Loop.default.run do |logger|
68
+ logger.progress do |level, errorid, error|
69
+ begin
70
+ puts "Log called: #{level}: #{errorid}\n#{error.message}\n#{error.backtrace.join("\n")}\n"
71
+ rescue Exception
72
+ p 'error in gazelle logger'
73
+ end
74
+ end
59
75
 
60
- begin
61
- server.run
62
- ensure
63
- puts "\nSpider-Gazelle leaps through the veldt"
76
+ puts "Look out! Here comes Spider-Gazelle #{::SpiderGazelle::VERSION}!"
77
+ puts "* Environment: #{ENV['RACK_ENV']} on #{RUBY_ENGINE || 'ruby'} #{RUBY_VERSION}"
78
+ server = ::SpiderGazelle::Spider.instance
79
+ server.loaded.then do
80
+ puts "* Loading: #{options[:rackup]}"
81
+ server.load(options[:rackup], options).catch(proc {|e|
82
+ puts "#{e.message}\n#{e.backtrace.join("\n") unless e.backtrace.nil?}\n"
83
+ }).finally do
84
+ # This will execute if the TCP binding is lost
85
+ p 'server failed to load - shutting down'
86
+ server.shutdown
87
+ end
88
+
89
+ puts "* Listening on tcp://#{options[:host]}:#{options[:port]}"
90
+ end
64
91
  end
92
+
93
+ puts "\nSpider-Gazelle leaps through the veldt"
@@ -6,8 +6,13 @@ require "spider-gazelle/version"
6
6
  require "spider-gazelle/request" # Holds request information and handles request processing
7
7
  require "spider-gazelle/connection" # Holds connection information and handles request pipelining
8
8
  require "spider-gazelle/gazelle" # Processes data received from connections
9
+
10
+ require "spider-gazelle/app_store" # Holds references to the loaded rack applications
11
+ require "spider-gazelle/binding" # Holds a reference to a bound port and associated rack application
9
12
  require "spider-gazelle/spider" # Accepts connections and offloads them to gazelles
10
13
 
14
+ require "spider-gazelle/upgrades/websocket" # Websocket implementation
15
+
11
16
 
12
17
  module SpiderGazelle
13
18
  # Delegate pipe used for passing sockets to the gazelles
@@ -0,0 +1,67 @@
1
+ require 'thread'
2
+ require 'radix'
3
+
4
+
5
+ module SpiderGazelle
6
+ module AppStore
7
+ # Basic compression using UTF (more efficient for ID's stored as strings)
8
+ B65 = ::Radix::Base.new(::Radix::BASE::B62 + ['-', '_', '~'])
9
+ B10 = ::Radix::Base.new(10)
10
+
11
+ @mutex = Mutex.new
12
+ @count = 0
13
+ @apps = ThreadSafe::Cache.new
14
+ @loaded = ThreadSafe::Cache.new
15
+
16
+ # Load an app and assign it an ID
17
+ def self.load(rackup)
18
+ id = @loaded[rackup.to_sym]
19
+
20
+ if id.nil?
21
+ app, options = ::Rack::Builder.parse_file rackup
22
+
23
+ count = 0
24
+ @mutex.synchronize {
25
+ count = @count += 1
26
+ }
27
+ id = Radix.convert(count, B10, B65).to_sym
28
+ @apps[id] = app
29
+ @loaded[rackup.to_sym] = id
30
+ end
31
+
32
+ id
33
+ end
34
+
35
+ # Manually load an app
36
+ def self.add(app)
37
+ id = @loaded[app.__id__]
38
+
39
+ if id.nil?
40
+ count = 0
41
+ @mutex.synchronize {
42
+ count = @count += 1
43
+ }
44
+ id = Radix.convert(count, B10, B65).to_sym
45
+ @apps[id] = app
46
+ @loaded[app.__id__] = id
47
+ end
48
+
49
+ id
50
+ end
51
+
52
+ # Lookup an application
53
+ def self.lookup(app)
54
+ if app.is_a?(String) || app.is_a?(Symbol)
55
+ @apps[@loaded[app.to_sym]]
56
+ else
57
+ @apps[@loaded[app.__id__]]
58
+ end
59
+ end
60
+
61
+ # Get an app using the id directly
62
+ def self.get(id)
63
+ id = id.to_sym if id.is_a?(String)
64
+ @apps[id]
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ require 'thread'
2
+ require 'set'
3
+
4
+
5
+ module SpiderGazelle
6
+ class Binding
7
+ DEFAULT_OPTIONS = {
8
+ :host => '0.0.0.0',
9
+ :port => 3000,
10
+ :tls => false,
11
+ :optimize_for_latency => true,
12
+ :backlog => 1024
13
+ }
14
+
15
+
16
+ attr_reader :app_id
17
+
18
+
19
+ def initialize(loop, delegate, app_id, options = {})
20
+ @app_id = app_id
21
+ @options = DEFAULT_OPTIONS.merge(options)
22
+ @loop = loop
23
+ @delegate = delegate
24
+ @port = @options[:port]
25
+ @tls = @options[:tls]
26
+ @optimize = @options[:optimize_for_latency]
27
+
28
+ # Connection management functions
29
+ @new_connection = method(:new_connection)
30
+ @accept_connection = method(:accept_connection)
31
+ end
32
+
33
+ # Bind the application to the selected port
34
+ def bind
35
+ # Bind the socket
36
+ @tcp = @loop.tcp
37
+ @tcp.bind(@options[:host], @port, @new_connection)
38
+ @tcp.listen(@options[:backlog])
39
+
40
+ # Delegate errors
41
+ @tcp.catch do |e|
42
+ @loop.log :error, 'application bind failed', e
43
+ end
44
+ @tcp
45
+ end
46
+
47
+ # Close the bindings
48
+ def unbind
49
+ # close unless we've never been bound
50
+ @tcp.close unless @tcp.nil?
51
+ @tcp
52
+ end
53
+
54
+
55
+ protected
56
+
57
+
58
+ # There is a new connection pending
59
+ # We accept it
60
+ def new_connection(server)
61
+ server.accept @accept_connection
62
+ end
63
+
64
+ # Once the connection is accepted we disable Nagles Algorithm
65
+ # This improves performance as we are using vectored or scatter/gather IO
66
+ # Then the spider delegates to the gazelle loops
67
+ def accept_connection(client)
68
+ client.enable_nodelay if @optimize == true
69
+ @delegate.call(client, @tls, @port, @app_id)
70
+ end
71
+ end
72
+ end
@@ -3,18 +3,35 @@ require 'stringio'
3
3
 
4
4
  module SpiderGazelle
5
5
  class Connection
6
+ Hijack = Struct.new(:socket, :env)
6
7
 
7
8
 
8
- SET_INSTANCE_TYPE = proc {|inst| inst.type = :request}
9
+ RACK = 'rack'.freeze # used for filtering headers
10
+ CLOSE = 'close'.freeze
11
+ CONNECTION = 'Connection'.freeze
12
+ CONTENT_LENGTH = 'Content-Length'.freeze
13
+ TRANSFER_ENCODING = 'Transfer-Encoding'.freeze
14
+ CHUNKED = 'chunked'.freeze
15
+ COLON_SPACE = ': '.freeze
16
+ EOF = "0\r\n\r\n".freeze
17
+ CRLF = "\r\n".freeze
9
18
 
19
+ HTTP_11_400 = "HTTP/1.1 400 Bad Request\r\n\r\n".freeze
10
20
 
21
+
22
+ def self.on_progress(data, socket); end
23
+ DUMMY_PROGRESS = self.method(:on_progress)
24
+
25
+
26
+ # For Gazelle
11
27
  attr_reader :state, :parsing
12
- attr_accessor :queue_worker
28
+ # For Request
29
+ attr_reader :tls, :port, :loop, :socket, :async_callback
13
30
 
14
31
 
15
- def initialize(loop, socket, queue) # TODO:: port information
32
+ def initialize(gazelle, loop, socket, port, state, app, queue)
16
33
  # A single parser instance per-connection (supports pipelining)
17
- @state = ::HttpParser::Parser.new_instance &SET_INSTANCE_TYPE
34
+ @state = state
18
35
  @pending = []
19
36
 
20
37
  # Work callback for thread pool processing
@@ -27,24 +44,38 @@ module SpiderGazelle
27
44
 
28
45
  # Used to chain promises (ensures requests are processed in order)
29
46
  @process_next = method(:process_next)
47
+ @write_chunk = method(:write_chunk)
30
48
  @current_worker = queue # keep track of work queue head to prevent unintentional GC
31
49
  @queue_worker = queue # start queue with an existing resolved promise (::Libuv::Q::ResolvedPromise.new(@loop, true))
32
50
 
33
51
  # Socket for writing the response
34
52
  @socket = socket
53
+ @app = app
54
+ @port = port
55
+ @tls = @socket.tls?
35
56
  @loop = loop
57
+ @gazelle = gazelle
58
+ @async_callback = method(:deferred_callback)
59
+
60
+ # Remove connection if the socket closes
61
+ socket.finally &method(:unlink)
62
+ end
63
+
64
+ # Lazy eval the IP
65
+ def remote_ip
66
+ @remote_ip ||= @socket.peername[0]
36
67
  end
37
68
 
38
69
  # Creates a new request state object
39
- def start_parsing(request)
40
- @parsing = request
70
+ def start_parsing
71
+ @parsing = Request.new(self, @app)
41
72
  end
42
73
 
43
74
  # Chains the work in a promise queue
44
75
  def finished_parsing
45
76
  if !@state.keep_alive?
46
77
  @parsing.keep_alive = false
47
- @socket.stop_read # we don't want to do any more work then we need to
78
+ @socket.stop_read # we don't want to do any more work than we need to
48
79
  end
49
80
  @parsing.upgrade = @state.upgrade?
50
81
  @pending.push @parsing
@@ -53,52 +84,251 @@ module SpiderGazelle
53
84
 
54
85
  # The parser encountered an error
55
86
  def parsing_error
56
- # TODO::log error (available in the @request object)
57
- p "parsing error #{@state.error}"
87
+ # Grab the error
88
+ send_error(@state.error)
58
89
 
59
90
  # We no longer care for any further requests from this client
60
91
  # however we will finish processing any valid pipelined requests before shutting down
61
92
  @socket.stop_read
62
93
  @queue_worker = @queue_worker.then do
63
94
  # TODO:: send response (400 bad request)
95
+ @socket.write HTTP_11_400
64
96
  @socket.shutdown
65
97
  end
66
98
  end
67
99
 
100
+ # Schedule send
101
+ def response(data)
102
+ @loop.schedule
103
+ end
104
+
68
105
 
69
106
  protected
70
107
 
71
108
 
109
+ # --------------
110
+ # State handlers:
111
+ # --------------
112
+
113
+
114
+ # Called when an error occurs at any point while responding
115
+ def send_error(reason)
116
+ # Close the socket as this is fatal (file read error, gazelle error etc)
117
+ @socket.close
118
+
119
+ # Log the error in a worker thread
120
+ @loop.work do
121
+ msg = "connection error: #{reason.message}\n#{reason.backtrace.join("\n") if reason.backtrace}\n"
122
+ puts msg
123
+ @gazelle.logger.error msg
124
+ end
125
+ end
126
+
127
+ # We use promise chaining to move the requests forward
128
+ # This provides an elegant way to handle persistent and pipelined connections
129
+ def process_next(result)
130
+ @request = @pending.shift
131
+ @current_worker = @loop.work @work
132
+ @current_worker.then @send_response, @send_error # resolves the promise with a promise
133
+ end
134
+
135
+ # returns the response as the result of the work
136
+ # We support the unofficial rack async api (multi-call version for chunked responses)
137
+ def work
138
+ @request.execute!
139
+ end
140
+
141
+ # Unlinks the connection from the rack app
142
+ # This occurs when requested and when the socket closes
143
+ def unlink
144
+ if not @gazelle.nil?
145
+ @socket.progress &DUMMY_PROGRESS # unlink the progress callback (prevent funny business)
146
+ @gazelle.discard(self)
147
+ @gazelle = nil
148
+ @state = nil
149
+ end
150
+ end
151
+
152
+
153
+ # ----------------------
154
+ # Core response handlers:
155
+ # ----------------------
156
+
157
+
72
158
  def send_response(result)
73
159
  # As we have come back from another thread the socket may have closed
74
160
  # This check is an optimisation, the call to write and shutdown would fail safely
75
- if !@socket.closed
76
- @socket.write @request.response
77
- if @request.keep_alive == false
78
- @socket.shutdown
161
+
162
+ if @request.hijacked
163
+ unlink # unlink the management of the socket
164
+
165
+ # Pass the hijack response to the captor using the promise
166
+ # This forwards the socket and environment as well as moving
167
+ # continued execution onto the event loop.
168
+ @request.hijacked.resolve(Hijack.new(@socket, @request.env))
169
+
170
+ elsif !@socket.closed
171
+ if @request.deferred
172
+ # Wait for the response using this promise
173
+ promise = @request.deferred.promise
174
+
175
+ # Process any responses that might have made it here first
176
+ if @deferred_responses
177
+ @deferred_responses.each &method(:respond_with)
178
+ @deferred_responses = nil
179
+ end
180
+
181
+ return promise
182
+
183
+ # NOTE:: Somehow getting to here with a nil request... needs investigation
184
+ elsif not result.nil?
185
+ # clear any cached responses just in case
186
+ # could be set by error in the rack application
187
+ @deferred_responses = nil if @deferred_responses
188
+
189
+ status, headers, body = result
190
+
191
+ # If a file, stream the body in a non-blocking fashion
192
+ if body.respond_to? :to_path
193
+ headers[CONNECTION] = CLOSE if @request.keep_alive == false
194
+
195
+ if headers[CONTENT_LENGTH]
196
+ type = :raw
197
+ else
198
+ type = :http
199
+ headers[TRANSFER_ENCODING] = CHUNKED
200
+ end
201
+
202
+ write_headers(status, headers)
203
+
204
+ file = @loop.file(body.to_path, File::RDONLY)
205
+ file.progress do # File is open and available for reading
206
+ file.send_file(@socket, type).finally do
207
+ file.close
208
+ if @request.keep_alive == false
209
+ @socket.shutdown
210
+ end
211
+ end
212
+ end
213
+
214
+ return file
215
+ else
216
+ write_response(status, headers, body)
217
+ end
79
218
  end
80
219
  end
220
+
81
221
  # continue processing (don't wait for write to complete)
82
222
  # if the write fails it will close the socket
83
223
  nil
84
224
  end
85
225
 
86
- def send_error(reason)
87
- p "send error: #{reason.message}\n#{reason.backtrace.join("\n")}\n"
88
- # log error reason
89
- # TODO:: send response (500 internal error)
90
- # no need to close the socket as this isn't fatal
91
- nil
226
+ def write_response(status, headers, body)
227
+ headers[CONNECTION] = CLOSE if @request.keep_alive == false
228
+
229
+ if headers[CONTENT_LENGTH]
230
+ headers[CONTENT_LENGTH] = headers[CONTENT_LENGTH].to_s
231
+ write_headers(status, headers)
232
+
233
+ # Stream the response (pass directly into @socket.write)
234
+ body.each &@socket.method(:write)
235
+
236
+ if @request.deferred
237
+ @request.deferred.resolve(true)
238
+ @request.deferred = nil # prevent data being sent after completed
239
+ end
240
+
241
+ @socket.shutdown if @request.keep_alive == false
242
+ else
243
+ headers[TRANSFER_ENCODING] = CHUNKED
244
+ write_headers(status, headers)
245
+
246
+ # Stream the response
247
+ body.each &@write_chunk
248
+
249
+ if @request.deferred.nil?
250
+ @socket.write EOF
251
+ @socket.shutdown if @request.keep_alive == false
252
+ else
253
+ @async_state = :chunked
254
+ end
255
+ end
92
256
  end
93
257
 
94
- def process_next(result)
95
- @request = @pending.shift
96
- @current_worker = @loop.work @work
97
- @current_worker.then @send_response, @send_error # resolves the promise with a promise
258
+ def write_headers(status, headers)
259
+ header = "HTTP/1.1 #{status}\r\n"
260
+ headers.each do |key, value|
261
+ next if key.start_with? RACK
262
+
263
+ header << key
264
+ header << COLON_SPACE
265
+ header << value
266
+ header << CRLF
267
+ end
268
+ header << CRLF
269
+ @socket.write header
98
270
  end
99
271
 
100
- def work
101
- @request.execute!
272
+ def write_chunk(part)
273
+ chunk = part.bytesize.to_s(16) << CRLF << part << CRLF
274
+ @socket.write chunk
275
+ end
276
+
277
+
278
+ # ------------------------
279
+ # Async response functions:
280
+ # ------------------------
281
+
282
+
283
+ # Callback from a response that was marked async
284
+ def deferred_callback(data)
285
+ # We call close here, like on a regular response
286
+ body = data[2]
287
+ body.close if body.respond_to?(:close)
288
+ @loop.next_tick do
289
+ callback(data)
290
+ end
291
+ end
292
+
293
+ # Process a response that was marked as async
294
+ # Save the data if the request hasn't responded yet
295
+ def callback(data)
296
+ begin
297
+ if @request.deferred && @deferred_responses.nil?
298
+ respond_with(data)
299
+ else
300
+ @deferred_responses ||= []
301
+ @deferred_responses << data
302
+ end
303
+ rescue Exception => e
304
+ # This provides the same level of protection that
305
+ # the regular responses provide
306
+ send_error(e)
307
+ end
308
+ end
309
+
310
+ # Process the async request in the same way as Mizuno
311
+ # See: http://polycrystal.org/2012/04/15/asynchronous_responses_in_rack.html
312
+ def respond_with(data)
313
+ status, headers, body = data
314
+
315
+ if @async_state.nil?
316
+ # Respond with the headers here
317
+ write_response(status, headers, body)
318
+ elsif body.empty?
319
+ @socket.write EOF
320
+ @socket.shutdown if @request.keep_alive == false
321
+
322
+ # Complete the request here
323
+ deferred = @request.deferred
324
+ @request.deferred = nil # prevent data being sent after completed
325
+ @async_state = nil
326
+ deferred.resolve(true)
327
+ else
328
+ # Send the chunks provided
329
+ body.each &@write_chunk
330
+ end
331
+ nil
102
332
  end
103
333
  end
104
334
  end