diff options
Diffstat (limited to 'code/spyglass/lib')
| -rw-r--r-- | code/spyglass/lib/spyglass.rb | 53 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/configurator.rb | 31 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/logging.rb | 25 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/lookout.rb | 83 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/master.rb | 102 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/server.rb | 17 | ||||
| -rw-r--r-- | code/spyglass/lib/spyglass/worker.rb | 93 |
7 files changed, 404 insertions, 0 deletions
diff --git a/code/spyglass/lib/spyglass.rb b/code/spyglass/lib/spyglass.rb new file mode 100644 index 0000000..0e3f9ba --- /dev/null +++ b/code/spyglass/lib/spyglass.rb @@ -0,0 +1,53 @@ +# Spyglass +# ======== +# +# This is Spyglass, a Rack web server that rides on Unix designed to be simple and teach +# others about Unix programming. +# +# It's namesake comes from the fact that when it boots up it's nothing more than a lone socket +# keeping a lookout for incoming connections. +# +# When a connection comes in it spins up a Master +# process which preforks some workers to actually handle http requests. If the Master process is +# left idle long enough it will shut itself (and it's workers) down and go back to just a lone +# listening socket, on the lookout for incoming connections. +# +# Components +# ========== +# +# * [Server](server.html) gets the ball rolling. +# The role of Server is pretty minimal. It opens the initial listening TCP socket, +# then passes that socket onto the Lookout. The Lookout will actually handle reading +# from the socket. +# +# * [Lookout](lookout.html) keeps a watch and notifies others when a connection +# comes in. +# The Lookout is a pretty 'dumb' object. All that it does is listen for incoming +# connections on the socket it's given. Once it receives a connection it does a fork(2) +# and invokes a Master process. The Master process actually handles the connection. +# +# * [Master](master.html) loads the application and babysits worker processes +# that actually talk to clients. +# The role of the Master class is to create and babysit worker processes +# that will actually handle web requests. The Master itself doesn't know +# anything about http, etc. it just knows how to manage processes. +# +# * [Worker](worker.html) parses HTTP, calls the app, and writes back to the client. +require 'singleton' +require 'socket' +require 'stringio' + +require 'rack/server' +require 'rack/builder' + +require 'spyglass_parser' +require 'spyglass/configurator' +require 'spyglass/logging' +require 'spyglass/server' +require 'spyglass/lookout' +require 'spyglass/master' +require 'spyglass/worker' + +module Spyglass + Version = '0.1.1' +end diff --git a/code/spyglass/lib/spyglass/configurator.rb b/code/spyglass/lib/spyglass/configurator.rb new file mode 100644 index 0000000..6c1e0a7 --- /dev/null +++ b/code/spyglass/lib/spyglass/configurator.rb @@ -0,0 +1,31 @@ +module Spyglass + class Configurator + # A hash of key => default + OPTIONS = { + :port => 4222, + :host => '0.0.0.0', + :workers => 2, + :timeout => 30, + :config_ru_path => 'config.ru', + :verbose => false, + :vverbose => false + } + + class << self + OPTIONS.each do |key, default| + # attr_writer key + + define_method(key) do |*args| + arg = args.shift + if arg + instance_variable_set("@#{key}", arg) + else + instance_variable_get("@#{key}") || default + end + end + end + end + end + + Config = Configurator +end diff --git a/code/spyglass/lib/spyglass/logging.rb b/code/spyglass/lib/spyglass/logging.rb new file mode 100644 index 0000000..0819ea2 --- /dev/null +++ b/code/spyglass/lib/spyglass/logging.rb @@ -0,0 +1,25 @@ +module Spyglass + module Logging + def out(message) + $stdout.puts preamble + message + end + + def err(message) + $stderr.puts preamble + message + end + + def verbose(message) + return unless Config.verbose + out(message) + end + + def vverbose(message) + return unless Config.vverbose + out(message) + end + + def preamble + "[#{Process.pid}] [#{self.class.name}] " + end + end +end diff --git a/code/spyglass/lib/spyglass/lookout.rb b/code/spyglass/lib/spyglass/lookout.rb new file mode 100644 index 0000000..a24e34a --- /dev/null +++ b/code/spyglass/lib/spyglass/lookout.rb @@ -0,0 +1,83 @@ +module Spyglass + class Lookout + include Singleton, Logging + + # This method is the main entry point for the Lookout class. It takes + # a socket object. + def start(socket) + trap_signals + + # The Lookout doesn't know anything about the app itself, so there's + # no app related setup to do here. + loop do + # Accepts a new connection on our socket. This class won't actually + # do anything interesting with this connection, it will pass it down + # to the `Master` class created below to do the actual request handling. + conn = socket.accept + out "Received incoming connection" + + # In this block the Lookout forks a new process and invokes a Master, + # passing along the socket it received and the connection it accepted + # above. + @master_pid = fork do + master = Master.new(conn, socket) + master.start + end + + # The Lookout can now close its handle on the client socket. This doesn't + # translate to the socket being closed on the clients end because the + # forked Master process also has a handle on the same socket. Since this + # handle is now cleaned up it's up to the Master process to ensure that + # its handle gets cleaned up. + conn.close + # Now this process blocks until the Master process exits. The Master process + # will only exit once traffic is slow enough that it has reached its timeout + # without receiving any new connections. + Process.waitpid(@master_pid) + + # The interaction of fork(2)/waitpid(2) above deserve some explanation. + + # ### Why fork(2)? Why not just spin up the Master? + # The whole point of the Lookout process is to be very lean. The only resource + # that it initializes is the listening socket for the server. It doesn't load + # any of your application into memory, so its resource footprint is very small. + + # The reason that it does a fork(2) before invoking the Master is because once + # the Master times out we want the Lookout process to remain lean when accepting + # the next connection. + + # If it were to load the application code without forking + # then there would be no (simple) way for it to later unload the application code. + + # By doing a fork(2), then waiting for the Master process to exit, that guarantees + # that all resources (notably memory usage) that were in use by the Master process + # will be reclaimed by the kernel. + + # ### Who knows what your app will demand! + # While handling requests your app may require lots of memory. Containing this in a + # child process, and exiting that process, is the easiest way to ensure that memory + # bloat isn't shared with our simple parent process. + + # This allows our Lookout process will to go back around + # the loop with nothing more than it started with, just a listening socket. + + # The fork(2)/waitpid(2) approach requires little code to implement, and pushes + # responsibility down to the kernel to track resource usage and nicely clean up + # the Master process when it's finished. + end + end + + def trap_signals + [:INT, :QUIT].each do |sig| + trap(sig) { + begin + Process.kill(sig, @master_pid) if @master_pid + rescue Errno::ESRCH + end + exit + } + end + end + end +end + diff --git a/code/spyglass/lib/spyglass/master.rb b/code/spyglass/lib/spyglass/master.rb new file mode 100644 index 0000000..84341f1 --- /dev/null +++ b/code/spyglass/lib/spyglass/master.rb @@ -0,0 +1,102 @@ +module Spyglass + class Master + include Logging + + def initialize(connection, socket) + @connection, @socket = connection, socket + @worker_pids = [] + + # The Master shares this pipe with each of its worker processes. It + # passes the writable end down to each spawned worker while it listens + # on the readable end. Each worker will write to the pipe each time + # it accepts a new connection. If The Master doesn't get anything on + # the pipe before `Config.timeout` elapses then it kills its workers + # and exits. + @readable_pipe, @writable_pipe = IO.pipe + end + + # This method starts the Master. It enters an infinite loop where it creates + # processes to handle web requests and ensures that they stay active. It takes + # a connection as an argument from the Lookout instance. A Master will only + # be started when a connection is received by the Lookout. + def start + trap_signals + + load_app + out "Loaded the app" + + # The first worker we spawn has to handle the connection that was already + # passed to us. + spawn_worker(@connection) + # The Master can now close its handle on the client socket since the + # forked worker also got a handle on the same socket. Since this one + # is now closed it's up to the Worker process to close its handle when + # it's done. At that point the client connection will perceive that + # it's been closed on their end. + @connection.close + + # We spawn the rest of the workers. + (Config.workers - 1).times { spawn_worker } + out "Spawned #{Config.workers} workers. Babysitting now..." + + loop do + if timed_out?(IO.select([@readable_pipe], nil, nil, Config.timeout)) + out "Timed out after #{Config.timeout} s. Exiting." + + kill_workers(:QUIT) + exit + else + # Clear the data on the pipe so it doesn't appear to be readable + # next time around the loop. + @readable_pipe.read_nonblock 1 + end + end + end + + def timed_out?(select_result) + !select_result + end + + def spawn_worker(connection = nil) + @worker_pids << fork { Worker.new(@socket, @app, @writable_pipe, connection).start } + end + + def trap_signals + # The QUIT signal triggers a graceful shutdown. The master shuts down + # immediately and lets each worker finish the request they are currently + # processing. + trap(:QUIT) do + verbose "Received QUIT" + + kill_workers(:QUIT) + exit + end + + trap(:CHLD) do + dead_worker = Process.wait + @worker_pids.delete(dead_worker) + + @worker_pids.each do |wpid| + begin + dead_worker = Process.waitpid(wpid, Process::WNOHANG) + @worker_pids.delete(dead_worker) + rescue Errno::ECHILD + end + end + + spawn_worker + end + end + + def kill_workers(sig) + @worker_pids.each do |wpid| + Process.kill(sig, wpid) + end + end + + def load_app + @app, options = Rack::Builder.parse_file(Config.config_ru_path) + end + end +end + diff --git a/code/spyglass/lib/spyglass/server.rb b/code/spyglass/lib/spyglass/server.rb new file mode 100644 index 0000000..cb97ff1 --- /dev/null +++ b/code/spyglass/lib/spyglass/server.rb @@ -0,0 +1,17 @@ +module Spyglass + + class Server + include Singleton + include Logging + + def start + # Opens the main listening socket for the server. Now the server is responsive to + # incoming connections. + sock = TCPServer.open(Config.host, Config.port) + out "Listening on port #{Config.host}:#{Config.port}" + + Lookout.instance.start(sock) + end + end +end + diff --git a/code/spyglass/lib/spyglass/worker.rb b/code/spyglass/lib/spyglass/worker.rb new file mode 100644 index 0000000..7d4adda --- /dev/null +++ b/code/spyglass/lib/spyglass/worker.rb @@ -0,0 +1,93 @@ +require 'time' +require 'rack/utils' + +# Worker +# ====== +# +module Spyglass + class Worker + include Logging + + def initialize(socket, app, writable_pipe, connection = nil) + @socket, @app, @writable_pipe = socket, app, writable_pipe + @parser = Spyglass::HttpParser.new + + handle_connection(connection) if connection + end + + def start + trap_signals + + loop do + handle_connection @socket.accept + end + end + + def handle_connection(conn) + verbose "Received connection" + # This notifies our Master that we have received a connection, expiring + # it's `IO.select` and preventing it from timing out. + @writable_pipe.write_nonblock('.') + + # This clears any state that the http parser has lying around + # from the last connection that was handled. + @parser.reset + + # The Rack spec requires that 'rack.input' be encoded as ASCII-8BIT. + empty_body = '' + empty_body.encode!(Encoding::ASCII_8BIT) if empty_body.respond_to?(:encode!) + + # The Rack spec requires that the env contain certain keys before being + # passed to the app. These are the keys that aren't provided by each + # incoming request, server-specific stuff. + env = { + 'rack.input' => StringIO.new(empty_body), + 'rack.multithread' => false, + 'rack.multiprocess' => true, + 'rack.run_once' => false, + 'rack.errors' => STDERR, + 'rack.version' => [1, 0] + } + + # This reads data in from the client connection. We'll read up to + # 10000 bytes at the moment. + data = conn.readpartial(10000) + # Here we pass the data and the env into the http parser. It parses + # the raw http request data and updates the env with all of the data + # it can withdraw. + @parser.execute(env, data, 0) + + # Call the Rack app, goes all the way down the rabbit hole and back again. + status, headers, body = @app.call(env) + + # These are the default headers we always include in a response. We + # only speak HTTP 1.1 and we always close the client connection. At + # the monment keepalive is not supported. + head = "HTTP/1.1 #{status}\r\n" \ + "Date: #{Time.now.httpdate}\r\n" \ + "Status: #{Rack::Utils::HTTP_STATUS_CODES[status]}\r\n" \ + "Connection: close\r\n" + + headers.each do |k,v| + head << "#{k}: #{v}\r\n" + end + conn.write "#{head}\r\n" + + body.each { |chunk| conn.write chunk } + body.close if body.respond_to?(:close) + # Since keepalive is not supported we can close the client connection + # immediately after writing the body. + conn.close + + verbose "Closed connection" + end + + def trap_signals + trap(:QUIT) do + out "Received QUIT" + exit + end + end + end +end + |
