spider-gazelle 0.1.0 → 0.1.1

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